From 3196972596bba991205279d97bc2171c60e3c006 Mon Sep 17 00:00:00 2001 From: lan Date: Tue, 5 May 2026 19:07:33 +0800 Subject: [PATCH] refactor: restructure core architecture and responsive system This commit implements a major architectural refactor to improve modularity, type safety, and maintainability across the codebase. Key changes include: - **Responsive System Refactor**: Migrated from a monolithic `useResponsive` hook to a set of specialized, granular hooks (`useBreakpoint`, `useOrientation`, `usePlatform`, etc.) located in `src/presentation/hooks/responsive`. This reduces unnecessary re-renders and improves developer experience. - **Core Service Restructuring**: Introduced a new directory structure for services, including dedicated folders for `datasources`, `mappers`, and domain-specific types (`message`, `post`). - **Data Layer Improvements**: - Centralized JSON parsing logic in `src/database/core/jsonUtils.ts`. - Cleaned up repository implementations by removing redundant local utility functions. - Refactored `MessageMapper` to use the new centralized JSON utility. - **Type System Cleanup**: - Decomposed the large `src/types/dto.ts` into a modular `src/types/dto/` directory. - Simplified `src/types/index.ts` and introduced backward-compatible aliases for core entities. - **Utility Consolidation**: - Created a centralized `src/utils/formatTime.ts` to replace fragmented date formatting logic across various screens and components. - Removed deprecated responsive utility files in favor of the new hook-based system. - **Service Logic Refinement**: Refactored `ApiClient` and `WebSocketService` to use a centralized `showVerificationModal` service, removing duplicated state management for verification prompts. --- app/(app)/(tabs)/_layout.tsx | 2 +- app/(app)/_layout.web.tsx | 2 +- src/components/business/CommentItem.tsx | 19 +- src/components/business/PostCard/PostCard.tsx | 2 +- .../PostCard/components/PostImages.tsx | 2 +- src/components/business/SystemMessageItem.tsx | 20 +- src/components/common/AdaptiveLayout.tsx | 42 +- src/components/common/ResponsiveContainer.tsx | 2 +- src/components/common/ResponsiveGrid.tsx | 2 +- src/components/common/ResponsiveStack.tsx | 212 ---- src/components/common/index.ts | 6 +- src/data/mappers/MessageMapper.ts | 15 +- src/data/models/index.ts | 29 - src/database/core/jsonUtils.ts | 7 + .../repositories/ConversationRepository.ts | 5 +- .../repositories/GroupCacheRepository.ts | 5 +- .../repositories/UserCacheRepository.ts | 5 +- src/hooks/index.ts | 30 +- src/hooks/responsive/constants.ts | 82 -- src/hooks/responsive/index.ts | 45 - src/hooks/responsive/useBreakpoint.ts | 62 -- src/hooks/responsive/useBreakpointRange.ts | 46 - src/hooks/responsive/useColumnCount.ts | 53 - src/hooks/responsive/useMediaQuery.ts | 39 - src/hooks/responsive/useOrientation.ts | 36 - src/hooks/responsive/usePlatform.ts | 31 - src/hooks/responsive/useResponsive.ts | 103 -- src/hooks/responsive/useResponsiveSpacing.ts | 48 - src/hooks/responsive/useResponsiveStyle.ts | 53 - src/hooks/responsive/useResponsiveValue.ts | 42 - src/hooks/responsive/useScreenSize.ts | 34 - src/hooks/responsive/useWindowDimensions.ts | 27 - src/hooks/useResponsive.ts | 485 --------- src/presentation/hooks/responsive/index.ts | 22 +- .../hooks/responsive/useBreakpoint.ts | 101 +- .../hooks/responsive/useMediaQuery.ts | 36 - .../hooks/responsive/useResponsive.ts | 5 - .../hooks/responsive/useResponsiveValue.ts | 48 - src/screens/apps/AppsScreen.tsx | 2 +- src/screens/home/HomeScreen.tsx | 2 +- src/screens/home/MarketView.tsx | 2 +- src/screens/home/PostDetailScreen.tsx | 35 +- src/screens/home/SearchScreen.tsx | 2 +- src/screens/material/MaterialDetailScreen.tsx | 12 +- src/screens/material/MaterialsScreen.tsx | 2 +- .../material/SubjectMaterialsScreen.tsx | 2 +- src/screens/message/ChatScreen.tsx | 2 +- src/screens/message/GroupInfoScreen.tsx | 2 +- src/screens/message/GroupMembersScreen.tsx | 2 +- src/screens/message/MessageListScreen.tsx | 30 +- .../components/ChatScreen/ChatHeader.tsx | 2 +- .../components/ChatScreen/ChatInput.tsx | 2 +- .../components/ChatScreen/EmojiPanel.tsx | 2 +- .../components/ChatScreen/GroupInfoPanel.tsx | 2 +- .../ChatScreen/GroupInfoPanel.web.tsx | 2 +- .../components/ChatScreen/MessageBubble.tsx | 2 +- .../components/ChatScreen/useChatScreen.ts | 22 +- src/screens/schedule/ScheduleScreen.tsx | 2 +- src/screens/trade/TradeDetailScreen.tsx | 2 +- src/services/auth/verificationService.ts | 3 +- src/services/core/api.ts | 11 +- .../core/datasources/ApiDataSource.ts | 98 ++ .../core/datasources/CacheDataSource.ts | 199 ++++ src/services/core/datasources/WSClient.ts | 184 ++++ src/services/core/datasources/index.ts | 9 + src/services/core/datasources/interfaces.ts | 68 ++ src/services/core/verification.ts | 12 + src/services/core/wsService.ts | 10 +- src/services/mappers/ConversationMapper.ts | 302 ++++++ src/services/mappers/MessageMapper.ts | 194 ++++ src/services/mappers/PostMapper.ts | 129 +++ src/services/mappers/UserMapper.ts | 134 +++ src/services/mappers/index.ts | 9 + src/services/message/types/repository.ts | 93 ++ src/services/post/types/repository.ts | 190 ++++ src/types/dto.ts | 998 ------------------ src/types/dto/common.ts | 210 ++++ src/types/dto/group.ts | 126 +++ src/types/dto/index.ts | 11 + src/types/dto/message.ts | 211 ++++ src/types/dto/pagination.ts | 92 ++ src/types/dto/post.ts | 75 ++ src/types/dto/segmentUtils.ts | 108 ++ src/types/dto/user.ts | 49 + src/types/index.ts | 111 +- src/utils/cache/types.ts | 202 ++++ src/utils/diff/messageDiffCalculator.ts | 268 +++++ src/utils/diff/messageTypes.ts | 209 ++++ src/utils/diff/postDiffCalculator.ts | 430 ++++++++ src/utils/diff/postTypes.ts | 325 ++++++ src/utils/formatTime.ts | 75 ++ src/utils/pagination/types.ts | 266 +++++ src/utils/platform/domUtils.ts | 83 ++ src/utils/platform/index.ts | 12 + src/utils/responsive.ts | 167 --- src/utils/sync/types.ts | 147 +++ 96 files changed, 4609 insertions(+), 3149 deletions(-) delete mode 100644 src/components/common/ResponsiveStack.tsx create mode 100644 src/database/core/jsonUtils.ts delete mode 100644 src/hooks/responsive/constants.ts delete mode 100644 src/hooks/responsive/index.ts delete mode 100644 src/hooks/responsive/useBreakpoint.ts delete mode 100644 src/hooks/responsive/useBreakpointRange.ts delete mode 100644 src/hooks/responsive/useColumnCount.ts delete mode 100644 src/hooks/responsive/useMediaQuery.ts delete mode 100644 src/hooks/responsive/useOrientation.ts delete mode 100644 src/hooks/responsive/usePlatform.ts delete mode 100644 src/hooks/responsive/useResponsive.ts delete mode 100644 src/hooks/responsive/useResponsiveSpacing.ts delete mode 100644 src/hooks/responsive/useResponsiveStyle.ts delete mode 100644 src/hooks/responsive/useResponsiveValue.ts delete mode 100644 src/hooks/responsive/useScreenSize.ts delete mode 100644 src/hooks/responsive/useWindowDimensions.ts delete mode 100644 src/hooks/useResponsive.ts delete mode 100644 src/presentation/hooks/responsive/useMediaQuery.ts create mode 100644 src/services/core/datasources/ApiDataSource.ts create mode 100644 src/services/core/datasources/CacheDataSource.ts create mode 100644 src/services/core/datasources/WSClient.ts create mode 100644 src/services/core/datasources/index.ts create mode 100644 src/services/core/datasources/interfaces.ts create mode 100644 src/services/core/verification.ts create mode 100644 src/services/mappers/ConversationMapper.ts create mode 100644 src/services/mappers/MessageMapper.ts create mode 100644 src/services/mappers/PostMapper.ts create mode 100644 src/services/mappers/UserMapper.ts create mode 100644 src/services/mappers/index.ts create mode 100644 src/services/message/types/repository.ts create mode 100644 src/services/post/types/repository.ts delete mode 100644 src/types/dto.ts create mode 100644 src/types/dto/common.ts create mode 100644 src/types/dto/group.ts create mode 100644 src/types/dto/index.ts create mode 100644 src/types/dto/message.ts create mode 100644 src/types/dto/pagination.ts create mode 100644 src/types/dto/post.ts create mode 100644 src/types/dto/segmentUtils.ts create mode 100644 src/types/dto/user.ts create mode 100644 src/utils/cache/types.ts create mode 100644 src/utils/diff/messageDiffCalculator.ts create mode 100644 src/utils/diff/messageTypes.ts create mode 100644 src/utils/diff/postDiffCalculator.ts create mode 100644 src/utils/diff/postTypes.ts create mode 100644 src/utils/formatTime.ts create mode 100644 src/utils/pagination/types.ts create mode 100644 src/utils/platform/domUtils.ts create mode 100644 src/utils/platform/index.ts delete mode 100644 src/utils/responsive.ts create mode 100644 src/utils/sync/types.ts diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index d9f4d60..95c57a0 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -5,7 +5,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAppColors, shadows } from '../../../src/theme'; -import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; +import { BREAKPOINTS } from '../../../src/hooks'; import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores'; const TAB_BAR_HEIGHT = 56; diff --git a/app/(app)/_layout.web.tsx b/app/(app)/_layout.web.tsx index a8e5521..374ffde 100644 --- a/app/(app)/_layout.web.tsx +++ b/app/(app)/_layout.web.tsx @@ -4,7 +4,7 @@ import { Redirect } from 'expo-router'; import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; -import { BREAKPOINTS } from '../../src/hooks/useResponsive'; +import { BREAKPOINTS } from '../../src/hooks'; import { messageManager, useAuthStore } from '../../src/stores'; export default function AppLayoutWeb() { diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index 707929b..7932c37 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -6,8 +6,7 @@ import React, { useMemo, useState } from 'react'; import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; +import { formatChatTime } from '../../utils/formatTime'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; import { Comment, CommentImage } from '../../types'; import Text from '../common/Text'; @@ -220,20 +219,6 @@ const CommentItem: React.FC = ({ const colors = useAppColors(); const styles = useMemo(() => createCommentItemStyles(colors), [colors]); const [isDeleting, setIsDeleting] = useState(false); - // 格式化时间 - const formatTime = (dateString: string): string => { - if (!dateString) return ''; - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - return formatDistanceToNow(date, { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } - }; // 格式化数字 const formatNumber = (num: number): string => { @@ -599,7 +584,7 @@ const CommentItem: React.FC = ({ {renderBadges()} · - {formatTime(comment.created_at || '')} + {formatChatTime(comment.created_at || '')} {renderFloorNumber()} diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index ce1d55e..3ba632c 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -24,7 +24,7 @@ import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from ' import { getPreviewImageUrl } from '../../../utils/imageHelper'; import PostImages from './components/PostImages'; import PostContentRenderer from '../PostContentRenderer'; -import { useResponsive } from '../../../hooks/useResponsive'; +import { useResponsive } from '../../../hooks'; import { formatPostCreatedAtString } from '../../../core/entities/Post'; /** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */ diff --git a/src/components/business/PostCard/components/PostImages.tsx b/src/components/business/PostCard/components/PostImages.tsx index 75e7161..dcf23c5 100644 --- a/src/components/business/PostCard/components/PostImages.tsx +++ b/src/components/business/PostCard/components/PostImages.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; import { spacing, borderRadius } from '../../../../theme'; -import { useResponsive } from '../../../../hooks/useResponsive'; +import { useResponsive } from '../../../../hooks'; import { ImageGrid, ImageGridItem } from '../../../common'; import { PostImagesProps } from '../types'; diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 87a9fbb..999bafc 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -7,8 +7,7 @@ import React, { useMemo } 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 { formatChatTime } from '../../utils/formatTime'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; import { SystemMessageResponse, SystemMessageType } from '../../types/dto'; import Text from '../common/Text'; @@ -329,21 +328,6 @@ const SystemMessageItem: React.FC = ({ const colors = useAppColors(); const styles = useMemo(() => createSystemMessageStyles(colors), [colors]); - // 格式化时间 - const formatTime = (dateString: string): string => { - if (!dateString) return ''; - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - return formatDistanceToNow(date, { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } - }; - const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors); const title = getSystemMessageTitle(message); const { extra_data } = message; @@ -444,7 +428,7 @@ const SystemMessageItem: React.FC = ({ {getStatusBadge()} - {formatTime(message.created_at)} + {formatChatTime(message.created_at)} diff --git a/src/components/common/AdaptiveLayout.tsx b/src/components/common/AdaptiveLayout.tsx index d71dda3..d6a145a 100644 --- a/src/components/common/AdaptiveLayout.tsx +++ b/src/components/common/AdaptiveLayout.tsx @@ -21,7 +21,7 @@ import { useResponsive, useBreakpointGTE, FineBreakpointKey, -} from '../../hooks/useResponsive'; +} from '../../hooks'; import { blurActiveElement } from '../../infrastructure/platform'; export interface AdaptiveLayoutProps { @@ -364,44 +364,4 @@ const styles = StyleSheet.create({ }, }); -/** - * 简化的侧边栏布局组件 - * 仅包含主内容和侧边栏,无头部底部 - */ -export interface SidebarLayoutProps { - children: React.ReactNode; - sidebar: React.ReactNode; - style?: StyleProp; - contentStyle?: StyleProp; - sidebarStyle?: StyleProp; - sidebarWidth?: number; - showSidebarBreakpoint?: FineBreakpointKey; - sidebarPosition?: 'left' | 'right'; -} - -export function SidebarLayout({ - children, - sidebar, - style, - contentStyle, - sidebarStyle, - sidebarWidth = 280, - showSidebarBreakpoint = 'lg', - sidebarPosition = 'left', -}: SidebarLayoutProps) { - return ( - - {children} - - ); -} - export default AdaptiveLayout; diff --git a/src/components/common/ResponsiveContainer.tsx b/src/components/common/ResponsiveContainer.tsx index a5c2b4b..5a03419 100644 --- a/src/components/common/ResponsiveContainer.tsx +++ b/src/components/common/ResponsiveContainer.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { View, StyleProp, ViewStyle } from 'react-native'; -import { useResponsive } from '../../hooks/useResponsive'; +import { useResponsive } from '../../hooks'; export interface ResponsiveContainerProps { children: React.ReactNode; diff --git a/src/components/common/ResponsiveGrid.tsx b/src/components/common/ResponsiveGrid.tsx index af645ad..a591c72 100644 --- a/src/components/common/ResponsiveGrid.tsx +++ b/src/components/common/ResponsiveGrid.tsx @@ -16,7 +16,7 @@ import { useColumnCount, useResponsiveSpacing, FineBreakpointKey, -} from '../../hooks/useResponsive'; +} from '../../hooks'; export interface ResponsiveGridProps { /** 子元素 */ diff --git a/src/components/common/ResponsiveStack.tsx b/src/components/common/ResponsiveStack.tsx deleted file mode 100644 index 1e4a841..0000000 --- a/src/components/common/ResponsiveStack.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * 响应式堆叠布局组件 - * 移动端垂直堆叠,平板/桌面端水平排列 - */ - -import React, { useMemo } from 'react'; -import { - View, - StyleProp, - ViewStyle, - StyleSheet, - FlexAlignType, -} from 'react-native'; -import { - useResponsive, - useResponsiveSpacing, - FineBreakpointKey, - useBreakpointGTE, -} from '../../hooks/useResponsive'; - -export type StackDirection = 'horizontal' | 'vertical' | 'responsive'; -export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline'; -export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'; - -export interface ResponsiveStackProps { - /** 子元素 */ - children: React.ReactNode; - /** 布局方向 */ - direction?: StackDirection; - /** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */ - horizontalBreakpoint?: FineBreakpointKey; - /** 间距 */ - gap?: Partial> | number; - /** 是否允许换行 */ - wrap?: boolean; - /** 对齐方式(交叉轴) */ - align?: StackAlignment; - /** 分布方式(主轴) */ - justify?: StackJustify; - /** 自定义样式 */ - style?: StyleProp; - /** 子元素样式 */ - itemStyle?: StyleProp; - /** 是否反转顺序 */ - reverse?: boolean; - /** 是否等分空间 */ - equalItem?: boolean; -} - -const alignMap: Record = { - start: 'flex-start', - center: 'center', - end: 'flex-end', - stretch: 'stretch', - baseline: 'baseline', -}; - -const justifyMap: Record = { - start: 'flex-start', - center: 'center', - end: 'flex-end', - between: 'space-between', - around: 'space-around', - evenly: 'space-evenly', -}; - -/** - * 响应式堆叠布局组件 - * - * - 移动端垂直堆叠 - * - 平板/桌面端水平排列 - * - 支持间距和换行配置 - * - * @example - * // 基础用法 - 自动响应式 - * - * - * - * - * - * - * @example - * // 自定义断点和间距 - * - * - * - * - * - * @example - * // 固定水平方向 - * - * {items.map(item => )} - * - */ -export function ResponsiveStack({ - children, - direction = 'responsive', - horizontalBreakpoint = 'lg', - gap: gapConfig, - wrap = false, - align = 'stretch', - justify = 'start', - style, - itemStyle, - reverse = false, - equalItem = false, -}: ResponsiveStackProps) { - const { isMobile, isTablet } = useResponsive(); - const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint); - - // 计算间距 - const gap = useMemo(() => { - if (typeof gapConfig === 'number') { - return gapConfig; - } - // 使用 hook 获取响应式间距 - return gapConfig; - }, [gapConfig]); - - const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap); - const finalGap = typeof gap === 'number' ? gap : responsiveGap; - - // 确定布局方向 - const isHorizontal = useMemo(() => { - if (direction === 'horizontal') return true; - if (direction === 'vertical') return false; - // direction === 'responsive' - return isHorizontalBreakpoint; - }, [direction, isHorizontalBreakpoint]); - - // 构建容器样式 - const containerStyle = useMemo((): ViewStyle => { - const flexDirection = isHorizontal - ? (reverse ? 'row-reverse' : 'row') - : (reverse ? 'column-reverse' : 'column'); - - return { - flexDirection, - flexWrap: wrap ? 'wrap' : 'nowrap', - alignItems: alignMap[align], - justifyContent: justifyMap[justify], - gap: finalGap, - }; - }, [isHorizontal, reverse, wrap, align, justify, finalGap]); - - // 处理子元素 - const processedChildren = useMemo(() => { - const childrenArray = React.Children.toArray(children); - - return childrenArray.map((child, index) => { - if (!React.isValidElement(child)) { - return child; - } - - const childStyle: ViewStyle = {}; - - if (equalItem) { - childStyle.flex = 1; - } - - // 如果不是最后一个元素,添加间距 - // 注意:使用 gap 后不需要手动添加 margin - - return ( - - {child} - - ); - }); - }, [children, equalItem, itemStyle]); - - return ( - - {processedChildren} - - ); -} - -const styles = StyleSheet.create({ - container: { - width: '100%', - }, - equalItem: { - flex: 1, - minWidth: 0, // 防止 flex item 溢出 - }, -}); - -/** - * 水平堆叠组件(快捷方式) - */ -export function HStack(props: Omit) { - return ; -} - -/** - * 垂直堆叠组件(快捷方式) - */ -export function VStack(props: Omit) { - return ; -} - -export default ResponsiveStack; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 5bd7d6f..8f0a238 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -15,8 +15,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer'; // 响应式布局组件 export { default as ResponsiveGrid } from './ResponsiveGrid'; -export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack'; -export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout'; +export { default as AdaptiveLayout } from './AdaptiveLayout'; export { default as AppBackButton } from './AppBackButton'; export { SimpleHeader } from './SimpleHeader'; export { default as PagerView } from './PagerView'; @@ -33,8 +32,7 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery'; // 响应式组件类型导出 export type { ResponsiveGridProps } from './ResponsiveGrid'; -export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack'; -export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout'; +export type { AdaptiveLayoutProps } from './AdaptiveLayout'; // 注册流程相关组件 export { default as VerificationCodeInput } from './VerificationCodeInput'; diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts index 86a2834..a01c879 100644 --- a/src/data/mappers/MessageMapper.ts +++ b/src/data/mappers/MessageMapper.ts @@ -12,6 +12,7 @@ import type { MessageResponse, UserDTO, } from '../../types/dto'; +import { safeParseJsonOrUndefined } from '../../database/core/jsonUtils'; import type { CachedMessage } from '../../database/types/CachedMessage'; // 数据库消息记录类型 @@ -73,7 +74,7 @@ export class MessageMapper { createdAt: new Date(record.createdAt), seq: record.seq || 0, status: record.status || 'normal', - segments: record.segments ? this.safeParseJson(record.segments) : undefined, + segments: record.segments ? safeParseJsonOrUndefined(record.segments) : undefined, }; } @@ -162,17 +163,7 @@ export class MessageMapper { }; } - /** - * 安全解析 JSON - */ - private static safeParseJson(value: string): T | undefined { - try { - return JSON.parse(value) as T; - } catch { - return undefined; - } - } - + private static mapSenderFromApi(sender: UserDTO): UserModel { return { id: String(sender.id || ''), diff --git a/src/data/models/index.ts b/src/data/models/index.ts index c8bc744..002ad60 100644 --- a/src/data/models/index.ts +++ b/src/data/models/index.ts @@ -124,32 +124,3 @@ export interface GroupModel { updatedAt: Date; } -// ==================== 分页模型 ==================== - -export interface PaginatedResult { - list: T[]; - total: number; - page: number; - pageSize: number; - totalPages: number; - hasMore: boolean; -} - -// ==================== 通用操作结果 ==================== - -export interface OperationResult { - success: boolean; - data?: T; - error?: string; - code?: string; -} - -// ==================== 同步状态模型 ==================== - -export interface SyncStatus { - entityType: string; - entityId: string; - lastSyncedAt: Date; - syncVersion: number; - pendingChanges: boolean; -} diff --git a/src/database/core/jsonUtils.ts b/src/database/core/jsonUtils.ts new file mode 100644 index 0000000..729ef61 --- /dev/null +++ b/src/database/core/jsonUtils.ts @@ -0,0 +1,7 @@ +export const safeParseJson = (value: string, fallback: T | null = null): T | null => { + try { return JSON.parse(value) as T; } catch { return fallback; } +}; + +export const safeParseJsonOrUndefined = (value: string): T | undefined => { + try { return JSON.parse(value) as T; } catch { return undefined; } +}; diff --git a/src/database/repositories/ConversationRepository.ts b/src/database/repositories/ConversationRepository.ts index 73588fa..7cafd94 100644 --- a/src/database/repositories/ConversationRepository.ts +++ b/src/database/repositories/ConversationRepository.ts @@ -1,13 +1,10 @@ import { localDataSource } from '../LocalDataSource'; import type { ILocalDataSource } from '@/data/datasources/interfaces'; import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto'; +import { safeParseJson } from '../core/jsonUtils'; type ConversationCacheData = ConversationResponse | ConversationDetailResponse; -const safeParseJson = (value: string): T | null => { - try { return JSON.parse(value) as T; } catch { return null; } -}; - export interface IConversationRepository { save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise; getAll(): Promise; diff --git a/src/database/repositories/GroupCacheRepository.ts b/src/database/repositories/GroupCacheRepository.ts index 5e79702..655d5a0 100644 --- a/src/database/repositories/GroupCacheRepository.ts +++ b/src/database/repositories/GroupCacheRepository.ts @@ -1,10 +1,7 @@ import { localDataSource } from '../LocalDataSource'; import type { ILocalDataSource } from '@/data/datasources/interfaces'; import type { GroupResponse, GroupMemberResponse } from '@/types/dto'; - -const safeParseJson = (value: string): T | null => { - try { return JSON.parse(value) as T; } catch { return null; } -}; +import { safeParseJson } from '../core/jsonUtils'; export interface IGroupCacheRepository { save(group: GroupResponse): Promise; diff --git a/src/database/repositories/UserCacheRepository.ts b/src/database/repositories/UserCacheRepository.ts index dbf7975..93a7dd4 100644 --- a/src/database/repositories/UserCacheRepository.ts +++ b/src/database/repositories/UserCacheRepository.ts @@ -1,10 +1,7 @@ import { localDataSource } from '../LocalDataSource'; import type { ILocalDataSource } from '@/data/datasources/interfaces'; import type { UserDTO } from '@/types/dto'; - -const safeParseJson = (value: string): T | null => { - try { return JSON.parse(value) as T; } catch { return null; } -}; +import { safeParseJson } from '../core/jsonUtils'; export interface IUserCacheRepository { save(user: UserDTO): Promise; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 001741a..b2232fe 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,33 +2,23 @@ * Hooks 导出 */ -// ==================== 新的响应式 Hooks (推荐) ==================== +// ==================== 响应式 Hooks ==================== export { - // 核心 hooks useBreakpoint, - useFineBreakpoint, useBreakpointGTE, - useBreakpointLT, - useBreakpointBetween, useScreenSize, useWindowDimensions, useOrientation, usePlatform, - useMediaQuery, useResponsiveValue, - useResponsiveStyle, useColumnCount, useResponsiveSpacing, - // 兼容层 useResponsive, - useLegacyResponsive, - // 工具函数 getBreakpoint, getFineBreakpoint, isBreakpointGTE, isBreakpointLT, isBreakpointBetween, - // 常量 BREAKPOINTS, FINE_BREAKPOINTS, } from '../presentation/hooks/responsive'; @@ -42,25 +32,9 @@ export type { PlatformInfo, ScreenSize, MediaQueryOptions, - // 兼容层类型 ResponsiveInfo, } from '../presentation/hooks/responsive'; -// ==================== 旧版响应式 Hooks (保持向后兼容) ==================== -// 注意:这些导出将在未来版本中移除,请使用新的 hooks -export { - BREAKPOINTS as BREAKPOINTS_OLD, - FINE_BREAKPOINTS as FINE_BREAKPOINTS_OLD, -} from './useResponsive'; - -export type { - ResponsiveInfo as ResponsiveInfo_OLD, - BreakpointKey as BreakpointKey_OLD, - BreakpointValue as BreakpointValue_OLD, - FineBreakpointKey as FineBreakpointKey_OLD, - ResponsiveValue as ResponsiveValue_OLD, -} from './useResponsive'; - // ==================== 其他 Hooks ==================== export { usePrefetch, @@ -123,4 +97,4 @@ export type { KeyboardEventType, PlatformKeyboardOptions, PlatformKeyboardResult, -} from './usePlatformKeyboard'; +} from './usePlatformKeyboard'; \ No newline at end of file diff --git a/src/hooks/responsive/constants.ts b/src/hooks/responsive/constants.ts deleted file mode 100644 index ae57078..0000000 --- a/src/hooks/responsive/constants.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 响应式常量定义 - */ - -// ==================== 断点定义 ==================== -export const BREAKPOINTS = { - mobile: 0, // 手机 - tablet: 768, // 平板竖屏 - desktop: 1024, // 平板横屏/桌面 - wide: 1440, // 宽屏桌面 -} as const; - -export type BreakpointKey = keyof typeof BREAKPOINTS; -export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey]; - -// ==================== 更细粒度的断点定义 ==================== -export const FINE_BREAKPOINTS = { - xs: 0, // 超小屏手机 - sm: 375, // 小屏手机 (iPhone SE, 小屏安卓) - md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓) - lg: 768, // 平板竖屏 / 大折叠屏手机展开 - xl: 1024, // 平板横屏 / 小桌面 - '2xl': 1280, // 桌面 - '3xl': 1440, // 大桌面 - '4xl': 1920, // 超大屏 -} as const; - -export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS; - -// ==================== 响应式值类型 ==================== -export type ResponsiveValue = T | Partial>; - -// ==================== 断点顺序(用于比较)==================== -export const BREAKPOINT_ORDER: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl']; -export const REVERSE_BREAKPOINT_ORDER: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - -// ==================== 获取当前断点 ==================== -export function getBreakpoint(width: number): BreakpointKey { - if (width >= BREAKPOINTS.wide) { - return 'wide'; - } - if (width >= BREAKPOINTS.desktop) { - return 'desktop'; - } - if (width >= BREAKPOINTS.tablet) { - return 'tablet'; - } - return 'mobile'; -} - -// ==================== 获取细粒度断点 ==================== -export function getFineBreakpoint(width: number): FineBreakpointKey { - if (width >= FINE_BREAKPOINTS['4xl']) return '4xl'; - if (width >= FINE_BREAKPOINTS['3xl']) return '3xl'; - if (width >= FINE_BREAKPOINTS['2xl']) return '2xl'; - if (width >= FINE_BREAKPOINTS.xl) return 'xl'; - if (width >= FINE_BREAKPOINTS.lg) return 'lg'; - if (width >= FINE_BREAKPOINTS.md) return 'md'; - if (width >= FINE_BREAKPOINTS.sm) return 'sm'; - return 'xs'; -} - -// ==================== 断点比较工具 ==================== -/** - * 检查当前断点是否大于等于目标断点 - */ -export function isBreakpointGTE( - current: FineBreakpointKey, - target: FineBreakpointKey -): boolean { - return BREAKPOINT_ORDER.indexOf(current) >= BREAKPOINT_ORDER.indexOf(target); -} - -/** - * 检查当前断点是否小于目标断点 - */ -export function isBreakpointLT( - current: FineBreakpointKey, - target: FineBreakpointKey -): boolean { - return !isBreakpointGTE(current, target); -} diff --git a/src/hooks/responsive/index.ts b/src/hooks/responsive/index.ts deleted file mode 100644 index 022fb89..0000000 --- a/src/hooks/responsive/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 响应式 Hooks 统一导出 - */ - -// 基础 Hooks -export { useWindowDimensions } from './useWindowDimensions'; -export { useBreakpoint, type BreakpointInfo } from './useBreakpoint'; -export { useOrientation, type Orientation, type OrientationInfo } from './useOrientation'; -export { usePlatform, type PlatformInfo } from './usePlatform'; -export { useScreenSize, type ScreenSizeInfo } from './useScreenSize'; - -// 响应式值 Hooks -export { useResponsiveValue } from './useResponsiveValue'; -export { useResponsiveStyle } from './useResponsiveStyle'; -export { useResponsiveSpacing } from './useResponsiveSpacing'; - -// 断点范围 Hooks -export { - useBreakpointGTE, - useBreakpointLT, - useBreakpointBetween, -} from './useBreakpointRange'; - -// 工具 Hooks -export { useMediaQuery } from './useMediaQuery'; -export { useColumnCount } from './useColumnCount'; - -// 常量和工具函数 -export { - BREAKPOINTS, - FINE_BREAKPOINTS, - BREAKPOINT_ORDER, - REVERSE_BREAKPOINT_ORDER, - type BreakpointKey, - type BreakpointValue, - type FineBreakpointKey, - type ResponsiveValue, - getBreakpoint, - getFineBreakpoint, - isBreakpointGTE, - isBreakpointLT, -} from './constants'; - -// 为了保持向后兼容,重新导出 useResponsive -export { useResponsive, type ResponsiveInfo } from './useResponsive'; diff --git a/src/hooks/responsive/useBreakpoint.ts b/src/hooks/responsive/useBreakpoint.ts deleted file mode 100644 index f05e948..0000000 --- a/src/hooks/responsive/useBreakpoint.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 断点检测 Hook - */ - -import { useMemo } from 'react'; -import { useWindowDimensions } from './useWindowDimensions'; -import { - BreakpointKey, - FineBreakpointKey, - getBreakpoint, - getFineBreakpoint, -} from './constants'; - -export interface BreakpointInfo { - // 基础断点 - breakpoint: BreakpointKey; - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - isWide: boolean; - isWideScreen: boolean; - - // 细粒度断点 - fineBreakpoint: FineBreakpointKey; - isXS: boolean; - isSM: boolean; - isMD: boolean; - isLG: boolean; - isXL: boolean; - is2XL: boolean; - is3XL: boolean; - is4XL: boolean; -} - -/** - * 断点检测 Hook - * 返回当前断点信息和布尔标志 - */ -export function useBreakpoint(): BreakpointInfo { - const { width } = useWindowDimensions(); - - const breakpoint = useMemo(() => getBreakpoint(width), [width]); - const fineBreakpoint = useMemo(() => getFineBreakpoint(width), [width]); - - return useMemo(() => ({ - breakpoint, - isMobile: breakpoint === 'mobile', - isTablet: breakpoint === 'tablet', - isDesktop: breakpoint === 'desktop', - isWide: breakpoint === 'wide', - isWideScreen: breakpoint === 'tablet' || breakpoint === 'desktop' || breakpoint === 'wide', - fineBreakpoint, - isXS: fineBreakpoint === 'xs', - isSM: fineBreakpoint === 'sm', - isMD: fineBreakpoint === 'md', - isLG: fineBreakpoint === 'lg', - isXL: fineBreakpoint === 'xl', - is2XL: fineBreakpoint === '2xl', - is3XL: fineBreakpoint === '3xl', - is4XL: fineBreakpoint === '4xl', - }), [breakpoint, fineBreakpoint]); -} diff --git a/src/hooks/responsive/useBreakpointRange.ts b/src/hooks/responsive/useBreakpointRange.ts deleted file mode 100644 index 232f7cd..0000000 --- a/src/hooks/responsive/useBreakpointRange.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * 断点范围检查 Hooks - */ - -import { useMemo } from 'react'; -import { useBreakpoint } from './useBreakpoint'; -import { FineBreakpointKey, isBreakpointGTE, isBreakpointLT } from './constants'; - -/** - * 检查当前断点是否大于等于目标断点 - * - * @example - * const isMediumUp = useBreakpointGTE('md'); - */ -export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { fineBreakpoint } = useBreakpoint(); - return useMemo(() => isBreakpointGTE(fineBreakpoint, target), [fineBreakpoint, target]); -} - -/** - * 检查当前断点是否小于目标断点 - * - * @example - * const isMobileOnly = useBreakpointLT('lg'); - */ -export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { fineBreakpoint } = useBreakpoint(); - return useMemo(() => isBreakpointLT(fineBreakpoint, target), [fineBreakpoint, target]); -} - -/** - * 检查当前是否在指定断点范围内 - * - * @example - * const isTabletRange = useBreakpointBetween('md', 'xl'); - */ -export function useBreakpointBetween( - min: FineBreakpointKey, - max: FineBreakpointKey -): boolean { - const { fineBreakpoint } = useBreakpoint(); - return useMemo( - () => isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max), - [fineBreakpoint, min, max] - ); -} diff --git a/src/hooks/responsive/useColumnCount.ts b/src/hooks/responsive/useColumnCount.ts deleted file mode 100644 index bc61348..0000000 --- a/src/hooks/responsive/useColumnCount.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 列数计算 Hook - */ - -import { useMemo } from 'react'; -import { useBreakpoint } from './useBreakpoint'; -import { FineBreakpointKey } from './constants'; - -const DEFAULT_COLUMN_CONFIG: Record = { - xs: 1, - sm: 1, - md: 2, - lg: 3, - xl: 4, - '2xl': 4, - '3xl': 5, - '4xl': 6, -}; - -/** - * 根据容器宽度计算合适的列数 - * - * @param columnConfig - 列数配置 - * @returns 列数 - * - * @example - * const columns = useColumnCount({ - * xs: 1, - * sm: 2, - * md: 3, - * lg: 4 - * }); - */ -export function useColumnCount( - columnConfig: Partial> = {} -): number { - const { fineBreakpoint } = useBreakpoint(); - - return useMemo(() => { - const config = { ...DEFAULT_COLUMN_CONFIG, ...columnConfig }; - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (config[bp] !== undefined) { - return config[bp]; - } - } - - return 1; - }, [fineBreakpoint, columnConfig]); -} diff --git a/src/hooks/responsive/useMediaQuery.ts b/src/hooks/responsive/useMediaQuery.ts deleted file mode 100644 index 7f28a13..0000000 --- a/src/hooks/responsive/useMediaQuery.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 媒体查询模拟 Hook - */ - -import { useMemo } from 'react'; -import { useScreenSize } from './useScreenSize'; -import { useOrientation } from './useOrientation'; - -interface MediaQueryOptions { - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - orientation?: 'portrait' | 'landscape'; -} - -/** - * 模拟 CSS 媒体查询 - * - * @param query - 查询条件 - * @returns 是否匹配 - * - * @example - * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); - * const isPortrait = useMediaQuery({ orientation: 'portrait' }); - */ -export function useMediaQuery(query: MediaQueryOptions): boolean { - const { width, height } = useScreenSize(); - const { orientation: currentOrientation } = useOrientation(); - - return useMemo(() => { - if (query.minWidth !== undefined && width < query.minWidth) return false; - if (query.maxWidth !== undefined && width > query.maxWidth) return false; - if (query.minHeight !== undefined && height < query.minHeight) return false; - if (query.maxHeight !== undefined && height > query.maxHeight) return false; - if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; - return true; - }, [width, height, currentOrientation, query]); -} diff --git a/src/hooks/responsive/useOrientation.ts b/src/hooks/responsive/useOrientation.ts deleted file mode 100644 index 07738dd..0000000 --- a/src/hooks/responsive/useOrientation.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 屏幕方向检测 Hook - */ - -import { useMemo } from 'react'; -import { useWindowDimensions } from './useWindowDimensions'; - -export type Orientation = 'portrait' | 'landscape'; - -export interface OrientationInfo { - orientation: Orientation; - isPortrait: boolean; - isLandscape: boolean; -} - -/** - * 获取屏幕方向 - */ -function getOrientation(width: number, height: number): Orientation { - return width > height ? 'landscape' : 'portrait'; -} - -/** - * 屏幕方向检测 Hook - */ -export function useOrientation(): OrientationInfo { - const { width, height } = useWindowDimensions(); - - const orientation = useMemo(() => getOrientation(width, height), [width, height]); - - return useMemo(() => ({ - orientation, - isPortrait: orientation === 'portrait', - isLandscape: orientation === 'landscape', - }), [orientation]); -} diff --git a/src/hooks/responsive/usePlatform.ts b/src/hooks/responsive/usePlatform.ts deleted file mode 100644 index 269050a..0000000 --- a/src/hooks/responsive/usePlatform.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * 平台检测 Hook - */ - -import { useMemo } from 'react'; -import { Platform } from 'react-native'; - -export interface PlatformInfo { - OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; - isWeb: boolean; - isIOS: boolean; - isAndroid: boolean; - isNative: boolean; -} - -/** - * 平台检测 Hook - * 提供便捷的平台检测方法 - * - * @example - * const { isWeb, isIOS, isAndroid, isNative } = usePlatform(); - */ -export function usePlatform(): PlatformInfo { - return useMemo(() => ({ - OS: Platform.OS, - isWeb: Platform.OS === 'web', - isIOS: Platform.OS === 'ios', - isAndroid: Platform.OS === 'android', - isNative: Platform.OS !== 'web', - }), []); -} diff --git a/src/hooks/responsive/useResponsive.ts b/src/hooks/responsive/useResponsive.ts deleted file mode 100644 index c5f1078..0000000 --- a/src/hooks/responsive/useResponsive.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 响应式设计 Hook(重构版) - * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 - * - * 注意:此 Hook 现在作为各个专注 Hook 的组合,保持向后兼容 - * 新项目建议直接使用专注的 Hooks - */ - -import { useMemo } from 'react'; -import { useWindowDimensions } from './useWindowDimensions'; -import { useBreakpoint } from './useBreakpoint'; -import { useOrientation } from './useOrientation'; -import { usePlatform } from './usePlatform'; - -// ==================== 返回值类型 ==================== -export interface ResponsiveInfo { - // 基础尺寸 - width: number; - height: number; - - // 基础断点 - breakpoint: import('./constants').BreakpointKey; - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - isWide: boolean; - isWideScreen: boolean; - - // 细粒度断点 - fineBreakpoint: import('./constants').FineBreakpointKey; - isXS: boolean; - isSM: boolean; - isMD: boolean; - isLG: boolean; - isXL: boolean; - is2XL: boolean; - is3XL: boolean; - is4XL: boolean; - - // 方向 - orientation: 'portrait' | 'landscape'; - isPortrait: boolean; - isLandscape: boolean; - - // 平台检测 - platform: { - OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; - isWeb: boolean; - isIOS: boolean; - isAndroid: boolean; - isNative: boolean; - }; -} - -/** - * 响应式设计 Hook - * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 - * - * @returns ResponsiveInfo 响应式信息对象 - * - * @example - * const { - * width, height, - * breakpoint, isMobile, isTablet, isDesktop, isWide, - * fineBreakpoint, isXS, isSM, isMD, isLG, isXL, - * orientation, isPortrait, isLandscape, - * platform: { isWeb, isIOS, isAndroid } - * } = useResponsive(); - * - * @deprecated 建议使用专注的 Hooks:useBreakpoint, useOrientation, usePlatform, useScreenSize - */ -export function useResponsive(): ResponsiveInfo { - const { width, height } = useWindowDimensions(); - const breakpointInfo = useBreakpoint(); - const orientationInfo = useOrientation(); - const platformInfo = usePlatform(); - - return useMemo(() => ({ - width, - height, - breakpoint: breakpointInfo.breakpoint, - isMobile: breakpointInfo.isMobile, - isTablet: breakpointInfo.isTablet, - isDesktop: breakpointInfo.isDesktop, - isWide: breakpointInfo.isWide, - isWideScreen: breakpointInfo.isWideScreen, - fineBreakpoint: breakpointInfo.fineBreakpoint, - isXS: breakpointInfo.isXS, - isSM: breakpointInfo.isSM, - isMD: breakpointInfo.isMD, - isLG: breakpointInfo.isLG, - isXL: breakpointInfo.isXL, - is2XL: breakpointInfo.is2XL, - is3XL: breakpointInfo.is3XL, - is4XL: breakpointInfo.is4XL, - orientation: orientationInfo.orientation, - isPortrait: orientationInfo.isPortrait, - isLandscape: orientationInfo.isLandscape, - platform: platformInfo, - }), [width, height, breakpointInfo, orientationInfo, platformInfo]); -} - -export default useResponsive; diff --git a/src/hooks/responsive/useResponsiveSpacing.ts b/src/hooks/responsive/useResponsiveSpacing.ts deleted file mode 100644 index 6175d05..0000000 --- a/src/hooks/responsive/useResponsiveSpacing.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 响应式间距 Hook - */ - -import { useMemo } from 'react'; -import { useBreakpoint } from './useBreakpoint'; -import { FineBreakpointKey } from './constants'; - -const DEFAULT_SPACING_CONFIG: Record = { - xs: 8, - sm: 8, - md: 12, - lg: 16, - xl: 20, - '2xl': 24, - '3xl': 32, - '4xl': 40, -}; - -/** - * 响应式间距 Hook - * - * @param spacingConfig - 间距配置 - * @returns 当前断点对应的间距值 - * - * @example - * const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); - */ -export function useResponsiveSpacing( - spacingConfig: Partial> = {} -): number { - const { fineBreakpoint } = useBreakpoint(); - - return useMemo(() => { - const config = { ...DEFAULT_SPACING_CONFIG, ...spacingConfig }; - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (config[bp] !== undefined) { - return config[bp]; - } - } - - return DEFAULT_SPACING_CONFIG.xs; - }, [fineBreakpoint, spacingConfig]); -} diff --git a/src/hooks/responsive/useResponsiveStyle.ts b/src/hooks/responsive/useResponsiveStyle.ts deleted file mode 100644 index 6f03124..0000000 --- a/src/hooks/responsive/useResponsiveStyle.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 响应式样式生成器 Hook - */ - -import { useMemo } from 'react'; -import { useBreakpoint } from './useBreakpoint'; -import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; - -/** - * 根据断点生成响应式样式 - * - * @param styles - 响应式样式对象 - * @returns 当前断点对应的样式 - * - * @example - * const containerStyle = useResponsiveStyle({ - * padding: { xs: 8, md: 16, lg: 24 }, - * fontSize: { xs: 14, lg: 16 } - * }); - */ -export function useResponsiveStyle>>( - styles: T -): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { - const { fineBreakpoint } = useBreakpoint(); - - return useMemo(() => { - const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; - - for (const key in styles) { - const value = styles[key]; - - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - (result as Record)[key] = value; - } else { - const valueMap = value as Partial>; - const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); - - let selectedValue: unknown = undefined; - for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { - const bp = REVERSE_BREAKPOINT_ORDER[i]; - if (bp in valueMap) { - selectedValue = valueMap[bp]; - break; - } - } - - (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; - } - } - - return result; - }, [styles, fineBreakpoint]); -} diff --git a/src/hooks/responsive/useResponsiveValue.ts b/src/hooks/responsive/useResponsiveValue.ts deleted file mode 100644 index 76d47da..0000000 --- a/src/hooks/responsive/useResponsiveValue.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 响应式值选择器 Hook - */ - -import { useMemo } from 'react'; -import { useBreakpoint } from './useBreakpoint'; -import { ResponsiveValue, FineBreakpointKey, REVERSE_BREAKPOINT_ORDER } from './constants'; - -/** - * 根据当前断点从响应式值对象中选择合适的值 - * - * @param value - 响应式值,可以是单一值或断点映射对象 - * @returns 选中的值 - * - * @example - * const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); - * // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24 - */ -export function useResponsiveValue(value: ResponsiveValue): T { - const { fineBreakpoint } = useBreakpoint(); - - return useMemo(() => { - // 如果不是对象,直接返回 - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return value as T; - } - - const valueMap = value as Partial>; - - // 从当前断点开始向下查找 - const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint); - for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) { - const bp = REVERSE_BREAKPOINT_ORDER[i]; - if (bp in valueMap) { - return valueMap[bp]!; - } - } - - // 如果没找到,返回 xs 的值或第一个值 - return (valueMap.xs ?? Object.values(valueMap)[0]) as T; - }, [value, fineBreakpoint]); -} diff --git a/src/hooks/responsive/useScreenSize.ts b/src/hooks/responsive/useScreenSize.ts deleted file mode 100644 index 0dc983c..0000000 --- a/src/hooks/responsive/useScreenSize.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 屏幕尺寸 Hook - */ - -import { useMemo } from 'react'; -import { useWindowDimensions } from './useWindowDimensions'; -import { useBreakpoint } from './useBreakpoint'; - -export interface ScreenSizeInfo { - width: number; - height: number; - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - isWide: boolean; -} - -/** - * 屏幕尺寸 Hook - * 返回屏幕尺寸和断点信息 - */ -export function useScreenSize(): ScreenSizeInfo { - const { width, height } = useWindowDimensions(); - const { isMobile, isTablet, isDesktop, isWide } = useBreakpoint(); - - return useMemo(() => ({ - width, - height, - isMobile, - isTablet, - isDesktop, - isWide, - }), [width, height, isMobile, isTablet, isDesktop, isWide]); -} diff --git a/src/hooks/responsive/useWindowDimensions.ts b/src/hooks/responsive/useWindowDimensions.ts deleted file mode 100644 index 00f36c6..0000000 --- a/src/hooks/responsive/useWindowDimensions.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 窗口尺寸 Hook - * 提供屏幕尺寸监听 - */ - -import { useState, useEffect } from 'react'; -import { Dimensions, ScaledSize } from 'react-native'; - -/** - * 获取窗口尺寸,支持屏幕旋转响应 - * 替代 Dimensions.get('window'),提供实时尺寸更新 - */ -export function useWindowDimensions(): ScaledSize { - const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ window }) => { - setDimensions(window); - }); - - return () => { - subscription.remove(); - }; - }, []); - - return dimensions; -} diff --git a/src/hooks/useResponsive.ts b/src/hooks/useResponsive.ts deleted file mode 100644 index b2aa6a9..0000000 --- a/src/hooks/useResponsive.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * 响应式设计 Hook - * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 - */ - -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Dimensions, ScaledSize, Platform } from 'react-native'; - -// ==================== 断点定义 ==================== -export const BREAKPOINTS = { - mobile: 0, // 手机 - tablet: 768, // 平板竖屏 - desktop: 1024, // 平板横屏/桌面 - wide: 1440, // 宽屏桌面 -} as const; - -export type BreakpointKey = keyof typeof BREAKPOINTS; -export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey]; - -// ==================== 更细粒度的断点定义 ==================== -export const FINE_BREAKPOINTS = { - xs: 0, // 超小屏手机 - sm: 375, // 小屏手机 (iPhone SE, 小屏安卓) - md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓) - lg: 768, // 平板竖屏 / 大折叠屏手机展开 - xl: 1024, // 平板横屏 / 小桌面 - '2xl': 1280, // 桌面 - '3xl': 1440, // 大桌面 - '4xl': 1920, // 超大屏 -} as const; - -export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS; - -// ==================== 响应式值类型 ==================== -export type ResponsiveValue = T | Partial>; - -// ==================== 返回值类型 ==================== -export interface ResponsiveInfo { - // 基础尺寸 - width: number; - height: number; - - // 基础断点 - breakpoint: BreakpointKey; - isMobile: boolean; - isTablet: boolean; - isDesktop: boolean; - isWide: boolean; - isWideScreen: boolean; - - // 细粒度断点 - fineBreakpoint: FineBreakpointKey; - isXS: boolean; - isSM: boolean; - isMD: boolean; - isLG: boolean; - isXL: boolean; - is2XL: boolean; - is3XL: boolean; - is4XL: boolean; - - // 方向 - orientation: 'portrait' | 'landscape'; - isPortrait: boolean; - isLandscape: boolean; - - // 平台检测 - platform: { - OS: 'ios' | 'android' | 'windows' | 'macos' | 'web'; - isWeb: boolean; - isIOS: boolean; - isAndroid: boolean; - isNative: boolean; - }; -} - -// ==================== 获取当前断点 ==================== -function getBreakpoint(width: number): BreakpointKey { - if (width >= BREAKPOINTS.wide) { - return 'wide'; - } - if (width >= BREAKPOINTS.desktop) { - return 'desktop'; - } - if (width >= BREAKPOINTS.tablet) { - return 'tablet'; - } - return 'mobile'; -} - -// ==================== 获取细粒度断点 ==================== -function getFineBreakpoint(width: number): FineBreakpointKey { - if (width >= FINE_BREAKPOINTS['4xl']) return '4xl'; - if (width >= FINE_BREAKPOINTS['3xl']) return '3xl'; - if (width >= FINE_BREAKPOINTS['2xl']) return '2xl'; - if (width >= FINE_BREAKPOINTS.xl) return 'xl'; - if (width >= FINE_BREAKPOINTS.lg) return 'lg'; - if (width >= FINE_BREAKPOINTS.md) return 'md'; - if (width >= FINE_BREAKPOINTS.sm) return 'sm'; - return 'xs'; -} - -// ==================== 获取屏幕方向 ==================== -function getOrientation(width: number, height: number): 'portrait' | 'landscape' { - return width > height ? 'landscape' : 'portrait'; -} - -// ==================== useWindowDimensions Hook ==================== -/** - * 获取窗口尺寸,支持屏幕旋转响应 - * 替代 Dimensions.get('window'),提供实时尺寸更新 - */ -function useWindowDimensions(): ScaledSize { - const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ window }) => { - setDimensions(window); - }); - - return () => { - subscription.remove(); - }; - }, []); - - return dimensions; -} - -// ==================== useResponsive Hook ==================== -/** - * 响应式设计 Hook - * 提供屏幕尺寸、断点、方向、平台检测等响应式信息 - * - * @returns ResponsiveInfo 响应式信息对象 - * - * @example - * const { - * width, height, - * breakpoint, isMobile, isTablet, isDesktop, isWide, - * fineBreakpoint, isXS, isSM, isMD, isLG, isXL, - * orientation, isPortrait, isLandscape, - * platform: { isWeb, isIOS, isAndroid } - * } = useResponsive(); - */ -export function useResponsive(): ResponsiveInfo { - const windowDimensions = useWindowDimensions(); - const { width, height } = windowDimensions; - - const breakpoint = getBreakpoint(width); - const fineBreakpoint = getFineBreakpoint(width); - const orientation = getOrientation(width, height); - - const isMobile = breakpoint === 'mobile'; - const isTablet = breakpoint === 'tablet'; - const isDesktop = breakpoint === 'desktop'; - const isWide = breakpoint === 'wide'; - const isWideScreen = isTablet || isDesktop || isWide; - - const isXS = fineBreakpoint === 'xs'; - const isSM = fineBreakpoint === 'sm'; - const isMD = fineBreakpoint === 'md'; - const isLG = fineBreakpoint === 'lg'; - const isXL = fineBreakpoint === 'xl'; - const is2XL = fineBreakpoint === '2xl'; - const is3XL = fineBreakpoint === '3xl'; - const is4XL = fineBreakpoint === '4xl'; - - const isPortrait = orientation === 'portrait'; - const isLandscape = orientation === 'landscape'; - - const platform = useMemo(() => ({ - OS: Platform.OS, - isWeb: Platform.OS === 'web', - isIOS: Platform.OS === 'ios', - isAndroid: Platform.OS === 'android', - isNative: Platform.OS !== 'web', - }), []); - - return { - width, - height, - breakpoint, - isMobile, - isTablet, - isDesktop, - isWide, - isWideScreen, - fineBreakpoint, - isXS, - isSM, - isMD, - isLG, - isXL, - is2XL, - is3XL, - is4XL, - orientation, - isPortrait, - isLandscape, - platform, - }; -} - -// ==================== 响应式值选择器 ==================== -/** - * 根据当前断点从响应式值对象中选择合适的值 - * - * @param value - 响应式值,可以是单一值或断点映射对象 - * @param currentBreakpoint - 当前细粒度断点 - * @returns 选中的值 - * - * @example - * const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); - * // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24 - */ -export function useResponsiveValue(value: ResponsiveValue): T { - const { fineBreakpoint } = useResponsive(); - - return useMemo(() => { - // 如果不是对象,直接返回 - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return value as T; - } - - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const valueMap = value as Partial>; - - // 从当前断点开始向下查找 - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (bp in valueMap) { - return valueMap[bp]!; - } - } - - // 如果没找到,返回 xs 的值或第一个值 - return (valueMap.xs ?? Object.values(valueMap)[0]) as T; - }, [value, fineBreakpoint]); -} - -// ==================== 响应式样式生成器 ==================== -/** - * 根据断点生成响应式样式 - * - * @param styles - 响应式样式对象 - * @returns 当前断点对应的样式 - * - * @example - * const containerStyle = useResponsiveStyle({ - * padding: { xs: 8, md: 16, lg: 24 }, - * fontSize: { xs: 14, lg: 16 } - * }); - */ -export function useResponsiveStyle>>( - styles: T -): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { - const { fineBreakpoint } = useResponsive(); - - return useMemo(() => { - const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; - - for (const key in styles) { - const value = styles[key]; - - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - (result as Record)[key] = value; - } else { - const valueMap = value as Partial>; - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - let selectedValue: unknown = undefined; - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (bp in valueMap) { - selectedValue = valueMap[bp]; - break; - } - } - - (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; - } - } - - return result; - }, [styles, fineBreakpoint]); -} - -// ==================== 断点比较工具 ==================== -/** - * 检查当前断点是否大于等于目标断点 - * - * @param current - 当前断点 - * @param target - 目标断点 - * @returns 是否满足条件 - */ -export function isBreakpointGTE( - current: FineBreakpointKey, - target: FineBreakpointKey -): boolean { - const order: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl']; - return order.indexOf(current) >= order.indexOf(target); -} - -/** - * 检查当前断点是否小于目标断点 - * - * @param current - 当前断点 - * @param target - 目标断点 - * @returns 是否满足条件 - */ -export function isBreakpointLT( - current: FineBreakpointKey, - target: FineBreakpointKey -): boolean { - return !isBreakpointGTE(current, target); -} - -// ==================== 断点范围检查 Hook ==================== -/** - * 检查当前是否在指定断点范围内 - * - * @param options - 断点范围选项 - * @returns 是否在范围内 - * - * @example - * const isMediumUp = useBreakpointGTE('md'); - * const isMobileOnly = useBreakpointLT('lg'); - */ -export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { fineBreakpoint } = useResponsive(); - return isBreakpointGTE(fineBreakpoint, target); -} - -export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { fineBreakpoint } = useResponsive(); - return isBreakpointLT(fineBreakpoint, target); -} - -export function useBreakpointBetween( - min: FineBreakpointKey, - max: FineBreakpointKey -): boolean { - const { fineBreakpoint } = useResponsive(); - return isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max); -} - -// ==================== 平台检测 Hook ==================== -/** - * 平台检测 Hook - * 提供便捷的平台检测方法 - * - * @example - * const { isWeb, isIOS, isAndroid, isNative } = usePlatform(); - */ -export function usePlatform() { - const { platform } = useResponsive(); - return platform; -} - -// ==================== 媒体查询模拟 ==================== -/** - * 模拟 CSS 媒体查询 - * - * @param query - 查询条件 - * @returns 是否匹配 - * - * @example - * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); - * const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 }); - * const isPortrait = useMediaQuery({ orientation: 'portrait' }); - */ -interface MediaQueryOptions { - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - orientation?: 'portrait' | 'landscape'; -} - -export function useMediaQuery(query: MediaQueryOptions): boolean { - const { width, height, orientation: currentOrientation } = useResponsive(); - - return useMemo(() => { - if (query.minWidth !== undefined && width < query.minWidth) return false; - if (query.maxWidth !== undefined && width > query.maxWidth) return false; - if (query.minHeight !== undefined && height < query.minHeight) return false; - if (query.maxHeight !== undefined && height > query.maxHeight) return false; - if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; - return true; - }, [width, height, currentOrientation, query]); -} - -// ==================== 列数计算工具 ==================== -/** - * 根据容器宽度计算合适的列数 - * - * @param containerWidth - 容器宽度 - * @param options - 配置选项 - * @returns 列数 - * - * @example - * const columns = useColumnCount({ - * xs: 1, - * sm: 2, - * md: 3, - * lg: 4 - * }); - */ -export function useColumnCount( - columnConfig: Partial> = {} -): number { - const { fineBreakpoint } = useResponsive(); - - const defaultConfig: Record = { - xs: 1, - sm: 1, - md: 2, - lg: 3, - xl: 4, - '2xl': 4, - '3xl': 5, - '4xl': 6, - }; - - return useMemo(() => { - const config = { ...defaultConfig, ...columnConfig }; - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (config[bp] !== undefined) { - return config[bp]; - } - } - - return 1; - }, [fineBreakpoint, columnConfig]); -} - -// ==================== 间距计算工具 ==================== -/** - * 响应式间距 Hook - * - * @param spacingConfig - 间距配置 - * @returns 当前断点对应的间距值 - * - * @example - * const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); - */ -export function useResponsiveSpacing( - spacingConfig: Partial> = {} -): number { - const { fineBreakpoint } = useResponsive(); - - const defaultConfig: Record = { - xs: 8, - sm: 8, - md: 12, - lg: 16, - xl: 20, - '2xl': 24, - '3xl': 32, - '4xl': 40, - }; - - return useMemo(() => { - const config = { ...defaultConfig, ...spacingConfig }; - const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs']; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (config[bp] !== undefined) { - return config[bp]; - } - } - - return defaultConfig.xs; - }, [fineBreakpoint, spacingConfig]); -} - -export default useResponsive; diff --git a/src/presentation/hooks/responsive/index.ts b/src/presentation/hooks/responsive/index.ts index 00aded3..4826b6f 100644 --- a/src/presentation/hooks/responsive/index.ts +++ b/src/presentation/hooks/responsive/index.ts @@ -1,32 +1,27 @@ /** * 响应式 Hooks 统一导出 * Responsive Hooks Index - * + * * 提供完整的响应式设计解决方案,包括: * - 断点检测 (useBreakpoint) * - 屏幕尺寸 (useScreenSize, useWindowDimensions) - * - 响应式值 (useResponsiveValue, useResponsiveStyle) + * - 响应式值 (useResponsiveValue) * - 方向检测 (useOrientation) * - 平台检测 (usePlatform) - * - 媒体查询 (useMediaQuery) * - 列数计算 (useColumnCount) * - 间距计算 (useResponsiveSpacing) - * - 断点检查 (useBreakpointGTE, useBreakpointLT, useBreakpointBetween) + * - 断点检查 (useBreakpointGTE) */ // ==================== 核心 Hooks ==================== export { useWindowDimensions, useScreenSize } from './useScreenSize'; -export { - useBreakpoint, - useFineBreakpoint, - useBreakpointGTE, - useBreakpointLT, - useBreakpointBetween, +export { + useBreakpoint, + useBreakpointGTE, } from './useBreakpoint'; -export { useResponsiveValue, useResponsiveStyle } from './useResponsiveValue'; +export { useResponsiveValue } from './useResponsiveValue'; export { useOrientation } from './useOrientation'; export { usePlatform } from './usePlatform'; -export { useMediaQuery } from './useMediaQuery'; export { useColumnCount } from './useColumnCount'; export { useResponsiveSpacing } from './useResponsiveSpacing'; @@ -59,8 +54,7 @@ export { // 兼容层类型 export type { ResponsiveInfo } from './useResponsive'; -// ==================== 向后兼容 ==================== -export { useResponsive, useLegacyResponsive } from './useResponsive'; +export { useResponsive } from './useResponsive'; // 默认导出 export { useScreenSize as default } from './useScreenSize'; diff --git a/src/presentation/hooks/responsive/useBreakpoint.ts b/src/presentation/hooks/responsive/useBreakpoint.ts index 6f35b46..59f7a07 100644 --- a/src/presentation/hooks/responsive/useBreakpoint.ts +++ b/src/presentation/hooks/responsive/useBreakpoint.ts @@ -3,8 +3,8 @@ * 断点检测 - 检测当前断点 */ -import { useMemo, useState, useEffect } from 'react'; -import { Dimensions, ScaledSize } from 'react-native'; +import { useMemo } from 'react'; +import { useWindowDimensions } from './useScreenSize'; import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; import type { BreakpointKey, FineBreakpointKey } from './types'; @@ -74,120 +74,33 @@ export function isBreakpointBetween( // ==================== Hooks ==================== -function useWindowDimensionsLocal(): ScaledSize { - const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); - - useEffect(() => { - const subscription = Dimensions.addEventListener('change', ({ window }) => { - setDimensions(window); - }); - - return () => { - subscription.remove(); - }; - }, []); - - return dimensions; -} - -/** - * 断点检测 Hook - * 返回当前的基础断点 - * - * @returns 当前断点 - * - * @example - * const breakpoint = useBreakpoint(); - * // 'mobile' | 'tablet' | 'desktop' | 'wide' - */ export function useBreakpoint(): BreakpointKey { - const { width } = useWindowDimensionsLocal(); + const { width } = useWindowDimensions(); return useMemo(() => { return getBreakpoint(width); }, [width]); } -/** - * 细粒度断点检测 Hook - * 返回当前的细粒度断点 - * - * @returns 当前细粒度断点 - * - * @example - * const fineBreakpoint = useFineBreakpoint(); - * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' - */ -export function useFineBreakpoint(): FineBreakpointKey { - const { width } = useWindowDimensionsLocal(); - - return useMemo(() => { - return getFineBreakpoint(width); - }, [width]); -} - /** * 检查当前断点是否大于等于目标断点 - * + * * @param target - 目标断点 * @returns 是否满足条件 - * + * * @example * const isMediumUp = useBreakpointGTE('md'); */ export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensionsLocal(); - + const { width } = useWindowDimensions(); + return useMemo(() => { const current = getFineBreakpoint(width); return isBreakpointGTE(current, target); }, [width, target]); } -/** - * 检查当前断点是否小于目标断点 - * - * @param target - 目标断点 - * @returns 是否满足条件 - * - * @example - * const isMobileOnly = useBreakpointLT('lg'); - */ -export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensionsLocal(); - - return useMemo(() => { - const current = getFineBreakpoint(width); - return isBreakpointLT(current, target); - }, [width, target]); -} - -/** - * 检查当前是否在指定断点范围内 - * - * @param min - 最小断点(包含) - * @param max - 最大断点(不包含) - * @returns 是否在范围内 - * - * @example - * const isTabletRange = useBreakpointBetween('md', 'lg'); - */ -export function useBreakpointBetween( - min: FineBreakpointKey, - max: FineBreakpointKey -): boolean { - const { width } = useWindowDimensionsLocal(); - - return useMemo(() => { - const current = getFineBreakpoint(width); - return isBreakpointBetween(current, min, max); - }, [width, min, max]); -} - export default { useBreakpoint, - useFineBreakpoint, useBreakpointGTE, - useBreakpointLT, - useBreakpointBetween, }; diff --git a/src/presentation/hooks/responsive/useMediaQuery.ts b/src/presentation/hooks/responsive/useMediaQuery.ts deleted file mode 100644 index 141bb4a..0000000 --- a/src/presentation/hooks/responsive/useMediaQuery.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * useMediaQuery Hook - * 媒体查询模拟 - 模拟 CSS 媒体查询 - */ - -import { useMemo } from 'react'; -import { useWindowDimensions } from './useScreenSize'; -import { getOrientation } from './useOrientation'; -import type { MediaQueryOptions } from './types'; - -/** - * 模拟 CSS 媒体查询 - * - * @param query - 查询条件 - * @returns 是否匹配 - * - * @example - * const isMinWidth768 = useMediaQuery({ minWidth: 768 }); - * const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 }); - * const isPortrait = useMediaQuery({ orientation: 'portrait' }); - */ -export function useMediaQuery(query: MediaQueryOptions): boolean { - const { width, height } = useWindowDimensions(); - const currentOrientation = getOrientation(width, height); - - return useMemo(() => { - if (query.minWidth !== undefined && width < query.minWidth) return false; - if (query.maxWidth !== undefined && width > query.maxWidth) return false; - if (query.minHeight !== undefined && height < query.minHeight) return false; - if (query.maxHeight !== undefined && height > query.maxHeight) return false; - if (query.orientation !== undefined && currentOrientation !== query.orientation) return false; - return true; - }, [width, height, currentOrientation, query]); -} - -export default useMediaQuery; diff --git a/src/presentation/hooks/responsive/useResponsive.ts b/src/presentation/hooks/responsive/useResponsive.ts index 168805b..6be50ff 100644 --- a/src/presentation/hooks/responsive/useResponsive.ts +++ b/src/presentation/hooks/responsive/useResponsive.ts @@ -104,9 +104,4 @@ export function useResponsive(): ResponsiveInfo { }, [width, height, platform]); } -/** - * 旧的 useResponsive 别名,保持完全向后兼容 - */ -export const useLegacyResponsive = useResponsive; - export default useResponsive; diff --git a/src/presentation/hooks/responsive/useResponsiveValue.ts b/src/presentation/hooks/responsive/useResponsiveValue.ts index 8d24dd9..e3d88f4 100644 --- a/src/presentation/hooks/responsive/useResponsiveValue.ts +++ b/src/presentation/hooks/responsive/useResponsiveValue.ts @@ -45,51 +45,3 @@ export function useResponsiveValue(value: ResponsiveValue): T { return (valueMap.xs ?? Object.values(valueMap)[0]) as T; }, [value, fineBreakpoint]); } - -/** - * 响应式样式生成器 - * 根据断点生成响应式样式 - * - * @param styles - 响应式样式对象 - * @returns 当前断点对应的样式 - * - * @example - * const containerStyle = useResponsiveStyle({ - * padding: { xs: 8, md: 16, lg: 24 }, - * fontSize: { xs: 14, lg: 16 } - * }); - */ -export function useResponsiveStyle>>( - styles: T -): { [K in keyof T]: T[K] extends ResponsiveValue ? V : never } { - const { width } = useWindowDimensions(); - const fineBreakpoint = getFineBreakpoint(width); - - return useMemo(() => { - const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue ? V : never }; - - for (const key in styles) { - const value = styles[key]; - - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - (result as Record)[key] = value; - } else { - const valueMap = value as Partial>; - const currentIndex = breakpointOrder.indexOf(fineBreakpoint); - - let selectedValue: unknown = undefined; - for (let i = currentIndex; i < breakpointOrder.length; i++) { - const bp = breakpointOrder[i]; - if (bp in valueMap) { - selectedValue = valueMap[bp]; - break; - } - } - - (result as Record)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0]; - } - } - - return result; - }, [styles, fineBreakpoint]); -} diff --git a/src/screens/apps/AppsScreen.tsx b/src/screens/apps/AppsScreen.tsx index efd6fd6..b48a3d5 100644 --- a/src/screens/apps/AppsScreen.tsx +++ b/src/screens/apps/AppsScreen.tsx @@ -19,7 +19,7 @@ import { } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { Text } from '../../components/common'; -import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks'; type AppItem = { id: string; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index cc1a3c2..96dfe86 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -32,7 +32,7 @@ import { channelService, postService } from '../../services'; import { PostCard, SearchBar, ShareSheet } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common'; -import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { postSyncService } from '@/services/post'; import { SearchScreen } from './SearchScreen'; diff --git a/src/screens/home/MarketView.tsx b/src/screens/home/MarketView.tsx index d0608e6..db476a7 100644 --- a/src/screens/home/MarketView.tsx +++ b/src/screens/home/MarketView.tsx @@ -13,7 +13,7 @@ import type { TradeType, } from '../../types/trade'; import { useRouter } from 'expo-router'; -import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; type TradeFilterType = 'all' | 'sell' | 'buy'; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 9b21c17..6392664 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -43,8 +43,9 @@ import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; -import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; import { handleError } from '@/services/core'; +import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime'; import * as hrefs from '../../navigation/hrefs'; const EMOJIS = [ @@ -458,37 +459,9 @@ export const PostDetailScreen: React.FC = () => { }); }, [comments.length, loadMoreComments, postId]); - const formatDateTime = (dateString?: string | null): string => { - if (!dateString) return ''; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const pad = (num: number) => String(num).padStart(2, '0'); - return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; - }; + const formatDateTime = (dateString?: string | null): string => formatDateTimeUtil(dateString); - const formatRelativeTime = (dateString?: string | null): string => { - if (!dateString) return ''; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - - const now = Date.now(); - const diffMs = now - date.getTime(); - if (diffMs < 0) return formatDateTime(dateString); - - const minuteMs = 60 * 1000; - const hourMs = 60 * minuteMs; - const dayMs = 24 * hourMs; - - if (diffMs < minuteMs) return '刚刚'; - if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}分钟前`; - if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}小时前`; - if (diffMs < 2 * dayMs) { - const pad = (num: number) => String(num).padStart(2, '0'); - return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`; - } - - return formatDateTime(dateString); - }; + const formatRelativeTime = (dateString?: string | null): string => formatRelativeTimeUtil(dateString); const getPostEditedDisplayAt = ( createdAt?: string | null, diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 722a3a7..a8c06e5 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -25,7 +25,7 @@ import { PostCard, TabBar, SearchBar } from '../../components/business'; import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; import * as hrefs from '../../navigation/hrefs'; -import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks'; import { useCursorPagination } from '../../hooks/useCursorPagination'; const TABS = ['帖子', '用户']; diff --git a/src/screens/material/MaterialDetailScreen.tsx b/src/screens/material/MaterialDetailScreen.tsx index 4f57785..2658c13 100644 --- a/src/screens/material/MaterialDetailScreen.tsx +++ b/src/screens/material/MaterialDetailScreen.tsx @@ -26,8 +26,9 @@ import { type AppColors, } from '../../theme'; import { Text, ResponsiveContainer, Loading } from '../../components/common'; -import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks'; import type { MaterialFile } from '../../types/material'; +import { formatDate as formatDateUtil } from '../../utils/formatTime'; import { materialRepository } from '../../data/repositories/MaterialRepository'; import { MATERIAL_FILE_TYPE_ICONS, @@ -106,14 +107,7 @@ export const MaterialDetailScreen: React.FC = () => { ); }, [material]); - const formatDate = useCallback((dateStr: string) => { - const date = new Date(dateStr); - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }, []); + const formatDate = useCallback((dateStr: string) => formatDateUtil(dateStr), []); // 扁平化卡片样式:更柔和的阴影,更大的圆角 const cardStyle = useMemo( diff --git a/src/screens/material/MaterialsScreen.tsx b/src/screens/material/MaterialsScreen.tsx index 13ad2a3..2c64bc8 100644 --- a/src/screens/material/MaterialsScreen.tsx +++ b/src/screens/material/MaterialsScreen.tsx @@ -26,7 +26,7 @@ import { } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { Text, Loading } from '../../components/common'; -import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks'; // 学科卡片最大宽度设置 const SUBJECT_CARD_MAX_WIDTH = 720; diff --git a/src/screens/material/SubjectMaterialsScreen.tsx b/src/screens/material/SubjectMaterialsScreen.tsx index 131a214..25e6088 100644 --- a/src/screens/material/SubjectMaterialsScreen.tsx +++ b/src/screens/material/SubjectMaterialsScreen.tsx @@ -27,7 +27,7 @@ import { } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { Text, Loading } from '../../components/common'; -import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks'; // 资料卡片最大宽度 const MATERIAL_CARD_MAX_WIDTH = 720; diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 105335e..bb88c08 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -37,7 +37,7 @@ import { Text, ImageGallery, ImageGridItem } from '../../components/common'; import { useAppColors, useResolvedColorScheme } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { messageManager, callStore } from '../../stores'; -import { useBreakpointGTE } from '../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../hooks'; import { useChatScreen, useChatScreenStyles, diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 569945d..3133d97 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -38,7 +38,7 @@ import { groupService } from '@/services/message'; import { uploadService } from '@/services/upload'; import { messageService } from '@/services/message'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; -import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useBreakpointGTE } from '../../hooks'; import { GroupResponse, GroupMemberResponse, diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 57261e3..956aff4 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -34,7 +34,7 @@ import { } from '../../stores/group'; import { enrichGroupMembersWithUserProfiles } from '../../stores/group'; import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer, AppBackButton } from '../../components/common'; -import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useBreakpointGTE } from '../../hooks'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { GroupMemberResponse, diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 344c0ab..0e8fbed 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -38,6 +38,7 @@ import { type AppColors, } from '../../theme'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; +import { formatTime as formatTimeUtil } from '../../utils/formatTime'; import { authService } from '../../services'; import { blurActiveElement } from '../../infrastructure/platform'; import { useUserStore, useAuthStore } from '../../stores'; @@ -52,7 +53,7 @@ import { useMessageStore, } from '../../stores'; import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; -import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useResponsive, useBreakpointGTE } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; // 导入 ChatScreen 组件用于桌面端双栏布局 import { ChatScreen } from './ChatScreen'; @@ -245,32 +246,7 @@ export const MessageListScreen: React.FC = () => { // 格式化时间(稳定引用,配合会话行 memo) const formatTime = useCallback((dateString: string): string => { - if (!dateString) return ''; - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const now = new Date(); - const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); - - if (diffInHours < 24 && date.getDate() === now.getDate()) { - return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (date.getDate() === yesterday.getDate()) { - return '昨天'; - } - - if (diffInHours < 24 * 7) { - const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; - return days[date.getDay()]; - } - - return `${date.getMonth() + 1}/${date.getDate()}`; - } catch { - return ''; - } + return formatTimeUtil(dateString); }, []); // 跳转到聊天页 diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 6c155e0..71a4de7 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../../components/common'; import { useAppColors, spacing } from '../../../../theme'; import { useChatScreenStyles, useChatDynamicStyles } from './styles'; -import { useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks'; import { ChatHeaderProps } from './types'; export const ChatHeader: React.FC = ({ diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 55ce9ea..38be5b6 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Text } from '../../../../components/common'; import { useAppColors } from '../../../../theme'; import { useChatScreenStyles, useChatDynamicStyles } from './styles'; -import { useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks'; import { ChatInputProps, GroupMessage, SenderInfo } from './types'; import { extractTextFromSegments } from '../../../../types/dto'; diff --git a/src/screens/message/components/ChatScreen/EmojiPanel.tsx b/src/screens/message/components/ChatScreen/EmojiPanel.tsx index ef9d899..65a846d 100644 --- a/src/screens/message/components/ChatScreen/EmojiPanel.tsx +++ b/src/screens/message/components/ChatScreen/EmojiPanel.tsx @@ -12,7 +12,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { Text } from '../../../../components/common'; import { useChatScreenStyles } from './styles'; -import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useResponsive, useBreakpointGTE } from '../../../../hooks'; import { EMOJIS } from './constants'; import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '@/services/message'; import { useAppColors, spacing } from '../../../../theme'; diff --git a/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx index 8c6f42c..33478e3 100644 --- a/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx +++ b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx @@ -19,7 +19,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../../components/common'; import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme'; import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto'; -import { useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks'; import { groupManager } from '../../../../stores/group'; import { groupService } from '@/services/message'; diff --git a/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx b/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx index ee30172..306eac1 100644 --- a/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx +++ b/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx @@ -19,7 +19,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../../components/common'; import { useAppColors, spacing, fontSizes, shadows, borderRadius, type AppColors } from '../../../../theme'; import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto'; -import { useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useBreakpointGTE } from '../../../../hooks'; import { groupManager } from '../../../../stores/group'; import { groupService } from '@/services/message'; diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index bc0ae45..3148405 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -15,7 +15,7 @@ import { import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text, ImageGridItem } from '../../../../components/common'; import { useChatScreenStyles } from './styles'; -import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { useResponsive, useBreakpointGTE } from '../../../../hooks'; import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types'; import { MessageSegmentsRenderer } from './SegmentRenderer'; import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto'; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 5f683bb..5ebbc81 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -18,8 +18,7 @@ import { Alert, } from 'react-native'; import { useLocalSearchParams, router } from 'expo-router'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; +import { formatChatTime } from '@/utils/formatTime'; import * as ImagePicker from 'expo-image-picker'; import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto'; import { messageService } from '@/services/message'; @@ -707,24 +706,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 格式化时间 const formatTime = useCallback((dateString: string): string => { - if (!dateString) return ''; - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const now = new Date(); - const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); - - if (diffInHours < 24 && date.getDate() === now.getDate()) { - return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); - } - - return formatDistanceToNow(date, { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } + return formatChatTime(dateString); }, []); // 检查是否需要显示时间分隔 diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index bf8ae53..34efa4a 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -33,7 +33,7 @@ import { useAppColors, type AppColors, } from '../../theme'; -import { useResponsive } from '../../hooks/useResponsive'; +import { useResponsive } from '../../hooks'; import { Course, COURSE_COLORS, diff --git a/src/screens/trade/TradeDetailScreen.tsx b/src/screens/trade/TradeDetailScreen.tsx index 77fd5b4..9efdd0b 100644 --- a/src/screens/trade/TradeDetailScreen.tsx +++ b/src/screens/trade/TradeDetailScreen.tsx @@ -11,7 +11,7 @@ import { useIsAuthenticated, useCurrentUser } from '../../stores/auth'; import type { TradeItemDetailDTO } from '../../types/trade'; import { TRADE_CONDITION_MAP } from '../../types/trade'; import { hrefChat, hrefEditTrade } from '../../navigation/hrefs'; -import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; function createStyles(colors: AppColors) { return StyleSheet.create({ diff --git a/src/services/auth/verificationService.ts b/src/services/auth/verificationService.ts index b1d2f23..88ff593 100644 --- a/src/services/auth/verificationService.ts +++ b/src/services/auth/verificationService.ts @@ -3,12 +3,11 @@ * 处理用户身份认证申请、状态查询等功能 */ -import { api } from '../core/api'; +import { api, PaginatedData } from '../core/api'; import { VerificationStatusDTO, VerificationRecordDTO, SubmitVerificationRequest, - PaginatedData, } from '@/types/dto'; // 提交认证响应 diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 5d66cd8..da83732 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -10,6 +10,7 @@ import Constants from 'expo-constants'; import { Platform } from 'react-native'; import { eventBus } from '@/core/events/EventBus'; +import { showVerificationModal } from './verification'; // 生产地址 https://withyou.littlelan.cn const getBaseUrl = () => { @@ -73,8 +74,7 @@ interface JwtPayload { // API 客户端类 class ApiClient { private baseUrl: string; - private verificationModalShown = false; - private refreshLock: Promise | null = null; + private refreshLock: Promise | null = null; private isAuthFailed = false; // 全局认证失败标志,防止无限重试 constructor(baseUrl: string) { @@ -82,12 +82,7 @@ class ApiClient { } private handleVerificationRequired() { - if (this.verificationModalShown) return; - this.verificationModalShown = true; - eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' }); - setTimeout(() => { - this.verificationModalShown = false; - }, 3000); + showVerificationModal(); } private navigateToLogin() { diff --git a/src/services/core/datasources/ApiDataSource.ts b/src/services/core/datasources/ApiDataSource.ts new file mode 100644 index 0000000..d094541 --- /dev/null +++ b/src/services/core/datasources/ApiDataSource.ts @@ -0,0 +1,98 @@ +/** + * API 数据源实现 + * 封装所有 API 调用,统一错误处理和请求拦截 + */ + +import { api, ApiResponse, ApiError } from '@/services/core'; +import { IApiDataSource, DataSourceError } from './interfaces'; + +export class ApiDataSource implements IApiDataSource { + private baseUrl: string; + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || ''; + } + + private buildUrl(url: string): string { + if (url.startsWith('http')) { + return url; + } + return `${this.baseUrl}${url}`; + } + + private handleError(error: unknown, operation: string): never { + if (error instanceof ApiError) { + throw new DataSourceError( + error.message, + String(error.code), + 'ApiDataSource', + error + ); + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new DataSourceError( + `API ${operation} failed: ${message}`, + 'API_ERROR', + 'ApiDataSource', + error instanceof Error ? error : undefined + ); + } + + async get(url: string, params?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.get(fullUrl, params); + return response.data; + } catch (error) { + this.handleError(error, 'GET'); + } + } + + async post(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.post(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'POST'); + } + } + + async put(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.put(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'PUT'); + } + } + + async delete(url: string, data?: any): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.delete(fullUrl, data); + return response.data; + } catch (error) { + this.handleError(error, 'DELETE'); + } + } + + async upload( + url: string, + file: { uri: string; name: string; type: string }, + additionalData?: Record + ): Promise { + try { + const fullUrl = this.buildUrl(url); + const response = await api.upload(fullUrl, file, additionalData); + return response.data; + } catch (error) { + this.handleError(error, 'UPLOAD'); + } + } +} + +// 导出单例实例 +export const apiDataSource = new ApiDataSource(); diff --git a/src/services/core/datasources/CacheDataSource.ts b/src/services/core/datasources/CacheDataSource.ts new file mode 100644 index 0000000..6169cc7 --- /dev/null +++ b/src/services/core/datasources/CacheDataSource.ts @@ -0,0 +1,199 @@ +/** + * 缓存数据源实现 + * 基于内存和 AsyncStorage 的混合缓存实现 + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ICacheDataSource, DataSourceError } from './interfaces'; + +interface CacheEntry { + value: T; + expiry: number | null; // null 表示永不过期 +} + +export class CacheDataSource implements ICacheDataSource { + private memoryCache: Map> = new Map(); + private maxSize: number; + private defaultTtl: number; // 默认过期时间(毫秒) + private persistPrefix: string; + + constructor(config?: { + maxSize?: number; + defaultTtl?: number; // 默认5分钟 + persistPrefix?: string; + }) { + this.maxSize = config?.maxSize || 100; + this.defaultTtl = config?.defaultTtl || 5 * 60 * 1000; + this.persistPrefix = config?.persistPrefix || 'cache_'; + } + + /** + * 检查 key 是否过期 + */ + private isExpired(entry: CacheEntry): boolean { + if (entry.expiry === null) return false; + return Date.now() > entry.expiry; + } + + /** + * 清理过期缓存 + */ + private cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.memoryCache.entries()) { + if (entry.expiry !== null && now > entry.expiry) { + this.memoryCache.delete(key); + } + } + + // 如果仍然超过最大大小,删除最旧的条目 + while (this.memoryCache.size > this.maxSize) { + const firstKey = this.memoryCache.keys().next().value; + if (firstKey !== undefined) { + this.memoryCache.delete(firstKey); + } + } + } + + /** + * 生成持久化存储 key + */ + private getPersistKey(key: string): string { + return `${this.persistPrefix}${key}`; + } + + // ==================== ICacheDataSource 实现 ==================== + + async get(key: string): Promise { + try { + // 先检查内存缓存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry) { + if (!this.isExpired(memoryEntry)) { + return memoryEntry.value as T; + } + // 过期了,从内存中删除 + this.memoryCache.delete(key); + } + + // 尝试从持久化存储读取 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + if (!this.isExpired(entry)) { + // 重新加载到内存缓存 + this.memoryCache.set(key, entry); + this.cleanup(); + return entry.value; + } + // 过期了,删除 + await AsyncStorage.removeItem(persistKey); + } + + return null; + } catch (error) { + console.warn(`[CacheDataSource] Failed to get ${key}:`, error); + return null; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const expiry = ttl === undefined + ? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null) + : (ttl > 0 ? Date.now() + ttl : null); + + const entry: CacheEntry = { value, expiry }; + + // 存入内存 + this.memoryCache.set(key, entry); + this.cleanup(); + + // 持久化存储(重要数据) + if (ttl === undefined || ttl > 0) { + const persistKey = this.getPersistKey(key); + await AsyncStorage.setItem(persistKey, JSON.stringify(entry)); + } + } catch (error) { + throw new DataSourceError( + `Failed to set cache ${key}`, + 'CACHE_SET_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async delete(key: string): Promise { + try { + // 从内存删除 + this.memoryCache.delete(key); + + // 从持久化存储删除 + const persistKey = this.getPersistKey(key); + await AsyncStorage.removeItem(persistKey); + } catch (error) { + throw new DataSourceError( + `Failed to delete cache ${key}`, + 'CACHE_DELETE_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async clear(): Promise { + try { + // 清空内存 + this.memoryCache.clear(); + + // 清空持久化存储中的所有缓存项 + const keys = await AsyncStorage.getAllKeys(); + const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix)); + if (cacheKeys.length > 0) { + await AsyncStorage.multiRemove(cacheKeys); + } + } catch (error) { + throw new DataSourceError( + 'Failed to clear cache', + 'CACHE_CLEAR_ERROR', + 'CacheDataSource', + error instanceof Error ? error : undefined + ); + } + } + + async has(key: string): Promise { + try { + // 检查内存 + const memoryEntry = this.memoryCache.get(key); + if (memoryEntry && !this.isExpired(memoryEntry)) { + return true; + } + + // 检查持久化存储 + const persistKey = this.getPersistKey(key); + const stored = await AsyncStorage.getItem(persistKey); + if (stored) { + const entry: CacheEntry = JSON.parse(stored); + return !this.isExpired(entry); + } + + return false; + } catch (error) { + return false; + } + } + + async getMultiple(keys: string[]): Promise<(T | null)[]> { + return Promise.all(keys.map(key => this.get(key))); + } + + async setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise { + await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl))); + } +} + +// 导出单例实例 +export const cacheDataSource = new CacheDataSource(); diff --git a/src/services/core/datasources/WSClient.ts b/src/services/core/datasources/WSClient.ts new file mode 100644 index 0000000..eb55ed3 --- /dev/null +++ b/src/services/core/datasources/WSClient.ts @@ -0,0 +1,184 @@ +/** + * WSClient - WebSocket连接管理 + * 只负责WebSocket连接管理,提供事件驱动架构 + */ + +import { + wsService, + WSChatMessage, + WSGroupChatMessage, + WSReadMessage, + WSGroupReadMessage, + WSRecallMessage, + WSGroupRecallMessage, + WSGroupTypingMessage, + WSGroupNoticeMessage, + WSMessageType, +} from '@/services/core'; + +// 事件处理器类型 +type EventHandler = (data: T) => void; + +// WS事件类型 +export interface WSEvents { + 'chat': WSChatMessage; + 'group_message': WSGroupChatMessage; + 'read': WSReadMessage; + 'group_read': WSGroupReadMessage; + 'recall': WSRecallMessage; + 'group_recall': WSGroupRecallMessage; + 'group_typing': WSGroupTypingMessage; + 'group_notice': WSGroupNoticeMessage; + 'connected': void; + 'disconnected': void; + 'error': Error; +} + +export type WSEventType = keyof WSEvents; + +class WSClient { + private handlers: Map>> = new Map(); + private unsubscribeFns: Array<() => void> = []; + private isInitialized = false; + + /** + * 初始化WebSocket监听 + */ + initialize(): void { + if (this.isInitialized) return; + + // 监听私聊消息 + const unsubChat = wsService.on('chat', (message) => { + this.emit('chat', message); + }); + + // 监听群聊消息 + const unsubGroupMessage = wsService.on('group_message', (message) => { + this.emit('group_message', message); + }); + + // 监听私聊已读回执 + const unsubRead = wsService.on('read', (message) => { + this.emit('read', message); + }); + + // 监听群聊已读回执 + const unsubGroupRead = wsService.on('group_read', (message) => { + this.emit('group_read', message); + }); + + // 监听私聊消息撤回 + const unsubRecall = wsService.on('recall', (message) => { + this.emit('recall', message); + }); + + // 监听群聊消息撤回 + const unsubGroupRecall = wsService.on('group_recall', (message) => { + this.emit('group_recall', message); + }); + + // 监听群聊输入状态 + const unsubGroupTyping = wsService.on('group_typing', (message) => { + this.emit('group_typing', message); + }); + + // 监听群通知 + const unsubGroupNotice = wsService.on('group_notice', (message) => { + this.emit('group_notice', message); + }); + + // 监听连接状态 + const unsubConnect = wsService.onConnect(() => { + this.emit('connected', undefined); + }); + + const unsubDisconnect = wsService.onDisconnect(() => { + this.emit('disconnected', undefined); + }); + + this.unsubscribeFns = [ + unsubChat, + unsubGroupMessage, + unsubRead, + unsubGroupRead, + unsubRecall, + unsubGroupRecall, + unsubGroupTyping, + unsubGroupNotice, + unsubConnect, + unsubDisconnect, + ]; + + this.isInitialized = true; + } + + /** + * 销毁WebSocket监听 + */ + destroy(): void { + this.unsubscribeFns.forEach((fn) => fn()); + this.unsubscribeFns = []; + this.handlers.clear(); + this.isInitialized = false; + } + + /** + * 订阅事件 + */ + on( + event: T, + handler: EventHandler + ): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + + return () => { + this.handlers.get(event)?.delete(handler); + }; + } + + /** + * 触发事件 + */ + private emit( + event: T, + data: WSEvents[T] + ): void { + const handlers = this.handlers.get(event); + if (handlers) { + handlers.forEach((handler) => { + try { + handler(data); + } catch (error) { + console.error(`[WSClient] 事件处理器执行失败: ${event}`, error); + } + }); + } + } + + /** + * 检查连接状态 + */ + isConnected(): boolean { + return wsService.isConnected(); + } + + /** + * 启动WebSocket连接 + */ + async connect(): Promise { + return wsService.start(); + } + + /** + * 断开WebSocket连接 + */ + disconnect(): void { + wsService.stop(); + } +} + +export const wsClient = new WSClient(); +export default wsClient; diff --git a/src/services/core/datasources/index.ts b/src/services/core/datasources/index.ts new file mode 100644 index 0000000..80dceed --- /dev/null +++ b/src/services/core/datasources/index.ts @@ -0,0 +1,9 @@ +/** + * 数据源导出 + * 统一导出所有数据源实现 + */ + +export * from './interfaces'; +export * from './ApiDataSource'; +export { LocalDataSource, localDataSource } from '@/database'; +export * from './CacheDataSource'; diff --git a/src/services/core/datasources/interfaces.ts b/src/services/core/datasources/interfaces.ts new file mode 100644 index 0000000..0469aeb --- /dev/null +++ b/src/services/core/datasources/interfaces.ts @@ -0,0 +1,68 @@ +/** + * 数据源接口定义 + * 定义所有数据源的标准接口,便于替换和测试 + */ + +// API 数据源接口 +export interface IApiDataSource { + get(url: string, params?: any): Promise; + post(url: string, data?: any): Promise; + put(url: string, data?: any): Promise; + delete(url: string, data?: any): Promise; + upload(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record): Promise; +} + +// 本地数据源接口 (SQLite) +export interface ILocalDataSource { + query(sql: string, params?: any[]): Promise; + getFirst(sql: string, params?: any[]): Promise; + execute(sql: string, params?: any[]): Promise; + run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>; + transaction(operations: () => Promise): Promise; + batch(operations: (db: ILocalDataSource) => Promise): Promise; + enqueueWrite(operation: () => Promise): Promise; + initialize(userId?: string): Promise; + switchDatabase(userId?: string | null): Promise; + closeDatabase(): Promise; +} + +// 缓存数据源接口 +export interface ICacheDataSource { + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + delete(key: string): Promise; + clear(): Promise; + has(key: string): Promise; + getMultiple(keys: string[]): Promise<(T | null)[]>; + setMultiple(entries: { key: string; value: T; ttl?: number }[]): Promise; +} + +// 数据源错误类型 +export class DataSourceError extends Error { + constructor( + message: string, + public code: string, + public source: string, + public originalError?: Error + ) { + super(message); + this.name = 'DataSourceError'; + } +} + +// 数据源配置 +export interface DataSourceConfig { + api?: { + baseUrl: string; + timeout?: number; + retryCount?: number; + }; + local?: { + dbName: string; + version: number; + }; + cache?: { + maxSize: number; + defaultTtl: number; + }; +} diff --git a/src/services/core/verification.ts b/src/services/core/verification.ts new file mode 100644 index 0000000..7cf32df --- /dev/null +++ b/src/services/core/verification.ts @@ -0,0 +1,12 @@ +import { eventBus } from '@/core/events/EventBus'; + +let verificationModalShown = false; + +export function showVerificationModal(): void { + if (verificationModalShown) return; + verificationModalShown = true; + eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' }); + setTimeout(() => { + verificationModalShown = false; + }, 3000); +} diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index 27ff0f1..5d41b12 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -4,6 +4,7 @@ import { api, WS_URL } from './api'; import { eventBus } from '@/core/events/EventBus'; import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '@/types/dto'; import { systemNotificationService } from '../notification/systemNotificationService'; +import { showVerificationModal } from './verification'; export type WSMessageType = | 'chat' @@ -1147,15 +1148,8 @@ class WebSocketService { this.lastActivityAt = Date.now(); } - private verificationModalShown = false; - private handleVerificationRequired(): void { - if (this.verificationModalShown) return; - this.verificationModalShown = true; - eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' }); - setTimeout(() => { - this.verificationModalShown = false; - }, 3000); + showVerificationModal(); } private startHeartbeat(): void { diff --git a/src/services/mappers/ConversationMapper.ts b/src/services/mappers/ConversationMapper.ts new file mode 100644 index 0000000..d3965a8 --- /dev/null +++ b/src/services/mappers/ConversationMapper.ts @@ -0,0 +1,302 @@ +/** + * 会话数据映射器 + * 负责 Conversation 模型与 API 响应、数据库记录之间的转换 + */ + +import { + ConversationModel, + ConversationType, + UserModel, + GroupModel, + MessageModel, +} from '../models'; +import type { + ConversationResponse, + ConversationDetailResponse, + UserDTO, + GroupResponse, + MessageResponse, +} from '../../types/dto'; +import { MessageMapper } from './MessageMapper'; + +// 数据库会话记录类型 +export interface ConversationDbRecord { + id: string; + participantId: string; + lastMessageId: string | null; + lastSeq: number; + unreadCount: number; + createdAt: string; + updatedAt: string; +} + +// 缓存数据类型 +export interface ConversationCacheData { + id: string; + type: string; + is_pinned?: boolean; + last_seq?: number; + last_message?: any; + last_message_at?: string; + unread_count?: number; + participants?: any[]; + my_last_read_seq?: number; + other_last_read_seq?: number; + group?: any; + created_at?: string; + updated_at?: string; +} + +export class ConversationMapper { + /** + * 将 API 列表响应转换为应用模型 + */ + static fromApiResponse(response: ConversationResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantId(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 详情响应转换为应用模型 + */ + static fromDetailApiResponse(response: ConversationDetailResponse): ConversationModel { + return { + id: String(response.id || ''), + type: (response.type as ConversationType) || 'private', + participantId: this.extractParticipantIdFromDetail(response), + lastMessageId: response.last_message?.id, + lastMessage: response.last_message + ? MessageMapper.fromApiResponse(response.last_message) + : undefined, + lastSeq: response.last_seq || 0, + myLastReadSeq: response.my_last_read_seq || 0, + otherLastReadSeq: response.other_last_read_seq || 0, + unreadCount: response.unread_count || 0, + isPinned: response.is_pinned || false, + participants: response.participants?.map(p => this.mapUserFromApi(p)), + group: response.group ? this.mapGroupFromApi(response.group) : undefined, + createdAt: new Date(response.created_at || Date.now()), + updatedAt: new Date(response.updated_at || Date.now()), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: ConversationResponse[]): ConversationModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 从缓存数据转换为应用模型 + */ + static fromCacheData(id: string, cached: ConversationCacheData): ConversationModel { + return { + id: String(cached?.id || id), + type: (cached?.type as ConversationType) || 'private', + participantId: '', + lastSeq: Number(cached?.last_seq || 0), + lastMessage: cached?.last_message + ? MessageMapper.fromApiResponse(cached.last_message) + : undefined, + lastMessageId: cached?.last_message?.id, + myLastReadSeq: Number(cached?.my_last_read_seq || 0), + otherLastReadSeq: Number(cached?.other_last_read_seq || 0), + unreadCount: Number(cached?.unread_count || 0), + isPinned: Boolean(cached?.is_pinned), + participants: Array.isArray(cached?.participants) + ? cached.participants.map(p => this.mapUserFromApi(p)) + : [], + group: cached?.group ? this.mapGroupFromApi(cached.group) : undefined, + createdAt: new Date(cached?.created_at || Date.now()), + updatedAt: new Date(cached?.updated_at || Date.now()), + }; + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: ConversationDbRecord): ConversationModel { + return { + id: record.id, + type: 'private', // 数据库中不存储类型,需要额外查询 + participantId: record.participantId, + lastMessageId: record.lastMessageId || undefined, + lastSeq: record.lastSeq, + myLastReadSeq: 0, + otherLastReadSeq: 0, + unreadCount: record.unreadCount, + isPinned: false, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + }; + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: ConversationModel): ConversationDbRecord { + return { + id: model.id, + participantId: model.participantId, + lastMessageId: model.lastMessageId || null, + lastSeq: model.lastSeq, + unreadCount: model.unreadCount, + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + }; + } + + /** + * 将应用模型转换为缓存数据 + */ + static toCacheData(model: ConversationModel): ConversationCacheData { + return { + id: model.id, + type: model.type, + is_pinned: model.isPinned, + last_seq: model.lastSeq, + last_message: model.lastMessage ? this.messageToCache(model.lastMessage) : undefined, + last_message_at: model.updatedAt.toISOString(), + unread_count: model.unreadCount, + participants: model.participants?.map(p => this.userToCache(p)), + my_last_read_seq: model.myLastReadSeq, + other_last_read_seq: model.otherLastReadSeq, + group: model.group ? this.groupToCache(model.group) : undefined, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + }; + } + + /** + * 提取会话参与者 ID + */ + private static extractParticipantId(response: ConversationResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 从详情响应提取参与者 ID + */ + private static extractParticipantIdFromDetail(response: ConversationDetailResponse): string { + if (response.participants && response.participants.length > 0) { + return String(response.participants[0].id || ''); + } + return ''; + } + + /** + * 映射用户 API 响应 + */ + private static mapUserFromApi(user: UserDTO): UserModel { + return { + id: String(user.id || ''), + username: user.username || '', + nickname: user.nickname, + avatar: user.avatar ?? undefined, + }; + } + + /** + * 映射群组 API 响应 + */ + private static mapGroupFromApi(group: GroupResponse): GroupModel { + // 将数字类型的 join_type 转换为字符串类型 + // JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许 + const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => { + switch (joinType) { + case 0: return 'anyone'; + case 1: return 'approval'; + case 2: return 'invite'; + default: return 'approval'; + } + }; + + return { + id: String(group.id || ''), + name: group.name || '', + avatar: group.avatar, + description: group.description, + // GroupResponse 没有 announcement 和 updated_at 字段,使用默认值 + announcement: undefined, + ownerId: String(group.owner_id || ''), + memberCount: group.member_count || 0, + maxMemberCount: group.max_members || 500, + joinType: mapJoinType(group.join_type), + isMuted: group.mute_all || false, + createdAt: new Date(group.created_at || Date.now()), + updatedAt: new Date(group.created_at || Date.now()), + }; + } + + /** + * 消息模型转缓存格式 + */ + private static messageToCache(message: MessageModel): any { + return { + id: message.id, + conversation_id: message.conversationId, + sender_id: message.senderId, + content: message.content, + message_type: message.type, + is_read: message.isRead, + created_at: message.createdAt.toISOString(), + seq: message.seq, + status: message.status, + segments: message.segments, + reply_to_id: message.replyToId, + sender: message.sender ? this.userToCache(message.sender) : undefined, + }; + } + + /** + * 用户模型转缓存格式 + */ + private static userToCache(user: UserModel): any { + return { + id: user.id, + username: user.username, + nickname: user.nickname, + avatar: user.avatar, + }; + } + + /** + * 群组模型转缓存格式 + */ + private static groupToCache(group: GroupModel): any { + return { + id: group.id, + name: group.name, + avatar: group.avatar, + description: group.description, + announcement: group.announcement, + owner_id: group.ownerId, + member_count: group.memberCount, + max_member_count: group.maxMemberCount, + join_type: group.joinType, + mute_all: group.isMuted, + created_at: group.createdAt.toISOString(), + updated_at: group.updatedAt.toISOString(), + }; + } +} \ No newline at end of file diff --git a/src/services/mappers/MessageMapper.ts b/src/services/mappers/MessageMapper.ts new file mode 100644 index 0000000..e22cedd --- /dev/null +++ b/src/services/mappers/MessageMapper.ts @@ -0,0 +1,194 @@ +/** + * 消息数据映射器 + * 负责 Message 模型与 API 响应、数据库记录之间的转换 + */ + +import { + MessageModel, + MessageSegment, + UserModel, +} from '../models'; +import type { + MessageResponse, + UserDTO, +} from '../../types/dto'; +import { safeParseJsonOrUndefined } from '../../database/core/jsonUtils'; +import type { CachedMessage } from '../../database/types/CachedMessage'; + +// 数据库消息记录类型 +export interface MessageDbRecord { + id: string; + conversationId: string; + senderId: string; + content: string | null; + type: string; + isRead: number; + createdAt: string; + seq: number; + status: string; + segments: string | null; +} + +export class MessageMapper { + /** + * 将 API 响应转换为应用模型 + */ + static fromApiResponse(response: MessageResponse): MessageModel { + const textSegment = response.segments?.find(s => s.type === 'text'); + const content = textSegment?.data?.text || textSegment?.data?.content || ''; + + return { + id: String(response.id || ''), + conversationId: String(response.conversation_id || ''), + senderId: String(response.sender_id || ''), + content, + type: 'text', + isRead: false, + createdAt: new Date(response.created_at || Date.now()), + seq: response.seq || 0, + status: response.status || 'normal', + segments: response.segments || [], + replyToId: response.reply_to_id, + sender: response.sender ? this.mapSenderFromApi(response.sender) : undefined, + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: MessageResponse[]): MessageModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将数据库记录转换为应用模型 + */ + static fromDbRecord(record: MessageDbRecord): MessageModel { + return { + id: record.id, + conversationId: record.conversationId, + senderId: record.senderId, + content: record.content || undefined, + type: record.type || 'text', + isRead: record.isRead === 1, + createdAt: new Date(record.createdAt), + seq: record.seq || 0, + status: record.status || 'normal', + segments: record.segments ? safeParseJsonOrUndefined(record.segments) : undefined, + }; + } + + /** + * 将数据库记录数组转换为应用模型数组 + */ + static fromDbRecordList(records: MessageDbRecord[]): MessageModel[] { + return records.map(r => this.fromDbRecord(r)); + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: MessageModel): MessageDbRecord { + return { + id: model.id, + conversationId: model.conversationId, + senderId: model.senderId, + content: model.content || null, + type: model.type, + isRead: model.isRead ? 1 : 0, + createdAt: model.createdAt.toISOString(), + seq: model.seq, + status: model.status, + segments: model.segments ? JSON.stringify(model.segments) : null, + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.segments) { + request.segments = model.segments; + } + if (model.content) { + request.content = model.content; + } + if (model.type) { + request.message_type = model.type; + } + if (model.replyToId) { + request.reply_to_id = model.replyToId; + } + + return request; + } + + /** + * 创建发送消息请求体 + */ + static createSendRequest( + detailType: 'private' | 'group', + segments: MessageSegment[], + replyToId?: string + ): Record { + const body: Record = { + detail_type: detailType, + segments, + }; + if (replyToId) { + body.reply_to_id = replyToId; + } + return body; + } + + /** + * 创建文本消息段 + */ + static createTextSegment(text: string): MessageSegment { + return { + type: 'text', + data: { text }, + }; + } + + /** + * 创建图片消息段 + */ + static createImageSegment(url: string): MessageSegment { + return { + type: 'image', + data: { url }, + }; + } + + + private static mapSenderFromApi(sender: UserDTO): UserModel { + return { + id: String(sender.id || ''), + username: sender.username || '', + nickname: sender.nickname, + avatar: sender.avatar || undefined, + }; + } + + static toCachedMessage(apiMessage: any, conversationId?: string): CachedMessage { + return { + id: apiMessage.id, + conversationId: apiMessage.conversation_id || conversationId || '', + senderId: apiMessage.sender_id, + content: apiMessage.content, + type: apiMessage.type || 'text', + isRead: apiMessage.is_read || false, + createdAt: apiMessage.created_at, + seq: apiMessage.seq, + status: apiMessage.status || 'normal', + segments: apiMessage.segments, + }; + } + + static toCachedMessages(apiMessages: any[], conversationId?: string): CachedMessage[] { + return apiMessages.map(m => this.toCachedMessage(m, conversationId)); + } +} \ No newline at end of file diff --git a/src/services/mappers/PostMapper.ts b/src/services/mappers/PostMapper.ts new file mode 100644 index 0000000..c37ce4e --- /dev/null +++ b/src/services/mappers/PostMapper.ts @@ -0,0 +1,129 @@ +/** + * 帖子数据映射器 + * 负责 Post 模型与 API 响应之间的转换 + */ + +import { PostModel, UserModel } from '../models'; +import type { PostDTO } from '../../types/dto'; + +export class PostMapper { + static fromApiResponse(response: PostDTO): PostModel { + return { + id: String(response.id || ''), + authorId: String(response.user_id || ''), + author: response.author ? this.mapAuthorFromApi(response.author) : undefined, + title: response.title || '', + content: response.content || '', + images: response.images?.map(img => img.url) || [], + likeCount: response.likes_count || 0, + commentCount: response.comments_count || 0, + shareCount: response.shares_count || 0, + viewCount: response.views_count || 0, + favoriteCount: response.favorites_count || 0, + isLiked: response.is_liked || false, + isFavorited: response.is_favorited || false, + isTop: response.is_pinned || false, + status: (response.status || 'published') as 'published' | 'draft' | 'deleted', + channelId: response.channel_id, + channel: + response.channel && response.channel.id + ? { id: String(response.channel.id), name: String(response.channel.name || '') } + : undefined, + tags: [], + createdAt: new Date(response.created_at || Date.now()), + // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 + updatedAt: new Date( + response.updated_at || + response.created_at || + Date.now() + ), + }; + } + + /** + * 将 API 响应数组转换为应用模型数组 + */ + static fromApiResponseList(responses: PostDTO[]): PostModel[] { + return responses.map(r => this.fromApiResponse(r)); + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.title !== undefined) { + request.title = model.title; + } + if (model.content !== undefined) { + request.content = model.content; + } + if (model.images !== undefined) { + request.images = model.images; + } + if (model.channelId !== undefined) { + request.channel_id = model.channelId; + } + if (model.tags !== undefined) { + request.tags = model.tags; + } + + return request; + } + + /** + * 创建帖子请求 + */ + static createPostRequest( + title: string, + content: string, + images?: string[], + channelId?: string + ): Record { + const request: Record = { + title, + content, + }; + if (images && images.length > 0) { + request.images = images; + } + if (channelId) { + request.channel_id = channelId; + } + return request; + } + + /** + * 更新帖子请求 + */ + static updatePostRequest( + title?: string, + content?: string, + images?: string[] + ): Record { + const request: Record = {}; + if (title !== undefined) { + request.title = title; + } + if (content !== undefined) { + request.content = content; + } + if (images !== undefined) { + request.images = images; + } + return request; + } + + /** + * 映射作者信息 + */ + private static mapAuthorFromApi(author: any): UserModel { + return { + id: String(author.id || ''), + username: author.username || '', + nickname: author.nickname, + avatar: author.avatar, + }; + } +} \ No newline at end of file diff --git a/src/services/mappers/UserMapper.ts b/src/services/mappers/UserMapper.ts new file mode 100644 index 0000000..f2e5259 --- /dev/null +++ b/src/services/mappers/UserMapper.ts @@ -0,0 +1,134 @@ +/** + * 用户数据映射器 + * 负责 User 模型与 API 响应、数据库记录之间的转换 + */ + +import { UserModel } from '../models'; +import type { UserDTO } from '../../types/dto'; + +// 数据库用户记录类型 +export interface UserDbRecord { + id: string; + data: string; + updatedAt: string; +} + +export class UserMapper { + static fromDTO(dto: UserDTO): UserModel { + return { + id: String(dto.id || ''), + username: dto.username || '', + nickname: dto.nickname, + avatar: dto.avatar, + bio: dto.bio, + website: dto.website, + location: dto.location, + email: dto.email, + phone: dto.phone, + followersCount: dto.followers_count, + followingCount: dto.following_count, + postsCount: dto.posts_count, + isFollowing: dto.is_following, + createdAt: dto.created_at ? new Date(dto.created_at) : undefined, + }; + } + + static fromDTOList(dtos: UserDTO[]): UserModel[] { + return dtos.map(dto => this.fromDTO(dto)); + } + + /** + * 从数据库记录转换为应用模型 + */ + static fromDbRecord(record: UserDbRecord): UserModel | null { + try { + const data = JSON.parse(record.data) as UserDTO; + return this.fromDTO(data); + } catch { + return null; + } + } + + /** + * 将应用模型转换为数据库记录 + */ + static toDbRecord(model: UserModel): UserDbRecord { + const dto: UserDTO = { + id: model.id, + username: model.username, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', + email: model.email, + phone: model.phone, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, + is_following: model.isFollowing, + created_at: model.createdAt?.toISOString() || '', + }; + + return { + id: model.id, + data: JSON.stringify(dto), + updatedAt: new Date().toISOString(), + }; + } + + /** + * 将应用模型转换为 API 请求数据 + */ + static toApiRequest(model: Partial): Record { + const request: Record = {}; + + if (model.nickname !== undefined) { + request.nickname = model.nickname; + } + if (model.avatar !== undefined) { + request.avatar = model.avatar; + } + if (model.bio !== undefined) { + request.bio = model.bio; + } + if (model.website !== undefined) { + request.website = model.website; + } + if (model.location !== undefined) { + request.location = model.location; + } + if (model.phone !== undefined) { + request.phone = model.phone; + } + if (model.email !== undefined) { + request.email = model.email; + } + + return request; + } + + /** + * 将应用模型转换为 DTO + */ + static toDTO(model: UserModel): UserDTO { + return { + id: model.id, + username: model.username, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', + email: model.email, + phone: model.phone, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, + is_following: model.isFollowing, + created_at: model.createdAt?.toISOString() || '', + }; + } +} \ No newline at end of file diff --git a/src/services/mappers/index.ts b/src/services/mappers/index.ts new file mode 100644 index 0000000..210ab2b --- /dev/null +++ b/src/services/mappers/index.ts @@ -0,0 +1,9 @@ +/** + * 映射器导出 + * 统一导出所有数据映射器 + */ + +export * from './MessageMapper'; +export * from './ConversationMapper'; +export * from './UserMapper'; +export * from './PostMapper'; \ No newline at end of file diff --git a/src/services/message/types/repository.ts b/src/services/message/types/repository.ts new file mode 100644 index 0000000..90957c8 --- /dev/null +++ b/src/services/message/types/repository.ts @@ -0,0 +1,93 @@ +/** + * 消息 Repository 接口 + * 定义消息数据访问的抽象 + */ + +import type { Message, Conversation } from '../../../core/entities/Message'; + +export interface IMessageRepository { + // ==================== 消息操作 ==================== + + /** + * 获取会话的消息列表 + */ + getMessages( + conversationId: string, + options?: { + beforeSeq?: number; + afterSeq?: number; + limit?: number; + } + ): Promise; + + /** + * 保存消息 + */ + saveMessage(message: Message): Promise; + + /** + * 批量保存消息 + */ + saveMessages(messages: Message[]): Promise; + + /** + * 更新消息状态 + */ + updateMessageStatus( + messageId: string, + status: 'normal' | 'recalled' | 'deleted' + ): Promise; + + /** + * 标记消息为已读 + */ + markAsRead(messageIds: string[]): Promise; + + /** + * 获取未读消息数 + */ + getUnreadCount(conversationId: string): Promise; + + // ==================== 会话操作 ==================== + + /** + * 获取会话列表 + */ + getConversations(): Promise; + + /** + * 获取单个会话 + */ + getConversation(conversationId: string): Promise; + + /** + * 保存会话 + */ + saveConversation(conversation: Conversation): Promise; + + /** + * 更新会话未读数 + */ + updateUnreadCount(conversationId: string, count: number): Promise; + + /** + * 更新会话最后消息 + */ + updateLastMessage( + conversationId: string, + messageId: string, + seq: number + ): Promise; + + // ==================== 同步操作 ==================== + + /** + * 从服务器同步消息 + */ + syncMessages(conversationId: string, lastSeq: number): Promise; + + /** + * 获取服务器最新消息序列号 + */ + getServerLastSeq(conversationId: string): Promise; +} \ No newline at end of file diff --git a/src/services/post/types/repository.ts b/src/services/post/types/repository.ts new file mode 100644 index 0000000..49fcb4e --- /dev/null +++ b/src/services/post/types/repository.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; + /** 帖子类型筛选(可选):follow, hot, latest */ + post_type?: 'follow' | 'hot' | 'latest'; + /** 频道ID过滤 */ + channelId?: 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 */ + channelId?: 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过滤 */ + channelId?: 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; +} \ No newline at end of file diff --git a/src/types/dto.ts b/src/types/dto.ts deleted file mode 100644 index d8cb0c9..0000000 --- a/src/types/dto.ts +++ /dev/null @@ -1,998 +0,0 @@ -/** - * DTO Types - Data Transfer Object types for API communication - * 使用snake_case命名规范 - */ - -// ==================== User DTOs ==================== - -export interface UserDTO { - id: string; - username: string; - nickname: string; - avatar: string; - cover_url: string; - bio: string; - website: string; - location: string; - phone?: string; - email?: string; - email_verified?: boolean; - posts_count: number; - followers_count: number; - following_count: number; - created_at: string; - is_following?: boolean; - is_following_me?: boolean; - identity?: UserIdentity; - verification_status?: VerificationStatus; -} - -export interface UserDetailDTO { - id: string; - username: string; - nickname: string; - email: string; - email_verified?: boolean; - avatar: string | null; - bio: string | null; - website: string | null; - location: string | null; - posts_count: number; - followers_count: number; - following_count: number; - is_verified: boolean; - created_at: string; - // 额外字段 - is_following?: boolean; // 当前用户是否关注了该用户 - is_following_me?: boolean; // 该用户是否关注了当前用户 - identity?: UserIdentity; - verification_status?: VerificationStatus; -} - -// ==================== Post DTOs ==================== - -export interface PostImageDTO { - id: string; - url: string; - thumbnail_url: string; - preview_url: string; // 列表/网格预览图 - preview_url_large: string; // 详情页预览图 - width: number; - height: number; -} - -/** 帖子所属频道(列表卡片展示) */ -export interface PostChannelBriefDTO { - id: string; - name: string; -} - -export interface PostDTO { - id: string; - user_id: string; - title: string; - content: string; - segments?: MessageSegment[]; - images: PostImageDTO[]; - status?: string; - likes_count: number; - comments_count: number; - favorites_count: number; - shares_count: number; - views_count: number; - is_pinned: boolean; - is_locked: boolean; - is_vote: boolean; - created_at: string; - updated_at?: string; - /** 用户编辑正文/标题/图片的时间;优先用于「已编辑」展示 */ - content_edited_at?: string; - author: UserDTO | null; - is_liked: boolean; - is_favorited: boolean; - // 额外字段 - channel_id?: string; - /** 频道摘要(与 channel_id 对应,服务端批量填充) */ - channel?: PostChannelBriefDTO | null; - top_comment?: CommentDTO | null; -} - -export interface PostDetailDTO extends PostDTO { - status: string; - updated_at: string; -} - -// ==================== Comment DTOs ==================== - -// 评论图片响应 -export interface CommentImageDTO { - url: string; -} - -export interface CommentDTO { - id: string; - post_id: string; - user_id: string; - parent_id: string | null; - root_id: string | null; - content: string; - segments?: MessageSegment[]; - images: CommentImageDTO[]; - likes_count: number; - replies_count: number; - created_at: string; - author: UserDTO; - is_liked: boolean; - target_id?: string | null; // 被回复的评论ID,前端根据此ID找到被回复用户的昵称 - replies?: CommentDTO[]; // 子回复列表(扁平化,所有层级都在这里) -} - -// ==================== Notification DTOs ==================== - -export interface NotificationDTO { - id: string; - user_id: string; - type: string; - title: string; - content: string; - data: string; - is_read: boolean; - created_at: string; -} - -// ==================== Chat System Types ==================== - -// 会话类型 -export type ConversationType = 'private' | 'group'; - -// 消息内容类型 -export type MessageContentType = 'text' | 'image' | 'video' | 'audio' | 'file'; - -// 消息状态 -export type MessageStatus = 'normal' | 'recalled' | 'deleted'; - -// ==================== Message Segment Types ==================== - -// Segment类型 -export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote' | 'post_ref'; - -// 文本Segment数据 -export interface TextSegmentData { - content?: string; // 旧格式字段 - text?: string; // 新格式字段(用于 REST API) -} - -// 图片Segment数据 -export interface ImageSegmentData { - url: string; - width?: number; - height?: number; - thumbnail_url?: string; - file_size?: number; -} - -// 语音Segment数据 -export interface VoiceSegmentData { - url: string; - duration?: number; // 秒 - file_size?: number; -} - -// 视频Segment数据 -export interface VideoSegmentData { - url: string; - width?: number; - height?: number; - duration?: number; // 秒 - thumbnail_url?: string; - file_size?: number; -} - -// 文件Segment数据 -export interface FileSegmentData { - url: string; - name: string; - size?: number; - mime_type?: string; -} - -// @Segment数据 -export interface AtSegmentData { - user_id: string; // "all" 表示@所有人 - nickname?: string; -} - -// 回复Segment数据 -export interface ReplySegmentData { - id: string; // 被回复消息的ID - seq?: number; // 被回复消息的序号(可选,用于快速查找) -} - -// 表情Segment数据 -export interface FaceSegmentData { - id: number; - name?: string; - url?: string; -} - -// 链接Segment数据 -export interface LinkSegmentData { - url: string; - title?: string; - description?: string; - image?: string; -} - -// 投票选项数据 -export interface VoteOptionData { - id: string; - content: string; -} - -// 投票Segment数据 -export interface VoteSegmentData { - options: VoteOptionData[]; - max_choices?: number; - is_multi_choice?: boolean; -} - -// 帖子内链Segment数据 -export interface PostRefSegmentData { - post_id: string; - title?: string; - author?: { - id: string; - nickname: string; - avatar?: string; - }; - status?: string; - accessible?: boolean; - click_count?: number; -} - -// MessageSegment 联合类型 - 使用 discriminated union -export type MessageSegment = - | { type: 'text'; data: TextSegmentData } - | { type: 'image'; data: ImageSegmentData } - | { type: 'voice'; data: VoiceSegmentData } - | { type: 'video'; data: VideoSegmentData } - | { type: 'file'; data: FileSegmentData } - | { type: 'at'; data: AtSegmentData } - | { type: 'reply'; data: ReplySegmentData } - | { type: 'face'; data: FaceSegmentData } - | { type: 'link'; data: LinkSegmentData } - | { type: 'vote'; data: VoteSegmentData } - | { type: 'post_ref'; data: PostRefSegmentData }; - -// 消息响应 -export interface MessageResponse { - id: string; - conversation_id: string; - sender_id: string; - seq: number; - segments: MessageSegment[]; - reply_to_id?: string; - status: MessageStatus; - category?: string; - created_at: string; - sender?: UserDTO; - is_system_notice?: boolean; - notice_content?: string; -} - -// 会话响应 -export interface ConversationResponse { - id: string; // 雪花算法ID (使用string避免JavaScript精度丢失) - type: ConversationType; - is_pinned?: boolean; - notification_muted?: boolean; - last_seq: number; - last_message?: MessageResponse; - last_message_at: string; - unread_count: number; - participants?: UserDTO[]; // 私聊时使用 - member_count?: number; // 群聊时使用 - group?: GroupResponse; // 群聊会话的群组信息 - my_last_read_seq?: number; // 当前用户的已读位置 - other_last_read_seq?: number; // 对方用户的已读位置 - created_at: string; - updated_at: string; -} - -// 会话参与者响应 -export interface ConversationParticipantResponse { - user_id: string; - last_read_seq: number; - notification_muted: boolean; - is_pinned: boolean; -} - -// 会话详情响应 -export interface ConversationDetailResponse { - id: string; - type: ConversationType; - is_pinned?: boolean; - notification_muted?: boolean; - last_seq: number; - last_message?: MessageResponse; - last_message_at: string; - unread_count: number; - participants: UserDTO[]; - my_last_read_seq: number; // 当前用户的已读位置 - other_last_read_seq: number; // 对方用户的已读位置 - group?: GroupResponse; // 群聊会话的群组信息 - created_at: string; - updated_at: string; -} - -// 创建会话请求 -export interface CreateConversationRequest { - user_id: string; // 目标用户ID (UUID格式) -} - -// 发送消息请求 -export interface SendMessageRequest { - segments?: MessageSegment[]; // 消息链(可选) - content?: string; // 纯文本内容(可选,用于兼容旧版本) - reply_to_id?: string; // 回复的消息ID (string类型) -} - -// 标记已读请求 -export interface MarkReadRequest { - last_read_seq: number; // 已读到的seq位置 -} - -// 未读数响应 -export interface UnreadCountResponse { - total_unread_count: number; // 所有会话的未读总数 -} - -// 单个会话未读数响应 -export interface ConversationUnreadCountResponse { - conversation_id: string; - unread_count: number; -} - -// 会话列表响应 -export interface ConversationListResponse { - list: ConversationResponse[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -// 消息列表响应 -export interface MessageListResponse { - messages: MessageResponse[]; - total: number; - page: number; - page_size: number; -} - -// 消息同步响应(增量同步) -export interface MessageSyncResponse { - messages: MessageResponse[]; - has_more: boolean; -} - -// ==================== Auth DTOs ==================== - -export interface LoginDTO { - user: UserDTO; - access_token: string; - refresh_token: string; -} - -export interface RegisterDTO { - user: UserDTO; - access_token: string; - refresh_token: string; -} - -export interface RefreshTokenDTO { - access_token: string; - refresh_token: string; -} - -// ==================== Common DTOs ==================== - -export interface SuccessDTO { - success: boolean; - message: string; -} - -export interface AvailableDTO { - available: boolean; -} - -export interface CountDTO { - count: number; -} - -export interface URLDTO { - url: string; -} - -// ==================== API Response Types ==================== - -export interface ApiResponse { - code: number; - message: string; - data: T; -} - -export interface PaginatedData { - list: T[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -// ==================== System Message Types ==================== - -// 消息分类 -export type MessageCategory = 'chat' | 'notification' | 'announcement' | 'marketing'; - -// 系统消息类型 -export type SystemMessageType = - | 'like_post' - | 'like_comment' - | 'like_reply' - | 'favorite_post' - | 'comment' - | 'reply' - | 'follow' - | 'mention' - | 'system' - | 'announcement' - | 'announce' - | 'group_invite' - | 'group_join_apply' - | 'group_join_approved' - | 'group_join_rejected'; - -// 系统消息额外数据 -export interface SystemMessageExtraData { - // 后端返回的字段(使用 actor_ 前缀) - actor_id?: number; // 操作者ID (数字格式,兼容旧数据) - actor_id_str?: string; // 操作者ID (UUID字符串格式) - actor_name?: string; // 操作者名称 - avatar_url?: string; // 操作者头像 - target_id?: string; // 目标ID (UUID字符串格式) - target_title?: string; // 目标标题(如帖子标题) - target_type?: string; // 目标类型 - action_url?: string; // 操作URL - action_time?: string; // 操作时间 - group_id?: string; - group_name?: string; - group_avatar?: string; - group_description?: string; - flag?: string; - request_type?: 'invite' | 'join_apply'; - request_status?: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired'; - reason?: string; - target_user_id?: string; - target_user_name?: string; - target_user_avatar?: string; - - // 兼容旧字段(使用 operator_ 前缀) - operator_id?: number; // 操作者ID - operator_name?: string; // 操作者名称 - operator_avatar?: string; // 操作者头像 - post_id?: number; // 帖子ID - post_title?: string; // 帖子标题 - comment_id?: number; // 评论ID - comment_preview?: string; // 评论预览 - reply_id?: number; // 回复ID -} - -// 系统消息响应 -export interface SystemMessageResponse { - id: string; // 后端返回字符串格式 - sender_id: string; - receiver_id: string; - content: string; - category: MessageCategory; - system_type: SystemMessageType; - extra_data?: SystemMessageExtraData; - is_read?: boolean; - created_at: string; -} - -// 系统消息列表响应 -export interface SystemMessageListResponse { - messages: SystemMessageResponse[]; - total: number; - has_more: boolean; -} - -// 系统消息未读数响应 -export interface SystemUnreadCountResponse { - unread_count: number; -} - -// ==================== Push Device Types ==================== - -// 设备类型 -export type DeviceType = 'ios' | 'android' | 'web'; - -// ==================== 游标分页相关 DTO ==================== - -/** - * 游标分页请求参数 - */ -export interface CursorPaginationRequest { - cursor?: string; - direction?: 'forward' | 'backward'; - page_size?: number; - post_type?: 'follow' | 'hot' | 'latest'; - channel_id?: string; -} - -/** - * 游标分页响应 - */ -export interface CursorPaginationResponse { - /** 数据项列表 */ - list: T[]; - /** 下一页游标 */ - next_cursor: string | null; - /** 上一页游标 */ - prev_cursor: string | null; - /** 是否有更多数据 */ - has_more: boolean; -} - -// 设备Token响应 -export interface DeviceTokenResponse { - id: number; - device_id: string; - device_type: DeviceType; - is_active: boolean; - device_name?: string; - last_used_at: string; - created_at: string; -} - -// 注册设备请求 -export interface RegisterDeviceRequest { - device_id: string; - device_type: DeviceType; - push_token?: string; - device_name?: string; -} - -// 推送记录响应 -export interface PushRecordResponse { - id: number; - message_id: number; - push_channel: string; - push_status: string; - retry_count: number; - pushed_at?: string; - delivered_at?: string; - created_at: string; -} - -// ==================== Group Chat Types ==================== - -// 群组加入类型 -export type JoinType = 0 | 1 | 2; // 0:允许任何人 1:需要审批 2:不允许 - -// 群组成员角色 -export type GroupRole = 'owner' | 'admin' | 'member'; - -// 群组信息 -export interface GroupResponse { - /** 应用内统一为十进制字符串(groupService 从 API 归一化),避免大整型精度问题 */ - id: string; - name: string; - avatar: string; - description: string; - owner_id: string; // UUID字符串 - member_count: number; - max_members: number; - join_type: JoinType; - mute_all: boolean; - created_at: string; -} - -// 群组成员信息 -export interface GroupMemberResponse { - id: number; - group_id: string; - user_id: string; // UUID字符串 - role: GroupRole; - nickname: string; - muted: boolean; - join_time: string; - user?: UserDTO; -} - -// 群公告信息 -export interface GroupAnnouncementResponse { - id: number; - group_id: string; - content: string; - author_id: string; // UUID字符串 - is_pinned: boolean; - created_at: string; -} - -// 创建群组请求 -export interface CreateGroupRequest { - name: string; - description?: string; - member_ids?: string[]; // UUID字符串数组 -} - -// 更新群组请求 -export interface UpdateGroupRequest { - name?: string; - description?: string; - avatar?: string; -} - -// 邀请成员请求 -export interface InviteMembersRequest { - member_ids: string[]; // UUID字符串数组 -} - -export interface HandleGroupRequestAction { - flag: string; - approve: boolean; - reason?: string; -} - -// 转让群主请求 -export interface TransferOwnerRequest { - new_owner_id: string; // UUID字符串 -} - -// 设置成员角色请求 -export interface SetRoleRequest { - role: 'admin' | 'member'; -} - -// 设置群昵称请求 -export interface SetNicknameRequest { - nickname: string; -} - -// 禁言成员请求 -export interface MuteMemberRequest { - muted: boolean; -} - -// 全员禁言请求 -export interface SetMuteAllRequest { - mute_all: boolean; -} - -// 当前用户群成员信息响应 -export interface MyMemberInfoResponse { - member: GroupMemberResponse; - mute_all: boolean; - is_muted: boolean; - can_speak: boolean; -} - -// 设置加群方式请求 -export interface SetJoinTypeRequest { - join_type: JoinType; -} - -// 设置群头像请求 -export interface SetGroupAvatarRequest { - avatar: string; -} - -// 创建群公告请求 -export interface CreateAnnouncementRequest { - content: string; -} - -// 群组列表响应 -export interface GroupListResponse { - list: GroupResponse[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -// 群组成员列表响应 -export interface GroupMemberListResponse { - list: GroupMemberResponse[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -// 群公告列表响应 -export interface GroupAnnouncementListResponse { - list: GroupAnnouncementResponse[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - -// ==================== WS Event Types ==================== - -// WS 事件类型 -export type WSEventType = 'message' | 'notice' | 'request' | 'meta'; - -// WS 消息详细类型 -export type WSDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read'; - -// WS 事件消息段(与后端 message 字段一致) -export interface WSSegment { - type: string; - data: Record; -} - -// WS 事件(后端推送的事件格式) -export interface WSEvent { - id: string; // 事件唯一ID - time: number; // 事件时间戳(毫秒) - type: WSEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件) - detail_type: WSDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等 - seq: string; // 消息序号 - message?: WSSegment[]; // 消息内容数组 - conversation_id: string; // 会话ID -} - -// 旧的类型名称,保持向后兼容 -export type SSEEventType = WSEventType; -export type SSEDetailType = WSDetailType; -export interface SSESegment extends WSSegment {} -export interface SSEEvent extends WSEvent {} - -/** - * 从消息segments中提取纯文本内容 - * 用于消息列表显示、通知等场景 - */ -export function extractTextFromSegments(segments?: MessageSegment[], memberMap?: Map): string { - if (!segments || segments.length === 0) { - return ''; - } - - let result = ''; - - for (const segment of segments) { - switch (segment.type) { - case 'text': - const textData = segment.data as TextSegmentData; - result += textData.content ?? textData.text ?? ''; - break; - case 'at': - const atData = segment.data as AtSegmentData; - if (atData.user_id === 'all') { - result += '@所有人 '; - } else { - const member = memberMap?.get(atData.user_id); - const displayName = member?.nickname || atData.nickname || atData.user_id; - result += `@${displayName} `; - } - break; - case 'image': - result += '[图片]'; - break; - case 'voice': - result += '[语音]'; - break; - case 'video': - result += '[视频]'; - break; - case 'file': - const fileData = segment.data as FileSegmentData; - result += `[文件: ${fileData.name || '未知'}]`; - break; - case 'face': - const faceData = segment.data as FaceSegmentData; - result += faceData.name ? `[${faceData.name}]` : '[表情]'; - break; - case 'link': - const linkData = segment.data as LinkSegmentData; - result += linkData.title ? `[链接: ${linkData.title}]` : '[链接]'; - break; - case 'reply': - // 回复不显示在预览中 - break; - case 'post_ref': - const refData = segment.data as PostRefSegmentData; - result += refData.title ? `[帖子: ${refData.title}]` : '[帖子引用]'; - break; - } - } - - return result.trim(); -} - -export type VisibilityLevel = 'everyone' | 'following' | 'self'; - -export interface PrivacySettingsDTO { - followers_visibility: VisibilityLevel; - following_visibility: VisibilityLevel; - posts_visibility: VisibilityLevel; - favorites_visibility: VisibilityLevel; -} - -export interface PrivacySettingsRequestDTO { - followers_visibility?: VisibilityLevel; - following_visibility?: VisibilityLevel; - posts_visibility?: VisibilityLevel; - favorites_visibility?: VisibilityLevel; -} - -export interface DeletionStatusDTO { - is_pending_deletion: boolean; - deletion_requested_at?: string; - cool_down_days?: number; -} - -// ==================== Vote DTOs ==================== - -export interface VoteOptionDTO { - id: string; - content: string; - votes_count: number; -} - -export interface VoteResultDTO { - options: VoteOptionDTO[]; - total_votes: number; - has_voted: boolean; - voted_option_id?: string; -} - -export interface CreateVotePostRequest { - title: string; - content?: string; - images?: string[]; - vote_options: Array<{ - content: string; - }>; - channel_id?: string; -} - -// ==================== Verification DTOs ==================== - -export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected' | 'none'; -export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external'; - -export interface VerificationStatusDTO { - verification_status: VerificationStatus; - identity?: UserIdentity; - has_pending_request?: boolean; - rejected_reason?: string; - verified_at?: string; - latest_record?: { - id: string; - reject_reason?: string; - real_name?: string; - department?: string; - created_at?: string; - }; -} - -export interface VerificationRecordDTO { - id: string; - user_id: string; - identity: UserIdentity; - status: VerificationStatus; - real_name: string; - student_id?: string; - employee_id?: string; - department?: string; - proof_materials: string; - reviewer_id?: string; - review_note?: string; - created_at: string; - updated_at: string; -} - -export interface SubmitVerificationRequest { - identity: UserIdentity; - real_name: string; - student_id?: string; - employee_id?: string; - department?: string; - proof_materials: string; -} - -export interface RequestDeletionRequestDTO { - password: string; -} - -export async function extractTextFromSegmentsAsync( - segments?: MessageSegment[], - getUserCache?: (userId: string) => Promise, - getUserById?: (userId: string) => Promise -): Promise { - if (!segments || segments.length === 0) { - return ''; - } - - let result = ''; - - for (const segment of segments) { - switch (segment.type) { - case 'text': - const textData = segment.data as TextSegmentData; - result += textData.content ?? textData.text ?? ''; - break; - case 'at': - const atData = segment.data as AtSegmentData; - if (atData.user_id === 'all') { - result += '@所有人 '; - } else if (atData.nickname) { - // 优先使用已有的 nickname - result += `@${atData.nickname} `; - } else { - // 异步获取用户名 - let nickname = atData.user_id; // 默认显示 user_id - try { - // 先尝试从缓存获取 - if (getUserCache) { - const cachedUser = await getUserCache(atData.user_id); - if (cachedUser?.nickname) { - nickname = cachedUser.nickname; - } else if (getUserById) { - // 缓存没有,从 API 获取 - const user = await getUserById(atData.user_id); - if (user?.nickname) { - nickname = user.nickname; - } - } - } else if (getUserById) { - // 没有缓存函数,直接从 API 获取 - const user = await getUserById(atData.user_id); - if (user?.nickname) { - nickname = user.nickname; - } - } - } catch (error) { - // 获取失败,使用 user_id 作为 fallback - console.warn('[extractTextFromSegmentsAsync] 获取用户信息失败:', error); - } - result += `@${nickname} `; - } - break; - case 'image': - result += '[图片]'; - break; - case 'voice': - result += '[语音]'; - break; - case 'video': - result += '[视频]'; - break; - case 'file': - const fileData = segment.data as FileSegmentData; - result += `[文件: ${fileData.name || '未知'}]`; - break; - case 'face': - const faceData = segment.data as FaceSegmentData; - result += faceData.name ? `[${faceData.name}]` : '[表情]'; - break; - case 'link': - const linkData = segment.data as LinkSegmentData; - result += linkData.title ? `[链接: ${linkData.title}]` : '[链接]'; - break; - case 'reply': - // 回复不显示在预览中 - break; - case 'post_ref': - const refDataAsync = segment.data as PostRefSegmentData; - result += refDataAsync.title ? `[帖子: ${refDataAsync.title}]` : '[帖子引用]'; - break; - } - } - - return result.trim(); -} diff --git a/src/types/dto/common.ts b/src/types/dto/common.ts new file mode 100644 index 0000000..013ad88 --- /dev/null +++ b/src/types/dto/common.ts @@ -0,0 +1,210 @@ +/** + * Auth, Notification & System DTOs + */ + +import type { UserDTO } from './user'; +import type { MessageSegment, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message'; + +// ==================== Auth DTOs ==================== + +export interface LoginDTO { + user: UserDTO; + access_token: string; + refresh_token: string; +} + +export interface RegisterDTO { + user: UserDTO; + access_token: string; + refresh_token: string; +} + +export interface RefreshTokenDTO { + access_token: string; + refresh_token: string; +} + +// ==================== Notification DTOs ==================== + +export interface NotificationDTO { + id: string; + user_id: string; + type: string; + title: string; + content: string; + data: string; + is_read: boolean; + created_at: string; +} + +// ==================== System Message Types ==================== + +export type MessageCategory = 'chat' | 'notification' | 'announcement' | 'marketing'; + +export type SystemMessageType = + | 'like_post' + | 'like_comment' + | 'like_reply' + | 'favorite_post' + | 'comment' + | 'reply' + | 'follow' + | 'mention' + | 'system' + | 'announcement' + | 'announce' + | 'group_invite' + | 'group_join_apply' + | 'group_join_approved' + | 'group_join_rejected'; + +export interface SystemMessageExtraData { + actor_id?: number; + actor_id_str?: string; + actor_name?: string; + avatar_url?: string; + target_id?: string; + target_title?: string; + target_type?: string; + action_url?: string; + action_time?: string; + group_id?: string; + group_name?: string; + group_avatar?: string; + group_description?: string; + flag?: string; + request_type?: 'invite' | 'join_apply'; + request_status?: 'pending' | 'accepted' | 'rejected' | 'cancelled' | 'expired'; + reason?: string; + target_user_id?: string; + target_user_name?: string; + target_user_avatar?: string; + operator_id?: number; + operator_name?: string; + operator_avatar?: string; + post_id?: number; + post_title?: string; + comment_id?: number; + comment_preview?: string; + reply_id?: number; +} + +export interface SystemMessageResponse { + id: string; + sender_id: string; + receiver_id: string; + content: string; + category: MessageCategory; + system_type: SystemMessageType; + extra_data?: SystemMessageExtraData; + is_read?: boolean; + created_at: string; +} + +export interface SystemMessageListResponse { + messages: SystemMessageResponse[]; + total: number; + has_more: boolean; +} + +export interface SystemUnreadCountResponse { + unread_count: number; +} + +// ==================== Push Device Types ==================== + +export type DeviceType = 'ios' | 'android' | 'web'; + +export interface DeviceTokenResponse { + id: number; + device_id: string; + device_type: DeviceType; + is_active: boolean; + device_name?: string; + last_used_at: string; + created_at: string; +} + +export interface RegisterDeviceRequest { + device_id: string; + device_type: DeviceType; + push_token?: string; + device_name?: string; +} + +export interface PushRecordResponse { + id: number; + message_id: number; + push_channel: string; + push_status: string; + retry_count: number; + pushed_at?: string; + delivered_at?: string; + created_at: string; +} + +// ==================== WS Event Types ==================== + +export type WSEventType = 'message' | 'notice' | 'request' | 'meta'; +export type WSDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read'; + +export interface WSSegment { + type: string; + data: Record; +} + +export interface WSEvent { + id: string; + time: number; + type: WSEventType; + detail_type: WSDetailType; + seq: string; + message?: WSSegment[]; + conversation_id: string; +} + +// ==================== Verification DTOs ==================== + +import type { VerificationStatus, UserIdentity } from './user'; + +export type { VerificationStatus, UserIdentity } from './user'; + +export interface VerificationStatusDTO { + verification_status: VerificationStatus; + identity?: UserIdentity; + has_pending_request?: boolean; + rejected_reason?: string; + verified_at?: string; + latest_record?: { + id: string; + reject_reason?: string; + real_name?: string; + department?: string; + created_at?: string; + }; +} + +export interface VerificationRecordDTO { + id: string; + user_id: string; + identity: UserIdentity; + status: VerificationStatus; + real_name: string; + student_id?: string; + employee_id?: string; + department?: string; + proof_materials: string; + reviewer_id?: string; + review_note?: string; + created_at: string; + updated_at: string; +} + +export interface SubmitVerificationRequest { + identity: UserIdentity; + real_name: string; + student_id?: string; + employee_id?: string; + department?: string; + proof_materials: string; +} \ No newline at end of file diff --git a/src/types/dto/group.ts b/src/types/dto/group.ts new file mode 100644 index 0000000..734efff --- /dev/null +++ b/src/types/dto/group.ts @@ -0,0 +1,126 @@ +/** + * Group Chat DTOs + */ + +import type { UserDTO } from './user'; + +export type JoinType = 0 | 1 | 2; +export type GroupRole = 'owner' | 'admin' | 'member'; + +export interface GroupResponse { + id: string; + name: string; + avatar: string; + description: string; + owner_id: string; + member_count: number; + max_members: number; + join_type: JoinType; + mute_all: boolean; + created_at: string; +} + +export interface GroupMemberResponse { + id: number; + group_id: string; + user_id: string; + role: GroupRole; + nickname: string; + muted: boolean; + join_time: string; + user?: UserDTO; +} + +export interface GroupAnnouncementResponse { + id: number; + group_id: string; + content: string; + author_id: string; + is_pinned: boolean; + created_at: string; +} + +export interface CreateGroupRequest { + name: string; + description?: string; + member_ids?: string[]; +} + +export interface UpdateGroupRequest { + name?: string; + description?: string; + avatar?: string; +} + +export interface InviteMembersRequest { + member_ids: string[]; +} + +export interface HandleGroupRequestAction { + flag: string; + approve: boolean; + reason?: string; +} + +export interface TransferOwnerRequest { + new_owner_id: string; +} + +export interface SetRoleRequest { + role: 'admin' | 'member'; +} + +export interface SetNicknameRequest { + nickname: string; +} + +export interface MuteMemberRequest { + muted: boolean; +} + +export interface SetMuteAllRequest { + mute_all: boolean; +} + +export interface MyMemberInfoResponse { + member: GroupMemberResponse; + mute_all: boolean; + is_muted: boolean; + can_speak: boolean; +} + +export interface SetJoinTypeRequest { + join_type: JoinType; +} + +export interface SetGroupAvatarRequest { + avatar: string; +} + +export interface CreateAnnouncementRequest { + content: string; +} + +export interface GroupListResponse { + list: GroupResponse[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface GroupMemberListResponse { + list: GroupMemberResponse[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface GroupAnnouncementListResponse { + list: GroupAnnouncementResponse[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} \ No newline at end of file diff --git a/src/types/dto/index.ts b/src/types/dto/index.ts new file mode 100644 index 0000000..3df3b4f --- /dev/null +++ b/src/types/dto/index.ts @@ -0,0 +1,11 @@ +/** + * DTO Types - Re-exports from individual modules + */ + +export * from './user'; +export * from './post'; +export * from './message'; +export * from './group'; +export * from './common'; +export * from './pagination'; +export * from './segmentUtils'; \ No newline at end of file diff --git a/src/types/dto/message.ts b/src/types/dto/message.ts new file mode 100644 index 0000000..368213e --- /dev/null +++ b/src/types/dto/message.ts @@ -0,0 +1,211 @@ +/** + * Message Segment Types & Chat DTOs + */ + +import type { UserDTO } from './user'; +import type { GroupResponse } from './group'; + +export type ConversationType = 'private' | 'group'; +export type MessageContentType = 'text' | 'image' | 'video' | 'audio' | 'file'; +export type MessageStatus = 'normal' | 'recalled' | 'deleted'; + +// ==================== Segment Types ==================== + +export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote' | 'post_ref'; + +export interface TextSegmentData { + content?: string; + text?: string; +} + +export interface ImageSegmentData { + url: string; + width?: number; + height?: number; + thumbnail_url?: string; + file_size?: number; +} + +export interface VoiceSegmentData { + url: string; + duration?: number; + file_size?: number; +} + +export interface VideoSegmentData { + url: string; + width?: number; + height?: number; + duration?: number; + thumbnail_url?: string; + file_size?: number; +} + +export interface FileSegmentData { + url: string; + name: string; + size?: number; + mime_type?: string; +} + +export interface AtSegmentData { + user_id: string; + nickname?: string; +} + +export interface ReplySegmentData { + id: string; + seq?: number; +} + +export interface FaceSegmentData { + id: number; + name?: string; + url?: string; +} + +export interface LinkSegmentData { + url: string; + title?: string; + description?: string; + image?: string; +} + +export interface VoteOptionData { + id: string; + content: string; +} + +export interface VoteSegmentData { + options: VoteOptionData[]; + max_choices?: number; + is_multi_choice?: boolean; +} + +export interface PostRefSegmentData { + post_id: string; + title?: string; + author?: { + id: string; + nickname: string; + avatar?: string; + }; + status?: string; + accessible?: boolean; + click_count?: number; +} + +export type MessageSegment = + | { type: 'text'; data: TextSegmentData } + | { type: 'image'; data: ImageSegmentData } + | { type: 'voice'; data: VoiceSegmentData } + | { type: 'video'; data: VideoSegmentData } + | { type: 'file'; data: FileSegmentData } + | { type: 'at'; data: AtSegmentData } + | { type: 'reply'; data: ReplySegmentData } + | { type: 'face'; data: FaceSegmentData } + | { type: 'link'; data: LinkSegmentData } + | { type: 'vote'; data: VoteSegmentData } + | { type: 'post_ref'; data: PostRefSegmentData }; + +// ==================== Chat DTOs ==================== + +export interface MessageResponse { + id: string; + conversation_id: string; + sender_id: string; + seq: number; + segments: MessageSegment[]; + reply_to_id?: string; + status: MessageStatus; + category?: string; + created_at: string; + sender?: UserDTO; + is_system_notice?: boolean; + notice_content?: string; +} + +export interface ConversationResponse { + id: string; + type: ConversationType; + is_pinned?: boolean; + notification_muted?: boolean; + last_seq: number; + last_message?: MessageResponse; + last_message_at: string; + unread_count: number; + participants?: UserDTO[]; + member_count?: number; + group?: GroupResponse; + my_last_read_seq?: number; + other_last_read_seq?: number; + created_at: string; + updated_at: string; +} + +export interface ConversationParticipantResponse { + user_id: string; + last_read_seq: number; + notification_muted: boolean; + is_pinned: boolean; +} + +export interface ConversationDetailResponse { + id: string; + type: ConversationType; + is_pinned?: boolean; + notification_muted?: boolean; + last_seq: number; + last_message?: MessageResponse; + last_message_at: string; + unread_count: number; + participants: UserDTO[]; + my_last_read_seq: number; + other_last_read_seq: number; + group?: GroupResponse; + created_at: string; + updated_at: string; +} + +export interface CreateConversationRequest { + user_id: string; +} + +export interface SendMessageRequest { + segments?: MessageSegment[]; + content?: string; + reply_to_id?: string; +} + +export interface MarkReadRequest { + last_read_seq: number; +} + +export interface UnreadCountResponse { + total_unread_count: number; +} + +export interface ConversationUnreadCountResponse { + conversation_id: string; + unread_count: number; +} + +export interface ConversationListResponse { + list: ConversationResponse[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface MessageListResponse { + messages: MessageResponse[]; + total: number; + page: number; + page_size: number; +} + +export interface MessageSyncResponse { + messages: MessageResponse[]; + has_more: boolean; +} \ No newline at end of file diff --git a/src/types/dto/pagination.ts b/src/types/dto/pagination.ts new file mode 100644 index 0000000..1b3236e --- /dev/null +++ b/src/types/dto/pagination.ts @@ -0,0 +1,92 @@ +/** + * Pagination, Vote, Privacy & Other DTOs + */ + +// ==================== Common ==================== + +export interface SuccessDTO { + success: boolean; + message: string; +} + +export interface AvailableDTO { + available: boolean; +} + +export interface CountDTO { + count: number; +} + +export interface URLDTO { + url: string; +} + +// ==================== Cursor Pagination ==================== + +export interface CursorPaginationRequest { + cursor?: string; + direction?: 'forward' | 'backward'; + page_size?: number; + post_type?: 'follow' | 'hot' | 'latest'; + channel_id?: string; +} + +export interface CursorPaginationResponse { + list: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +} + +// ==================== Vote DTOs ==================== + +export interface VoteOptionDTO { + id: string; + content: string; + votes_count: number; +} + +export interface VoteResultDTO { + options: VoteOptionDTO[]; + total_votes: number; + has_voted: boolean; + voted_option_id?: string; +} + +export interface CreateVotePostRequest { + title: string; + content?: string; + images?: string[]; + vote_options: Array<{ + content: string; + }>; + channel_id?: string; +} + +// ==================== Privacy & Account ==================== + +export type VisibilityLevel = 'everyone' | 'following' | 'self'; + +export interface PrivacySettingsDTO { + followers_visibility: VisibilityLevel; + following_visibility: VisibilityLevel; + posts_visibility: VisibilityLevel; + favorites_visibility: VisibilityLevel; +} + +export interface PrivacySettingsRequestDTO { + followers_visibility?: VisibilityLevel; + following_visibility?: VisibilityLevel; + posts_visibility?: VisibilityLevel; + favorites_visibility?: VisibilityLevel; +} + +export interface DeletionStatusDTO { + is_pending_deletion: boolean; + deletion_requested_at?: string; + cool_down_days?: number; +} + +export interface RequestDeletionRequestDTO { + password: string; +} \ No newline at end of file diff --git a/src/types/dto/post.ts b/src/types/dto/post.ts new file mode 100644 index 0000000..11add99 --- /dev/null +++ b/src/types/dto/post.ts @@ -0,0 +1,75 @@ +/** + * Post & Comment DTOs + */ + +import type { UserDTO } from './user'; +import type { MessageSegment } from './message'; + +export interface PostImageDTO { + id: string; + url: string; + thumbnail_url: string; + preview_url: string; + preview_url_large: string; + width: number; + height: number; +} + +export interface PostChannelBriefDTO { + id: string; + name: string; +} + +export interface PostDTO { + id: string; + user_id: string; + title: string; + content: string; + segments?: MessageSegment[]; + images: PostImageDTO[]; + status?: string; + likes_count: number; + comments_count: number; + favorites_count: number; + shares_count: number; + views_count: number; + is_pinned: boolean; + is_locked: boolean; + is_vote: boolean; + created_at: string; + updated_at?: string; + content_edited_at?: string; + author: UserDTO | null; + is_liked: boolean; + is_favorited: boolean; + channel_id?: string; + channel?: PostChannelBriefDTO | null; + top_comment?: CommentDTO | null; +} + +export interface PostDetailDTO extends PostDTO { + status: string; + updated_at: string; +} + +export interface CommentImageDTO { + url: string; +} + +export interface CommentDTO { + id: string; + post_id: string; + user_id: string; + parent_id: string | null; + root_id: string | null; + content: string; + segments?: MessageSegment[]; + images: CommentImageDTO[]; + likes_count: number; + replies_count: number; + created_at: string; + author: UserDTO; + is_liked: boolean; + target_id?: string | null; + replies?: CommentDTO[]; +} \ No newline at end of file diff --git a/src/types/dto/segmentUtils.ts b/src/types/dto/segmentUtils.ts new file mode 100644 index 0000000..937bae0 --- /dev/null +++ b/src/types/dto/segmentUtils.ts @@ -0,0 +1,108 @@ +/** + * Segment text extraction utilities + */ + +import type { MessageSegment, TextSegmentData, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message'; +import type { UserDTO } from './user'; + +/** + * Extract plain text from message segments for display + */ +export function extractTextFromSegments(segments?: MessageSegment[], memberMap?: Map): string { + if (!segments || segments.length === 0) { + return ''; + } + + let result = ''; + + for (const segment of segments) { + switch (segment.type) { + case 'text': + const textData = segment.data as TextSegmentData; + result += textData.content ?? textData.text ?? ''; + break; + case 'at': + const atData = segment.data as AtSegmentData; + if (atData.user_id === 'all') { + result += '@所有人 '; + } else { + const member = memberMap?.get(atData.user_id); + const displayName = member?.nickname || atData.nickname || atData.user_id; + result += `@${displayName} `; + } + break; + case 'image': + result += '[图片]'; + break; + case 'voice': + result += '[语音]'; + break; + case 'video': + result += '[视频]'; + break; + case 'file': + const fileData = segment.data as FileSegmentData; + result += `[文件: ${fileData.name || '未知'}]`; + break; + case 'face': + const faceData = segment.data as FaceSegmentData; + result += faceData.name ? `[${faceData.name}]` : '[表情]'; + break; + case 'link': + const linkData = segment.data as LinkSegmentData; + result += linkData.title ? `[链接: ${linkData.title}]` : '[链接]'; + break; + case 'reply': + break; + case 'post_ref': + const refData = segment.data as PostRefSegmentData; + result += refData.title ? `[帖子: ${refData.title}]` : '[帖子引用]'; + break; + } + } + + return result.trim(); +} + +/** + * Async version that resolves missing @at nicknames + */ +export async function extractTextFromSegmentsAsync( + segments?: MessageSegment[], + getUserCache?: (userId: string) => Promise, + getUserById?: (userId: string) => Promise +): Promise { + if (!segments || segments.length === 0) { + return ''; + } + + const resolvedSegments = [...segments]; + for (let i = 0; i < resolvedSegments.length; i++) { + const segment = resolvedSegments[i]; + if (segment.type === 'at') { + const atData = segment.data as AtSegmentData; + if (atData.user_id !== 'all' && !atData.nickname) { + try { + let nickname = atData.user_id; + if (getUserCache) { + const cachedUser = await getUserCache(atData.user_id); + if (cachedUser?.nickname) { + nickname = cachedUser.nickname; + } else if (getUserById) { + const user = await getUserById(atData.user_id); + if (user?.nickname) nickname = user.nickname; + } + } else if (getUserById) { + const user = await getUserById(atData.user_id); + if (user?.nickname) nickname = user.nickname; + } + resolvedSegments[i] = { ...segment, data: { ...atData, nickname } }; + } catch (error) { + console.warn('[extractTextFromSegmentsAsync] 获取用户信息失败:', error); + } + } + } + } + + return extractTextFromSegments(resolvedSegments); +} \ No newline at end of file diff --git a/src/types/dto/user.ts b/src/types/dto/user.ts new file mode 100644 index 0000000..291d940 --- /dev/null +++ b/src/types/dto/user.ts @@ -0,0 +1,49 @@ +/** + * User DTOs + */ + +export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected' | 'none'; +export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external'; + +export interface UserDTO { + id: string; + username: string; + nickname: string; + avatar: string; + cover_url: string; + bio: string; + website: string; + location: string; + phone?: string; + email?: string; + email_verified?: boolean; + posts_count: number; + followers_count: number; + following_count: number; + created_at: string; + is_following?: boolean; + is_following_me?: boolean; + identity?: UserIdentity; + verification_status?: VerificationStatus; +} + +export interface UserDetailDTO { + id: string; + username: string; + nickname: string; + email: string; + email_verified?: boolean; + avatar: string | null; + bio: string | null; + website: string | null; + location: string | null; + posts_count: number; + followers_count: number; + following_count: number; + is_verified: boolean; + created_at: string; + is_following?: boolean; + is_following_me?: boolean; + identity?: UserIdentity; + verification_status?: VerificationStatus; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 76e1a80..f95a701 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,73 +1,30 @@ -// ============================================ -// WithYou - Core Type Definitions -// 使用后端DTO类型,保持snake_case命名规范 -// ============================================ +/** + * WithYou - Core Type Definitions + */ -// 导出DTO类型作为主要类型 export * from './dto'; export * from './schedule'; export * from './material'; export * from './trade'; -// 兼容旧类型(用于内部组件) import type { UserDTO, PostDTO, PostImageDTO, - // 新的聊天系统类型 - ConversationType, - MessageContentType, - MessageStatus, - MessageResponse, - ConversationResponse, - ConversationParticipantResponse, - ConversationDetailResponse, - CreateConversationRequest, - SendMessageRequest, - MarkReadRequest, - UnreadCountResponse, - ConversationUnreadCountResponse, - ConversationListResponse, - MessageListResponse, - MessageSyncResponse, - // Segment 类型 - SegmentType, MessageSegment, - TextSegmentData, - ImageSegmentData, - VoiceSegmentData, - VideoSegmentData, - FileSegmentData, -AtSegmentData, - ReplySegmentData, - FaceSegmentData, - LinkSegmentData, - VoteSegmentData, - VoteOptionData, + ConversationResponse, + MessageResponse, } from './dto'; // ============================================ -// User Types +// Backward-compat aliases — prefer DTO names directly // ============================================ export type User = UserDTO; - -export interface UserProfile extends User { - favorites_count: number; - liked_posts_count: number; -} - -// ============================================ -// Post Types -// ============================================ - export type Post = PostDTO; -export type PostDetail = PostDTO; export type PostImage = PostImageDTO; - -// ============================================ -// Vote Types -// ============================================ +export type Conversation = ConversationResponse; +export type Message = MessageResponse; export type { VoteOptionDTO as VoteOption, VoteResultDTO as VoteResult, CreateVotePostRequest as CreateVotePostReq } from './dto'; @@ -83,7 +40,6 @@ export interface CreatePostInput { // Comment Types // ============================================ -// 评论图片 export interface CommentImage { url: string; } @@ -102,7 +58,7 @@ export interface Comment { is_liked?: boolean; author: User; replies?: Comment[]; - target_id?: string | null; // 被回复的评论ID,前端根据此ID找到被回复用户的昵称 + target_id?: string | null; created_at: string; } @@ -110,7 +66,7 @@ export interface CreateCommentInput { postId: string; parentId?: string; content: string; - images?: string[]; // 图片URL列表 + images?: string[]; replyToUserId?: string; } @@ -149,49 +105,4 @@ export interface NotificationBadge { comments: number; follows: number; system: number; -} - -// ============================================ -// Message Types (New Chat System) -// ============================================ - -/** - * 新版会话类型 - 使用雪花算法ID和seq机制 - */ -export type Conversation = ConversationResponse; - -/** - * 新版消息类型 - 使用雪花算法ID和seq机制 - */ -export type Message = MessageResponse; - -// ============================================ -// API Response Types -// ============================================ - -export interface ApiResponse { - success: boolean; - data: T; - message?: string; -} - -export interface PaginatedResponse { - success: boolean; - data: T[]; - pagination: { - page: number; - limit: number; - total: number; - hasMore: boolean; - nextCursor?: string; - }; -} - -export interface ApiError { - success: false; - error: { - code: string; - message: string; - details?: Record; - }; -} +} \ No newline at end of file diff --git a/src/utils/cache/types.ts b/src/utils/cache/types.ts new file mode 100644 index 0000000..b4e1d00 --- /dev/null +++ b/src/utils/cache/types.ts @@ -0,0 +1,202 @@ +/** + * 媒体缓存类型定义 + * @module src/utils/cache/types + * @description 定义媒体缓存相关的类型、枚举和接口 + */ + +/** + * 媒体类型枚举 + * @enum {string} + * @description 支持的媒体类型 + */ +export enum MediaType { + IMAGE = 'IMAGE', + VIDEO = 'VIDEO', + AUDIO = 'AUDIO', +} + +/** + * 清理触发器枚举 + * @enum {string} + * @description 触发清理操作的来源 + */ +export enum CleanupTrigger { + STARTUP = 'startup', + SCHEDULED = 'scheduled', + MANUAL = 'manual', + LOW_STORAGE = 'low_storage', + CONVERSATION_DELETED = 'conversation_deleted', +} + +/** + * 缓存条目接口 + * @interface CacheEntry + * @description 单个媒体缓存条目的信息 + */ +export interface CacheEntry { + /** 缓存键值 */ + key: string; + /** 媒体类型 */ + type: MediaType; + /** 原始URI */ + uri: string; + /** 本地存储路径 */ + localPath: string; + /** 文件大小(字节) */ + size: number; + /** 所属会话ID(可选) */ + conversationId?: string; + /** 关联消息ID(可选) */ + messageId?: string; + /** 创建时间 */ + createdAt: number; + /** 最后访问时间 */ + lastAccessedAt: number; +} + +/** + * 缓存统计接口 + * @interface CacheStats + * @description 媒体缓存的整体统计信息 + */ +export interface CacheStats { + /** 总缓存大小(字节) */ + totalSize: number; + /** 图片缓存数量 */ + imageCount: number; + /** 视频缓存数量 */ + videoCount: number; + /** 音频缓存数量 */ + audioCount: number; + /** 总条目数 */ + totalEntries: number; + /** 最早缓存项时间 */ + oldestItem: number; + /** 最新缓存项时间 */ + newestItem: number; +} + +/** + * 清理策略接口 + * @interface CleanupPolicy + * @description 定义缓存清理的规则和限制 + */ +export interface CleanupPolicy { + /** 最大缓存时间(毫秒) */ + maxAge: number; + /** 最大缓存大小(字节) */ + maxSize: number; + /** 最大条目数 */ + maxEntries: number; + /** 启动时是否清理 */ + cleanupOnStart: boolean; + /** 定期清理间隔(毫秒) */ + cleanupInterval: number; + /** 是否启用LRU清理 */ + enableLRU: boolean; + /** 低存储空间阈值(字节) */ + lowStorageThreshold: number; +} + +/** + * 清理结果接口 + * @interface CleanupResult + * @description 清理操作的执行结果 + */ +export interface CleanupResult { + /** 触发器类型 */ + trigger: CleanupTrigger; + /** 执行时间戳 */ + timestamp: number; + /** 删除条目数量 */ + deletedCount: number; + /** 释放空间大小(字节) */ + freedSize: number; + /** 错误信息列表 */ + errors: string[]; + /** 清理的条目详情 */ + deletedItems?: string[]; +} + +/** + * 媒体缓存配置接口 + * @interface MediaCacheConfig + * @description 媒体缓存的全局配置 + */ +export interface MediaCacheConfig { + /** 图片配置 */ + image: { + /** 内存缓存数量 */ + maxMemoryCacheSize: number; + /** 磁盘缓存大小(MB) */ + maxDiskCacheSize: number; + /** 缓存有效期(小时) */ + maxAge: number; + /** 会话内媒体保留时间(天) */ + maxAgeForConversation: number; + }; + /** 视频配置 */ + video: { + /** 磁盘缓存大小(MB) */ + maxDiskCacheSize: number; + /** 缓存有效期(小时) */ + maxAge: number; + /** 是否自动预加载 */ + autoPreload: boolean; + }; + /** 音频配置 */ + audio: { + /** 磁盘缓存大小(MB) */ + maxDiskCacheSize: number; + /** 缓存有效期(天) */ + maxAge: number; + }; + /** 全局配置 */ + global: { + /** 启动时检查清理 */ + checkOnStartup: boolean; + /** 定期检查间隔(小时) */ + checkInterval: number; + /** 低存储空间阈值(MB) */ + lowStorageThreshold: number; + }; +} + +/** + * 默认媒体缓存配置 + */ +export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { + image: { + maxMemoryCacheSize: 50, + maxDiskCacheSize: 500, + maxAge: 168, // 7 days + maxAgeForConversation: 30, + }, + video: { + maxDiskCacheSize: 1000, + maxAge: 72, // 3 days + autoPreload: false, + }, + audio: { + maxDiskCacheSize: 200, + maxAge: 168, // 7 days + }, + global: { + checkOnStartup: true, + checkInterval: 24, + lowStorageThreshold: 500, + }, +}; + +/** + * 默认清理策略 + */ +export const DEFAULT_CLEANUP_POLICY: CleanupPolicy = { + maxAge: 7 * 24 * 60 * 60 * 1000, // 7天 + maxSize: 500 * 1024 * 1024, // 500MB + maxEntries: 1000, + cleanupOnStart: true, + cleanupInterval: 24 * 60 * 60 * 1000, // 24小时 + enableLRU: true, + lowStorageThreshold: 500 * 1024 * 1024, // 500MB +}; diff --git a/src/utils/diff/messageDiffCalculator.ts b/src/utils/diff/messageDiffCalculator.ts new file mode 100644 index 0000000..9ef7f87 --- /dev/null +++ b/src/utils/diff/messageDiffCalculator.ts @@ -0,0 +1,268 @@ +/** + * 消息差异计算器 + * 计算两个消息列表之间的差异,支持增量更新 + */ + +import { + MessageIdentifier, + DiffResult, + MessageComparator, + MessageSorter, + DiffConfig, + DEFAULT_DIFF_CONFIG, +} from './messageTypes'; + +/** + * 消息差异计算器类 + * 用于高效计算消息列表的变化 + */ +export class MessageDiffCalculator { + private config: Required; + private previousMessages: Map = new Map(); + private previousOrder: string[] = []; + + constructor(config: DiffConfig = {}) { + this.config = { ...DEFAULT_DIFF_CONFIG, ...config }; + } + + /** + * 计算两个消息列表的差异 + * @param oldList 旧消息列表 + * @param newList 新消息列表 + * @returns 差异结果 + */ + calculateDiff(oldList: T[], newList: T[]): DiffResult { + const result: DiffResult = { + added: [], + updated: [], + deleted: [], + moved: [], + unchanged: [], + shouldReset: false, + }; + + // 如果旧列表为空,直接返回新列表作为新增 + if (oldList.length === 0) { + result.added = [...newList]; + this.updateCache(newList); + return result; + } + + // 如果新列表为空,表示全部删除 + if (newList.length === 0) { + result.deleted = oldList.map((msg, index) => ({ message: msg, index })); + this.clearCache(); + return result; + } + + // 检查是否需要重置(变化太大) + const changeRatio = this.calculateChangeRatio(oldList, newList); + if (changeRatio > 0.5) { + result.shouldReset = true; + result.added = [...newList]; + this.updateCache(newList); + return result; + } + + // 构建旧列表的索引映射 + const oldIndexMap = new Map(); + oldList.forEach((msg, index) => { + oldIndexMap.set(msg.id, index); + }); + + // 构建新列表的索引映射 + const newIndexMap = new Map(); + newList.forEach((msg, index) => { + newIndexMap.set(msg.id, index); + }); + + // 检测新增、更新和未变更的消息 + const processedIds = new Set(); + + for (let newIndex = 0; newIndex < newList.length; newIndex++) { + const newMsg = newList[newIndex]; + const oldIndex = oldIndexMap.get(newMsg.id); + + if (oldIndex === undefined) { + // 新增消息 + result.added.push(newMsg); + } else { + const oldMsg = oldList[oldIndex]; + processedIds.add(newMsg.id); + + // 检测是否有更新 + const changes = this.detectChanges(oldMsg, newMsg); + if (changes && Object.keys(changes).length > 0) { + result.updated.push({ + message: newMsg, + index: oldIndex, + changes, + }); + } else { + result.unchanged.push(newMsg); + } + + // 检测是否移动 + if (oldIndex !== newIndex) { + result.moved.push({ + message: newMsg, + fromIndex: oldIndex, + toIndex: newIndex, + }); + } + } + } + + // 检测删除的消息 + for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) { + const oldMsg = oldList[oldIndex]; + if (!processedIds.has(oldMsg.id)) { + result.deleted.push({ message: oldMsg, index: oldIndex }); + } + } + + this.updateCache(newList); + return result; + } + + /** + * 计算增量差异(基于缓存的上次状态) + * @param newList 新消息列表 + * @returns 差异结果 + */ + calculateIncrementalDiff(newList: T[]): DiffResult { + const oldList = Array.from(this.previousMessages.values()); + return this.calculateDiff(oldList, newList); + } + + /** + * 计算变化比例 + * @param oldList 旧列表 + * @param newList 新列表 + * @returns 变化比例(0-1) + */ + private calculateChangeRatio(oldList: T[], newList: T[]): number { + if (oldList.length === 0 && newList.length === 0) return 0; + if (oldList.length === 0 || newList.length === 0) return 1; + + const oldIds = new Set(oldList.map(m => m.id)); + const newIds = new Set(newList.map(m => m.id)); + + let commonCount = 0; + oldIds.forEach(id => { + if (newIds.has(id)) { + commonCount++; + } + }); + + const maxLength = Math.max(oldList.length, newList.length); + return 1 - commonCount / maxLength; + } + + /** + * 检测消息变化 + * @param oldMsg 旧消息 + * @param newMsg 新消息 + * @returns 变化字段 + */ + private detectChanges(oldMsg: T, newMsg: T): Partial | null { + const changes: Partial = {}; + let hasChanges = false; + + for (const key of Object.keys(newMsg) as Array) { + if (key === 'id') continue; // ID 不变 + + const oldValue = oldMsg[key]; + const newValue = newMsg[key]; + + if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { + (changes as any)[key] = newValue; + hasChanges = true; + } + } + + return hasChanges ? changes : null; + } + + /** + * 更新缓存 + * @param messages 消息列表 + */ + private updateCache(messages: T[]): void { + this.previousMessages.clear(); + this.previousOrder = []; + + for (const msg of messages) { + this.previousMessages.set(msg.id, msg); + this.previousOrder.push(msg.id); + } + } + + /** + * 清除缓存 + */ + private clearCache(): void { + this.previousMessages.clear(); + this.previousOrder = []; + } + + /** + * 设置配置 + * @param config 配置 + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * 获取当前配置 + * @returns 当前配置 + */ + getConfig(): Required { + return { ...this.config }; + } + + /** + * 重置计算器状态 + */ + reset(): void { + this.clearCache(); + } + + /** + * 获取缓存的消息数量 + * @returns 缓存数量 + */ + getCachedCount(): number { + return this.previousMessages.size; + } + + /** + * 检查消息是否在缓存中 + * @param messageId 消息 ID + * @returns 是否存在 + */ + hasMessage(messageId: string): boolean { + return this.previousMessages.has(messageId); + } + + /** + * 获取缓存中的消息 + * @param messageId 消息 ID + * @returns 消息或 undefined + */ + getCachedMessage(messageId: string): T | undefined { + return this.previousMessages.get(messageId); + } +} + +/** + * 创建差异计算器实例的工厂函数 + * @param config 配置 + * @returns MessageDiffCalculator 实例 + */ +export function createMessageDiffCalculator( + config?: DiffConfig +): MessageDiffCalculator { + return new MessageDiffCalculator(config); +} diff --git a/src/utils/diff/messageTypes.ts b/src/utils/diff/messageTypes.ts new file mode 100644 index 0000000..a0d85d1 --- /dev/null +++ b/src/utils/diff/messageTypes.ts @@ -0,0 +1,209 @@ +/** + * 差异更新类型定义 + * 用于消息列表的增量更新,减少 UI 重渲染次数 + */ + +/** + * 消息更新类型枚举 + */ +export enum MessageUpdateType { + /** 添加单条消息 */ + ADD = 'ADD', + /** 更新单条消息 */ + UPDATE = 'UPDATE', + /** 删除单条消息 */ + DELETE = 'DELETE', + /** 移动消息位置 */ + MOVE = 'MOVE', + /** 批量添加 */ + BATCH_ADD = 'BATCH_ADD', + /** 批量更新 */ + BATCH_UPDATE = 'BATCH_UPDATE', + /** 批量删除 */ + BATCH_DELETE = 'BATCH_DELETE', + /** 重置整个列表 */ + RESET = 'RESET', +} + +/** + * 消息唯一标识接口 + */ +export interface MessageIdentifier { + /** 消息唯一 ID */ + id: string; + /** 消息序列号(可选,用于排序) */ + seq?: number; +} + +/** + * 基础更新操作接口 + */ +export interface BaseMessageUpdate { + /** 更新类型 */ + type: MessageUpdateType; + /** 更新时间戳 */ + timestamp: number; + /** 会话 ID(可选) */ + conversationId?: string; + /** 载荷数据(可选,用于合并更新) */ + payload?: Record; +} + +/** + * 添加单条消息更新 + */ +export interface AddMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.ADD; + /** 消息数据 */ + message: MessageIdentifier; + /** 插入位置索引(可选,默认追加到末尾) */ + index?: number; +} + +/** + * 更新单条消息更新 + */ +export interface UpdateMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.UPDATE; + /** 消息 ID */ + messageId: string; + /** 更新的字段 */ + updates: Partial; +} + +/** + * 删除单条消息更新 + */ +export interface DeleteMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.DELETE; + /** 消息 ID */ + messageId: string; +} + +/** + * 移动消息更新 + */ +export interface MoveMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.MOVE; + /** 消息 ID */ + messageId: string; + /** 目标位置索引 */ + toIndex: number; +} + +/** + * 批量添加消息更新 + */ +export interface BatchAddMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.BATCH_ADD; + /** 消息列表 */ + messages: MessageIdentifier[]; + /** 插入位置索引(可选,默认追加到末尾) */ + startIndex?: number; +} + +/** + * 批量更新消息更新 + */ +export interface BatchUpdateMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.BATCH_UPDATE; + /** 批量更新项 */ + updates: Array<{ + messageId: string; + changes: Partial; + }>; +} + +/** + * 批量删除消息更新 + */ +export interface BatchDeleteMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.BATCH_DELETE; + /** 要删除的消息 ID 列表 */ + messageIds: string[]; +} + +/** + * 重置消息列表更新 + */ +export interface ResetMessageUpdate extends BaseMessageUpdate { + type: MessageUpdateType.RESET; + /** 新的消息列表 */ + messages: MessageIdentifier[]; +} + +/** + * 消息更新联合类型 + */ +export type MessageUpdate = + | AddMessageUpdate + | UpdateMessageUpdate + | DeleteMessageUpdate + | MoveMessageUpdate + | BatchAddMessageUpdate + | BatchUpdateMessageUpdate + | BatchDeleteMessageUpdate + | ResetMessageUpdate; + +/** + * 差异配置接口 + */ +export interface DiffConfig { + /** 批量处理间隔(毫秒),默认 16ms(60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 100 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同消息的多次更新,默认 true */ + enableMerge?: boolean; + /** 消息唯一标识字段,默认 'id' */ + idField?: string; + /** 排序字段,默认 'seq' */ + sortField?: string; +} + +/** + * 差异计算结果接口 + */ +export interface DiffResult { + /** 新增的消息 */ + added: T[]; + /** 更新的消息 */ + updated: Array<{ message: T; index: number; changes: Partial }>; + /** 删除的消息 */ + deleted: Array<{ message: T; index: number }>; + /** 移动的消息 */ + moved: Array<{ message: T; fromIndex: number; toIndex: number }>; + /** 未变更的消息 */ + unchanged: T[]; + /** 是否需要重置 */ + shouldReset: boolean; +} + +/** + * 批量更新监听器类型 + */ +export type BatchUpdateListener = (updates: MessageUpdate[]) => void; + +/** + * 消息比较函数类型 + */ +export type MessageComparator = (a: T, b: T) => boolean; + +/** + * 消息排序函数类型 + */ +export type MessageSorter = (a: T, b: T) => number; + +/** + * 默认配置常量 + */ +export const DEFAULT_DIFF_CONFIG: Required = { + batchInterval: 16, + maxBatchSize: 100, + enableDeduplication: true, + enableMerge: true, + idField: 'id', + sortField: 'seq', +}; diff --git a/src/utils/diff/postDiffCalculator.ts b/src/utils/diff/postDiffCalculator.ts new file mode 100644 index 0000000..cc6f8bd --- /dev/null +++ b/src/utils/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/utils/diff/postTypes.ts b/src/utils/diff/postTypes.ts new file mode 100644 index 0000000..480bfd6 --- /dev/null +++ b/src/utils/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(可选) */ + channelId?: 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; +} diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts new file mode 100644 index 0000000..e983276 --- /dev/null +++ b/src/utils/formatTime.ts @@ -0,0 +1,75 @@ +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; + +const pad = (num: number) => String(num).padStart(2, '0'); +const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +export function formatRelativeTime(dateString?: string | null): string { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + + const now = Date.now(); + const diffMs = now - date.getTime(); + + if (diffMs < 0) return formatDateTime(dateString); + if (diffMs < MINUTE_MS) return '刚刚'; + if (diffMs < HOUR_MS) return `${Math.floor(diffMs / MINUTE_MS)}分钟前`; + if (diffMs < DAY_MS) return `${Math.floor(diffMs / HOUR_MS)}小时前`; + if (diffMs < 2 * DAY_MS) return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`; + + return formatDateTime(dateString); +} + +export function formatTime(dateString: string): string { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / HOUR_MS; + + if (diffInHours < 24 && date.getDate() === now.getDate()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.getDate() === yesterday.getDate()) return '昨天'; + + if (diffInHours < 24 * 7) return WEEKDAYS[date.getDay()]; + + return `${date.getMonth() + 1}/${date.getDate()}`; +} + +export function formatChatTime(dateString: string): string { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / HOUR_MS; + + if (diffInHours < 24 && date.getDate() === now.getDate()) { + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } + + return formatDistanceToNow(date, { addSuffix: true, locale: zhCN }); +} + +export function formatDateTime(dateString?: string | null): string { + if (!dateString) return ''; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ''; + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }); +} \ No newline at end of file diff --git a/src/utils/pagination/types.ts b/src/utils/pagination/types.ts new file mode 100644 index 0000000..a79e8b4 --- /dev/null +++ b/src/utils/pagination/types.ts @@ -0,0 +1,266 @@ +/** + * 分页状态管理类型定义 + * + * 提供分页相关的接口和类型定义 + * 用于统一管理分页状态、缓存和配置 + */ + +/** + * 分页状态 + * 跟踪特定分页键的分页信息 + */ +export interface PaginationState { + /** 当前页码(1-based) */ + currentPage: number; + /** 每页大小 */ + pageSize: number; + /** 已加载的总条目数 */ + totalLoaded: number; + /** 是否存在更多数据 */ + hasMore: boolean; + /** 是否正在加载 */ + isLoading: boolean; + /** 最后加载时间戳 */ + lastLoadTime: number; + /** 分页游标(用于基于游标的分页) */ + cursor?: string | number | null; + /** 是否发生过错误 */ + hasError: boolean; + /** 错误信息 */ + error?: string; +} + +/** + * 页面缓存 + * 存储已加载页面的数据 + */ +export interface PageCache { + /** 页码 */ + page: number; + /** 该页的数据 */ + data: T[]; + /** 缓存时间戳 */ + cachedAt: number; + /** 分页游标 */ + cursor?: string | number | null; +} + +/** + * 分页配置 + * 配置分页行为 + */ +export interface PaginationConfig { + /** 每页默认大小 */ + pageSize: number; + /** 预加载阈值(距离底部多少条时触发预加载) */ + prefetchThreshold: number; + /** 最大缓存页数 */ + maxCachedPages: number; + /** 缓存过期时间(毫秒) */ + cacheTtl: number; + /** 是否启用预加载 */ + enablePrefetch: boolean; + /** 是否启用缓存 */ + enableCache: boolean; + /** 最大重试次数 */ + maxRetries: number; + /** 重试延迟(毫秒) */ + retryDelay: number; +} + +/** + * 加载更多结果 + * loadMore 方法的返回类型 + */ +export interface LoadMoreResult { + /** 是否成功 */ + success: boolean; + /** 加载的数据 */ + data: T[]; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否来自缓存 */ + fromCache: boolean; + /** 错误信息(如果失败) */ + error?: string; +} + +/** + * 刷新结果 + * refresh 方法的返回类型 + */ +export interface RefreshResult { + /** 是否成功 */ + success: boolean; + /** 刷新后的数据 */ + data: T[]; + /** 是否有更多数据 */ + hasMore: boolean; + /** 错误信息(如果失败) */ + error?: string; +} + +/** + * 预加载结果 + * prefetch 方法的返回类型 + */ +export interface PrefetchResult { + /** 是否成功 */ + success: boolean; + /** 预加载的数据 */ + data?: T[]; + /** 错误信息(如果失败) */ + error?: string; +} + +/** + * 分页数据加载函数 + * 用于从数据源加载分页数据 + */ +export type FetchPageFunction = ( + page: number, + pageSize: number, + cursor?: string | number | null +) => Promise<{ + data: T[]; + hasMore: boolean; + cursor?: string | number | null; +}>; + +/** + * 默认分页配置 + */ +export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = { + pageSize: 20, + prefetchThreshold: 5, + maxCachedPages: 10, + cacheTtl: 5 * 60 * 1000, // 5分钟 + enablePrefetch: true, + enableCache: true, + maxRetries: 3, + retryDelay: 1000, +}; + +/** + * 初始分页状态 + */ +export function createInitialPaginationState(pageSize: number = 20): PaginationState { + return { + currentPage: 0, + pageSize, + totalLoaded: 0, + hasMore: true, + isLoading: false, + lastLoadTime: 0, + cursor: null, + hasError: false, + error: undefined, + }; +} + +/** + * 创建页面缓存 + */ +export function createPageCache( + page: number, + data: T[], + cursor?: string | number | null +): PageCache { + return { + page, + data, + cachedAt: Date.now(), + cursor, + }; +} + +/** + * 检查缓存是否过期 + */ +export function isCacheExpired(cache: PageCache, ttl: number): boolean { + return Date.now() - cache.cachedAt > ttl; +} + +// ==================== 游标分页相关类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + /** 每页数量 */ + pageSize: number; + /** 是否启用双向分页 */ + bidirectional?: boolean; + /** 是否自动加载第一页,默认为 true */ + autoLoad?: boolean; +} + +/** + * 游标分页状态 + */ +export interface CursorPaginationState { + /** 数据项列表 */ + list: T[]; + /** 下一页游标 */ + nextCursor: string | null; + /** 上一页游标 */ + prevCursor: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否首屏加载(空列表初始化) */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否为首次加载 */ + isFirstLoad: boolean; +} + +/** + * 游标分页操作 + */ +export interface CursorPaginationActions { + /** 加载更多(下一页) */ + loadMore: () => Promise; + /** 加载上一页(双向分页) */ + loadPrevious: () => Promise; + /** 刷新数据(重新从第一页加载) */ + refresh: () => Promise; + /** 重置状态 */ + reset: () => void; + /** 设置数据(用于外部数据注入) */ + setList: (list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; + /** 就地更新单条数据 */ + updateItem: (id: string, updates: Partial) => void; +} + +/** + * 游标分页 Hook 返回值 + */ +export interface UseCursorPaginationReturn extends CursorPaginationState, CursorPaginationActions {} + +/** + * 游标分页数据获取函数 + */ +export type CursorFetchFunction = (params: { + cursor?: string; + direction: CursorDirection; + pageSize: number; + /** 额外参数 */ + extraParams?: P; +}) => Promise<{ + list: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +}>; diff --git a/src/utils/platform/domUtils.ts b/src/utils/platform/domUtils.ts new file mode 100644 index 0000000..a735860 --- /dev/null +++ b/src/utils/platform/domUtils.ts @@ -0,0 +1,83 @@ +/** + * Web Platform DOM Utilities + * + * 提供Web平台特定的DOM操作工具函数 + * 解决跨平台代码中需要访问Web DOM的补丁问题 + */ + +import { Platform, Keyboard } from 'react-native'; + +/** + * Blur当前激活的DOM元素(Web)并收起键盘(移动端) + * + * - Web: blur DOM active element + * - iOS/Android: Keyboard.dismiss() + * + * 替代散落在多个文件中的重复代码 + */ +export function blurActiveElement(): void { + if (Platform.OS === 'web') { + try { + const activeElement = (globalThis as any)?.document?.activeElement as + { blur?: () => void } | undefined; + + if (activeElement?.blur) { + activeElement.blur(); + } + } catch { + // 安全忽略:某些环境下document可能不可访问 + } + } else { + Keyboard.dismiss(); + } +} + +/** + * 检查当前是否有激活的DOM元素(仅Web平台) + */ +export function hasActiveElement(): boolean { + if (Platform.OS !== 'web') return false; + + try { + const doc = (globalThis as any)?.document; + return !!doc?.activeElement && doc.activeElement !== doc.body; + } catch { + return false; + } +} + +/** + * 获取当前激活元素的类型(仅Web平台) + * 用于判断是否需要特殊处理(如输入框) + */ +export function getActiveElementType(): string | null { + if (Platform.OS !== 'web') return null; + + try { + const activeElement = (globalThis as any)?.document?.activeElement as + { tagName?: string; type?: string } | undefined; + + if (!activeElement) return null; + + const tagName = activeElement.tagName?.toLowerCase(); + const type = activeElement.type?.toLowerCase(); + + return tagName === 'input' ? `input:${type || 'text'}` : tagName || null; + } catch { + return null; + } +} + +/** + * 判断当前激活元素是否为输入类型 + */ +export function isInputFocused(): boolean { + const elementType = getActiveElementType(); + if (!elementType) return false; + + return ( + elementType.startsWith('input:') || + elementType === 'textarea' || + elementType === 'select' + ); +} diff --git a/src/utils/platform/index.ts b/src/utils/platform/index.ts new file mode 100644 index 0000000..f521568 --- /dev/null +++ b/src/utils/platform/index.ts @@ -0,0 +1,12 @@ +/** + * Platform Utilities + * + * 平台相关的工具函数 + */ + +export { + blurActiveElement, + hasActiveElement, + getActiveElementType, + isInputFocused, +} from './domUtils'; diff --git a/src/utils/responsive.ts b/src/utils/responsive.ts deleted file mode 100644 index 1a3f629..0000000 --- a/src/utils/responsive.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * 响应式样式工具 - * 提供基于断点的样式生成功能 - */ - -import { ViewStyle } from 'react-native'; -import { BREAKPOINTS } from '../presentation/hooks/responsive'; - -/** - * 断点键名 - */ -export type ResponsiveBreakpoint = 'mobile' | 'tablet' | 'desktop' | 'wide'; - -/** - * 断点值映射 - */ -export type ResponsiveValues = { - mobile?: T; - tablet?: T; - desktop?: T; - wide?: T; -}; - -/** - * 断点样式映射 - */ -export type ResponsiveStyles = { - mobile?: ViewStyle; - tablet?: ViewStyle; - desktop?: ViewStyle; - wide?: ViewStyle; -}; - -/** - * 根据当前断点返回对应的值 - * - * @param breakpoint - 当前断点 - * @param values - 各断点对应的值 - * @returns 对应断点的值,如果未找到则返回 undefined - * - * @example - * const fontSize = responsiveValue('tablet', { - * mobile: 14, - * tablet: 16, - * desktop: 18, - * wide: 20 - * }); - */ -export function responsiveValue( - breakpoint: string, - values: ResponsiveValues -): T | undefined { - // 按断点优先级顺序查找 - const breakpoints: ResponsiveBreakpoint[] = ['wide', 'desktop', 'tablet', 'mobile']; - - for (const bp of breakpoints) { - if (breakpoint === bp && values[bp] !== undefined) { - return values[bp]; - } - } - - return undefined; -} - -/** - * 创建响应式样式 - * 根据当前断点返回对应的样式对象 - * - * @param breakpoint - 当前断点 - * @param styles - 各断点对应的样式 - * @returns 合并后的样式对象 - * - * @example - * const containerStyle = createResponsiveStyles(breakpoint, { - * mobile: { padding: 12 }, - * tablet: { padding: 24 }, - * desktop: { padding: 32 }, - * wide: { padding: 48 } - * }); - */ -export function createResponsiveStyles( - breakpoint: string, - styles: ResponsiveStyles -): ViewStyle { - const result: ViewStyle = {}; - - // 按断点优先级从低到高合并样式 - const breakpoints: ResponsiveBreakpoint[] = ['mobile', 'tablet', 'desktop', 'wide']; - - for (const bp of breakpoints) { - const style = styles[bp]; - if (style) { - Object.assign(result, style); - } - // 到达当前断点后停止合并 - if (breakpoint === bp) { - break; - } - } - - return result; -} - -/** - * 获取响应式数值 - * 根据屏幕宽度返回对应的数值 - * - * @param width - 当前屏幕宽度 - * @param config - 各断点对应的数值 - * @returns 对应的数值 - * - * @example - * const padding = getResponsiveValue(screenWidth, { - * mobile: 12, - * tablet: 16, - * desktop: 24, - * wide: 32 - * }); - */ -export function getResponsiveValue( - width: number, - config: ResponsiveValues -): number | undefined { - if (width >= BREAKPOINTS.wide && config.wide !== undefined) { - return config.wide; - } - if (width >= BREAKPOINTS.desktop && config.desktop !== undefined) { - return config.desktop; - } - if (width >= BREAKPOINTS.tablet && config.tablet !== undefined) { - return config.tablet; - } - if (config.mobile !== undefined) { - return config.mobile; - } - return undefined; -} - -/** - * 判断当前是否为宽屏 - * - * @param width - 当前屏幕宽度 - * @returns 是否为宽屏(tablet 及以上) - */ -export function isWideScreen(width: number): boolean { - return width >= BREAKPOINTS.tablet; -} - -/** - * 判断当前是否为桌面端 - * - * @param width - 当前屏幕宽度 - * @returns 是否为桌面端(desktop 及以上) - */ -export function isDesktop(width: number): boolean { - return width >= BREAKPOINTS.desktop; -} - -/** - * 判断当前是否为超大屏 - * - * @param width - 当前屏幕宽度 - * @returns 是否为超大屏(wide) - */ -export function isWide(width: number): boolean { - return width >= BREAKPOINTS.wide; -} diff --git a/src/utils/sync/types.ts b/src/utils/sync/types.ts new file mode 100644 index 0000000..eb00214 --- /dev/null +++ b/src/utils/sync/types.ts @@ -0,0 +1,147 @@ +/** + * 同步状态管理类型定义 + * + * 提供连接状态相关的类型、接口和枚举 + * 用于统一管理 SSE 连接状态 + */ + +/** + * 连接状态枚举 + * 表示连接生命周期的各个阶段 + */ +export enum ConnectionState { + /** 初始状态,未开始连接 */ + IDLE = 'IDLE', + /** 正在建立连接 */ + CONNECTING = 'CONNECTING', + /** 连接已建立 */ + CONNECTED = 'CONNECTED', + /** 连接断开,正在重连 */ + RECONNECTING = 'RECONNECTING', + /** 连接已断开 */ + DISCONNECTED = 'DISCONNECTED', + /** 发生连接错误 */ + ERROR = 'ERROR', +} + +/** + * 连接状态信息 + * 包含完整的状态信息 + */ +export interface ConnectionStateInfo { + /** 当前连接状态 */ + state: ConnectionState; + /** 状态变更时间戳 */ + timestamp: number; + /** 错误信息(仅在 ERROR 状态下有效) */ + error?: Error; + /** 当前重连次数(仅在 RECONNECTING 状态下有效) */ + reconnectAttempts?: number; + /** 上次成功连接时间 */ + lastConnectedAt?: number | null; + /** 状态描述(供显示用) */ + description: string; +} + +/** + * 连接状态监听器类型 + */ +export type ConnectionStateListener = ( + state: ConnectionStateInfo, + previousState: ConnectionStateInfo | null +) => void; + +/** + * 重连配置接口 + */ +export interface ReconnectConfig { + /** 最大重连次数 */ + maxReconnectAttempts: number; + /** 初始重连延迟(毫秒) */ + initialDelay: number; + /** 最大重连延迟(毫秒) */ + maxDelay: number; + /** 延迟倍数(指数退避) */ + backoffMultiplier: number; +} + +/** + * 状态转换规则 + * 定义哪些状态可以转换到哪些状态 + */ +export type StateTransitions = { + [key in ConnectionState]?: ConnectionState[]; +}; + +/** + * 默认状态转换规则 + */ +export const DEFAULT_STATE_TRANSITIONS: StateTransitions = { + [ConnectionState.IDLE]: [ConnectionState.CONNECTING], + [ConnectionState.CONNECTING]: [ + ConnectionState.CONNECTED, + ConnectionState.ERROR, + ConnectionState.DISCONNECTED, + ], + [ConnectionState.CONNECTED]: [ + ConnectionState.DISCONNECTED, + ConnectionState.ERROR, + ConnectionState.RECONNECTING, + ], + [ConnectionState.RECONNECTING]: [ + ConnectionState.CONNECTED, + ConnectionState.ERROR, + ConnectionState.DISCONNECTED, + ], + [ConnectionState.DISCONNECTED]: [ + ConnectionState.CONNECTING, + ConnectionState.RECONNECTING, + ], + [ConnectionState.ERROR]: [ + ConnectionState.CONNECTING, + ConnectionState.RECONNECTING, + ConnectionState.DISCONNECTED, + ], +}; + +/** + * 默认重连配置 + */ +export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { + maxReconnectAttempts: 20, + initialDelay: 1000, + maxDelay: 30000, + backoffMultiplier: 2, +}; + +/** + * 获取状态的中文描述 + * @param state 连接状态 + * @param reconnectAttempts 当前重连次数 + * @param maxReconnectAttempts 最大重连次数 + * @returns 状态描述字符串 + */ +export function getStateDescription( + state: ConnectionState, + reconnectAttempts?: number, + maxReconnectAttempts?: number +): string { + switch (state) { + case ConnectionState.IDLE: + return '未连接'; + case ConnectionState.CONNECTING: + return '正在连接...'; + case ConnectionState.CONNECTED: + return '已连接'; + case ConnectionState.RECONNECTING: + return reconnectAttempts !== undefined && maxReconnectAttempts !== undefined + ? `正在重连 (${reconnectAttempts}/${maxReconnectAttempts})` + : '正在重连...'; + case ConnectionState.DISCONNECTED: + return '已断开连接'; + case ConnectionState.ERROR: + return '连接错误'; + default: + return '未知状态'; + } +}