From e9d42fdb01a59508494b3f5a068ef4d4eb11ba4f Mon Sep 17 00:00:00 2001 From: lan Date: Fri, 13 Mar 2026 20:40:32 +0800 Subject: [PATCH] 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 --- src/screens/schedule/CourseDetailScreen.tsx | 81 +++-- src/screens/schedule/ScheduleScreen.tsx | 334 +++++++++++++++++++- src/services/scheduleService.ts | 20 +- 3 files changed, 391 insertions(+), 44 deletions(-) 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..f15dc5b 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,14 @@ export const ScheduleScreen: React.FC = () => { const [startWeekInput, setStartWeekInput] = useState(String(currentWeek)); const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS)); const weekScrollRef = useRef(null); + + // 设置弹窗状态 + const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false); + // 同步教务系统弹窗状态 + const [isSyncModalVisible, setIsSyncModalVisible] = useState(false); + const [syncUsername, setSyncUsername] = useState(''); + const [syncPassword, setSyncPassword] = useState(''); + const [isSyncing, setIsSyncing] = useState(false); const todayColumnIndex = getTodayColumnIndex(); // currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列 const isViewingCurrentWeek = currentWeek === 1; @@ -308,7 +324,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 { + // 删除所有相关课程 + for (const relatedCourse of relatedCourses) { + await scheduleService.deleteCourse(relatedCourse.id); + } + // 从本地状态中移除 + const relatedIds = new Set(relatedCourses.map(c => c.id)); + setCourses(prev => prev.filter(c => !relatedIds.has(c.id))); + Alert.alert('成功', `已删除 ${relatedCourses.length} 节课程`); + } catch (error) { + console.error('批量删除课程失败:', error); + Alert.alert('删除失败', '请稍后重试'); + } + }, + }, + ] + : []), + ], + { 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 +551,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 +573,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 +656,154 @@ 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 (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); + Alert.alert('同步失败', '网络错误,请稍后重试'); + } finally { + 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 };