Files
frontend/src/components/common/ImageGrid.tsx
lafay 7305254e11
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m39s
Frontend CI / ota-android (push) Successful in 10m59s
Frontend CI / build-android-apk (push) Successful in 59m29s
refactor(PostCard, ImageGrid, SmartImage): enhance post editing display and image handling
- Introduced a new method to determine the last edited time for posts, prioritizing content_edited_at for better accuracy in display.
- Updated PostCard and PostDetailScreen components to utilize the new editing display logic.
- Enhanced ImageGrid and SmartImage components to support additional preview URLs and improve image loading behavior, including lazy loading optimizations for web.
- Modified Post and PostDTO interfaces to include contentEditedAt for better tracking of post edits.
2026-03-23 14:21:22 +08:00

646 lines
17 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 } 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;
preview_url?: string;
preview_url_large?: 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;
usePreview: boolean;
displayMode: ImageDisplayMode;
}
const SingleImageItem: React.FC<SingleImageItemProps> = ({
image,
containerWidth,
maxHeight,
borderRadiusValue,
onPress,
usePreview,
displayMode,
}) => {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const mainUri = image.uri || image.url || '';
const thumbnailUri =
image.thumbnail_url ||
image.thumbnailUrl ||
image.preview_url ||
'';
useEffect(() => {
if (image.width && image.height) {
setAspectRatio(image.width / image.height);
return;
}
const probeUri = thumbnailUri || mainUri;
if (!probeUri) return;
let cancelled = false;
const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
}, 3000);
Image.getSize(
probeUri,
(w, h) => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(w / h);
}
},
() => {
if (!cancelled) {
clearTimeout(timeoutId);
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
}
},
);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [image.width, image.height, thumbnailUri, mainUri]);
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);
}
const previewForSmart =
usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
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;
return (
<SingleImageItem
image={image}
containerWidth={containerWidth}
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/>
);
};
// 渲染横向双图
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;