refactor: restructure core architecture and responsive system
All checks were successful
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / build-and-push-web (push) Successful in 3m30s
Frontend CI / build-android-apk (push) Successful in 1h1m8s

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:
2026-05-05 19:07:33 +08:00
parent f5f9c3a619
commit 3196972596
96 changed files with 4609 additions and 3149 deletions

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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()}

View File

@@ -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 挡住「分钟前」更新 */

View File

@@ -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';

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -16,7 +16,7 @@ import {
useColumnCount,
useResponsiveSpacing,
FineBreakpointKey,
} from '../../hooks/useResponsive';
} from '../../hooks';
export interface ResponsiveGridProps {
/** 子元素 */

View File

@@ -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;

View File

@@ -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';

View File

@@ -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,16 +163,6 @@ 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 {

View File

@@ -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;
}

View 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; }
};

View File

@@ -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[]>;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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]);
}

View File

@@ -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]
);
}

View File

@@ -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]);
}

View File

@@ -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]);
}

View File

@@ -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]);
}

View File

@@ -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',
}), []);
}

View File

@@ -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 建议使用专注的 HooksuseBreakpoint, 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;

View File

@@ -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]);
}

View File

@@ -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]);
}

View File

@@ -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 屏幕返回 8md 屏幕返回 16lg 及以上返回 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]);
}

View File

@@ -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]);
}

View File

@@ -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;
}

View File

@@ -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 屏幕返回 8md 屏幕返回 16lg 及以上返回 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;

View File

@@ -5,28 +5,23 @@
* 提供完整的响应式设计解决方案,包括:
* - 断点检测 (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,
} 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';

View File

@@ -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,58 +74,14 @@ 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]);
}
/**
* 检查当前断点是否大于等于目标断点
*
@@ -136,7 +92,7 @@ export function useFineBreakpoint(): FineBreakpointKey {
* const isMediumUp = useBreakpointGTE('md');
*/
export function useBreakpointGTE(target: FineBreakpointKey): boolean {
const { width } = useWindowDimensionsLocal();
const { width } = useWindowDimensions();
return useMemo(() => {
const current = getFineBreakpoint(width);
@@ -144,50 +100,7 @@ export function useBreakpointGTE(target: FineBreakpointKey): boolean {
}, [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,
};

View File

@@ -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;

View File

@@ -104,9 +104,4 @@ export function useResponsive(): ResponsiveInfo {
}, [width, height, platform]);
}
/**
* 旧的 useResponsive 别名,保持完全向后兼容
*/
export const useLegacyResponsive = useResponsive;
export default useResponsive;

View File

@@ -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]);
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,

View File

@@ -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 = ['帖子', '用户'];

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}, []);
// 跳转到聊天页

View File

@@ -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> = ({

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);
}, []);
// 检查是否需要显示时间分隔

View File

@@ -33,7 +33,7 @@ import {
useAppColors,
type AppColors,
} from '../../theme';
import { useResponsive } from '../../hooks/useResponsive';
import { useResponsive } from '../../hooks';
import {
Course,
COURSE_COLORS,

View File

@@ -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({

View File

@@ -3,12 +3,11 @@
* 处理用户身份认证申请、状态查询等功能
*/
import { api } from '../core/api';
import { api, PaginatedData } from '../core/api';
import {
VerificationStatusDTO,
VerificationRecordDTO,
SubmitVerificationRequest,
PaginatedData,
} from '@/types/dto';
// 提交认证响应

View File

@@ -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,7 +74,6 @@ interface JwtPayload {
// API 客户端类
class ApiClient {
private baseUrl: string;
private verificationModalShown = false;
private refreshLock: Promise<boolean> | null = null;
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
@@ -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() {

View 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();

View 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();

View 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;

View File

@@ -0,0 +1,9 @@
/**
* 数据源导出
* 统一导出所有数据源实现
*/
export * from './interfaces';
export * from './ApiDataSource';
export { LocalDataSource, localDataSource } from '@/database';
export * from './CacheDataSource';

View 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;
};
}

View 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);
}

View File

@@ -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 {

View 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(),
};
}
}

View 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));
}
}

View 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,
};
}
}

View 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() || '',
};
}
}

View File

@@ -0,0 +1,9 @@
/**
* 映射器导出
* 统一导出所有数据映射器
*/
export * from './MessageMapper';
export * from './ConversationMapper';
export * from './UserMapper';
export * from './PostMapper';

View 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>;
}

View 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>;
}

View File

@@ -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
View 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
View 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
View 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
View 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;
}

View 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
View 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[];
}

View 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
View 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;
}

View File

@@ -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;
}
@@ -150,48 +106,3 @@ export interface NotificationBadge {
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
View 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
};

View 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);
}

View 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 {
/** 批量处理间隔(毫秒),默认 16ms60fps */
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',
};

View 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
View 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 {
/** 批量处理间隔(毫秒),默认 16ms60fps */
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
View 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' });
}

View 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;
}>;

View 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'
);
}

View File

@@ -0,0 +1,12 @@
/**
* Platform Utilities
*
* 平台相关的工具函数
*/
export {
blurActiveElement,
hasActiveElement,
getActiveElementType,
isInputFocused,
} from './domUtils';

View File

@@ -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
View 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 '未知状态';
}
}