From 592167e667cd8bab30e615263a6c11aebaa5132f Mon Sep 17 00:00:00 2001 From: lan Date: Sun, 15 Mar 2026 02:25:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=92=8C=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SmartImage 组件优化图片加载体验 - 新增 ImageGrid 组件支持多图布局展示 - 新增 imageHelper 工具函数 - 优化 PostCard 帖子卡片图片展示 - 优化消息页面 SegmentRenderer - 优化日程页面 ScheduleScreen - 优化上传服务 uploadService --- src/components/business/PostCard.tsx | 13 ++- src/components/common/ImageGrid.tsx | 75 +++++++++---- src/components/common/SmartImage.tsx | 60 +++++++++-- .../components/ChatScreen/SegmentRenderer.tsx | 21 +++- src/screens/schedule/ScheduleScreen.tsx | 57 ++++++++-- src/services/uploadService.ts | 16 ++- src/types/dto.ts | 2 + src/utils/imageHelper.ts | 101 ++++++++++++++++++ 8 files changed, 299 insertions(+), 46 deletions(-) create mode 100644 src/utils/imageHelper.ts diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx index 5e103f0..c992e42 100644 --- a/src/components/business/PostCard.tsx +++ b/src/components/business/PostCard.tsx @@ -20,6 +20,7 @@ import Avatar from '../common/Avatar'; import { ImageGrid, ImageGridItem, SmartImage } from '../common'; import VotePreview from './VotePreview'; import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive'; +import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; interface PostCardProps { post: Post; @@ -206,6 +207,8 @@ const PostCard: React.FC = ({ id: img.id, url: img.url, thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, width: img.width, height: img.height, })); @@ -224,6 +227,8 @@ const PostCard: React.FC = ({ borderRadius={imageBorderRadius} showMoreOverlay={true} onImagePress={onImagePress} + displayMode={variant === 'grid' ? 'grid' : 'list'} + usePreview={true} /> ); @@ -335,6 +340,11 @@ const PostCard: React.FC = ({ const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; const aspectRatio = aspectRatios[hash % aspectRatios.length]; + // 获取封面图预览 URL + const coverUrl = hasImage + ? getPreviewImageUrl(coverImage, 'grid') + : ''; + // 获取正文预览(无图时显示) const getContentPreview = (content: string | undefined | null): string => { if (!content) return ''; @@ -369,9 +379,10 @@ const PostCard: React.FC = ({ onPress={() => onImagePress?.(post.images || [], 0)} > )} diff --git a/src/components/common/ImageGrid.tsx b/src/components/common/ImageGrid.tsx index d3b0d08..ca28182 100644 --- a/src/components/common/ImageGrid.tsx +++ b/src/components/common/ImageGrid.tsx @@ -2,6 +2,7 @@ * ImageGrid 图片网格组件 * 支持 1-9 张图片的智能布局 * 根据图片数量自动选择最佳展示方式 + * 支持预览图优化 */ import React, { useMemo, useCallback, useState, useEffect } from 'react'; @@ -16,6 +17,7 @@ import { } from 'react-native'; import { SmartImage, ImageSource } from './SmartImage'; import { colors, spacing, borderRadius } from '../../theme'; +import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -69,6 +71,10 @@ export interface ImageGridProps { onImagePress?: (images: ImageGridItem[], index: number) => void; /** 测试ID */ testID?: string; + /** 显示模式 */ + displayMode?: ImageDisplayMode; + /** 是否使用预览图 */ + usePreview?: boolean; } /** @@ -201,6 +207,7 @@ const SingleImageItem: React.FC = ({ /** * 图片网格组件 * 智能布局:根据图片数量自动选择最佳展示方式 + * 支持预览图优化 */ export const ImageGrid: React.FC = ({ images, @@ -214,6 +221,8 @@ export const ImageGrid: React.FC = ({ gridColumns = 3, onImagePress, testID, + displayMode = 'list', + usePreview = true, }) => { // 通过 onLayout 拿到容器实际宽度 const [containerWidth, setContainerWidth] = useState(0); @@ -256,6 +265,12 @@ export const ImageGrid: React.FC = ({ const renderSingleImage = () => { const image = displayImages[0]; if (!image) return null; + + // 获取预览图 URL + const previewUrl = usePreview && displayMode + ? getPreviewImageUrl(image as any, displayMode) + : (image.uri || image.url || ''); + return ( = ({ const renderHorizontal = () => { return ( - {displayImages.map((image, index) => ( - handleImagePress(index)} - style={[ - styles.horizontalItem, - { - flex: 1, - aspectRatio: 1, - borderRadius: borderRadiusValue, - }, - ]} - > - - - ))} + {displayImages.map((image, index) => { + const previewUrl = usePreview && displayMode + ? getPreviewImageUrl(image as any, displayMode) + : (image.uri || image.url || ''); + + return ( + handleImagePress(index)} + style={[ + styles.horizontalItem, + { + flex: 1, + aspectRatio: 1, + borderRadius: borderRadiusValue, + }, + ]} + > + + + ); + })} ); }; @@ -303,6 +324,10 @@ export const ImageGrid: React.FC = ({ {displayImages.map((image, index) => { const isLastVisible = index === displayImages.length - 1; const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay; + + const previewUrl = usePreview && displayMode + ? getPreviewImageUrl(image as any, displayMode) + : (image.uri || image.url || ''); return ( = ({ ]} > = ({ ? image.width / image.height : 1; const height = itemWidth / aspectRatio; + + const previewUrl = usePreview && displayMode + ? getPreviewImageUrl(image as any, displayMode) + : (image.uri || image.url || ''); return ( = ({ ]} > void; /** 测试ID */ testID?: string; + /** 预览图 URL */ + previewUrl?: string; + /** 是否使用预览图(优先加载预览图) */ + usePreview?: boolean; + /** 是否懒加载 */ + lazyLoad?: boolean; + /** 缓存策略 */ + cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; } /** @@ -74,14 +82,33 @@ export const SmartImage: React.FC = ({ onLoad, onError, testID, + previewUrl, + usePreview = false, + lazyLoad = false, + cachePolicy = 'memory-disk', }) => { const [loadState, setLoadState] = useState('loading'); + const [isVisible, setIsVisible] = useState(!lazyLoad); + const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); // 解析图片源 - 支持 uri 或 url 字段 - const imageUri = typeof source === 'string' - ? source + 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'); @@ -90,16 +117,25 @@ export const SmartImage: React.FC = ({ // 处理加载完成 const handleLoad = useCallback(() => { setLoadState('success'); + // 如果正在加载预览图,切换到原图 + if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) { + setShouldLoadOriginal(true); + } onLoad?.(); - }, [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] + [onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri] ); // 重试加载 @@ -144,7 +180,7 @@ export const SmartImage: React.FC = ({ }; // 图片源配置 - const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined; + const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined; // 如果没有有效的图片源,显示错误占位 if (!imageSource) { @@ -164,6 +200,16 @@ export const SmartImage: React.FC = ({ ); } + // 如果是懒加载且不可见,显示占位 + if (lazyLoad && !isVisible) { + return ( + + ); + } + return ( = ({ resizeMode === 'center' ? 'scale-down' : resizeMode } - cachePolicy="memory-disk" + cachePolicy={cachePolicy} onLoadStart={handleLoadStart} onLoad={handleLoad} onError={handleError} diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index 6657952..f2623fd 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -438,11 +438,20 @@ const renderLinkSegment = ( ): React.ReactNode => { const { onLinkPress, isMe } = props; - const handlePress = () => { + const handlePress = async () => { if (onLinkPress) { onLinkPress(data.url); } 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 = ( )} - {new URL(data.url).hostname} + {(() => { + try { + return new URL(data.url).hostname; + } catch { + return data.url; + } + })()} diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index f15dc5b..f73a915 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -145,6 +145,8 @@ export const ScheduleScreen: React.FC = () => { const [startWeekInput, setStartWeekInput] = useState(String(currentWeek)); const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS)); const weekScrollRef = useRef(null); + // 组件卸载状态标记,用于防止组件卸载后仍调用 setState + const isMountedRef = useRef(true); // 设置弹窗状态 const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); @@ -153,6 +155,13 @@ export const ScheduleScreen: React.FC = () => { const [syncUsername, setSyncUsername] = useState(''); const [syncPassword, setSyncPassword] = useState(''); const [isSyncing, setIsSyncing] = useState(false); + + // 组件卸载时设置标记 + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); const todayColumnIndex = getTodayColumnIndex(); // currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列 const isViewingCurrentWeek = currentWeek === 1; @@ -463,14 +472,31 @@ export const ScheduleScreen: React.FC = () => { style: 'destructive' as const, onPress: async () => { try { - // 删除所有相关课程 - for (const relatedCourse of relatedCourses) { - await scheduleService.deleteCourse(relatedCourse.id); + // 使用 Promise.allSettled 批量删除,即使部分失败也能继续 + const results = await Promise.allSettled( + 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) { console.error('批量删除课程失败:', error); Alert.alert('删除失败', '请稍后重试'); @@ -755,14 +781,17 @@ export const ScheduleScreen: React.FC = () => { ]} onPress={async () => { if (!syncUsername.trim() || !syncPassword.trim()) return; - + setIsSyncing(true); try { const result = await scheduleService.syncSchedule({ username: syncUsername.trim(), password: syncPassword.trim(), }); - + + // 检查组件是否已卸载 + if (!isMountedRef.current) return; + if (result.success) { Alert.alert( '同步成功', @@ -787,9 +816,15 @@ export const ScheduleScreen: React.FC = () => { } } catch (error) { console.error('同步课表失败:', error); - Alert.alert('同步失败', '网络错误,请稍后重试'); + // 检查组件是否已卸载 + if (isMountedRef.current) { + Alert.alert('同步失败', '网络错误,请稍后重试'); + } } finally { - setIsSyncing(false); + // 检查组件是否已卸载 + if (isMountedRef.current) { + setIsSyncing(false); + } } }} activeOpacity={0.8} diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index 1529b0d..8b4b3b6 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -35,6 +35,8 @@ function resolveFileInfo(uri: string, providedType?: string): { name: string; ty // 上传响应 export interface UploadResponse { url: string; + preview_url?: string; // 列表/网格预览图 + preview_url_large?: string; // 详情页预览图 width?: number; height?: number; size?: number; @@ -162,7 +164,19 @@ class UploadService { for (const file of files) { const result = await this.uploadImage(file); 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('|')); } } diff --git a/src/types/dto.ts b/src/types/dto.ts index cd625fa..67a2cce 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -52,6 +52,8 @@ export interface PostImageDTO { id: string; url: string; thumbnail_url: string; + preview_url: string; // 列表/网格预览图 + preview_url_large: string; // 详情页预览图 width: number; height: number; } diff --git a/src/utils/imageHelper.ts b/src/utils/imageHelper.ts new file mode 100644 index 0000000..c494748 --- /dev/null +++ b/src/utils/imageHelper.ts @@ -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'; +}