Files
frontend/src/components/common/ImageGrid.tsx
lan 592167e667 feat: 优化图片加载和展示功能
- 新增 SmartImage 组件优化图片加载体验
- 新增 ImageGrid 组件支持多图布局展示
- 新增 imageHelper 工具函数
- 优化 PostCard 帖子卡片图片展示
- 优化消息页面 SegmentRenderer
- 优化日程页面 ScheduleScreen
- 优化上传服务 uploadService
2026-03-15 02:25:55 +08:00

628 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ImageGrid 图片网格组件
* 支持 1-9 张图片的智能布局
* 根据图片数量自动选择最佳展示方式
* 支持预览图优化
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import {
View,
StyleSheet,
Dimensions,
ViewStyle,
Pressable,
Text,
Image,
} from 'react-native';
import { SmartImage, ImageSource } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 默认容器内边距(用于计算可用宽度)
const DEFAULT_CONTAINER_PADDING = spacing.lg * 2; // 左右各 spacing.lg
// 单张图片的默认宽高比
const SINGLE_IMAGE_DEFAULT_ASPECT_RATIO = 4 / 3; // 默认4:3比例
// 单张图片的最大高度
const SINGLE_IMAGE_MAX_HEIGHT = 400;
// 单张图片的最小高度
const SINGLE_IMAGE_MIN_HEIGHT = 150;
// 图片项类型 - 兼容 PostImageDTO 和 CommentImage
export interface ImageGridItem {
id?: string;
uri?: string;
url?: string;
thumbnailUrl?: string;
thumbnail_url?: string;
width?: number;
height?: number;
}
// 布局模式
export type GridLayoutMode = 'auto' | 'single' | 'horizontal' | 'grid' | 'masonry';
// 组件 Props
export interface ImageGridProps {
/** 图片列表 */
images: ImageGridItem[];
/** 容器样式 */
style?: ViewStyle;
/** 最大显示数量 */
maxDisplayCount?: number;
/** 布局模式 */
mode?: GridLayoutMode;
/** 图片间距 */
gap?: number;
/** 圆角大小 */
borderRadius?: number;
/** 是否显示更多遮罩 */
showMoreOverlay?: boolean;
/** 单张图片最大高度 */
singleImageMaxHeight?: number;
/** 网格列数(2或3) */
gridColumns?: 2 | 3;
/** 图片点击回调 */
onImagePress?: (images: ImageGridItem[], index: number) => void;
/** 测试ID */
testID?: string;
/** 显示模式 */
displayMode?: ImageDisplayMode;
/** 是否使用预览图 */
usePreview?: boolean;
}
/**
* 计算图片网格尺寸
*/
const calculateGridDimensions = (
count: number,
containerWidth: number,
gap: number,
columns: number
) => {
const totalGap = (columns - 1) * gap;
const itemSize = (containerWidth - totalGap) / columns;
// 计算行数
const rows = Math.ceil(count / columns);
return {
itemSize,
rows,
containerHeight: rows * itemSize + (rows - 1) * gap,
};
};
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
interface SingleImageItemProps {
image: ImageGridItem;
containerWidth: number;
maxHeight: number;
borderRadiusValue: number;
onPress: () => void;
}
const SingleImageItem: React.FC<SingleImageItemProps> = ({
image,
containerWidth,
maxHeight,
borderRadiusValue,
onPress,
}) => {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const uri = image.uri || image.url || '';
useEffect(() => {
if (!uri) return;
let cancelled = false;
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
}, 3000);
Image.getSize(
uri,
(w, h) => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(w / h);
}
},
() => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
},
);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [uri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
// 尺寸还没拿到时先占位,避免闪烁
if (aspectRatio === null) {
return (
<View
style={[
styles.singleContainer,
{
width: effectiveContainerWidth,
height: SINGLE_IMAGE_MIN_HEIGHT,
borderRadius: borderRadiusValue,
},
]}
/>
);
}
// 适配最大边界框,保证宽高比不变
let width: number;
let height: number;
if (aspectRatio > effectiveContainerWidth / maxHeight) {
// 宽图:宽度撑满容器
width = effectiveContainerWidth;
height = effectiveContainerWidth / aspectRatio;
} else {
// 高图:高度触及上限
height = maxHeight;
width = maxHeight * aspectRatio;
}
// 最小高度兜底
if (height < SINGLE_IMAGE_MIN_HEIGHT) {
height = SINGLE_IMAGE_MIN_HEIGHT;
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
}
return (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
};
/**
* 图片网格组件
* 智能布局:根据图片数量自动选择最佳展示方式
* 支持预览图优化
*/
export const ImageGrid: React.FC<ImageGridProps> = ({
images,
style,
maxDisplayCount = 9,
mode = 'auto',
gap = 4,
borderRadius: borderRadiusValue = borderRadius.md,
showMoreOverlay = true,
singleImageMaxHeight = 300,
gridColumns = 3,
onImagePress,
testID,
displayMode = 'list',
usePreview = true,
}) => {
// 通过 onLayout 拿到容器实际宽度
const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段
const validImages = useMemo(() => {
const filtered = images.filter(img => img.uri || img.url || typeof img === 'string');
return filtered;
}, [images]);
// 实际显示的图片
const displayImages = useMemo(() => {
return validImages.slice(0, maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 剩余图片数量
const remainingCount = useMemo(() => {
return Math.max(0, validImages.length - maxDisplayCount);
}, [validImages, maxDisplayCount]);
// 处理图片点击
const handleImagePress = useCallback(
(index: number) => {
onImagePress?.(validImages, index);
},
[onImagePress, validImages]
);
// 确定布局模式
const layoutMode = useMemo(() => {
if (mode !== 'auto') return mode;
const count = displayImages.length;
if (count === 1) return 'single';
if (count === 2) return 'horizontal';
return 'grid';
}, [mode, displayImages.length]);
// 渲染单张图片
const renderSingleImage = () => {
const image = displayImages[0];
if (!image) return null;
// 获取预览图 URL
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<SingleImageItem
image={image}
containerWidth={containerWidth}
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
/>
);
};
// 渲染横向双图
const renderHorizontal = () => {
return (
<View style={[styles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.horizontalItem,
{
flex: 1,
aspectRatio: 1,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
// 渲染网格布局
const renderGrid = () => {
return (
<View style={[styles.gridContainer, { gap }]}>
{displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
{
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{showOverlay && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text>
</View>
)}
</Pressable>
);
})}
</View>
);
};
// 渲染瀑布流布局
const renderMasonry = () => {
const containerWidth = SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
const columns = 2;
const itemWidth = (containerWidth - gap) / columns;
// 将图片分配到两列
const leftColumn: ImageGridItem[] = [];
const rightColumn: ImageGridItem[] = [];
displayImages.forEach((image, index) => {
if (index % 2 === 0) {
leftColumn.push(image);
} else {
rightColumn.push(image);
}
});
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return (
<View style={[styles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height
? image.width / image.height
: 1;
const height = itemWidth / aspectRatio;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<Pressable
key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)}
style={[
styles.masonryItem,
{
width: itemWidth,
height: Math.max(height, itemWidth * 0.7),
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View>
);
};
return (
<View style={[styles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)}
</View>
);
};
// 根据布局模式渲染
const renderContent = () => {
switch (layoutMode) {
case 'single':
return renderSingleImage();
case 'horizontal':
return renderHorizontal();
case 'masonry':
return renderMasonry();
case 'grid':
default:
return renderGrid();
}
};
// 如果没有图片返回null
if (displayImages.length === 0) {
return null;
}
return (
<View
style={[styles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
);
};
// 紧凑模式 - 用于评论等小空间场景
export interface CompactImageGridProps extends Omit<ImageGridProps, 'mode' | 'gridColumns'> {
/** 最大尺寸限制 */
maxWidth?: number;
}
/**
* 紧凑图片网格
* 适用于评论等空间有限的场景
*/
export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
maxWidth,
gap = 4,
borderRadius: borderRadiusValue = borderRadius.sm,
...props
}) => {
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => {
const { images } = props;
const count = images.length;
if (count === 0) return null;
if (count === 1) {
const image = images[0];
const size = Math.min(containerWidth * 0.6, 150);
return (
<Pressable
onPress={() => props.onImagePress?.(images, 0)}
style={[
styles.compactItem,
{
width: size,
height: size,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
}
// 多张图片使用小网格
const columns = count <= 4 ? 2 : 3;
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return (
<View style={[styles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => props.onImagePress?.(images, index)}
style={[
styles.compactItem,
{
width: itemSize,
height: itemSize,
borderRadius: borderRadiusValue,
},
]}
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text>
</View>
)}
</Pressable>
))}
</View>
);
};
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
};
const styles = StyleSheet.create({
container: {
marginTop: spacing.sm,
},
fullSize: {
flex: 1,
width: '100%',
height: '100%',
},
// 单图样式
singleContainer: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 横向布局样式
horizontalContainer: {
flexDirection: 'row',
},
horizontalItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 网格布局样式
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
gridItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
aspectRatio: 1,
},
gridItem2: {
width: '48%', // 2列布局每列约48%宽度,留有余量避免换行
},
gridItem3: {
width: '31%', // 3列布局每列约31%宽度,留有余量避免换行
},
// 瀑布流样式
masonryContainer: {
flexDirection: 'row',
},
masonryColumn: {
flex: 1,
},
masonryItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
// 更多遮罩 - 类似微博的灰色蒙版
moreOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: borderRadius.md,
},
moreText: {
color: colors.text.inverse,
fontSize: fontSizes.xl,
fontWeight: '500',
},
// 紧凑模式样式
compactContainer: {
marginTop: spacing.xs,
},
compactGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
compactItem: {
overflow: 'hidden',
backgroundColor: colors.background.disabled,
},
});
// 导入字体大小
import { fontSizes } from '../../theme';
export default ImageGrid;