Files
frontend/src/components/common/SmartImage.tsx

324 lines
8.2 KiB
TypeScript
Raw Normal View History

/**
* SmartImage
*
* expo-image GIF/WebP
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
View,
StyleSheet,
ViewStyle,
ImageStyle,
ActivityIndicator,
Pressable,
StyleProp,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius } from '../../theme';
// 图片加载状态
export type ImageLoadState = 'loading' | 'success' | 'error';
// 图片源类型 - 兼容多种数据格式
export interface ImageSource {
uri?: string;
url?: string;
width?: number;
height?: number;
}
// SmartImage Props
export interface SmartImageProps {
/** 图片源 */
source: ImageSource | string;
/** 容器样式 */
style?: StyleProp<ViewStyle>;
/** 图片样式 */
imageStyle?: StyleProp<ImageStyle>;
/** 图片填充模式 */
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
/** 是否显示加载指示器 */
showLoading?: boolean;
/** 是否显示错误占位图 */
showError?: boolean;
/** 圆角大小 */
borderRadius?: number;
/** 点击回调 */
onPress?: () => void;
/** 长按回调 */
onLongPress?: () => void;
/** 加载完成回调 */
onLoad?: () => void;
/** 加载失败回调 */
onError?: (error: any) => void;
/** 测试ID */
testID?: string;
/** 预览图 URL */
previewUrl?: string;
/** 是否使用预览图(优先加载预览图) */
usePreview?: boolean;
/** 是否懒加载 */
lazyLoad?: boolean;
/** 缓存策略 */
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
}
/**
*
*
*/
export const SmartImage: React.FC<SmartImageProps> = ({
source,
style,
imageStyle,
resizeMode = 'cover',
showLoading = true,
showError = true,
borderRadius: borderRadiusValue = 0,
onPress,
onLongPress,
onLoad,
onError,
testID,
previewUrl,
usePreview = false,
lazyLoad = false,
cachePolicy = 'memory-disk',
}) => {
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(!lazyLoad);
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
// 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string'
? source
: (source.uri || source.url || '');
// 解析预览图 URL
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
// 智能选择图片 URL
const getImageUrl = useCallback((): string => {
// 如果应该加载原图(预览图已加载完成或没有预览图)
if (shouldLoadOriginal || !usePreview || !previewUri) {
return imageUri;
}
// 否则使用预览图
return previewUri;
}, [imageUri, previewUri, usePreview, shouldLoadOriginal]);
// 处理加载开始
const handleLoadStart = useCallback(() => {
setLoadState('loading');
}, []);
// 处理加载完成
const handleLoad = useCallback(() => {
setLoadState('success');
// 如果正在加载预览图,切换到原图
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
setShouldLoadOriginal(true);
}
onLoad?.();
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
// 处理加载错误
const handleError = useCallback(
(error: any) => {
// 如果预览图加载失败,尝试加载原图
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
setShouldLoadOriginal(true);
return;
}
setLoadState('error');
onError?.(error);
},
[onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri]
);
// 重试加载
const handleRetry = useCallback(() => {
setLoadState('loading');
}, []);
// 渲染加载指示器
const renderLoading = () => {
if (!showLoading || loadState !== 'loading') return null;
return (
<View style={styles.overlay}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
</View>
</View>
);
};
// 渲染错误占位图
const renderError = () => {
if (!showError || loadState !== 'error') return null;
return (
<Pressable style={styles.overlay} onPress={handleRetry}>
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="image-off-outline"
size={24}
color={colors.text.hint}
/>
</View>
</Pressable>
);
};
// 容器样式
const containerStyle: ViewStyle = {
borderRadius: borderRadiusValue,
overflow: 'hidden',
};
// 图片源配置
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined;
// 如果没有有效的图片源,显示错误占位
if (!imageSource) {
return (
<Pressable
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.disabled }]}
testID={testID}
>
<View style={styles.errorContainer}>
<MaterialCommunityIcons
name="image-off-outline"
size={24}
color={colors.text.hint}
/>
</View>
</Pressable>
);
}
// 如果是懒加载且不可见,显示占位
if (lazyLoad && !isVisible) {
return (
<View
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
testID={testID}
/>
);
}
return (
<Pressable
style={[containerStyle, style as ViewStyle]}
onPress={onPress}
onLongPress={onLongPress}
disabled={!onPress && !onLongPress}
testID={testID}
>
<ExpoImage
source={imageSource}
style={[styles.image, imageStyle as ImageStyle]}
contentFit={
resizeMode === 'stretch' ? 'fill' :
resizeMode === 'repeat' ? 'cover' :
resizeMode === 'center' ? 'scale-down' :
resizeMode
}
cachePolicy={cachePolicy}
onLoadStart={handleLoadStart}
onLoad={handleLoad}
onError={handleError}
/>
{renderLoading()}
{renderError()}
</Pressable>
);
};
// 预定义尺寸变体
export interface ImageVariantProps extends Omit<SmartImageProps, 'style'> {
/** 尺寸变体 */
variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full';
/** 自定义尺寸 */
size?: number;
/** 宽高比 */
aspectRatio?: number;
}
/**
*
*
*/
export const VariantImage: React.FC<ImageVariantProps> = ({
variant = 'medium',
size,
aspectRatio,
imageStyle,
...props
}) => {
const getVariantStyle = (): ViewStyle => {
switch (variant) {
case 'thumbnail':
return { width: size || 40, height: size || 40 };
case 'small':
return { width: size || 80, height: size || 80 };
case 'medium':
return { width: size || 120, height: size || 120 };
case 'large':
return { width: size || 200, height: size || 200 };
case 'full':
return { flex: 1 };
default:
return { width: size || 120, height: size || 120 };
}
};
const variantStyle = getVariantStyle();
// 如果有宽高比,调整高度
const finalStyle: ViewStyle = { ...variantStyle };
if (aspectRatio && finalStyle.width && !size) {
finalStyle.height = (finalStyle.width as number) / aspectRatio;
}
return (
<SmartImage
{...props}
style={finalStyle}
imageStyle={[styles.variantImage, imageStyle]}
/>
);
};
const styles = StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
variantImage: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.disabled,
},
loadingContainer: {
padding: 8,
borderRadius: borderRadius.md,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
errorContainer: {
padding: 12,
borderRadius: borderRadius.md,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
},
});
export default SmartImage;