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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<CommentItemProps> = ({
|
||||
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<CommentItemProps> = ({
|
||||
{renderBadges()}
|
||||
<Text style={styles.metaDot}>·</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
{formatChatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
</View>
|
||||
{renderFloorNumber()}
|
||||
|
||||
@@ -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 挡住「分钟前」更新 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<SystemMessageItemProps> = ({
|
||||
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<SystemMessageItemProps> = ({
|
||||
{getStatusBadge()}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
||||
{formatTime(message.created_at)}
|
||||
{formatChatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
sidebarStyle?: StyleProp<ViewStyle>;
|
||||
sidebarWidth?: number;
|
||||
showSidebarBreakpoint?: FineBreakpointKey;
|
||||
sidebarPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function SidebarLayout({
|
||||
children,
|
||||
sidebar,
|
||||
style,
|
||||
contentStyle,
|
||||
sidebarStyle,
|
||||
sidebarWidth = 280,
|
||||
showSidebarBreakpoint = 'lg',
|
||||
sidebarPosition = 'left',
|
||||
}: SidebarLayoutProps) {
|
||||
return (
|
||||
<AdaptiveLayout
|
||||
sidebar={sidebar}
|
||||
style={style}
|
||||
contentStyle={contentStyle}
|
||||
sidebarStyle={sidebarStyle}
|
||||
sidebarWidth={sidebarWidth}
|
||||
showSidebarBreakpoint={showSidebarBreakpoint}
|
||||
sidebarPosition={sidebarPosition}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdaptiveLayout;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
} from '../../hooks';
|
||||
|
||||
export interface ResponsiveGridProps {
|
||||
/** 子元素 */
|
||||
|
||||
@@ -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<Record<FineBreakpointKey, number>> | number;
|
||||
/** 是否允许换行 */
|
||||
wrap?: boolean;
|
||||
/** 对齐方式(交叉轴) */
|
||||
align?: StackAlignment;
|
||||
/** 分布方式(主轴) */
|
||||
justify?: StackJustify;
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 子元素样式 */
|
||||
itemStyle?: StyleProp<ViewStyle>;
|
||||
/** 是否反转顺序 */
|
||||
reverse?: boolean;
|
||||
/** 是否等分空间 */
|
||||
equalItem?: boolean;
|
||||
}
|
||||
|
||||
const alignMap: Record<StackAlignment, FlexAlignType> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
stretch: 'stretch',
|
||||
baseline: 'baseline',
|
||||
};
|
||||
|
||||
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
between: 'space-between',
|
||||
around: 'space-around',
|
||||
evenly: 'space-evenly',
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
*
|
||||
* - 移动端垂直堆叠
|
||||
* - 平板/桌面端水平排列
|
||||
* - 支持间距和换行配置
|
||||
*
|
||||
* @example
|
||||
* // 基础用法 - 自动响应式
|
||||
* <ResponsiveStack>
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* <Item3 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 自定义断点和间距
|
||||
* <ResponsiveStack
|
||||
* direction="responsive"
|
||||
* horizontalBreakpoint="md"
|
||||
* gap={{ xs: 8, md: 16, lg: 24 }}
|
||||
* align="center"
|
||||
* justify="between"
|
||||
* >
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 固定水平方向
|
||||
* <ResponsiveStack direction="horizontal" wrap gap={16}>
|
||||
* {items.map(item => <Tag key={item.id} {...item} />)}
|
||||
* </ResponsiveStack>
|
||||
*/
|
||||
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 (
|
||||
<View
|
||||
key={child.key ?? `stack-item-${index}`}
|
||||
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}, [children, equalItem, itemStyle]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle, style]}>
|
||||
{processedChildren}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
equalItem: {
|
||||
flex: 1,
|
||||
minWidth: 0, // 防止 flex item 溢出
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 水平堆叠组件(快捷方式)
|
||||
*/
|
||||
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="horizontal" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 垂直堆叠组件(快捷方式)
|
||||
*/
|
||||
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="vertical" />;
|
||||
}
|
||||
|
||||
export default ResponsiveStack;
|
||||
@@ -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';
|
||||
|
||||
@@ -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<MessageSegment[]>(record.segments) : undefined,
|
||||
segments: record.segments ? safeParseJsonOrUndefined<MessageSegment[]>(record.segments) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,17 +163,7 @@ export class MessageMapper {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析 JSON
|
||||
*/
|
||||
private static safeParseJson<T>(value: string): T | undefined {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static mapSenderFromApi(sender: UserDTO): UserModel {
|
||||
return {
|
||||
id: String(sender.id || ''),
|
||||
|
||||
@@ -124,32 +124,3 @@ export interface GroupModel {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ==================== 分页模型 ====================
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// ==================== 通用操作结果 ====================
|
||||
|
||||
export interface OperationResult<T = void> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
// ==================== 同步状态模型 ====================
|
||||
|
||||
export interface SyncStatus {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
lastSyncedAt: Date;
|
||||
syncVersion: number;
|
||||
pendingChanges: boolean;
|
||||
}
|
||||
|
||||
7
src/database/core/jsonUtils.ts
Normal file
7
src/database/core/jsonUtils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const safeParseJson = <T>(value: string, fallback: T | null = null): T | null => {
|
||||
try { return JSON.parse(value) as T; } catch { return fallback; }
|
||||
};
|
||||
|
||||
export const safeParseJsonOrUndefined = <T>(value: string): T | undefined => {
|
||||
try { return JSON.parse(value) as T; } catch { return undefined; }
|
||||
};
|
||||
@@ -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 = <T>(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<void>;
|
||||
getAll(): Promise<any[]>;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
|
||||
|
||||
const safeParseJson = <T>(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<void>;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { localDataSource } from '../LocalDataSource';
|
||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||
import type { UserDTO } from '@/types/dto';
|
||||
|
||||
const safeParseJson = <T>(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<void>;
|
||||
|
||||
@@ -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';
|
||||
@@ -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> = T | Partial<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// ==================== 断点顺序(用于比较)====================
|
||||
export const BREAKPOINT_ORDER: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
|
||||
export const REVERSE_BREAKPOINT_ORDER: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
|
||||
// ==================== 获取当前断点 ====================
|
||||
export function getBreakpoint(width: number): BreakpointKey {
|
||||
if (width >= BREAKPOINTS.wide) {
|
||||
return 'wide';
|
||||
}
|
||||
if (width >= BREAKPOINTS.desktop) {
|
||||
return 'desktop';
|
||||
}
|
||||
if (width >= BREAKPOINTS.tablet) {
|
||||
return 'tablet';
|
||||
}
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
// ==================== 获取细粒度断点 ====================
|
||||
export function getFineBreakpoint(width: number): FineBreakpointKey {
|
||||
if (width >= FINE_BREAKPOINTS['4xl']) return '4xl';
|
||||
if (width >= FINE_BREAKPOINTS['3xl']) return '3xl';
|
||||
if (width >= FINE_BREAKPOINTS['2xl']) return '2xl';
|
||||
if (width >= FINE_BREAKPOINTS.xl) return 'xl';
|
||||
if (width >= FINE_BREAKPOINTS.lg) return 'lg';
|
||||
if (width >= FINE_BREAKPOINTS.md) return 'md';
|
||||
if (width >= FINE_BREAKPOINTS.sm) return 'sm';
|
||||
return 'xs';
|
||||
}
|
||||
|
||||
// ==================== 断点比较工具 ====================
|
||||
/**
|
||||
* 检查当前断点是否大于等于目标断点
|
||||
*/
|
||||
export function isBreakpointGTE(
|
||||
current: FineBreakpointKey,
|
||||
target: FineBreakpointKey
|
||||
): boolean {
|
||||
return BREAKPOINT_ORDER.indexOf(current) >= BREAKPOINT_ORDER.indexOf(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前断点是否小于目标断点
|
||||
*/
|
||||
export function isBreakpointLT(
|
||||
current: FineBreakpointKey,
|
||||
target: FineBreakpointKey
|
||||
): boolean {
|
||||
return !isBreakpointGTE(current, target);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 列数计算 Hook
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useBreakpoint } from './useBreakpoint';
|
||||
import { FineBreakpointKey } from './constants';
|
||||
|
||||
const DEFAULT_COLUMN_CONFIG: Record<FineBreakpointKey, number> = {
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
'2xl': 4,
|
||||
'3xl': 5,
|
||||
'4xl': 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据容器宽度计算合适的列数
|
||||
*
|
||||
* @param columnConfig - 列数配置
|
||||
* @returns 列数
|
||||
*
|
||||
* @example
|
||||
* const columns = useColumnCount({
|
||||
* xs: 1,
|
||||
* sm: 2,
|
||||
* md: 3,
|
||||
* lg: 4
|
||||
* });
|
||||
*/
|
||||
export function useColumnCount(
|
||||
columnConfig: Partial<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useBreakpoint();
|
||||
|
||||
return useMemo(() => {
|
||||
const config = { ...DEFAULT_COLUMN_CONFIG, ...columnConfig };
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (config[bp] !== undefined) {
|
||||
return config[bp];
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}, [fineBreakpoint, columnConfig]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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',
|
||||
}), []);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 响应式间距 Hook
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useBreakpoint } from './useBreakpoint';
|
||||
import { FineBreakpointKey } from './constants';
|
||||
|
||||
const DEFAULT_SPACING_CONFIG: Record<FineBreakpointKey, number> = {
|
||||
xs: 8,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 32,
|
||||
'4xl': 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式间距 Hook
|
||||
*
|
||||
* @param spacingConfig - 间距配置
|
||||
* @returns 当前断点对应的间距值
|
||||
*
|
||||
* @example
|
||||
* const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
|
||||
*/
|
||||
export function useResponsiveSpacing(
|
||||
spacingConfig: Partial<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useBreakpoint();
|
||||
|
||||
return useMemo(() => {
|
||||
const config = { ...DEFAULT_SPACING_CONFIG, ...spacingConfig };
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (config[bp] !== undefined) {
|
||||
return config[bp];
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_SPACING_CONFIG.xs;
|
||||
}, [fineBreakpoint, spacingConfig]);
|
||||
}
|
||||
@@ -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<T extends Record<string, ResponsiveValue<unknown>>>(
|
||||
styles: T
|
||||
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
|
||||
const { fineBreakpoint } = useBreakpoint();
|
||||
|
||||
return useMemo(() => {
|
||||
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key];
|
||||
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
|
||||
const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint);
|
||||
|
||||
let selectedValue: unknown = undefined;
|
||||
for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) {
|
||||
const bp = REVERSE_BREAKPOINT_ORDER[i];
|
||||
if (bp in valueMap) {
|
||||
selectedValue = valueMap[bp];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [styles, fineBreakpoint]);
|
||||
}
|
||||
@@ -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<T>(value: ResponsiveValue<T>): T {
|
||||
const { fineBreakpoint } = useBreakpoint();
|
||||
|
||||
return useMemo(() => {
|
||||
// 如果不是对象,直接返回
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// 从当前断点开始向下查找
|
||||
const currentIndex = REVERSE_BREAKPOINT_ORDER.indexOf(fineBreakpoint);
|
||||
for (let i = currentIndex; i < REVERSE_BREAKPOINT_ORDER.length; i++) {
|
||||
const bp = REVERSE_BREAKPOINT_ORDER[i];
|
||||
if (bp in valueMap) {
|
||||
return valueMap[bp]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,返回 xs 的值或第一个值
|
||||
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
|
||||
}, [value, fineBreakpoint]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> = T | Partial<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// ==================== 返回值类型 ====================
|
||||
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<T>(value: ResponsiveValue<T>): 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<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// 从当前断点开始向下查找
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (bp in valueMap) {
|
||||
return valueMap[bp]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,返回 xs 的值或第一个值
|
||||
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
|
||||
}, [value, fineBreakpoint]);
|
||||
}
|
||||
|
||||
// ==================== 响应式样式生成器 ====================
|
||||
/**
|
||||
* 根据断点生成响应式样式
|
||||
*
|
||||
* @param styles - 响应式样式对象
|
||||
* @returns 当前断点对应的样式
|
||||
*
|
||||
* @example
|
||||
* const containerStyle = useResponsiveStyle({
|
||||
* padding: { xs: 8, md: 16, lg: 24 },
|
||||
* fontSize: { xs: 14, lg: 16 }
|
||||
* });
|
||||
*/
|
||||
export function useResponsiveStyle<T extends Record<string, ResponsiveValue<unknown>>>(
|
||||
styles: T
|
||||
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
return useMemo(() => {
|
||||
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key];
|
||||
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
|
||||
const 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<string, unknown>)[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<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
const defaultConfig: Record<FineBreakpointKey, number> = {
|
||||
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<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
const defaultConfig: Record<FineBreakpointKey, number> = {
|
||||
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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -104,9 +104,4 @@ export function useResponsive(): ResponsiveInfo {
|
||||
}, [width, height, platform]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧的 useResponsive 别名,保持完全向后兼容
|
||||
*/
|
||||
export const useLegacyResponsive = useResponsive;
|
||||
|
||||
export default useResponsive;
|
||||
|
||||
@@ -45,51 +45,3 @@ export function useResponsiveValue<T>(value: ResponsiveValue<T>): 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<T extends Record<string, ResponsiveValue<unknown>>>(
|
||||
styles: T
|
||||
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
|
||||
const { width } = useWindowDimensions();
|
||||
const fineBreakpoint = getFineBreakpoint(width);
|
||||
|
||||
return useMemo(() => {
|
||||
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key];
|
||||
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
let selectedValue: unknown = undefined;
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (bp in valueMap) {
|
||||
selectedValue = valueMap[bp];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [styles, fineBreakpoint]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ['帖子', '用户'];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
// 跳转到聊天页
|
||||
|
||||
@@ -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<ChatHeaderProps> = ({
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
// 检查是否需要显示时间分隔
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import {
|
||||
Course,
|
||||
COURSE_COLORS,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
* 处理用户身份认证申请、状态查询等功能
|
||||
*/
|
||||
|
||||
import { api } from '../core/api';
|
||||
import { api, PaginatedData } from '../core/api';
|
||||
import {
|
||||
VerificationStatusDTO,
|
||||
VerificationRecordDTO,
|
||||
SubmitVerificationRequest,
|
||||
PaginatedData,
|
||||
} from '@/types/dto';
|
||||
|
||||
// 提交认证响应
|
||||
|
||||
@@ -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<boolean> | null = null;
|
||||
private refreshLock: Promise<boolean> | 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() {
|
||||
|
||||
98
src/services/core/datasources/ApiDataSource.ts
Normal file
98
src/services/core/datasources/ApiDataSource.ts
Normal file
@@ -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<T>(url: string, params?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.get<T>(fullUrl, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'GET');
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.post<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'POST');
|
||||
}
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.put<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'PUT');
|
||||
}
|
||||
}
|
||||
|
||||
async delete<T>(url: string, data?: any): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.delete<T>(fullUrl, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'DELETE');
|
||||
}
|
||||
}
|
||||
|
||||
async upload<T>(
|
||||
url: string,
|
||||
file: { uri: string; name: string; type: string },
|
||||
additionalData?: Record<string, string>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const fullUrl = this.buildUrl(url);
|
||||
const response = await api.upload<T>(fullUrl, file, additionalData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'UPLOAD');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const apiDataSource = new ApiDataSource();
|
||||
199
src/services/core/datasources/CacheDataSource.ts
Normal file
199
src/services/core/datasources/CacheDataSource.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 缓存数据源实现
|
||||
* 基于内存和 AsyncStorage 的混合缓存实现
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ICacheDataSource, DataSourceError } from './interfaces';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiry: number | null; // null 表示永不过期
|
||||
}
|
||||
|
||||
export class CacheDataSource implements ICacheDataSource {
|
||||
private memoryCache: Map<string, CacheEntry<any>> = new Map();
|
||||
private maxSize: number;
|
||||
private defaultTtl: number; // 默认过期时间(毫秒)
|
||||
private persistPrefix: string;
|
||||
|
||||
constructor(config?: {
|
||||
maxSize?: number;
|
||||
defaultTtl?: number; // 默认5分钟
|
||||
persistPrefix?: string;
|
||||
}) {
|
||||
this.maxSize = config?.maxSize || 100;
|
||||
this.defaultTtl = config?.defaultTtl || 5 * 60 * 1000;
|
||||
this.persistPrefix = config?.persistPrefix || 'cache_';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 key 是否过期
|
||||
*/
|
||||
private isExpired(entry: CacheEntry<any>): boolean {
|
||||
if (entry.expiry === null) return false;
|
||||
return Date.now() > entry.expiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.memoryCache.entries()) {
|
||||
if (entry.expiry !== null && now > entry.expiry) {
|
||||
this.memoryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然超过最大大小,删除最旧的条目
|
||||
while (this.memoryCache.size > this.maxSize) {
|
||||
const firstKey = this.memoryCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.memoryCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成持久化存储 key
|
||||
*/
|
||||
private getPersistKey(key: string): string {
|
||||
return `${this.persistPrefix}${key}`;
|
||||
}
|
||||
|
||||
// ==================== ICacheDataSource 实现 ====================
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
// 先检查内存缓存
|
||||
const memoryEntry = this.memoryCache.get(key);
|
||||
if (memoryEntry) {
|
||||
if (!this.isExpired(memoryEntry)) {
|
||||
return memoryEntry.value as T;
|
||||
}
|
||||
// 过期了,从内存中删除
|
||||
this.memoryCache.delete(key);
|
||||
}
|
||||
|
||||
// 尝试从持久化存储读取
|
||||
const persistKey = this.getPersistKey(key);
|
||||
const stored = await AsyncStorage.getItem(persistKey);
|
||||
if (stored) {
|
||||
const entry: CacheEntry<T> = JSON.parse(stored);
|
||||
if (!this.isExpired(entry)) {
|
||||
// 重新加载到内存缓存
|
||||
this.memoryCache.set(key, entry);
|
||||
this.cleanup();
|
||||
return entry.value;
|
||||
}
|
||||
// 过期了,删除
|
||||
await AsyncStorage.removeItem(persistKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`[CacheDataSource] Failed to get ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
try {
|
||||
const expiry = ttl === undefined
|
||||
? (this.defaultTtl > 0 ? Date.now() + this.defaultTtl : null)
|
||||
: (ttl > 0 ? Date.now() + ttl : null);
|
||||
|
||||
const entry: CacheEntry<T> = { value, expiry };
|
||||
|
||||
// 存入内存
|
||||
this.memoryCache.set(key, entry);
|
||||
this.cleanup();
|
||||
|
||||
// 持久化存储(重要数据)
|
||||
if (ttl === undefined || ttl > 0) {
|
||||
const persistKey = this.getPersistKey(key);
|
||||
await AsyncStorage.setItem(persistKey, JSON.stringify(entry));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
`Failed to set cache ${key}`,
|
||||
'CACHE_SET_ERROR',
|
||||
'CacheDataSource',
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
// 从内存删除
|
||||
this.memoryCache.delete(key);
|
||||
|
||||
// 从持久化存储删除
|
||||
const persistKey = this.getPersistKey(key);
|
||||
await AsyncStorage.removeItem(persistKey);
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
`Failed to delete cache ${key}`,
|
||||
'CACHE_DELETE_ERROR',
|
||||
'CacheDataSource',
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
// 清空内存
|
||||
this.memoryCache.clear();
|
||||
|
||||
// 清空持久化存储中的所有缓存项
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix));
|
||||
if (cacheKeys.length > 0) {
|
||||
await AsyncStorage.multiRemove(cacheKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DataSourceError(
|
||||
'Failed to clear cache',
|
||||
'CACHE_CLEAR_ERROR',
|
||||
'CacheDataSource',
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
try {
|
||||
// 检查内存
|
||||
const memoryEntry = this.memoryCache.get(key);
|
||||
if (memoryEntry && !this.isExpired(memoryEntry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查持久化存储
|
||||
const persistKey = this.getPersistKey(key);
|
||||
const stored = await AsyncStorage.getItem(persistKey);
|
||||
if (stored) {
|
||||
const entry: CacheEntry<any> = JSON.parse(stored);
|
||||
return !this.isExpired(entry);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getMultiple<T>(keys: string[]): Promise<(T | null)[]> {
|
||||
return Promise.all(keys.map(key => this.get<T>(key)));
|
||||
}
|
||||
|
||||
async setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void> {
|
||||
await Promise.all(entries.map(entry => this.set(entry.key, entry.value, entry.ttl)));
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const cacheDataSource = new CacheDataSource();
|
||||
184
src/services/core/datasources/WSClient.ts
Normal file
184
src/services/core/datasources/WSClient.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* WSClient - WebSocket连接管理
|
||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
wsService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
WSGroupReadMessage,
|
||||
WSRecallMessage,
|
||||
WSGroupRecallMessage,
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '@/services/core';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (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<WSEventType, Set<EventHandler<any>>> = 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<T extends WSEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<WSEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends WSEventType>(
|
||||
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<boolean> {
|
||||
return wsService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
wsService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WSClient();
|
||||
export default wsClient;
|
||||
9
src/services/core/datasources/index.ts
Normal file
9
src/services/core/datasources/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 数据源导出
|
||||
* 统一导出所有数据源实现
|
||||
*/
|
||||
|
||||
export * from './interfaces';
|
||||
export * from './ApiDataSource';
|
||||
export { LocalDataSource, localDataSource } from '@/database';
|
||||
export * from './CacheDataSource';
|
||||
68
src/services/core/datasources/interfaces.ts
Normal file
68
src/services/core/datasources/interfaces.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 数据源接口定义
|
||||
* 定义所有数据源的标准接口,便于替换和测试
|
||||
*/
|
||||
|
||||
// API 数据源接口
|
||||
export interface IApiDataSource {
|
||||
get<T>(url: string, params?: any): Promise<T>;
|
||||
post<T>(url: string, data?: any): Promise<T>;
|
||||
put<T>(url: string, data?: any): Promise<T>;
|
||||
delete<T>(url: string, data?: any): Promise<T>;
|
||||
upload<T>(url: string, file: { uri: string; name: string; type: string }, additionalData?: Record<string, string>): Promise<T>;
|
||||
}
|
||||
|
||||
// 本地数据源接口 (SQLite)
|
||||
export interface ILocalDataSource {
|
||||
query<T>(sql: string, params?: any[]): Promise<T[]>;
|
||||
getFirst<T>(sql: string, params?: any[]): Promise<T | null>;
|
||||
execute(sql: string, params?: any[]): Promise<void>;
|
||||
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
||||
transaction(operations: () => Promise<void>): Promise<void>;
|
||||
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
||||
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
|
||||
initialize(userId?: string): Promise<void>;
|
||||
switchDatabase(userId?: string | null): Promise<void>;
|
||||
closeDatabase(): Promise<void>;
|
||||
}
|
||||
|
||||
// 缓存数据源接口
|
||||
export interface ICacheDataSource {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
has(key: string): Promise<boolean>;
|
||||
getMultiple<T>(keys: string[]): Promise<(T | null)[]>;
|
||||
setMultiple<T>(entries: { key: string; value: T; ttl?: number }[]): Promise<void>;
|
||||
}
|
||||
|
||||
// 数据源错误类型
|
||||
export class DataSourceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public source: string,
|
||||
public originalError?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DataSourceError';
|
||||
}
|
||||
}
|
||||
|
||||
// 数据源配置
|
||||
export interface DataSourceConfig {
|
||||
api?: {
|
||||
baseUrl: string;
|
||||
timeout?: number;
|
||||
retryCount?: number;
|
||||
};
|
||||
local?: {
|
||||
dbName: string;
|
||||
version: number;
|
||||
};
|
||||
cache?: {
|
||||
maxSize: number;
|
||||
defaultTtl: number;
|
||||
};
|
||||
}
|
||||
12
src/services/core/verification.ts
Normal file
12
src/services/core/verification.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
302
src/services/mappers/ConversationMapper.ts
Normal file
302
src/services/mappers/ConversationMapper.ts
Normal file
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
194
src/services/mappers/MessageMapper.ts
Normal file
194
src/services/mappers/MessageMapper.ts
Normal file
@@ -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<MessageSegment[]>(record.segments) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库记录数组转换为应用模型数组
|
||||
*/
|
||||
static fromDbRecordList(records: MessageDbRecord[]): MessageModel[] {
|
||||
return records.map(r => this.fromDbRecord(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将应用模型转换为数据库记录
|
||||
*/
|
||||
static toDbRecord(model: MessageModel): MessageDbRecord {
|
||||
return {
|
||||
id: model.id,
|
||||
conversationId: model.conversationId,
|
||||
senderId: model.senderId,
|
||||
content: model.content || null,
|
||||
type: model.type,
|
||||
isRead: model.isRead ? 1 : 0,
|
||||
createdAt: model.createdAt.toISOString(),
|
||||
seq: model.seq,
|
||||
status: model.status,
|
||||
segments: model.segments ? JSON.stringify(model.segments) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将应用模型转换为 API 请求数据
|
||||
*/
|
||||
static toApiRequest(model: Partial<MessageModel>): Record<string, any> {
|
||||
const request: Record<string, any> = {};
|
||||
|
||||
if (model.segments) {
|
||||
request.segments = model.segments;
|
||||
}
|
||||
if (model.content) {
|
||||
request.content = model.content;
|
||||
}
|
||||
if (model.type) {
|
||||
request.message_type = model.type;
|
||||
}
|
||||
if (model.replyToId) {
|
||||
request.reply_to_id = model.replyToId;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建发送消息请求体
|
||||
*/
|
||||
static createSendRequest(
|
||||
detailType: 'private' | 'group',
|
||||
segments: MessageSegment[],
|
||||
replyToId?: string
|
||||
): Record<string, any> {
|
||||
const body: Record<string, any> = {
|
||||
detail_type: detailType,
|
||||
segments,
|
||||
};
|
||||
if (replyToId) {
|
||||
body.reply_to_id = replyToId;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本消息段
|
||||
*/
|
||||
static createTextSegment(text: string): MessageSegment {
|
||||
return {
|
||||
type: 'text',
|
||||
data: { text },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片消息段
|
||||
*/
|
||||
static createImageSegment(url: string): MessageSegment {
|
||||
return {
|
||||
type: 'image',
|
||||
data: { url },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
129
src/services/mappers/PostMapper.ts
Normal file
129
src/services/mappers/PostMapper.ts
Normal file
@@ -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<PostModel>): Record<string, any> {
|
||||
const request: Record<string, any> = {};
|
||||
|
||||
if (model.title !== undefined) {
|
||||
request.title = model.title;
|
||||
}
|
||||
if (model.content !== undefined) {
|
||||
request.content = model.content;
|
||||
}
|
||||
if (model.images !== undefined) {
|
||||
request.images = model.images;
|
||||
}
|
||||
if (model.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<string, any> {
|
||||
const request: Record<string, any> = {
|
||||
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<string, any> {
|
||||
const request: Record<string, any> = {};
|
||||
if (title !== undefined) {
|
||||
request.title = title;
|
||||
}
|
||||
if (content !== undefined) {
|
||||
request.content = content;
|
||||
}
|
||||
if (images !== undefined) {
|
||||
request.images = images;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射作者信息
|
||||
*/
|
||||
private static mapAuthorFromApi(author: any): UserModel {
|
||||
return {
|
||||
id: String(author.id || ''),
|
||||
username: author.username || '',
|
||||
nickname: author.nickname,
|
||||
avatar: author.avatar,
|
||||
};
|
||||
}
|
||||
}
|
||||
134
src/services/mappers/UserMapper.ts
Normal file
134
src/services/mappers/UserMapper.ts
Normal file
@@ -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<UserModel>): Record<string, any> {
|
||||
const request: Record<string, any> = {};
|
||||
|
||||
if (model.nickname !== undefined) {
|
||||
request.nickname = model.nickname;
|
||||
}
|
||||
if (model.avatar !== undefined) {
|
||||
request.avatar = model.avatar;
|
||||
}
|
||||
if (model.bio !== undefined) {
|
||||
request.bio = model.bio;
|
||||
}
|
||||
if (model.website !== undefined) {
|
||||
request.website = model.website;
|
||||
}
|
||||
if (model.location !== undefined) {
|
||||
request.location = model.location;
|
||||
}
|
||||
if (model.phone !== undefined) {
|
||||
request.phone = model.phone;
|
||||
}
|
||||
if (model.email !== undefined) {
|
||||
request.email = model.email;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将应用模型转换为 DTO
|
||||
*/
|
||||
static toDTO(model: UserModel): UserDTO {
|
||||
return {
|
||||
id: model.id,
|
||||
username: model.username,
|
||||
nickname: model.nickname || '',
|
||||
avatar: model.avatar || '',
|
||||
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() || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/services/mappers/index.ts
Normal file
9
src/services/mappers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 映射器导出
|
||||
* 统一导出所有数据映射器
|
||||
*/
|
||||
|
||||
export * from './MessageMapper';
|
||||
export * from './ConversationMapper';
|
||||
export * from './UserMapper';
|
||||
export * from './PostMapper';
|
||||
93
src/services/message/types/repository.ts
Normal file
93
src/services/message/types/repository.ts
Normal file
@@ -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<Message[]>;
|
||||
|
||||
/**
|
||||
* 保存消息
|
||||
*/
|
||||
saveMessage(message: Message): Promise<void>;
|
||||
|
||||
/**
|
||||
* 批量保存消息
|
||||
*/
|
||||
saveMessages(messages: Message[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* 更新消息状态
|
||||
*/
|
||||
updateMessageStatus(
|
||||
messageId: string,
|
||||
status: 'normal' | 'recalled' | 'deleted'
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记消息为已读
|
||||
*/
|
||||
markAsRead(messageIds: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取未读消息数
|
||||
*/
|
||||
getUnreadCount(conversationId: string): Promise<number>;
|
||||
|
||||
// ==================== 会话操作 ====================
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
getConversations(): Promise<Conversation[]>;
|
||||
|
||||
/**
|
||||
* 获取单个会话
|
||||
*/
|
||||
getConversation(conversationId: string): Promise<Conversation | null>;
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
saveConversation(conversation: Conversation): Promise<void>;
|
||||
|
||||
/**
|
||||
* 更新会话未读数
|
||||
*/
|
||||
updateUnreadCount(conversationId: string, count: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* 更新会话最后消息
|
||||
*/
|
||||
updateLastMessage(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
seq: number
|
||||
): Promise<void>;
|
||||
|
||||
// ==================== 同步操作 ====================
|
||||
|
||||
/**
|
||||
* 从服务器同步消息
|
||||
*/
|
||||
syncMessages(conversationId: string, lastSeq: number): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* 获取服务器最新消息序列号
|
||||
*/
|
||||
getServerLastSeq(conversationId: string): Promise<number>;
|
||||
}
|
||||
190
src/services/post/types/repository.ts
Normal file
190
src/services/post/types/repository.ts
Normal file
@@ -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<PostsResult>;
|
||||
|
||||
/**
|
||||
* 获取单个帖子详情
|
||||
* @param id 帖子ID
|
||||
* @returns 帖子实体,不存在则返回null
|
||||
*/
|
||||
getPostById(id: string): Promise<Post | null>;
|
||||
|
||||
/**
|
||||
* 获取用户发布的帖子列表
|
||||
* @param userId 用户ID
|
||||
* @param params 查询参数
|
||||
* @returns 帖子列表结果
|
||||
*/
|
||||
getPostsByUser(userId: string, params?: GetPostsParams): Promise<PostsResult>;
|
||||
|
||||
/**
|
||||
* 搜索帖子
|
||||
* @param params 搜索参数
|
||||
* @returns 帖子列表结果
|
||||
*/
|
||||
searchPosts(params: SearchPostsParams): Promise<PostsResult>;
|
||||
|
||||
// ==================== 帖子操作 ====================
|
||||
|
||||
/**
|
||||
* 创建帖子
|
||||
* @param data 创建帖子数据
|
||||
* @returns 创建的帖子实体
|
||||
*/
|
||||
createPost(data: CreatePostData): Promise<Post>;
|
||||
|
||||
/**
|
||||
* 更新帖子
|
||||
* @param id 帖子ID
|
||||
* @param data 更新数据
|
||||
* @returns 更新后的帖子实体
|
||||
*/
|
||||
updatePost(id: string, data: UpdatePostData): Promise<Post>;
|
||||
|
||||
/**
|
||||
* 删除帖子
|
||||
* @param id 帖子ID
|
||||
*/
|
||||
deletePost(id: string): Promise<void>;
|
||||
|
||||
// ==================== 互动操作 ====================
|
||||
|
||||
/**
|
||||
* 点赞帖子
|
||||
* @param id 帖子ID
|
||||
* @returns 更新后的帖子实体
|
||||
*/
|
||||
likePost(id: string): Promise<Post>;
|
||||
|
||||
/**
|
||||
* 取消点赞
|
||||
* @param id 帖子ID
|
||||
* @returns 更新后的帖子实体
|
||||
*/
|
||||
unlikePost(id: string): Promise<Post>;
|
||||
|
||||
/**
|
||||
* 收藏帖子
|
||||
* @param id 帖子ID
|
||||
* @returns 更新后的帖子实体
|
||||
*/
|
||||
favoritePost(id: string): Promise<Post>;
|
||||
|
||||
/**
|
||||
* 取消收藏
|
||||
* @param id 帖子ID
|
||||
* @returns 更新后的帖子实体
|
||||
*/
|
||||
unfavoritePost(id: string): Promise<Post>;
|
||||
}
|
||||
998
src/types/dto.ts
998
src/types/dto.ts
@@ -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<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
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<T> {
|
||||
/** 数据项列表 */
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
// 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, { nickname: string; user_id: string }>): 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<UserDTO | null>,
|
||||
getUserById?: (userId: string) => Promise<UserDTO | null>
|
||||
): Promise<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 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();
|
||||
}
|
||||
210
src/types/dto/common.ts
Normal file
210
src/types/dto/common.ts
Normal file
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
126
src/types/dto/group.ts
Normal file
126
src/types/dto/group.ts
Normal file
@@ -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;
|
||||
}
|
||||
11
src/types/dto/index.ts
Normal file
11
src/types/dto/index.ts
Normal file
@@ -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';
|
||||
211
src/types/dto/message.ts
Normal file
211
src/types/dto/message.ts
Normal file
@@ -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;
|
||||
}
|
||||
92
src/types/dto/pagination.ts
Normal file
92
src/types/dto/pagination.ts
Normal file
@@ -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<T> {
|
||||
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;
|
||||
}
|
||||
75
src/types/dto/post.ts
Normal file
75
src/types/dto/post.ts
Normal file
@@ -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[];
|
||||
}
|
||||
108
src/types/dto/segmentUtils.ts
Normal file
108
src/types/dto/segmentUtils.ts
Normal file
@@ -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, { nickname: string; user_id: string }>): 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<UserDTO | null>,
|
||||
getUserById?: (userId: string) => Promise<UserDTO | null>
|
||||
): Promise<string> {
|
||||
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);
|
||||
}
|
||||
49
src/types/dto/user.ts
Normal file
49
src/types/dto/user.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
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<string, string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
202
src/utils/cache/types.ts
vendored
Normal file
202
src/utils/cache/types.ts
vendored
Normal file
@@ -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
|
||||
};
|
||||
268
src/utils/diff/messageDiffCalculator.ts
Normal file
268
src/utils/diff/messageDiffCalculator.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 消息差异计算器
|
||||
* 计算两个消息列表之间的差异,支持增量更新
|
||||
*/
|
||||
|
||||
import {
|
||||
MessageIdentifier,
|
||||
DiffResult,
|
||||
MessageComparator,
|
||||
MessageSorter,
|
||||
DiffConfig,
|
||||
DEFAULT_DIFF_CONFIG,
|
||||
} from './messageTypes';
|
||||
|
||||
/**
|
||||
* 消息差异计算器类
|
||||
* 用于高效计算消息列表的变化
|
||||
*/
|
||||
export class MessageDiffCalculator<T extends MessageIdentifier> {
|
||||
private config: Required<DiffConfig>;
|
||||
private previousMessages: Map<string, T> = new Map();
|
||||
private previousOrder: string[] = [];
|
||||
|
||||
constructor(config: DiffConfig = {}) {
|
||||
this.config = { ...DEFAULT_DIFF_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个消息列表的差异
|
||||
* @param oldList 旧消息列表
|
||||
* @param newList 新消息列表
|
||||
* @returns 差异结果
|
||||
*/
|
||||
calculateDiff(oldList: T[], newList: T[]): DiffResult<T> {
|
||||
const result: DiffResult<T> = {
|
||||
added: [],
|
||||
updated: [],
|
||||
deleted: [],
|
||||
moved: [],
|
||||
unchanged: [],
|
||||
shouldReset: false,
|
||||
};
|
||||
|
||||
// 如果旧列表为空,直接返回新列表作为新增
|
||||
if (oldList.length === 0) {
|
||||
result.added = [...newList];
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果新列表为空,表示全部删除
|
||||
if (newList.length === 0) {
|
||||
result.deleted = oldList.map((msg, index) => ({ message: msg, index }));
|
||||
this.clearCache();
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否需要重置(变化太大)
|
||||
const changeRatio = this.calculateChangeRatio(oldList, newList);
|
||||
if (changeRatio > 0.5) {
|
||||
result.shouldReset = true;
|
||||
result.added = [...newList];
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 构建旧列表的索引映射
|
||||
const oldIndexMap = new Map<string, number>();
|
||||
oldList.forEach((msg, index) => {
|
||||
oldIndexMap.set(msg.id, index);
|
||||
});
|
||||
|
||||
// 构建新列表的索引映射
|
||||
const newIndexMap = new Map<string, number>();
|
||||
newList.forEach((msg, index) => {
|
||||
newIndexMap.set(msg.id, index);
|
||||
});
|
||||
|
||||
// 检测新增、更新和未变更的消息
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (let newIndex = 0; newIndex < newList.length; newIndex++) {
|
||||
const newMsg = newList[newIndex];
|
||||
const oldIndex = oldIndexMap.get(newMsg.id);
|
||||
|
||||
if (oldIndex === undefined) {
|
||||
// 新增消息
|
||||
result.added.push(newMsg);
|
||||
} else {
|
||||
const oldMsg = oldList[oldIndex];
|
||||
processedIds.add(newMsg.id);
|
||||
|
||||
// 检测是否有更新
|
||||
const changes = this.detectChanges(oldMsg, newMsg);
|
||||
if (changes && Object.keys(changes).length > 0) {
|
||||
result.updated.push({
|
||||
message: newMsg,
|
||||
index: oldIndex,
|
||||
changes,
|
||||
});
|
||||
} else {
|
||||
result.unchanged.push(newMsg);
|
||||
}
|
||||
|
||||
// 检测是否移动
|
||||
if (oldIndex !== newIndex) {
|
||||
result.moved.push({
|
||||
message: newMsg,
|
||||
fromIndex: oldIndex,
|
||||
toIndex: newIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测删除的消息
|
||||
for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) {
|
||||
const oldMsg = oldList[oldIndex];
|
||||
if (!processedIds.has(oldMsg.id)) {
|
||||
result.deleted.push({ message: oldMsg, index: oldIndex });
|
||||
}
|
||||
}
|
||||
|
||||
this.updateCache(newList);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算增量差异(基于缓存的上次状态)
|
||||
* @param newList 新消息列表
|
||||
* @returns 差异结果
|
||||
*/
|
||||
calculateIncrementalDiff(newList: T[]): DiffResult<T> {
|
||||
const oldList = Array.from(this.previousMessages.values());
|
||||
return this.calculateDiff(oldList, newList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算变化比例
|
||||
* @param oldList 旧列表
|
||||
* @param newList 新列表
|
||||
* @returns 变化比例(0-1)
|
||||
*/
|
||||
private calculateChangeRatio(oldList: T[], newList: T[]): number {
|
||||
if (oldList.length === 0 && newList.length === 0) return 0;
|
||||
if (oldList.length === 0 || newList.length === 0) return 1;
|
||||
|
||||
const oldIds = new Set(oldList.map(m => m.id));
|
||||
const newIds = new Set(newList.map(m => m.id));
|
||||
|
||||
let commonCount = 0;
|
||||
oldIds.forEach(id => {
|
||||
if (newIds.has(id)) {
|
||||
commonCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const maxLength = Math.max(oldList.length, newList.length);
|
||||
return 1 - commonCount / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测消息变化
|
||||
* @param oldMsg 旧消息
|
||||
* @param newMsg 新消息
|
||||
* @returns 变化字段
|
||||
*/
|
||||
private detectChanges(oldMsg: T, newMsg: T): Partial<T> | null {
|
||||
const changes: Partial<T> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
for (const key of Object.keys(newMsg) as Array<keyof T>) {
|
||||
if (key === 'id') continue; // ID 不变
|
||||
|
||||
const oldValue = oldMsg[key];
|
||||
const newValue = newMsg[key];
|
||||
|
||||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
||||
(changes as any)[key] = newValue;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? changes : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缓存
|
||||
* @param messages 消息列表
|
||||
*/
|
||||
private updateCache(messages: T[]): void {
|
||||
this.previousMessages.clear();
|
||||
this.previousOrder = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
this.previousMessages.set(msg.id, msg);
|
||||
this.previousOrder.push(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存
|
||||
*/
|
||||
private clearCache(): void {
|
||||
this.previousMessages.clear();
|
||||
this.previousOrder = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置
|
||||
* @param config 配置
|
||||
*/
|
||||
setConfig(config: Partial<DiffConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns 当前配置
|
||||
*/
|
||||
getConfig(): Required<DiffConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置计算器状态
|
||||
*/
|
||||
reset(): void {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的消息数量
|
||||
* @returns 缓存数量
|
||||
*/
|
||||
getCachedCount(): number {
|
||||
return this.previousMessages.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否在缓存中
|
||||
* @param messageId 消息 ID
|
||||
* @returns 是否存在
|
||||
*/
|
||||
hasMessage(messageId: string): boolean {
|
||||
return this.previousMessages.has(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存中的消息
|
||||
* @param messageId 消息 ID
|
||||
* @returns 消息或 undefined
|
||||
*/
|
||||
getCachedMessage(messageId: string): T | undefined {
|
||||
return this.previousMessages.get(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建差异计算器实例的工厂函数
|
||||
* @param config 配置
|
||||
* @returns MessageDiffCalculator 实例
|
||||
*/
|
||||
export function createMessageDiffCalculator<T extends MessageIdentifier>(
|
||||
config?: DiffConfig
|
||||
): MessageDiffCalculator<T> {
|
||||
return new MessageDiffCalculator<T>(config);
|
||||
}
|
||||
209
src/utils/diff/messageTypes.ts
Normal file
209
src/utils/diff/messageTypes.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 差异更新类型定义
|
||||
* 用于消息列表的增量更新,减少 UI 重渲染次数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 消息更新类型枚举
|
||||
*/
|
||||
export enum MessageUpdateType {
|
||||
/** 添加单条消息 */
|
||||
ADD = 'ADD',
|
||||
/** 更新单条消息 */
|
||||
UPDATE = 'UPDATE',
|
||||
/** 删除单条消息 */
|
||||
DELETE = 'DELETE',
|
||||
/** 移动消息位置 */
|
||||
MOVE = 'MOVE',
|
||||
/** 批量添加 */
|
||||
BATCH_ADD = 'BATCH_ADD',
|
||||
/** 批量更新 */
|
||||
BATCH_UPDATE = 'BATCH_UPDATE',
|
||||
/** 批量删除 */
|
||||
BATCH_DELETE = 'BATCH_DELETE',
|
||||
/** 重置整个列表 */
|
||||
RESET = 'RESET',
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息唯一标识接口
|
||||
*/
|
||||
export interface MessageIdentifier {
|
||||
/** 消息唯一 ID */
|
||||
id: string;
|
||||
/** 消息序列号(可选,用于排序) */
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础更新操作接口
|
||||
*/
|
||||
export interface BaseMessageUpdate {
|
||||
/** 更新类型 */
|
||||
type: MessageUpdateType;
|
||||
/** 更新时间戳 */
|
||||
timestamp: number;
|
||||
/** 会话 ID(可选) */
|
||||
conversationId?: string;
|
||||
/** 载荷数据(可选,用于合并更新) */
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单条消息更新
|
||||
*/
|
||||
export interface AddMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.ADD;
|
||||
/** 消息数据 */
|
||||
message: MessageIdentifier;
|
||||
/** 插入位置索引(可选,默认追加到末尾) */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单条消息更新
|
||||
*/
|
||||
export interface UpdateMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.UPDATE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 更新的字段 */
|
||||
updates: Partial<MessageIdentifier>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条消息更新
|
||||
*/
|
||||
export interface DeleteMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.DELETE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动消息更新
|
||||
*/
|
||||
export interface MoveMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.MOVE;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 目标位置索引 */
|
||||
toIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加消息更新
|
||||
*/
|
||||
export interface BatchAddMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_ADD;
|
||||
/** 消息列表 */
|
||||
messages: MessageIdentifier[];
|
||||
/** 插入位置索引(可选,默认追加到末尾) */
|
||||
startIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新消息更新
|
||||
*/
|
||||
export interface BatchUpdateMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_UPDATE;
|
||||
/** 批量更新项 */
|
||||
updates: Array<{
|
||||
messageId: string;
|
||||
changes: Partial<MessageIdentifier>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除消息更新
|
||||
*/
|
||||
export interface BatchDeleteMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.BATCH_DELETE;
|
||||
/** 要删除的消息 ID 列表 */
|
||||
messageIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置消息列表更新
|
||||
*/
|
||||
export interface ResetMessageUpdate extends BaseMessageUpdate {
|
||||
type: MessageUpdateType.RESET;
|
||||
/** 新的消息列表 */
|
||||
messages: MessageIdentifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息更新联合类型
|
||||
*/
|
||||
export type MessageUpdate =
|
||||
| AddMessageUpdate
|
||||
| UpdateMessageUpdate
|
||||
| DeleteMessageUpdate
|
||||
| MoveMessageUpdate
|
||||
| BatchAddMessageUpdate
|
||||
| BatchUpdateMessageUpdate
|
||||
| BatchDeleteMessageUpdate
|
||||
| ResetMessageUpdate;
|
||||
|
||||
/**
|
||||
* 差异配置接口
|
||||
*/
|
||||
export interface DiffConfig {
|
||||
/** 批量处理间隔(毫秒),默认 16ms(60fps) */
|
||||
batchInterval?: number;
|
||||
/** 最大批量大小,默认 100 */
|
||||
maxBatchSize?: number;
|
||||
/** 是否启用去重,默认 true */
|
||||
enableDeduplication?: boolean;
|
||||
/** 是否合并相同消息的多次更新,默认 true */
|
||||
enableMerge?: boolean;
|
||||
/** 消息唯一标识字段,默认 'id' */
|
||||
idField?: string;
|
||||
/** 排序字段,默认 'seq' */
|
||||
sortField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异计算结果接口
|
||||
*/
|
||||
export interface DiffResult<T extends MessageIdentifier> {
|
||||
/** 新增的消息 */
|
||||
added: T[];
|
||||
/** 更新的消息 */
|
||||
updated: Array<{ message: T; index: number; changes: Partial<T> }>;
|
||||
/** 删除的消息 */
|
||||
deleted: Array<{ message: T; index: number }>;
|
||||
/** 移动的消息 */
|
||||
moved: Array<{ message: T; fromIndex: number; toIndex: number }>;
|
||||
/** 未变更的消息 */
|
||||
unchanged: T[];
|
||||
/** 是否需要重置 */
|
||||
shouldReset: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新监听器类型
|
||||
*/
|
||||
export type BatchUpdateListener = (updates: MessageUpdate[]) => void;
|
||||
|
||||
/**
|
||||
* 消息比较函数类型
|
||||
*/
|
||||
export type MessageComparator<T extends MessageIdentifier> = (a: T, b: T) => boolean;
|
||||
|
||||
/**
|
||||
* 消息排序函数类型
|
||||
*/
|
||||
export type MessageSorter<T extends MessageIdentifier> = (a: T, b: T) => number;
|
||||
|
||||
/**
|
||||
* 默认配置常量
|
||||
*/
|
||||
export const DEFAULT_DIFF_CONFIG: Required<DiffConfig> = {
|
||||
batchInterval: 16,
|
||||
maxBatchSize: 100,
|
||||
enableDeduplication: true,
|
||||
enableMerge: true,
|
||||
idField: 'id',
|
||||
sortField: 'seq',
|
||||
};
|
||||
430
src/utils/diff/postDiffCalculator.ts
Normal file
430
src/utils/diff/postDiffCalculator.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 帖子差异计算器
|
||||
* 计算两个帖子列表之间的差异,支持增量更新
|
||||
*/
|
||||
|
||||
import {
|
||||
PostIdentifier,
|
||||
PostDiffResult,
|
||||
PostDiffConfig,
|
||||
DEFAULT_POST_DIFF_CONFIG,
|
||||
PostComparator,
|
||||
PostSorter,
|
||||
PostDiffStats,
|
||||
} from './postTypes';
|
||||
|
||||
/**
|
||||
* 帖子差异计算器类
|
||||
* 用于高效计算帖子列表的变化
|
||||
*/
|
||||
export class PostDiffCalculator<T extends PostIdentifier> {
|
||||
private config: Required<PostDiffConfig>;
|
||||
private previousPosts: Map<string, T> = 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<T> {
|
||||
const result: PostDiffResult<T> = {
|
||||
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<string, number>();
|
||||
oldList.forEach((post, index) => {
|
||||
oldIndexMap.set(post.id, index);
|
||||
});
|
||||
|
||||
// 构建新列表的索引映射
|
||||
const newIndexMap = new Map<string, number>();
|
||||
newList.forEach((post, index) => {
|
||||
newIndexMap.set(post.id, index);
|
||||
});
|
||||
|
||||
// 检测新增、更新和未变更的帖子
|
||||
const processedIds = new Set<string>();
|
||||
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<T> {
|
||||
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<T> | null {
|
||||
const changes: Partial<T> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
// 如果配置了追踪特定字段,只检测这些字段
|
||||
const fieldsToCheck = this.config.detectPartialUpdates
|
||||
? this.config.trackedFields
|
||||
: (Object.keys(newPost) as Array<keyof T>);
|
||||
|
||||
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<PostDiffConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns 当前配置
|
||||
*/
|
||||
getConfig(): Required<PostDiffConfig> {
|
||||
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<T> }> {
|
||||
const results: Array<{ post: T; changes: Partial<T> }> = [];
|
||||
|
||||
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<string> {
|
||||
const ids1 = new Set(list1.map(p => p.id));
|
||||
const ids2 = new Set(list2.map(p => p.id));
|
||||
const intersection = new Set<string>();
|
||||
|
||||
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<string> {
|
||||
const ids1 = new Set(list1.map(p => p.id));
|
||||
const ids2 = new Set(list2.map(p => p.id));
|
||||
const difference = new Set<string>();
|
||||
|
||||
ids1.forEach(id => {
|
||||
if (!ids2.has(id)) {
|
||||
difference.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建帖子差异计算器实例的工厂函数
|
||||
* @param config 配置
|
||||
* @returns PostDiffCalculator 实例
|
||||
*/
|
||||
export function createPostDiffCalculator<T extends PostIdentifier>(
|
||||
config?: PostDiffConfig
|
||||
): PostDiffCalculator<T> {
|
||||
return new PostDiffCalculator<T>(config);
|
||||
}
|
||||
325
src/utils/diff/postTypes.ts
Normal file
325
src/utils/diff/postTypes.ts
Normal file
@@ -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<T extends PostIdentifier = Post> {
|
||||
/** 变化类型 */
|
||||
type: PostChangeType;
|
||||
/** 帖子数据 */
|
||||
post: T;
|
||||
/** 变化前的索引位置(用于删除和移动) */
|
||||
oldIndex?: number;
|
||||
/** 变化后的索引位置(用于新增和移动) */
|
||||
newIndex?: number;
|
||||
/** 变化的字段(用于更新) */
|
||||
changes?: Partial<T>;
|
||||
}
|
||||
|
||||
// ==================== 差异结果接口 ====================
|
||||
|
||||
/**
|
||||
* Post差异计算结果接口
|
||||
*/
|
||||
export interface PostDiffResult<T extends PostIdentifier = Post> {
|
||||
/** 新增的帖子 */
|
||||
added: T[];
|
||||
/** 更新的帖子(包含变化详情) */
|
||||
updated: Array<{
|
||||
post: T;
|
||||
index: number;
|
||||
changes: Partial<T>;
|
||||
}>;
|
||||
/** 删除的帖子 */
|
||||
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<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加单条帖子更新
|
||||
*/
|
||||
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<PostIdentifier>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条帖子更新
|
||||
*/
|
||||
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<PostIdentifier>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除帖子更新
|
||||
*/
|
||||
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<PostDiffConfig> = {
|
||||
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<T extends PostIdentifier> = (a: T, b: T) => boolean;
|
||||
|
||||
/**
|
||||
* Post排序函数类型
|
||||
*/
|
||||
export type PostSorter<T extends PostIdentifier> = (a: T, b: T) => number;
|
||||
|
||||
// ==================== 统计接口 ====================
|
||||
|
||||
/**
|
||||
* Post差异统计接口
|
||||
*/
|
||||
export interface PostDiffStats {
|
||||
/** 总批次数 */
|
||||
totalBatches: number;
|
||||
/** 总更新数 */
|
||||
totalUpdates: number;
|
||||
/** 平均批量大小 */
|
||||
averageBatchSize: number;
|
||||
/** 上次批次时间 */
|
||||
lastBatchTime: number;
|
||||
/** 新增帖子数 */
|
||||
addedCount: number;
|
||||
/** 更新帖子数 */
|
||||
updatedCount: number;
|
||||
/** 删除帖子数 */
|
||||
deletedCount: number;
|
||||
}
|
||||
75
src/utils/formatTime.ts
Normal file
75
src/utils/formatTime.ts
Normal file
@@ -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' });
|
||||
}
|
||||
266
src/utils/pagination/types.ts
Normal file
266
src/utils/pagination/types.ts
Normal file
@@ -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<T = any> {
|
||||
/** 页码 */
|
||||
page: number;
|
||||
/** 该页的数据 */
|
||||
data: T[];
|
||||
/** 缓存时间戳 */
|
||||
cachedAt: number;
|
||||
/** 分页游标 */
|
||||
cursor?: string | number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页配置
|
||||
* 配置分页行为
|
||||
*/
|
||||
export interface PaginationConfig {
|
||||
/** 每页默认大小 */
|
||||
pageSize: number;
|
||||
/** 预加载阈值(距离底部多少条时触发预加载) */
|
||||
prefetchThreshold: number;
|
||||
/** 最大缓存页数 */
|
||||
maxCachedPages: number;
|
||||
/** 缓存过期时间(毫秒) */
|
||||
cacheTtl: number;
|
||||
/** 是否启用预加载 */
|
||||
enablePrefetch: boolean;
|
||||
/** 是否启用缓存 */
|
||||
enableCache: boolean;
|
||||
/** 最大重试次数 */
|
||||
maxRetries: number;
|
||||
/** 重试延迟(毫秒) */
|
||||
retryDelay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载更多结果
|
||||
* loadMore 方法的返回类型
|
||||
*/
|
||||
export interface LoadMoreResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 加载的数据 */
|
||||
data: T[];
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 是否来自缓存 */
|
||||
fromCache: boolean;
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新结果
|
||||
* refresh 方法的返回类型
|
||||
*/
|
||||
export interface RefreshResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 刷新后的数据 */
|
||||
data: T[];
|
||||
/** 是否有更多数据 */
|
||||
hasMore: boolean;
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载结果
|
||||
* prefetch 方法的返回类型
|
||||
*/
|
||||
export interface PrefetchResult<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 预加载的数据 */
|
||||
data?: T[];
|
||||
/** 错误信息(如果失败) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页数据加载函数
|
||||
* 用于从数据源加载分页数据
|
||||
*/
|
||||
export type FetchPageFunction<T = any> = (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
cursor?: string | number | null
|
||||
) => Promise<{
|
||||
data: T[];
|
||||
hasMore: boolean;
|
||||
cursor?: string | number | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 默认分页配置
|
||||
*/
|
||||
export const DEFAULT_PAGINATION_CONFIG: PaginationConfig = {
|
||||
pageSize: 20,
|
||||
prefetchThreshold: 5,
|
||||
maxCachedPages: 10,
|
||||
cacheTtl: 5 * 60 * 1000, // 5分钟
|
||||
enablePrefetch: true,
|
||||
enableCache: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始分页状态
|
||||
*/
|
||||
export function createInitialPaginationState(pageSize: number = 20): PaginationState {
|
||||
return {
|
||||
currentPage: 0,
|
||||
pageSize,
|
||||
totalLoaded: 0,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
lastLoadTime: 0,
|
||||
cursor: null,
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建页面缓存
|
||||
*/
|
||||
export function createPageCache<T>(
|
||||
page: number,
|
||||
data: T[],
|
||||
cursor?: string | number | null
|
||||
): PageCache<T> {
|
||||
return {
|
||||
page,
|
||||
data,
|
||||
cachedAt: Date.now(),
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否过期
|
||||
*/
|
||||
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
|
||||
return Date.now() - cache.cachedAt > ttl;
|
||||
}
|
||||
|
||||
// ==================== 游标分页相关类型 ====================
|
||||
|
||||
/**
|
||||
* 游标分页方向
|
||||
*/
|
||||
export type CursorDirection = 'forward' | 'backward';
|
||||
|
||||
/**
|
||||
* 游标分页配置
|
||||
*/
|
||||
export interface CursorPaginationConfig {
|
||||
/** 每页数量 */
|
||||
pageSize: number;
|
||||
/** 是否启用双向分页 */
|
||||
bidirectional?: boolean;
|
||||
/** 是否自动加载第一页,默认为 true */
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页状态
|
||||
*/
|
||||
export interface CursorPaginationState<T> {
|
||||
/** 数据项列表 */
|
||||
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<T> {
|
||||
/** 加载更多(下一页) */
|
||||
loadMore: () => Promise<void>;
|
||||
/** 加载上一页(双向分页) */
|
||||
loadPrevious: () => Promise<void>;
|
||||
/** 刷新数据(重新从第一页加载) */
|
||||
refresh: () => Promise<void>;
|
||||
/** 重置状态 */
|
||||
reset: () => void;
|
||||
/** 设置数据(用于外部数据注入) */
|
||||
setList: (list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void;
|
||||
/** 就地更新单条数据 */
|
||||
updateItem: (id: string, updates: Partial<T>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游标分页 Hook 返回值
|
||||
*/
|
||||
export interface UseCursorPaginationReturn<T> extends CursorPaginationState<T>, CursorPaginationActions<T> {}
|
||||
|
||||
/**
|
||||
* 游标分页数据获取函数
|
||||
*/
|
||||
export type CursorFetchFunction<T, P = void> = (params: {
|
||||
cursor?: string;
|
||||
direction: CursorDirection;
|
||||
pageSize: number;
|
||||
/** 额外参数 */
|
||||
extraParams?: P;
|
||||
}) => Promise<{
|
||||
list: T[];
|
||||
next_cursor: string | null;
|
||||
prev_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>;
|
||||
83
src/utils/platform/domUtils.ts
Normal file
83
src/utils/platform/domUtils.ts
Normal file
@@ -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'
|
||||
);
|
||||
}
|
||||
12
src/utils/platform/index.ts
Normal file
12
src/utils/platform/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Platform Utilities
|
||||
*
|
||||
* 平台相关的工具函数
|
||||
*/
|
||||
|
||||
export {
|
||||
blurActiveElement,
|
||||
hasActiveElement,
|
||||
getActiveElementType,
|
||||
isInputFocused,
|
||||
} from './domUtils';
|
||||
@@ -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<T> = {
|
||||
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<T>(
|
||||
breakpoint: string,
|
||||
values: ResponsiveValues<T>
|
||||
): 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>
|
||||
): 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;
|
||||
}
|
||||
147
src/utils/sync/types.ts
Normal file
147
src/utils/sync/types.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 同步状态管理类型定义
|
||||
*
|
||||
* 提供连接状态相关的类型、接口和枚举
|
||||
* 用于统一管理 SSE 连接状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* 连接状态枚举
|
||||
* 表示连接生命周期的各个阶段
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
/** 初始状态,未开始连接 */
|
||||
IDLE = 'IDLE',
|
||||
/** 正在建立连接 */
|
||||
CONNECTING = 'CONNECTING',
|
||||
/** 连接已建立 */
|
||||
CONNECTED = 'CONNECTED',
|
||||
/** 连接断开,正在重连 */
|
||||
RECONNECTING = 'RECONNECTING',
|
||||
/** 连接已断开 */
|
||||
DISCONNECTED = 'DISCONNECTED',
|
||||
/** 发生连接错误 */
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态信息
|
||||
* 包含完整的状态信息
|
||||
*/
|
||||
export interface ConnectionStateInfo {
|
||||
/** 当前连接状态 */
|
||||
state: ConnectionState;
|
||||
/** 状态变更时间戳 */
|
||||
timestamp: number;
|
||||
/** 错误信息(仅在 ERROR 状态下有效) */
|
||||
error?: Error;
|
||||
/** 当前重连次数(仅在 RECONNECTING 状态下有效) */
|
||||
reconnectAttempts?: number;
|
||||
/** 上次成功连接时间 */
|
||||
lastConnectedAt?: number | null;
|
||||
/** 状态描述(供显示用) */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态监听器类型
|
||||
*/
|
||||
export type ConnectionStateListener = (
|
||||
state: ConnectionStateInfo,
|
||||
previousState: ConnectionStateInfo | null
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 重连配置接口
|
||||
*/
|
||||
export interface ReconnectConfig {
|
||||
/** 最大重连次数 */
|
||||
maxReconnectAttempts: number;
|
||||
/** 初始重连延迟(毫秒) */
|
||||
initialDelay: number;
|
||||
/** 最大重连延迟(毫秒) */
|
||||
maxDelay: number;
|
||||
/** 延迟倍数(指数退避) */
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态转换规则
|
||||
* 定义哪些状态可以转换到哪些状态
|
||||
*/
|
||||
export type StateTransitions = {
|
||||
[key in ConnectionState]?: ConnectionState[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认状态转换规则
|
||||
*/
|
||||
export const DEFAULT_STATE_TRANSITIONS: StateTransitions = {
|
||||
[ConnectionState.IDLE]: [ConnectionState.CONNECTING],
|
||||
[ConnectionState.CONNECTING]: [
|
||||
ConnectionState.CONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
[ConnectionState.CONNECTED]: [
|
||||
ConnectionState.DISCONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.RECONNECTING,
|
||||
],
|
||||
[ConnectionState.RECONNECTING]: [
|
||||
ConnectionState.CONNECTED,
|
||||
ConnectionState.ERROR,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
[ConnectionState.DISCONNECTED]: [
|
||||
ConnectionState.CONNECTING,
|
||||
ConnectionState.RECONNECTING,
|
||||
],
|
||||
[ConnectionState.ERROR]: [
|
||||
ConnectionState.CONNECTING,
|
||||
ConnectionState.RECONNECTING,
|
||||
ConnectionState.DISCONNECTED,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认重连配置
|
||||
*/
|
||||
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxReconnectAttempts: 20,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态的中文描述
|
||||
* @param state 连接状态
|
||||
* @param reconnectAttempts 当前重连次数
|
||||
* @param maxReconnectAttempts 最大重连次数
|
||||
* @returns 状态描述字符串
|
||||
*/
|
||||
export function getStateDescription(
|
||||
state: ConnectionState,
|
||||
reconnectAttempts?: number,
|
||||
maxReconnectAttempts?: number
|
||||
): string {
|
||||
switch (state) {
|
||||
case ConnectionState.IDLE:
|
||||
return '未连接';
|
||||
case ConnectionState.CONNECTING:
|
||||
return '正在连接...';
|
||||
case ConnectionState.CONNECTED:
|
||||
return '已连接';
|
||||
case ConnectionState.RECONNECTING:
|
||||
return reconnectAttempts !== undefined && maxReconnectAttempts !== undefined
|
||||
? `正在重连 (${reconnectAttempts}/${maxReconnectAttempts})`
|
||||
: '正在重连...';
|
||||
case ConnectionState.DISCONNECTED:
|
||||
return '已断开连接';
|
||||
case ConnectionState.ERROR:
|
||||
return '连接错误';
|
||||
default:
|
||||
return '未知状态';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user