Files
frontend/src/components/common/SmartImage.tsx
lafay 4ee3079b9f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
feat(Theme): enhance theming and UI consistency across components
- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability.
- Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage.
- Introduced SystemChrome component to manage system UI background color based on theme.
- Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure.
- Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system.
- Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
2026-03-25 05:16:54 +08:00

374 lines
9.7 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.absoluteFillObject,
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 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);
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(() => {
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>
);
}
// 如果是懒加载且不可见显示占位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
}
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;