Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
277
src/components/common/SmartImage.tsx
Normal file
277
src/components/common/SmartImage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SmartImage 智能图片组件
|
||||
* 支持加载状态、错误处理、自适应尺寸
|
||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能图片组件
|
||||
* 自动处理加载状态、错误状态
|
||||
*/
|
||||
export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
source,
|
||||
style,
|
||||
imageStyle,
|
||||
resizeMode = 'cover',
|
||||
showLoading = true,
|
||||
showError = true,
|
||||
borderRadius: borderRadiusValue = 0,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onLoad,
|
||||
onError,
|
||||
testID,
|
||||
}) => {
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
|
||||
// 解析图片源 - 支持 uri 或 url 字段
|
||||
const imageUri = typeof source === 'string'
|
||||
? source
|
||||
: (source.uri || source.url || '');
|
||||
|
||||
// 处理加载开始
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
}, []);
|
||||
|
||||
// 处理加载完成
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoadState('success');
|
||||
onLoad?.();
|
||||
}, [onLoad]);
|
||||
|
||||
// 处理加载错误
|
||||
const handleError = useCallback(
|
||||
(error: any) => {
|
||||
setLoadState('error');
|
||||
onError?.(error);
|
||||
},
|
||||
[onError]
|
||||
);
|
||||
|
||||
// 重试加载
|
||||
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: imageUri } : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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="memory-disk"
|
||||
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;
|
||||
Reference in New Issue
Block a user