feat: 优化图片加载和展示功能

- 新增 SmartImage 组件优化图片加载体验
- 新增 ImageGrid 组件支持多图布局展示
- 新增 imageHelper 工具函数
- 优化 PostCard 帖子卡片图片展示
- 优化消息页面 SegmentRenderer
- 优化日程页面 ScheduleScreen
- 优化上传服务 uploadService
This commit is contained in:
2026-03-15 02:25:55 +08:00
parent e9d42fdb01
commit 592167e667
8 changed files with 299 additions and 46 deletions

View File

@@ -20,6 +20,7 @@ import Avatar from '../common/Avatar';
import { ImageGrid, ImageGridItem, SmartImage } from '../common'; import { ImageGrid, ImageGridItem, SmartImage } from '../common';
import VotePreview from './VotePreview'; import VotePreview from './VotePreview';
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
interface PostCardProps { interface PostCardProps {
post: Post; post: Post;
@@ -206,6 +207,8 @@ const PostCard: React.FC<PostCardProps> = ({
id: img.id, id: img.id,
url: img.url, url: img.url,
thumbnail_url: img.thumbnail_url, thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width, width: img.width,
height: img.height, height: img.height,
})); }));
@@ -224,6 +227,8 @@ const PostCard: React.FC<PostCardProps> = ({
borderRadius={imageBorderRadius} borderRadius={imageBorderRadius}
showMoreOverlay={true} showMoreOverlay={true}
onImagePress={onImagePress} onImagePress={onImagePress}
displayMode={variant === 'grid' ? 'grid' : 'list'}
usePreview={true}
/> />
</View> </View>
); );
@@ -335,6 +340,11 @@ const PostCard: React.FC<PostCardProps> = ({
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
const aspectRatio = aspectRatios[hash % aspectRatios.length]; const aspectRatio = aspectRatios[hash % aspectRatios.length];
// 获取封面图预览 URL
const coverUrl = hasImage
? getPreviewImageUrl(coverImage, 'grid')
: '';
// 获取正文预览(无图时显示) // 获取正文预览(无图时显示)
const getContentPreview = (content: string | undefined | null): string => { const getContentPreview = (content: string | undefined | null): string => {
if (!content) return ''; if (!content) return '';
@@ -369,9 +379,10 @@ const PostCard: React.FC<PostCardProps> = ({
onPress={() => onImagePress?.(post.images || [], 0)} onPress={() => onImagePress?.(post.images || [], 0)}
> >
<SmartImage <SmartImage
source={{ uri: coverImage.thumbnail_url || coverImage.url || '' }} source={{ uri: coverUrl }}
style={[styles.gridCoverImage, { aspectRatio }]} style={[styles.gridCoverImage, { aspectRatio }]}
resizeMode="cover" resizeMode="cover"
usePreview={true}
/> />
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -2,6 +2,7 @@
* ImageGrid 图片网格组件 * ImageGrid 图片网格组件
* 支持 1-9 张图片的智能布局 * 支持 1-9 张图片的智能布局
* 根据图片数量自动选择最佳展示方式 * 根据图片数量自动选择最佳展示方式
* 支持预览图优化
*/ */
import React, { useMemo, useCallback, useState, useEffect } from 'react'; import React, { useMemo, useCallback, useState, useEffect } from 'react';
@@ -16,6 +17,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SmartImage, ImageSource } from './SmartImage'; import { SmartImage, ImageSource } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme'; import { colors, spacing, borderRadius } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -69,6 +71,10 @@ export interface ImageGridProps {
onImagePress?: (images: ImageGridItem[], index: number) => void; onImagePress?: (images: ImageGridItem[], index: number) => void;
/** 测试ID */ /** 测试ID */
testID?: string; testID?: string;
/** 显示模式 */
displayMode?: ImageDisplayMode;
/** 是否使用预览图 */
usePreview?: boolean;
} }
/** /**
@@ -201,6 +207,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
/** /**
* 图片网格组件 * 图片网格组件
* 智能布局:根据图片数量自动选择最佳展示方式 * 智能布局:根据图片数量自动选择最佳展示方式
* 支持预览图优化
*/ */
export const ImageGrid: React.FC<ImageGridProps> = ({ export const ImageGrid: React.FC<ImageGridProps> = ({
images, images,
@@ -214,6 +221,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
gridColumns = 3, gridColumns = 3,
onImagePress, onImagePress,
testID, testID,
displayMode = 'list',
usePreview = true,
}) => { }) => {
// 通过 onLayout 拿到容器实际宽度 // 通过 onLayout 拿到容器实际宽度
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
@@ -256,6 +265,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderSingleImage = () => { const renderSingleImage = () => {
const image = displayImages[0]; const image = displayImages[0];
if (!image) return null; if (!image) return null;
// 获取预览图 URL
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return ( return (
<SingleImageItem <SingleImageItem
image={image} image={image}
@@ -271,27 +286,33 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderHorizontal = () => { const renderHorizontal = () => {
return ( return (
<View style={[styles.horizontalContainer, { gap }]}> <View style={[styles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => ( {displayImages.map((image, index) => {
<Pressable const previewUrl = usePreview && displayMode
key={image.id || index} ? getPreviewImageUrl(image as any, displayMode)
onPress={() => handleImagePress(index)} : (image.uri || image.url || '');
style={[
styles.horizontalItem, return (
{ <Pressable
flex: 1, key={image.id || index}
aspectRatio: 1, onPress={() => handleImagePress(index)}
borderRadius: borderRadiusValue, style={[
}, styles.horizontalItem,
]} {
> flex: 1,
<SmartImage aspectRatio: 1,
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} borderRadius: borderRadiusValue,
style={styles.fullSize} },
resizeMode="cover" ]}
borderRadius={borderRadiusValue} >
/> <SmartImage
</Pressable> source={{ uri: previewUrl }}
))} style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
</Pressable>
);
})}
</View> </View>
); );
}; };
@@ -303,6 +324,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
{displayImages.map((image, index) => { {displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1; const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay; const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return ( return (
<Pressable <Pressable
@@ -317,7 +342,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
]} ]}
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: previewUrl }}
style={styles.fullSize} style={styles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
@@ -361,6 +386,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
? image.width / image.height ? image.width / image.height
: 1; : 1;
const height = itemWidth / aspectRatio; const height = itemWidth / aspectRatio;
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return ( return (
<Pressable <Pressable
@@ -376,7 +405,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
]} ]}
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: previewUrl }}
style={styles.fullSize} style={styles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}

View File

@@ -4,7 +4,7 @@
* 基于 expo-image 封装,原生支持 GIF/WebP 动图 * 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/ */
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -55,6 +55,14 @@ export interface SmartImageProps {
onError?: (error: any) => void; onError?: (error: any) => void;
/** 测试ID */ /** 测试ID */
testID?: string; testID?: string;
/** 预览图 URL */
previewUrl?: string;
/** 是否使用预览图(优先加载预览图) */
usePreview?: boolean;
/** 是否懒加载 */
lazyLoad?: boolean;
/** 缓存策略 */
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
} }
/** /**
@@ -74,14 +82,33 @@ export const SmartImage: React.FC<SmartImageProps> = ({
onLoad, onLoad,
onError, onError,
testID, testID,
previewUrl,
usePreview = false,
lazyLoad = false,
cachePolicy = 'memory-disk',
}) => { }) => {
const [loadState, setLoadState] = useState<ImageLoadState>('loading'); const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(!lazyLoad);
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
// 解析图片源 - 支持 uri 或 url 字段 // 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string' const imageUri = typeof source === 'string'
? source ? source
: (source.uri || source.url || ''); : (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(() => { const handleLoadStart = useCallback(() => {
setLoadState('loading'); setLoadState('loading');
@@ -90,16 +117,25 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 处理加载完成 // 处理加载完成
const handleLoad = useCallback(() => { const handleLoad = useCallback(() => {
setLoadState('success'); setLoadState('success');
// 如果正在加载预览图,切换到原图
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
setShouldLoadOriginal(true);
}
onLoad?.(); onLoad?.();
}, [onLoad]); }, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
// 处理加载错误 // 处理加载错误
const handleError = useCallback( const handleError = useCallback(
(error: any) => { (error: any) => {
// 如果预览图加载失败,尝试加载原图
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
setShouldLoadOriginal(true);
return;
}
setLoadState('error'); setLoadState('error');
onError?.(error); onError?.(error);
}, },
[onError] [onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri]
); );
// 重试加载 // 重试加载
@@ -144,7 +180,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
}; };
// 图片源配置 // 图片源配置
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined; const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined;
// 如果没有有效的图片源,显示错误占位 // 如果没有有效的图片源,显示错误占位
if (!imageSource) { if (!imageSource) {
@@ -164,6 +200,16 @@ export const SmartImage: React.FC<SmartImageProps> = ({
); );
} }
// 如果是懒加载且不可见,显示占位
if (lazyLoad && !isVisible) {
return (
<View
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
testID={testID}
/>
);
}
return ( return (
<Pressable <Pressable
style={[containerStyle, style as ViewStyle]} style={[containerStyle, style as ViewStyle]}
@@ -181,7 +227,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
resizeMode === 'center' ? 'scale-down' : resizeMode === 'center' ? 'scale-down' :
resizeMode resizeMode
} }
cachePolicy="memory-disk" cachePolicy={cachePolicy}
onLoadStart={handleLoadStart} onLoadStart={handleLoadStart}
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}

View File

@@ -438,11 +438,20 @@ const renderLinkSegment = (
): React.ReactNode => { ): React.ReactNode => {
const { onLinkPress, isMe } = props; const { onLinkPress, isMe } = props;
const handlePress = () => { const handlePress = async () => {
if (onLinkPress) { if (onLinkPress) {
onLinkPress(data.url); onLinkPress(data.url);
} else { } else {
Linking.openURL(data.url).catch(() => {}); try {
const supported = await Linking.canOpenURL(data.url);
if (supported) {
await Linking.openURL(data.url);
} else {
console.warn('无法打开链接:', data.url);
}
} catch (error) {
console.warn('打开链接失败:', error);
}
} }
}; };
@@ -466,7 +475,13 @@ const renderLinkSegment = (
</Text> </Text>
)} )}
<Text style={styles.linkUrl} numberOfLines={1}> <Text style={styles.linkUrl} numberOfLines={1}>
{new URL(data.url).hostname} {(() => {
try {
return new URL(data.url).hostname;
} catch {
return data.url;
}
})()}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -145,6 +145,8 @@ export const ScheduleScreen: React.FC = () => {
const [startWeekInput, setStartWeekInput] = useState(String(currentWeek)); const [startWeekInput, setStartWeekInput] = useState(String(currentWeek));
const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS)); const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS));
const weekScrollRef = useRef<ScrollView | null>(null); const weekScrollRef = useRef<ScrollView | null>(null);
// 组件卸载状态标记,用于防止组件卸载后仍调用 setState
const isMountedRef = useRef(true);
// 设置弹窗状态 // 设置弹窗状态
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
@@ -153,6 +155,13 @@ export const ScheduleScreen: React.FC = () => {
const [syncUsername, setSyncUsername] = useState(''); const [syncUsername, setSyncUsername] = useState('');
const [syncPassword, setSyncPassword] = useState(''); const [syncPassword, setSyncPassword] = useState('');
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
// 组件卸载时设置标记
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const todayColumnIndex = getTodayColumnIndex(); const todayColumnIndex = getTodayColumnIndex();
// currentWeek === 1 对应今天所在的真实周offset 0其他周不高亮今日列 // currentWeek === 1 对应今天所在的真实周offset 0其他周不高亮今日列
const isViewingCurrentWeek = currentWeek === 1; const isViewingCurrentWeek = currentWeek === 1;
@@ -463,14 +472,31 @@ export const ScheduleScreen: React.FC = () => {
style: 'destructive' as const, style: 'destructive' as const,
onPress: async () => { onPress: async () => {
try { try {
// 删除所有相关课程 // 使用 Promise.allSettled 批量删除,即使部分失败也能继续
for (const relatedCourse of relatedCourses) { const results = await Promise.allSettled(
await scheduleService.deleteCourse(relatedCourse.id); relatedCourses.map(course => scheduleService.deleteCourse(course.id))
);
// 统计成功和失败的数量
const fulfilledCount = results.filter(r => r.status === 'fulfilled').length;
const rejectedCount = results.filter(r => r.status === 'rejected').length;
// 只移除成功删除的课程
const successfullyDeletedIds = new Set(
relatedCourses
.filter((_, index) => results[index].status === 'fulfilled')
.map(c => c.id)
);
setCourses(prev => prev.filter(c => !successfullyDeletedIds.has(c.id)));
if (rejectedCount > 0) {
Alert.alert(
'部分删除成功',
`成功删除 ${fulfilledCount} 节课程,${rejectedCount} 节删除失败,请重试`
);
} else {
Alert.alert('成功', `已删除 ${fulfilledCount} 节课程`);
} }
// 从本地状态中移除
const relatedIds = new Set(relatedCourses.map(c => c.id));
setCourses(prev => prev.filter(c => !relatedIds.has(c.id)));
Alert.alert('成功', `已删除 ${relatedCourses.length} 节课程`);
} catch (error) { } catch (error) {
console.error('批量删除课程失败:', error); console.error('批量删除课程失败:', error);
Alert.alert('删除失败', '请稍后重试'); Alert.alert('删除失败', '请稍后重试');
@@ -755,14 +781,17 @@ export const ScheduleScreen: React.FC = () => {
]} ]}
onPress={async () => { onPress={async () => {
if (!syncUsername.trim() || !syncPassword.trim()) return; if (!syncUsername.trim() || !syncPassword.trim()) return;
setIsSyncing(true); setIsSyncing(true);
try { try {
const result = await scheduleService.syncSchedule({ const result = await scheduleService.syncSchedule({
username: syncUsername.trim(), username: syncUsername.trim(),
password: syncPassword.trim(), password: syncPassword.trim(),
}); });
// 检查组件是否已卸载
if (!isMountedRef.current) return;
if (result.success) { if (result.success) {
Alert.alert( Alert.alert(
'同步成功', '同步成功',
@@ -787,9 +816,15 @@ export const ScheduleScreen: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('同步课表失败:', error); console.error('同步课表失败:', error);
Alert.alert('同步失败', '网络错误,请稍后重试'); // 检查组件是否已卸载
if (isMountedRef.current) {
Alert.alert('同步失败', '网络错误,请稍后重试');
}
} finally { } finally {
setIsSyncing(false); // 检查组件是否已卸载
if (isMountedRef.current) {
setIsSyncing(false);
}
} }
}} }}
activeOpacity={0.8} activeOpacity={0.8}

View File

@@ -35,6 +35,8 @@ function resolveFileInfo(uri: string, providedType?: string): { name: string; ty
// 上传响应 // 上传响应
export interface UploadResponse { export interface UploadResponse {
url: string; url: string;
preview_url?: string; // 列表/网格预览图
preview_url_large?: string; // 详情页预览图
width?: number; width?: number;
height?: number; height?: number;
size?: number; size?: number;
@@ -162,7 +164,19 @@ class UploadService {
for (const file of files) { for (const file of files) {
const result = await this.uploadImage(file); const result = await this.uploadImage(file);
if (result) { if (result) {
urls.push(result.url); // 将预览图信息合并到 URL 中url|preview_url|preview_url_large
const parts = [result.url];
if (result.preview_url) {
parts.push(result.preview_url);
} else {
parts.push(''); // 空占位
}
if (result.preview_url_large) {
parts.push(result.preview_url_large);
} else {
parts.push(''); // 空占位
}
urls.push(parts.join('|'));
} }
} }

View File

@@ -52,6 +52,8 @@ export interface PostImageDTO {
id: string; id: string;
url: string; url: string;
thumbnail_url: string; thumbnail_url: string;
preview_url: string; // 列表/网格预览图
preview_url_large: string; // 详情页预览图
width: number; width: number;
height: number; height: number;
} }

101
src/utils/imageHelper.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* 图片工具函数
* 提供图片 URL 选择、格式检测等实用功能
*/
import { PostImageDTO } from '../types';
/**
* 图片显示模式
*/
export type ImageDisplayMode = 'list' | 'grid' | 'detail';
/**
* 根据显示模式获取预览图 URL
* @param image 图片对象
* @param mode 显示模式
* @returns 图片 URL
*/
export function getPreviewImageUrl(image: PostImageDTO, mode: ImageDisplayMode): string {
// 如果没有预览图,返回原图
if (!image) {
return '';
}
switch (mode) {
case 'list':
case 'grid':
// 列表和网格模式使用普通预览图
return image.preview_url || image.url || '';
case 'detail':
// 详情页使用大预览图
return image.preview_url_large || image.preview_url || image.url || '';
default:
return image.url || '';
}
}
/**
* 检测 WebP 支持并构建 Accept header
* @returns Accept header 字符串
*/
export function getImageAcceptHeader(): string {
// 检测浏览器是否支持 WebP
// 如果支持,优先请求 WebP
return 'image/webp,image/apng,image/*,*/*;q=0.8';
}
/**
* 判断是否应该使用预览图
* @param mode 显示模式
* @param hasPreview 是否有预览图
* @returns 是否使用预览图
*/
export function shouldUsePreview(mode: ImageDisplayMode, hasPreview: boolean): boolean {
// 列表和网格模式优先使用预览图
if (mode === 'list' || mode === 'grid') {
return hasPreview;
}
// 详情页也使用预览图,但点击后加载原图
if (mode === 'detail') {
return hasPreview;
}
return false;
}
/**
* 从图片 URL 提取文件扩展名
* @param url 图片 URL
* @returns 文件扩展名(如 '.jpg'
*/
export function getImageExtension(url: string): string {
if (!url) return '';
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const ext = pathname.match(/\.[0-9a-z]+$/i);
return ext ? ext[0].toLowerCase() : '';
} catch {
// 如果 URL 解析失败,直接从字符串匹配
const match = url.match(/\.[0-9a-z]+$/i);
return match ? match[0].toLowerCase() : '';
}
}
/**
* 判断是否是 GIF 图片
* @param url 图片 URL
* @returns 是否是 GIF
*/
export function isGifImage(url: string): boolean {
return getImageExtension(url) === '.gif';
}
/**
* 判断是否是 WebP 图片
* @param url 图片 URL
* @returns 是否是 WebP
*/
export function isWebPImage(url: string): boolean {
return getImageExtension(url) === '.webp';
}