Files
frontend/src/components/common/SmartImage.tsx
lafay 8ee6e77cb4
All checks were successful
Frontend CI / ota-android (push) Successful in 1m50s
Frontend CI / ota-ios (push) Successful in 2m38s
Frontend CI / build-and-push-web (push) Successful in 10m12s
Frontend CI / build-android-apk (push) Successful in 17m46s
chore: update styles, add splash config, and improve search functionality
- Replace deprecated StyleSheet.absoluteFillObject with StyleSheet.absoluteFill (React Native 0.76+)
- Add splash screen configuration in app.json
- Fix duplicate UIBackgroundModes and add audio mode for iOS
- Disable Android ripple effect on tab bar buttons
- Improve SmartImage loading state to prevent unnecessary re-renders
- Refactor ImageGrid to use mainUri with separate previewUrl for better preview handling
- Add market search support with trade items tab in SearchScreen
- Pass homeTab prop to SearchScreen for context-aware search behavior
- Simplify fetchUnreadCount return type to void in MessageSyncService
- Fix StatusBar import from expo to react-native in ChatScreen
2026-06-02 08:14:04 +08:00

388 lines
10 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.
/**
* SmartImage 智能图片组件
* 支持加载状态、错误处理、自适应尺寸
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
View,
StyleSheet,
ViewStyle,
ImageStyle,
ActivityIndicator,
Pressable,
StyleProp,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { borderRadius, useAppColors, type AppColors } from '../../theme';
function createSmartImageStyles(colors: AppColors) {
return StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
variantImage: {
flex: 1,
},
overlay: {
...StyleSheet.absoluteFill,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.disabled,
},
loadingContainer: {
padding: 8,
borderRadius: borderRadius.md,
backgroundColor: `${colors.background.paper}E6`,
},
errorContainer: {
padding: 12,
borderRadius: borderRadius.md,
backgroundColor: `${colors.text.primary}0D`,
},
});
}
// 图片加载状态
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;
/** Web 端 IntersectionObserver rootMargin略提前加载进入视口附近的图 */
lazyRootMargin?: string;
/** 缓存策略 */
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,
lazyRootMargin = '160px',
cachePolicy = 'memory-disk',
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const isUpgradingFromPreview = useRef(false);
const lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string'
? source
: (source.uri || source.url || '');
// 解析预览图 URL
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
useEffect(() => {
setShouldLoadOriginal(false);
isUpgradingFromPreview.current = false;
setLoadState('loading');
}, [imageUri]);
useEffect(() => {
if (!lazyLoad) {
setIsVisible(true);
return;
}
if (Platform.OS !== 'web') {
setIsVisible(true);
return;
}
setIsVisible(false);
const node = lazyTargetRef.current as unknown as Element | null;
if (!node || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting)) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: lazyRootMargin }
);
observer.observe(node);
return () => observer.disconnect();
}, [lazyLoad, lazyRootMargin, imageUri]);
// 智能选择图片 URL
const getImageUrl = useCallback((): string => {
// 如果应该加载原图(预览图已加载完成或没有预览图)
if (shouldLoadOriginal || !usePreview || !previewUri) {
return imageUri;
}
// 否则使用预览图
return previewUri;
}, [imageUri, previewUri, usePreview, shouldLoadOriginal]);
// 处理加载开始
const handleLoadStart = useCallback(() => {
// 从预览图切换到原图时不重置为loading状态避免闪烁
if (isUpgradingFromPreview.current) {
return;
}
// 已成功加载的图片不再回到loading状态列表回收/re-render时onLoadStart可能重新触发
setLoadState(prev => (prev === 'success' ? prev : 'loading'));
}, []);
// 处理加载完成
const handleLoad = useCallback(() => {
// 如果正在加载预览图切换到原图不重置loading状态避免闪烁
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
isUpgradingFromPreview.current = true;
setShouldLoadOriginal(true);
return;
}
// 原图加载完成(包括从预览图升级的情况)
isUpgradingFromPreview.current = false;
setLoadState('success');
onLoad?.();
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
// 处理加载错误
const handleError = useCallback(
(error: any) => {
// 如果预览图加载失败,尝试加载原图
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
isUpgradingFromPreview.current = true;
setShouldLoadOriginal(true);
return;
}
isUpgradingFromPreview.current = false;
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>
);
}
// 如果是懒加载且不可见显示占位Web 上由 IntersectionObserver 驱动进入视口后再加载)
if (lazyLoad && !isVisible) {
return (
<View
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
collapsable={false}
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
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
}
transition={200}
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={[variantStyles.variantImage, imageStyle]}
/>
);
};
const variantStyles = StyleSheet.create({
variantImage: {
flex: 1,
},
});
export default SmartImage;