Merge pull request 'feat: 优化图片加载和展示功能' (#1) from feature/image-optimization into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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('|'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
101
src/utils/imageHelper.ts
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user