Compare commits

...

5 Commits

Author SHA1 Message Date
b50bd9abb3 修复了课表周数硬编码 2026-03-17 12:29:04 +08:00
582589d252 Merge remote-tracking branch 'origin/master' into feature/adap-pc 2026-03-16 19:27:05 +08:00
eb24a3bbdf Merge pull request 'feat: 优化图片加载和展示功能' (#1) from feature/image-optimization into master
Reviewed-on: #1
2026-03-15 12:09:44 +08:00
lan
592167e667 feat: 优化图片加载和展示功能
- 新增 SmartImage 组件优化图片加载体验
- 新增 ImageGrid 组件支持多图布局展示
- 新增 imageHelper 工具函数
- 优化 PostCard 帖子卡片图片展示
- 优化消息页面 SegmentRenderer
- 优化日程页面 ScheduleScreen
- 优化上传服务 uploadService
2026-03-15 02:25:55 +08:00
lan
e9d42fdb01 feat(schedule): add academic system sync and course management improvements
- add educational system synchronization with username/password authentication
- implement long-press course deletion with single/all options
- add multi-lane course display for overlapping same-name courses
- refactor course detail screen with ScrollView and improved layout
- fix teacher display to show per-time-slot instructors instead of single field
- add settings modal with sync option and loading states
2026-03-13 20:40:32 +08:00
18 changed files with 1284 additions and 122 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/frontend.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

448
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,448 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
</set>
</option>
</GradleProjectSettings>
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
<option name="gradleJvm" value="ms-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

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

@@ -13,7 +13,6 @@ import { colors, spacing, borderRadius } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto'; import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
interface SystemMessageItemProps { interface SystemMessageItemProps {
message: SystemMessageResponse; message: SystemMessageResponse;
@@ -165,14 +164,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onRequestAction, onRequestAction,
requestActionLoading = false, requestActionLoading = false,
}) => { }) => {
// 响应式适配
const { isWideScreen, isDesktop } = useResponsive();
const containerPadding = useResponsiveValue({ xs: spacing.md, lg: spacing.lg, xl: spacing.xl });
const avatarSize = useResponsiveValue({ xs: 40, lg: 48, xl: 52 });
const iconSize = useResponsiveValue({ xs: 20, lg: 22, xl: 24 });
const contentFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
const titleFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
const timeFontSize = useResponsiveValue({ xs: 12, lg: 13, xl: 14 });
// 格式化时间 // 格式化时间
const formatTime = (dateString: string): string => { const formatTime = (dateString: string): string => {
try { try {
@@ -198,7 +189,15 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
requestStatus === 'pending' && requestStatus === 'pending' &&
!!onRequestAction; !!onRequestAction;
const styles = React.useMemo(() => StyleSheet.create({ // 使用固定的响应式值兼容移动端和Web端
const containerPadding = spacing.md;
const avatarSize = 44;
const iconSize = 22;
const contentFontSize = 14;
const titleFontSize = 14;
const timeFontSize = 12;
const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
@@ -208,7 +207,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
iconContainer: { iconContainer: {
marginRight: isWideScreen ? spacing.lg : spacing.md, marginRight: spacing.md,
}, },
iconWrapper: { iconWrapper: {
width: avatarSize, width: avatarSize,
@@ -220,6 +219,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
content: { content: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
minWidth: 0,
}, },
titleRow: { titleRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -237,7 +237,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
fontSize: timeFontSize, fontSize: timeFontSize,
}, },
messageContent: { messageContent: {
lineHeight: isWideScreen ? 22 : 18, lineHeight: 20,
fontSize: contentFontSize, fontSize: contentFontSize,
}, },
extraInfo: { extraInfo: {
@@ -245,7 +245,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
alignItems: 'center', alignItems: 'center',
marginTop: spacing.xs, marginTop: spacing.xs,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
paddingHorizontal: isWideScreen ? spacing.md : spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
borderRadius: borderRadius.sm, borderRadius: borderRadius.sm,
alignSelf: 'flex-start', alignSelf: 'flex-start',
@@ -259,8 +259,8 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
marginTop: spacing.sm, marginTop: spacing.sm,
}, },
actionBtn: { actionBtn: {
paddingVertical: isWideScreen ? spacing.sm : spacing.xs, paddingVertical: spacing.xs,
paddingHorizontal: isWideScreen ? spacing.lg : spacing.md, paddingHorizontal: spacing.md,
borderRadius: borderRadius.sm, borderRadius: borderRadius.sm,
borderWidth: 1, borderWidth: 1,
marginRight: spacing.sm, marginRight: spacing.sm,
@@ -278,8 +278,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: 20, height: 20,
width: 24,
}, },
}), [containerPadding, isWideScreen, avatarSize, titleFontSize, timeFontSize, contentFontSize]); });
// 判断是否显示操作者头像 // 判断是否显示操作者头像
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(

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>
); );
}; };
@@ -304,6 +325,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
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
key={image.id || index} key={image.id || index}
@@ -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}
@@ -362,6 +387,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
: 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
key={image.id || actualIndex} key={image.id || actualIndex}
@@ -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

@@ -13,6 +13,7 @@ import {
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
ActivityIndicator, ActivityIndicator,
Dimensions,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native'; import { useIsFocused } from '@react-navigation/native';
@@ -25,7 +26,6 @@ import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService'; import { commentService } from '../../services/commentService';
import { SystemMessageItem } from '../../components/business'; import { SystemMessageItem } from '../../components/business';
import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
@@ -48,9 +48,27 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// 响应式布局 // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
const { isDesktop, isTablet } = useResponsive(); const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
const isWideScreen = useBreakpointGTE('lg'); const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
// 延迟设置窗口尺寸,确保在客户端完全加载后再获取
const timer = setTimeout(() => {
const { width, height } = Dimensions.get('window');
setWindowSize({ width, height });
setIsHydrated(true);
}, 100);
return () => clearTimeout(timer);
}, []);
// 使用本地尺寸状态计算断点 (lg = 768)
const isWideScreen = windowSize.width >= 768;
const isDesktop = windowSize.width >= 1024;
const isTablet = windowSize.width >= 768 && windowSize.width < 1024;
// Web端使用更大的容器宽度
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
const [messages, setMessages] = useState<SystemMessageResponse[]>([]); const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
const [activeType, setActiveType] = useState('all'); const [activeType, setActiveType] = useState('all');
@@ -320,7 +338,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
const shouldShowBackButton = onBack && !isWideScreen; const shouldShowBackButton = onBack && !isWideScreen;
return ( return (
<View style={[styles.header, isWideScreen && styles.headerWide]}> <View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? ( {shouldShowBackButton ? (
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
@@ -342,21 +360,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
// 渲染空状态 // 渲染空状态
const renderEmpty = () => ( const renderEmpty = () => (
<EmptyState <View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
title="暂无通知" <EmptyState
description="关注一些用户来获取通知" title="暂无通知"
icon="bell-outline" description="关注一些用户来获取通知吧"
/> icon="bell-outline"
/>
</View>
); );
// 如果还未hydrated显示加载占位符避免竞态条件导致的渲染问题
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={[]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
</SafeAreaView>
);
}
return ( return (
<SafeAreaView style={styles.container} edges={[]}> <SafeAreaView style={styles.container} edges={[]}>
{isWideScreen ? ( {isWideScreen ? (
<ResponsiveContainer maxWidth={900}> <ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */} {/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()} {renderHeader()}
{/* 分类筛选 */} {/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}> <View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{MESSAGE_TYPES.map(type => { {MESSAGE_TYPES.map(type => {
const count = type.key === 'all' const count = type.key === 'all'
? displayMessages.length ? displayMessages.length
@@ -518,10 +549,11 @@ const styles = StyleSheet.create({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
filterContainerWide: { filterContainerWideWeb: {
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
paddingVertical: spacing.md, paddingVertical: spacing.md,
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.background.paper,
}, },
filterTag: { filterTag: {
flexDirection: 'row', flexDirection: 'row',
@@ -596,4 +628,14 @@ const styles = StyleSheet.create({
loadingMoreText: { loadingMoreText: {
marginLeft: spacing.sm, marginLeft: spacing.sm,
}, },
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
}); });

View File

@@ -483,11 +483,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);
}
} }
}; };
@@ -511,7 +520,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

@@ -1,5 +1,5 @@
import React from 'react'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -64,8 +64,9 @@ const CourseDetailScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => navigation.goBack()}> <View style={styles.mask}>
<TouchableOpacity activeOpacity={1} style={styles.card} onPress={e => e.stopPropagation()}> <Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
<View style={styles.card}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<View style={styles.iconBadge}> <View style={styles.iconBadge}>
@@ -78,39 +79,44 @@ const CourseDetailScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.row}> <ScrollView
<Text style={styles.label}></Text> style={styles.contentScroll}
<Text style={styles.value} numberOfLines={2}>{course.name}</Text> contentContainerStyle={styles.contentScrollContainer}
</View> nestedScrollEnabled
showsVerticalScrollIndicator={false}
<View style={styles.row}> >
<Text style={styles.label}></Text> <View style={styles.row}>
<Text style={styles.value}>{course.teacher || '未填写'}</Text> <Text style={styles.label}></Text>
</View> <Text style={styles.value} numberOfLines={2}>{course.name}</Text>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.label}></Text>
<View style={styles.timeList}>
{timeLines.map((item, index) => (
<View key={`${item.id}-${index}`} style={styles.timeItemCard}>
<Text style={styles.timeItemMain}>
{formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]}
{getMergedSectionIndex(item.startSection)}
</Text>
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
</View>
))}
</View> </View>
</View>
<View style={styles.actionRow}> <View style={[styles.row, styles.rowLast]}>
<TouchableOpacity style={styles.deleteButton} onPress={handleDeleteCourse} activeOpacity={0.85}> <Text style={styles.label}></Text>
<MaterialCommunityIcons name="delete-outline" size={16} color={colors.error.main} /> <View style={styles.timeList}>
<Text style={styles.deleteButtonText}></Text> {timeLines.map((item, index) => (
</TouchableOpacity> <View key={`${item.id}-${index}`} style={styles.timeItemCard}>
</View> <Text style={styles.timeItemMain}>
</TouchableOpacity> {formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]}
</TouchableOpacity> {getMergedSectionIndex(item.startSection)}
</Text>
<Text style={styles.timeItemSub}>
{item.teacher || '未填写'}
</Text>
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
</View>
))}
</View>
</View>
<View style={styles.actionRow}>
<TouchableOpacity style={styles.deleteButton} onPress={handleDeleteCourse} activeOpacity={0.85}>
<MaterialCommunityIcons name="delete-outline" size={16} color={colors.error.main} />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</SafeAreaView> </SafeAreaView>
); );
}; };
@@ -130,8 +136,15 @@ const styles = StyleSheet.create({
borderRadius: borderRadius.xl, borderRadius: borderRadius.xl,
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
maxHeight: '82%',
...shadows.lg, ...shadows.lg,
}, },
contentScroll: {
maxHeight: '100%',
},
contentScrollContainer: {
paddingBottom: spacing.xs,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',

View File

@@ -15,6 +15,7 @@ import {
Modal, Modal,
TextInput, TextInput,
Alert, Alert,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler'; import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
@@ -29,6 +30,7 @@ import {
TimeSlot, TimeSlot,
getCourseColor, getCourseColor,
hasCourseInWeek, hasCourseInWeek,
getCurrentWeek,
} from '../../types/schedule'; } from '../../types/schedule';
import type { ScheduleStackParamList } from '../../navigation/types'; import type { ScheduleStackParamList } from '../../navigation/types';
import { scheduleService } from '../../services/scheduleService'; import { scheduleService } from '../../services/scheduleService';
@@ -71,6 +73,13 @@ const getMergedSectionEnd = (mergedSection: number) => mergedSection * 2;
const SWIPE_THRESHOLD = 18; const SWIPE_THRESHOLD = 18;
type RepeatMode = 'single' | 'weekly' | 'odd' | 'even'; type RepeatMode = 'single' | 'weekly' | 'odd' | 'even';
type CourseDisplayItem = {
key: string;
course: Course;
locationText: string;
laneIndex: number;
laneCount: number;
};
const TOTAL_WEEKS = 20; const TOTAL_WEEKS = 20;
const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/; const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/;
const parseWeekInput = (value: string, fallback: number) => { const parseWeekInput = (value: string, fallback: number) => {
@@ -122,9 +131,17 @@ const getWeekDates = (weekOffset: number = 0) => {
return getWeekInfo(weekOffset).dates; return getWeekInfo(weekOffset).dates;
}; };
// 学期起始日期字符串(用于 getCurrentWeek 函数)
const SEMESTER_START_DATE = '2026-03-09';
// 计算当前实际周数
const getInitialWeek = (): number => {
return getCurrentWeek(SEMESTER_START_DATE, TOTAL_WEEKS);
};
export const ScheduleScreen: React.FC = () => { export const ScheduleScreen: React.FC = () => {
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
const [currentWeek, setCurrentWeek] = useState(1); const [currentWeek, setCurrentWeek] = useState(getInitialWeek);
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [pendingDay, setPendingDay] = useState<number>(0); const [pendingDay, setPendingDay] = useState<number>(0);
@@ -137,9 +154,28 @@ 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 [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(); const todayColumnIndex = getTodayColumnIndex();
// currentWeek === 1 对应今天所在的真实周offset 0其他周不高亮今日列 // 获取当前实际周数,用于判断是否在查看当前周
const isViewingCurrentWeek = currentWeek === 1; const actualCurrentWeek = getInitialWeek();
// 只有在查看当前实际周时才高亮今日列
const isViewingCurrentWeek = currentWeek === actualCurrentWeek;
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1; const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
const dayColumnWidth = getDayColumnWidth(); const dayColumnWidth = getDayColumnWidth();
@@ -308,7 +344,10 @@ export const ScheduleScreen: React.FC = () => {
// 渲染周选择器 // 渲染周选择器
const renderWeekSelector = () => ( const renderWeekSelector = () => (
<View style={styles.weekSelector}> <View style={styles.weekSelector}>
<TouchableOpacity style={styles.settingsButton}> <TouchableOpacity
style={styles.settingsButton}
onPress={() => setIsSettingsModalVisible(true)}
>
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" /> <MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" />
</TouchableOpacity> </TouchableOpacity>
<ScrollView <ScrollView
@@ -410,26 +449,137 @@ export const ScheduleScreen: React.FC = () => {
</View> </View>
); );
// 处理长按课程删除
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<string, Course[]>();
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 renderCourseCard = ({ key, course, locationText, laneIndex, laneCount }: CourseDisplayItem) => {
const color = getCourseColor(course.id, course.color); // 颜色仅按课程名计算,确保同名课在 UI 上颜色一致
const color = getCourseColor(course.name);
const startMergedSection = getMergedSectionIndex(course.startSection); const startMergedSection = getMergedSectionIndex(course.startSection);
const endMergedSection = getMergedSectionIndex(course.endSection); const endMergedSection = getMergedSectionIndex(course.endSection);
const duration = endMergedSection - startMergedSection + 1; const duration = endMergedSection - startMergedSection + 1;
const top = (startMergedSection - 1) * SECTION_HEIGHT; const top = (startMergedSection - 1) * SECTION_HEIGHT;
const height = duration * SECTION_HEIGHT - 4; 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 ( return (
<TouchableOpacity <TouchableOpacity
key={course.id} key={key}
style={[ style={[
styles.courseCard, styles.courseCard,
{ {
top, top,
height, height,
backgroundColor: color, backgroundColor: color,
left: 2, left,
right: 2, width: cardWidth,
}, },
]} ]}
onPress={() => onPress={() =>
@@ -438,14 +588,16 @@ export const ScheduleScreen: React.FC = () => {
relatedCourses: getRelatedCourses(course), relatedCourses: getRelatedCourses(course),
}) })
} }
onLongPress={() => handleCourseLongPress(course)}
delayLongPress={500}
activeOpacity={0.82} activeOpacity={0.82}
> >
<Text style={styles.courseName} numberOfLines={3}> <Text style={styles.courseName} numberOfLines={3}>
{formatCourseName(course.name)} {formatCourseName(course.name)}
</Text> </Text>
{course.location ? ( {locationText ? (
<Text style={styles.courseLocation}> <Text style={styles.courseLocation}>
@{course.location} @{locationText}
</Text> </Text>
) : null} ) : null}
</TouchableOpacity> </TouchableOpacity>
@@ -458,6 +610,7 @@ export const ScheduleScreen: React.FC = () => {
const dayCourses = currentWeekCourses.filter( const dayCourses = currentWeekCourses.filter(
course => course.dayOfWeek === dayOfWeek course => course.dayOfWeek === dayOfWeek
); );
const displayCourses = buildDayDisplayCourses(dayCourses);
return ( return (
<View <View
@@ -493,7 +646,7 @@ export const ScheduleScreen: React.FC = () => {
); );
})} })}
{/* 课程卡片 */} {/* 课程卡片 */}
{dayCourses.map(renderCourseCard)} {displayCourses.map(renderCourseCard)}
</View> </View>
); );
}; };
@@ -540,6 +693,163 @@ export const ScheduleScreen: React.FC = () => {
{/* 课程表主体 */} {/* 课程表主体 */}
{renderScheduleBody()} {renderScheduleBody()}
{/* 设置弹窗 */}
<Modal
visible={isSettingsModalVisible}
transparent
animationType="fade"
onRequestClose={() => setIsSettingsModalVisible(false)}
>
<View style={styles.addModalMask}>
<View style={styles.settingsModalCard}>
<Text style={styles.addModalTitle}></Text>
<TouchableOpacity
style={styles.settingsItem}
onPress={() => {
setIsSettingsModalVisible(false);
setIsSyncModalVisible(true);
}}
>
<MaterialCommunityIcons name="cloud-sync" size={22} color={colors.primary.main} />
<Text style={styles.settingsItemText}></Text>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
<TouchableOpacity
style={styles.settingsCloseBtn}
onPress={() => setIsSettingsModalVisible(false)}
>
<Text style={styles.settingsCloseText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
{/* 同步教务系统弹窗 */}
<Modal
visible={isSyncModalVisible}
transparent
animationType="fade"
onRequestClose={() => {
if (!isSyncing) {
setIsSyncModalVisible(false);
}
}}
>
<View style={styles.addModalMask}>
<View style={styles.addModalCard}>
<Text style={styles.addModalTitle}></Text>
<Text style={styles.addModalDesc}>
</Text>
<TextInput
value={syncUsername}
onChangeText={setSyncUsername}
placeholder="学号/工号"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
autoCapitalize="none"
editable={!isSyncing}
/>
<TextInput
value={syncPassword}
onChangeText={setSyncPassword}
placeholder="密码"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
secureTextEntry
editable={!isSyncing}
/>
{isSyncing && (
<View style={styles.syncingContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text style={styles.syncingText}>...</Text>
</View>
)}
<View style={styles.addActions}>
<TouchableOpacity
style={[styles.addCancelBtn, isSyncing && styles.addConfirmBtnDisabled]}
onPress={() => {
if (!isSyncing) {
setIsSyncModalVisible(false);
setSyncUsername('');
setSyncPassword('');
}
}}
activeOpacity={0.8}
disabled={isSyncing}
>
<Text style={styles.addCancelText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.addConfirmBtn,
(!syncUsername.trim() || !syncPassword.trim() || isSyncing) && styles.addConfirmBtnDisabled,
]}
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(
'同步成功',
`成功同步 ${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}
>
<Text style={styles.addConfirmText}>
{isSyncing ? '同步中...' : '开始同步'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
<Modal <Modal
visible={isAddModalVisible} visible={isAddModalVisible}
transparent transparent
@@ -1060,6 +1370,58 @@ const styles = StyleSheet.create({
color: colors.text.primary, color: colors.text.primary,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
}, },
// ── 设置弹窗 ──────────────────────────────────────────
settingsModalCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.lg,
minWidth: 280,
},
settingsItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
marginTop: spacing.md,
},
settingsItemText: {
flex: 1,
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.md,
},
settingsCloseBtn: {
marginTop: spacing.lg,
height: 46,
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
alignItems: 'center',
justifyContent: 'center',
},
settingsCloseText: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.secondary,
},
// ── 同步状态 ──────────────────────────────────────────
syncingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
gap: spacing.sm,
},
syncingText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
fontWeight: '500',
},
}); });
export default ScheduleScreen; export default ScheduleScreen;

View File

@@ -32,22 +32,55 @@ interface CreateScheduleCourseResponse {
course: ScheduleCourseDTO; course: ScheduleCourseDTO;
} }
const toCourse = (dto: ScheduleCourseDTO): Course => ({ interface SyncScheduleRequest {
id: dto.id, username: string;
name: dto.name, password: string;
teacher: dto.teacher, semester?: string;
location: dto.location, }
dayOfWeek: dto.day_of_week,
startSection: dto.start_section, interface SyncScheduleResponse {
endSection: dto.end_section, success: boolean;
weeks: dto.weeks ?? [], message: string;
color: dto.color, synced_count: number;
}); error_message?: string;
}
const toCourse = (dto: ScheduleCourseDTO): Course => {
// 调试日志:检查后端返回的 weeks 字段
console.log('[ScheduleService] 转换课程 DTO:', {
id: dto.id,
name: dto.name,
day_of_week: dto.day_of_week,
weeks: dto.weeks,
weeksType: typeof dto.weeks,
weeksLength: dto.weeks?.length,
});
return {
id: dto.id,
name: dto.name,
teacher: dto.teacher,
location: dto.location,
dayOfWeek: dto.day_of_week,
startSection: dto.start_section,
endSection: dto.end_section,
weeks: dto.weeks ?? [],
color: dto.color,
};
};
class ScheduleService { class ScheduleService {
async getCourses(week?: number): Promise<Course[]> { async getCourses(week?: number): Promise<Course[]> {
const params = week ? { week } : undefined; const params = week ? { week } : undefined;
console.log('[ScheduleService] 获取课程列表, params:', params);
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params); const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params);
console.log('[ScheduleService] 课程列表响应, 数量:', response.data.list.length);
// 打印原始响应数据中的 weeks 字段
response.data.list.forEach((item, index) => {
console.log(`[ScheduleService] 课程[${index}] ${item.name}:`, {
day_of_week: item.day_of_week,
weeks: item.weeks,
});
});
return response.data.list.map(toCourse); return response.data.list.map(toCourse);
} }
@@ -64,7 +97,14 @@ class ScheduleService {
async deleteCourse(courseId: string): Promise<void> { async deleteCourse(courseId: string): Promise<void> {
await api.delete(`/schedule/courses/${courseId}`); await api.delete(`/schedule/courses/${courseId}`);
} }
async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> {
console.log('[ScheduleService] 开始同步教务系统, username:', req.username);
const response = await api.post<SyncScheduleResponse>('/schedule/sync', req);
console.log('[ScheduleService] 同步响应:', response.data);
return response.data;
}
} }
export const scheduleService = new ScheduleService(); export const scheduleService = new ScheduleService();
export type { CreateScheduleCourseRequest }; export type { CreateScheduleCourseRequest, SyncScheduleRequest };

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';
}