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 2055831..a7d92e0 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -483,11 +483,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); + } } }; @@ -511,7 +520,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/CourseDetailScreen.tsx b/src/screens/schedule/CourseDetailScreen.tsx index f3431ab..b59455f 100644 --- a/src/screens/schedule/CourseDetailScreen.tsx +++ b/src/screens/schedule/CourseDetailScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -64,8 +64,9 @@ const CourseDetailScreen: React.FC = () => { return ( - navigation.goBack()}> - e.stopPropagation()}> + + navigation.goBack()} /> + @@ -78,39 +79,44 @@ const CourseDetailScreen: React.FC = () => { - - 课程名称 - {course.name} - - - - 教师 - {course.teacher || '未填写'} - - - - 上课时间 - - {timeLines.map((item, index) => ( - - - 第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第 - {getMergedSectionIndex(item.startSection)}节 - - {item.location ? {item.location} : null} - - ))} + + + 课程名称 + {course.name} - - - - - 删除本条课程 - - - - + + 上课时间 + + {timeLines.map((item, index) => ( + + + 第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第 + {getMergedSectionIndex(item.startSection)}节 + + + 任课教师:{item.teacher || '未填写'} + + {item.location ? {item.location} : null} + + ))} + + + + + + + 删除本条课程 + + + + + ); }; @@ -130,8 +136,15 @@ const styles = StyleSheet.create({ borderRadius: borderRadius.xl, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg, + maxHeight: '82%', ...shadows.lg, }, + contentScroll: { + maxHeight: '100%', + }, + contentScrollContainer: { + paddingBottom: spacing.xs, + }, header: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx index 16a4995..f73a915 100644 --- a/src/screens/schedule/ScheduleScreen.tsx +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -15,6 +15,7 @@ import { Modal, TextInput, Alert, + ActivityIndicator, } from 'react-native'; import { PanGestureHandler, State } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; @@ -71,6 +72,13 @@ const getMergedSectionEnd = (mergedSection: number) => mergedSection * 2; const SWIPE_THRESHOLD = 18; type RepeatMode = 'single' | 'weekly' | 'odd' | 'even'; +type CourseDisplayItem = { + key: string; + course: Course; + locationText: string; + laneIndex: number; + laneCount: number; +}; const TOTAL_WEEKS = 20; const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/; const parseWeekInput = (value: string, fallback: number) => { @@ -137,6 +145,23 @@ 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); + // 同步教务系统弹窗状态 + const [isSyncModalVisible, setIsSyncModalVisible] = useState(false); + 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; @@ -308,7 +333,10 @@ export const ScheduleScreen: React.FC = () => { // 渲染周选择器 const renderWeekSelector = () => ( - + setIsSettingsModalVisible(true)} + > { ); + // 处理长按课程删除 + const handleCourseLongPress = useCallback((course: Course) => { + const relatedCourses = getRelatedCourses(course); + const hasMultiple = relatedCourses.length > 1; + + Alert.alert( + '删除课程', + `课程:${course.name}`, + [ + { + text: '取消', + style: 'cancel', + }, + { + text: '删除本节', + style: 'destructive', + onPress: async () => { + try { + await scheduleService.deleteCourse(course.id); + setCourses(prev => prev.filter(c => c.id !== course.id)); + Alert.alert('成功', '已删除本节课程'); + } catch (error) { + console.error('删除课程失败:', error); + Alert.alert('删除失败', '请稍后重试'); + } + }, + }, + ...(hasMultiple + ? [ + { + text: '删除全部', + style: 'destructive' as const, + onPress: async () => { + try { + // 使用 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} 节课程`); + } + } catch (error) { + console.error('批量删除课程失败:', error); + Alert.alert('删除失败', '请稍后重试'); + } + }, + }, + ] + : []), + ], + { cancelable: true } + ); + }, [getRelatedCourses]); + + const buildDayDisplayCourses = useCallback((dayCourses: Course[]): CourseDisplayItem[] => { + const groupedByNameAndSlot = new Map(); + + dayCourses.forEach(course => { + // 同名 + 同时间段分组;若出现多条记录,则拆分并排展示 + const key = `${course.name}|${course.dayOfWeek}|${course.startSection}|${course.endSection}`; + const group = groupedByNameAndSlot.get(key); + if (group) { + group.push(course); + } else { + groupedByNameAndSlot.set(key, [course]); + } + }); + + const items: CourseDisplayItem[] = []; + groupedByNameAndSlot.forEach((groupCourses, groupKey) => { + groupCourses.forEach((course, index) => { + items.push({ + key: `${groupKey}|${course.id}|${index}`, + course, + locationText: course.location?.trim() ?? '', + laneIndex: index, + laneCount: groupCourses.length, + }); + }); + }); + + return items; + }, []); + // 渲染课程卡片 - const renderCourseCard = (course: Course) => { - const color = getCourseColor(course.id, course.color); + const renderCourseCard = ({ key, course, locationText, laneIndex, laneCount }: CourseDisplayItem) => { + // 颜色仅按课程名计算,确保同名课在 UI 上颜色一致 + const color = getCourseColor(course.name); const startMergedSection = getMergedSectionIndex(course.startSection); const endMergedSection = getMergedSectionIndex(course.endSection); const duration = endMergedSection - startMergedSection + 1; const top = (startMergedSection - 1) * SECTION_HEIGHT; const height = duration * SECTION_HEIGHT - 4; + const laneGap = 2; + const baseLeft = 2; + const availableWidth = dayColumnWidth - baseLeft * 2; + const cardWidth = + laneCount > 1 + ? (availableWidth - laneGap * (laneCount - 1)) / laneCount + : availableWidth; + const left = baseLeft + laneIndex * (cardWidth + laneGap); return ( @@ -438,14 +577,16 @@ export const ScheduleScreen: React.FC = () => { relatedCourses: getRelatedCourses(course), }) } + onLongPress={() => handleCourseLongPress(course)} + delayLongPress={500} activeOpacity={0.82} > {formatCourseName(course.name)} - {course.location ? ( + {locationText ? ( - @{course.location} + @{locationText} ) : null} @@ -458,6 +599,7 @@ export const ScheduleScreen: React.FC = () => { const dayCourses = currentWeekCourses.filter( course => course.dayOfWeek === dayOfWeek ); + const displayCourses = buildDayDisplayCourses(dayCourses); return ( { ); })} {/* 课程卡片 */} - {dayCourses.map(renderCourseCard)} + {displayCourses.map(renderCourseCard)} ); }; @@ -540,6 +682,163 @@ export const ScheduleScreen: React.FC = () => { {/* 课程表主体 */} {renderScheduleBody()} + {/* 设置弹窗 */} + setIsSettingsModalVisible(false)} + > + + + 设置 + + { + setIsSettingsModalVisible(false); + setIsSyncModalVisible(true); + }} + > + + 同步教务系统 + + + + setIsSettingsModalVisible(false)} + > + 关闭 + + + + + + {/* 同步教务系统弹窗 */} + { + if (!isSyncing) { + setIsSyncModalVisible(false); + } + }} + > + + + 同步教务系统 + + 输入教务系统账号密码,自动同步课表数据 + + + + + + {isSyncing && ( + + + 正在同步课表数据... + + )} + + + { + if (!isSyncing) { + setIsSyncModalVisible(false); + setSyncUsername(''); + setSyncPassword(''); + } + }} + activeOpacity={0.8} + disabled={isSyncing} + > + 取消 + + { + 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( + '同步成功', + `成功同步 ${result.synced_count} 门课程`, + [ + { + text: '确定', + onPress: () => { + setIsSyncModalVisible(false); + setSyncUsername(''); + setSyncPassword(''); + loadCourses(); // 刷新课表 + }, + }, + ] + ); + } else { + Alert.alert( + '同步失败', + result.error_message || result.message || '请检查账号密码是否正确' + ); + } + } catch (error) { + console.error('同步课表失败:', error); + // 检查组件是否已卸载 + if (isMountedRef.current) { + Alert.alert('同步失败', '网络错误,请稍后重试'); + } + } finally { + // 检查组件是否已卸载 + if (isMountedRef.current) { + setIsSyncing(false); + } + } + }} + activeOpacity={0.8} + disabled={!syncUsername.trim() || !syncPassword.trim() || isSyncing} + > + + {isSyncing ? '同步中...' : '开始同步'} + + + + + + + ({ id: dto.id, name: dto.name, @@ -64,7 +77,12 @@ class ScheduleService { async deleteCourse(courseId: string): Promise { await api.delete(`/schedule/courses/${courseId}`); } + + async syncSchedule(req: SyncScheduleRequest): Promise { + const response = await api.post('/schedule/sync', req); + return response.data; + } } export const scheduleService = new ScheduleService(); -export type { CreateScheduleCourseRequest }; +export type { CreateScheduleCourseRequest, SyncScheduleRequest }; 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'; +}