diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx index 2e7759c..95d72d9 100644 --- a/src/navigation/MainNavigator.tsx +++ b/src/navigation/MainNavigator.tsx @@ -40,12 +40,14 @@ import type { MainTabParamList, HomeStackParamList, MessageStackParamList, + ScheduleStackParamList, ProfileStackParamList, AuthStackParamList, } from './types'; // ==================== 导入屏幕组件 ==================== import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; +import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; import { MessageListScreen, ChatScreen, @@ -69,6 +71,7 @@ const RootStack = createNativeStackNavigator(); const AuthStack = createNativeStackNavigator(); const HomeStack = createNativeStackNavigator(); const MessageStack = createNativeStackNavigator(); +const ScheduleStack = createNativeStackNavigator(); const ProfileStack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -79,7 +82,7 @@ const SIDEBAR_COLLAPSED_WIDTH = 72; const MOBILE_TAB_FLOATING_MARGIN = 12; // ==================== 导航项类型 ==================== -type TabName = 'HomeTab' | 'MessageTab' | 'ProfileTab'; +type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; type IconName = React.ComponentProps['name']; @@ -306,6 +309,7 @@ function Sidebar({ const navItems: NavItem[] = [ { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount }, + { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, ]; @@ -418,6 +422,37 @@ function Text({ style, children, numberOfLines }: { style?: any; children: React ); } +// ==================== 课程表Stack导航 ==================== +function ScheduleStackNavigator() { + return ( + + + + + ); +} + // ==================== 响应式 Tab Navigator ==================== interface ResponsiveTabNavigatorProps { children?: React.ReactNode; @@ -441,7 +476,7 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { useEffect(() => { const targetTab = route.params?.screen; if (!targetTab) return; - if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) { + if ((targetTab === 'HomeTab' || targetTab === 'MessageTab' || targetTab === 'ScheduleTab' || targetTab === 'ProfileTab') && targetTab !== activeTab) { setActiveTab(targetTab); } }, [route.params?.screen, activeTab]); @@ -482,6 +517,8 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { return ; case 'MessageTab': return ; + case 'ScheduleTab': + return ; case 'ProfileTab': return ; default: @@ -609,6 +646,22 @@ function MainTabNavigator() { ), }} /> + ( + + + + ), + }} + /> ; MessageTab: NavigatorScreenParams; + ScheduleTab: NavigatorScreenParams; ProfileTab: NavigatorScreenParams; }; @@ -59,6 +61,12 @@ export type ProfileStackParamList = { BlockedUsers: undefined; }; +// ==================== 课程表Stack ==================== +export type ScheduleStackParamList = { + Schedule: undefined; + CourseDetail: { course: Course; relatedCourses: Course[] }; +}; + // ==================== 认证Stack ==================== export type AuthStackParamList = { Login: undefined; diff --git a/src/screens/message/GroupInviteDetailScreen.tsx b/src/screens/message/GroupInviteDetailScreen.tsx index 067f39b..cb7788f 100644 --- a/src/screens/message/GroupInviteDetailScreen.tsx +++ b/src/screens/message/GroupInviteDetailScreen.tsx @@ -65,13 +65,18 @@ const GroupInviteDetailScreen: React.FC = () => { const handleDecision = async (approve: boolean) => { const flag = extra_data?.flag; + const groupId = extra_data?.group_id; if (!flag) { Alert.alert('提示', '缺少邀请标识,无法处理'); return; } + if (!groupId) { + Alert.alert('提示', '缺少群组标识,无法处理'); + return; + } setSubmitting(true); try { - await groupService.respondInvite({ flag, approve }); + await groupService.respondInvite(groupId, { flag, approve }); Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [ { text: '确定', onPress: () => navigation.goBack() }, ]); diff --git a/src/screens/message/GroupRequestDetailScreen.tsx b/src/screens/message/GroupRequestDetailScreen.tsx index 8fda914..833be5a 100644 --- a/src/screens/message/GroupRequestDetailScreen.tsx +++ b/src/screens/message/GroupRequestDetailScreen.tsx @@ -77,20 +77,25 @@ const GroupRequestDetailScreen: React.FC = () => { const handleDecision = async (approve: boolean) => { const flag = extra_data?.flag; + const groupId = extra_data?.group_id; if (!flag) { Alert.alert('提示', '缺少请求标识,无法处理'); return; } + if (!groupId) { + Alert.alert('提示', '缺少群组标识,无法处理'); + return; + } setSubmitting(true); try { if (message.system_type === 'group_invite') { - await groupService.respondInvite({ + await groupService.respondInvite(groupId, { flag, approve, }); } else { - await groupService.reviewJoinRequest({ + await groupService.reviewJoinRequest(groupId, { flag, approve, }); diff --git a/src/screens/schedule/CourseDetailScreen.tsx b/src/screens/schedule/CourseDetailScreen.tsx new file mode 100644 index 0000000..f3431ab --- /dev/null +++ b/src/screens/schedule/CourseDetailScreen.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert } 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'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { ScheduleStackParamList } from '../../navigation/types'; +import { colors, spacing, borderRadius, fontSizes, shadows } from '../../theme'; +import { scheduleService } from '../../services/scheduleService'; + +type CourseDetailRouteProp = RouteProp; + +const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; +const getMergedSectionIndex = (section: number) => Math.ceil(section / 2); + +const formatWeekRanges = (weeks: number[]) => { + if (weeks.length === 0) return ''; + const sorted = [...weeks].sort((a, b) => a - b); + const ranges: string[] = []; + let start = sorted[0]; + let prev = sorted[0]; + + for (let i = 1; i < sorted.length; i += 1) { + const current = sorted[i]; + if (current === prev + 1) { + prev = current; + continue; + } + ranges.push(start === prev ? `${start}` : `${start}-${prev}`); + start = current; + prev = current; + } + ranges.push(start === prev ? `${start}` : `${start}-${prev}`); + return ranges.join(', '); +}; + +const CourseDetailScreen: React.FC = () => { + const route = useRoute(); + const navigation = useNavigation>(); + const { course, relatedCourses } = route.params; + + const timeLines = relatedCourses.length > 0 ? relatedCourses : [course]; + + const handleDeleteCourse = () => { + Alert.alert('删除课程', '确认删除这条课程安排吗?', [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + try { + await scheduleService.deleteCourse(course.id); + Alert.alert('删除成功', '课程已删除'); + navigation.goBack(); + } catch (error) { + console.error('删除课程失败:', error); + Alert.alert('删除失败', '删除课程失败,请稍后重试'); + } + }, + }, + ]); + }; + + return ( + + navigation.goBack()}> + e.stopPropagation()}> + + + + + + 课程详情 + + navigation.goBack()} style={styles.closeButton} hitSlop={10}> + + + + + + 课程名称 + {course.name} + + + + 教师 + {course.teacher || '未填写'} + + + + 上课时间 + + {timeLines.map((item, index) => ( + + + 第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第 + {getMergedSectionIndex(item.startSection)}节 + + {item.location ? {item.location} : null} + + ))} + + + + + + + 删除本条课程 + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mask: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.32)', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + }, + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.xl, + paddingVertical: spacing.lg, + paddingHorizontal: spacing.lg, + ...shadows.lg, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}AA`, + paddingBottom: spacing.md, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + iconBadge: { + width: 30, + height: 30, + borderRadius: borderRadius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: `${colors.primary.main}12`, + marginRight: spacing.sm, + }, + title: { + fontSize: fontSizes.xl, + fontWeight: '700', + color: colors.text.primary, + }, + closeButton: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: borderRadius.full, + backgroundColor: colors.background.default, + }, + row: { + marginBottom: spacing.lg, + flexDirection: 'row', + alignItems: 'flex-start', + }, + rowLast: { + marginBottom: 0, + }, + label: { + width: 76, + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.text.secondary, + lineHeight: 22, + marginTop: 1, + }, + value: { + flex: 1, + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + lineHeight: 24, + }, + timeList: { + flex: 1, + gap: spacing.sm, + }, + timeItemCard: { + borderWidth: 1, + borderColor: `${colors.primary.main}20`, + borderRadius: borderRadius.md, + backgroundColor: `${colors.primary.main}08`, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + }, + timeItemMain: { + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.text.primary, + lineHeight: 20, + }, + timeItemSub: { + marginTop: spacing.xs, + fontSize: fontSizes.sm, + color: colors.text.secondary, + lineHeight: 18, + }, + actionRow: { + marginTop: spacing.lg, + alignItems: 'flex-end', + }, + deleteButton: { + height: 38, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.full, + backgroundColor: `${colors.error.main}10`, + borderWidth: 1, + borderColor: `${colors.error.main}25`, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + deleteButtonText: { + fontSize: fontSizes.sm, + fontWeight: '700', + color: colors.error.main, + }, +}); + +export default CourseDetailScreen; diff --git a/src/screens/schedule/ScheduleScreen.tsx b/src/screens/schedule/ScheduleScreen.tsx new file mode 100644 index 0000000..16a4995 --- /dev/null +++ b/src/screens/schedule/ScheduleScreen.tsx @@ -0,0 +1,1065 @@ +/** + * 课程表主屏幕 + * 参考哈课表APP的设计风格 + */ + +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Dimensions, + StatusBar, + Modal, + TextInput, + Alert, +} from 'react-native'; +import { PanGestureHandler, State } from 'react-native-gesture-handler'; +import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme'; +import { + Course, + COURSE_COLORS, + TimeSlot, + getCourseColor, + hasCourseInWeek, +} from '../../types/schedule'; +import type { ScheduleStackParamList } from '../../navigation/types'; +import { scheduleService } from '../../services/scheduleService'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +// 时间段列宽度(按需求缩短) +const TIME_COLUMN_WIDTH = 38; +const VISIBLE_DAY_VALUES = [0, 1, 2, 3, 4, 5, 6]; // 周一到周日(0=周一) +const VISIBLE_WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; +// 星期列宽度(根据屏幕宽度自动计算) +const getDayColumnWidth = () => Math.max(46, (SCREEN_WIDTH - TIME_COLUMN_WIDTH - 2) / VISIBLE_DAY_VALUES.length); + +// 每个大节(两小节)高度 +const SECTION_HEIGHT = 105; +// 顶部周选择器高度 +const WEEK_SELECTOR_HEIGHT = 44; +// 星期标题行高度 +const HEADER_HEIGHT = 40; +// 底部Tab栏高度(用于避让) +const TAB_BAR_HEIGHT = 80; + +// 大节时间配置(左侧显示 1-6) +const GROUPED_TIME_SLOTS: TimeSlot[] = [ + { section: 1, startTime: '8:00', endTime: '9:45' }, + { section: 2, startTime: '10:05', endTime: '11:50' }, + { section: 3, startTime: '14:00', endTime: '15:45' }, + { section: 4, startTime: '16:05', endTime: '17:50' }, + { section: 5, startTime: '18:40', endTime: '20:25' }, + { section: 6, startTime: '20:45', endTime: '22:30' }, +]; + +const getMergedSectionIndex = (section: number) => Math.ceil(section / 2); +const MAX_COURSE_NAME_CHARS = 11; +const formatCourseName = (name: string) => + name.length > MAX_COURSE_NAME_CHARS ? `${name.slice(0, MAX_COURSE_NAME_CHARS)}…` : name; +const MERGED_SECTION_COUNT = GROUPED_TIME_SLOTS.length; +const getMergedSectionStart = (mergedSection: number) => (mergedSection - 1) * 2 + 1; +const getMergedSectionEnd = (mergedSection: number) => mergedSection * 2; +const SWIPE_THRESHOLD = 18; + +type RepeatMode = 'single' | 'weekly' | 'odd' | 'even'; +const TOTAL_WEEKS = 20; +const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/; +const parseWeekInput = (value: string, fallback: number) => { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return fallback; + return Math.min(Math.max(parsed, 1), TOTAL_WEEKS); +}; +const normalizeHexColor = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (!HEX_COLOR_REGEX.test(trimmed)) return ''; + return (trimmed.startsWith('#') ? trimmed : `#${trimmed}`).toUpperCase(); +}; + +// 获取今天在课表中的列索引(周一=0 ... 周日=6) +const getTodayColumnIndex = () => { + const day = new Date().getDay(); + // day: 0=周日, 1=周一, ..., 6=周六 + // 映射到: 周一=0, 周二=1, ..., 周六=5, 周日=6 + if (day === 0) return 6; // 周日在最后 + return day - 1; +}; + +// 第1周固定从2026年3月9日(周一)开始 +const FIRST_WEEK_START = new Date(2026, 2, 9); // 2026年3月9日 + +// 获取指定周的起始日期、每天的日期和月份 +// weekIndex: 0表示第1周,1表示第2周,以此类推 +const getWeekInfo = (weekIndex: number = 0) => { + // 计算该周的周一日期 + const monday = new Date(FIRST_WEEK_START); + monday.setDate(monday.getDate() + weekIndex * 7); + + const dates = []; + for (let i = 0; i < 7; i++) { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + dates.push(date.getDate()); + } + + // 获取该周周一所在的月份 + const month = monday.getMonth() + 1; + + return { dates, month }; +}; + +// 为了向后兼容,保留 getWeekDates 函数 +const getWeekDates = (weekOffset: number = 0) => { + return getWeekInfo(weekOffset).dates; +}; + +export const ScheduleScreen: React.FC = () => { + const navigation = useNavigation>(); + const [currentWeek, setCurrentWeek] = useState(1); + const [courses, setCourses] = useState([]); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [pendingDay, setPendingDay] = useState(0); + const [pendingMergedSection, setPendingMergedSection] = useState(1); + const [newCourseName, setNewCourseName] = useState(''); + const [newCourseTeacher, setNewCourseTeacher] = useState(''); + const [newCourseLocation, setNewCourseLocation] = useState(''); + const [newCourseColor, setNewCourseColor] = useState(''); + const [repeatMode, setRepeatMode] = useState('single'); + const [startWeekInput, setStartWeekInput] = useState(String(currentWeek)); + const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS)); + const weekScrollRef = useRef(null); + const todayColumnIndex = getTodayColumnIndex(); + // currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列 + const isViewingCurrentWeek = currentWeek === 1; + const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1; + + const dayColumnWidth = getDayColumnWidth(); + const usedCourseColors = useMemo( + () => new Set(courses.map(item => normalizeHexColor(item.color || '')).filter(Boolean)), + [courses] + ); + const suggestColor = useCallback(() => { + const firstAvailable = COURSE_COLORS.find(color => !usedCourseColors.has(normalizeHexColor(color))); + if (firstAvailable) return normalizeHexColor(firstAvailable); + return ''; + }, [usedCourseColors]); + + const loadCourses = useCallback(async () => { + try { + const remoteCourses = await scheduleService.getCourses(); + setCourses(remoteCourses); + } catch (error) { + console.error('加载课表失败:', error); + Alert.alert('加载失败', '获取课表数据失败,请稍后重试'); + } + }, []); + + // 生成周数列表 + const weeks = useMemo(() => { + return Array.from({ length: TOTAL_WEEKS }, (_, i) => i + 1); + }, []); + + useEffect(() => { + const WEEK_ITEM_ESTIMATED_WIDTH = 92; + const centerOffset = SCREEN_WIDTH / 2 - WEEK_ITEM_ESTIMATED_WIDTH / 2 - 40; + const targetX = Math.max(0, (currentWeek - 1) * WEEK_ITEM_ESTIMATED_WIDTH - centerOffset); + weekScrollRef.current?.scrollTo({ x: targetX, animated: true }); + }, [currentWeek]); + + useFocusEffect( + useCallback(() => { + loadCourses(); + }, [loadCourses]) + ); + + // 获取当前周的课程 + const getCurrentWeekCourses = useCallback(() => { + return courses.filter(course => hasCourseInWeek(course, currentWeek)); + }, [courses, currentWeek]); + + const getRelatedCourses = useCallback( + (targetCourse: Course) => { + return courses + .filter(course => course.name === targetCourse.name) + .sort((a, b) => { + if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; + return a.startSection - b.startSection; + }); + }, + [courses] + ); + + const handleWeekSwipe = useCallback( + ({ nativeEvent }: PanGestureHandlerStateChangeEvent) => { + if (nativeEvent.oldState !== State.ACTIVE && nativeEvent.oldState !== State.BEGAN) return; + const { translationX, translationY } = nativeEvent; + if (Math.abs(translationX) < SWIPE_THRESHOLD) return; + if (Math.abs(translationX) <= Math.abs(translationY) * 0.8) return; + + if (translationX < 0) { + setCurrentWeek(prev => Math.min(prev + 1, weeks.length)); + } else { + setCurrentWeek(prev => Math.max(prev - 1, 1)); + } + }, + [weeks.length] + ); + + const isSlotOccupied = useCallback( + (dayOfWeek: number, mergedSection: number, weekCourses: Course[]) => { + return weekCourses.some(course => { + if (course.dayOfWeek !== dayOfWeek) return false; + const start = getMergedSectionIndex(course.startSection); + const end = getMergedSectionIndex(course.endSection); + return mergedSection >= start && mergedSection <= end; + }); + }, + [] + ); + + const openAddCourseModal = useCallback((dayOfWeek: number, mergedSection: number) => { + setPendingDay(dayOfWeek); + setPendingMergedSection(mergedSection); + setNewCourseName(''); + setNewCourseTeacher(''); + setNewCourseLocation(''); + setNewCourseColor(suggestColor()); + setRepeatMode('single'); + setStartWeekInput(String(currentWeek)); + setEndWeekInput(String(TOTAL_WEEKS)); + setIsAddModalVisible(true); + }, [currentWeek, suggestColor]); + + const closeAddCourseModal = useCallback(() => { + setIsAddModalVisible(false); + }, []); + + const confirmAddCourse = useCallback(async () => { + const name = newCourseName.trim(); + if (!name) return; + + const startSection = getMergedSectionStart(pendingMergedSection); + const endSection = getMergedSectionEnd(pendingMergedSection); + const startWeek = parseWeekInput(startWeekInput, currentWeek); + const endWeek = parseWeekInput(endWeekInput, TOTAL_WEEKS); + const normalizedStartWeek = Math.min(startWeek, endWeek); + const normalizedEndWeek = Math.max(startWeek, endWeek); + + const repeatWeeks = weeks.filter(week => { + if (week < normalizedStartWeek || week > normalizedEndWeek) return false; + if (repeatMode === 'single') return week === currentWeek; + if (repeatMode === 'weekly') return true; + if (repeatMode === 'odd') return week % 2 === 1; + return week % 2 === 0; + }); + + const normalizedColor = normalizeHexColor(newCourseColor); + if (!normalizedColor) { + Alert.alert('颜色无效', '请输入有效的颜色值,例如 #FF6B6B'); + return; + } + if (usedCourseColors.has(normalizedColor)) { + Alert.alert('颜色重复', '该颜色已被其他课程使用,请更换颜色'); + return; + } + + try { + const created = await scheduleService.createCourse({ + name, + teacher: newCourseTeacher.trim() || undefined, + location: newCourseLocation.trim() || undefined, + day_of_week: pendingDay, + start_section: startSection, + end_section: endSection, + weeks: repeatWeeks, + color: normalizedColor, + }); + setCourses(prev => [...prev, created]); + closeAddCourseModal(); + } catch (error) { + console.error('新增课程失败:', error); + Alert.alert('保存失败', '课程保存失败,请稍后重试'); + } + }, [ + closeAddCourseModal, + currentWeek, + newCourseLocation, + newCourseName, + newCourseTeacher, + newCourseColor, + pendingDay, + pendingMergedSection, + repeatMode, + startWeekInput, + usedCourseColors, + endWeekInput, + weeks, + ]); + + // 渲染周选择器 + const renderWeekSelector = () => ( + + + + + + {weeks.map(week => ( + setCurrentWeek(week)} + > + + 第{week}周 + + + ))} + + + ); + + // 渲染星期标题行 + const renderHeader = () => { + const { month: currentMonth, dates: weekDates } = getWeekInfo(currentWeek - 1); + + return ( + + {/* 左上角月份显示 */} + + {currentMonth}月 + + {/* 星期标题 */} + {VISIBLE_WEEKDAY_NAMES.map((day, index) => ( + + + {day} + + + + {weekDates[index]} + + + + ))} + + ); + }; + + // 渲染时间段列 + const renderTimeColumn = () => ( + + {GROUPED_TIME_SLOTS.map(slot => ( + + + {slot.section} + + + {slot.startTime} + {'\n|\n'} + {slot.endTime} + + + ))} + + ); + + // 渲染课程卡片 + const renderCourseCard = (course: Course) => { + const color = getCourseColor(course.id, course.color); + 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; + + return ( + + navigation.navigate('CourseDetail', { + course, + relatedCourses: getRelatedCourses(course), + }) + } + activeOpacity={0.82} + > + + {formatCourseName(course.name)} + + {course.location ? ( + + @{course.location} + + ) : null} + + ); + }; + + // 渲染单日课程列 + const renderDayColumn = (dayOfWeek: number, columnIndex: number, isLastColumn: boolean) => { + const currentWeekCourses = getCurrentWeekCourses(); + const dayCourses = currentWeekCourses.filter( + course => course.dayOfWeek === dayOfWeek + ); + + return ( + + {/* 课程网格线 */} + {GROUPED_TIME_SLOTS.map(slot => { + const occupied = isSlotOccupied(dayOfWeek, slot.section, currentWeekCourses); + + return ( + { + if (!occupied) { + openAddCourseModal(dayOfWeek, slot.section); + } + }} + /> + ); + })} + {/* 课程卡片 */} + {dayCourses.map(renderCourseCard)} + + ); + }; + + // 渲染课程表主体 + const renderScheduleBody = () => ( + + + + {renderTimeColumn()} + + + {VISIBLE_DAY_VALUES.map((dayOfWeek, index) => + renderDayColumn(dayOfWeek, index, index === VISIBLE_DAY_VALUES.length - 1) + )} + + + + + + ); + + return ( + + + + {/* 周选择器 */} + {renderWeekSelector()} + + {/* 星期标题 */} + {renderHeader()} + + {/* 课程表主体 */} + {renderScheduleBody()} + + + + + 添加课程 + + 第{currentWeek}周 · {VISIBLE_WEEKDAY_NAMES[pendingDay]} · 第{pendingMergedSection}大节 + + + + + + + + 课程颜色(不可重复) + + {COURSE_COLORS.filter(color => !usedCourseColors.has(normalizeHexColor(color))).map(color => { + const normalized = normalizeHexColor(color); + const selected = normalizeHexColor(newCourseColor) === normalized; + return ( + setNewCourseColor(normalized)} + activeOpacity={0.85} + > + {selected ? : null} + + ); + })} + + + + + {repeatMode !== 'single' ? ( + + + 起始周 + + + + 结束周 + + + + ) : null} + + + 重复 + + setRepeatMode('single')} + activeOpacity={0.8} + > + + 仅本周 + + + setRepeatMode('weekly')} + activeOpacity={0.8} + > + + 每周 + + + setRepeatMode('odd')} + activeOpacity={0.8} + > + + 单周 + + + setRepeatMode('even')} + activeOpacity={0.8} + > + + 双周 + + + + {repeatMode !== 'single' ? ( + + 在第{parseWeekInput(startWeekInput, currentWeek)}周到第{parseWeekInput(endWeekInput, TOTAL_WEEKS)}周生效 + + ) : null} + + + + + 取消 + + + 保存 + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + + // ── 周选择器 ────────────────────────────────────────── + weekSelector: { + height: WEEK_SELECTOR_HEIGHT, + backgroundColor: colors.primary.main, + flexDirection: 'row', + alignItems: 'center', + }, + settingsButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'center', + marginLeft: spacing.xs, + }, + weekList: { + paddingHorizontal: spacing.sm, + alignItems: 'center', + }, + weekItem: { + paddingHorizontal: spacing.md, + paddingVertical: 5, + marginHorizontal: 3, + borderRadius: borderRadius.full, + }, + weekItemActive: { + backgroundColor: 'rgba(255,255,255,0.28)', + }, + weekItemText: { + fontSize: fontSizes.sm, + fontWeight: '500', + color: 'rgba(255,255,255,0.75)', + letterSpacing: 0.2, + }, + weekItemTextActive: { + color: colors.text.inverse, + fontWeight: '700', + }, + + // ── 星期标题行 ───────────────────────────────────────── + header: { + flexDirection: 'row', + height: HEADER_HEIGHT, + backgroundColor: colors.background.paper, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + timeHeader: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: colors.divider, + alignItems: 'center', + justifyContent: 'center', + }, + monthText: { + fontSize: fontSizes.xs, + fontWeight: '700', + color: colors.primary.main, + letterSpacing: 0.3, + }, + dayHeader: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 3, + }, + dayHeaderText: { + fontSize: fontSizes.xs, + fontWeight: '500', + color: colors.text.secondary, + letterSpacing: 0.3, + }, + dayHeaderTextActive: { + color: colors.primary.main, + fontWeight: '700', + }, + todayDateBadge: { + width: 22, + height: 22, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + }, + dateText: { + fontSize: fontSizes.xs, + color: colors.text.secondary, + marginTop: 2, + fontWeight: '400', + }, + dateTextActive: { + color: colors.text.inverse, + fontWeight: '700', + marginTop: 0, + }, + + // ── 课程表主体 ───────────────────────────────────────── + scheduleBody: { + flex: 1, + backgroundColor: colors.background.default, + marginBottom: TAB_BAR_HEIGHT, + }, + scheduleBodyGestureLayer: { + flex: 1, + }, + scheduleBodyContent: { + flexDirection: 'row', + }, + + // ── 时间列 ──────────────────────────────────────────── + timeColumn: { + backgroundColor: colors.background.paper, + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: colors.divider, + }, + timeCell: { + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#EBEBEB', + gap: 4, + }, + timeSectionBadge: { + width: 22, + height: 22, + borderRadius: borderRadius.full, + backgroundColor: `${colors.primary.main}12`, + alignItems: 'center', + justifyContent: 'center', + }, + timeSection: { + fontSize: fontSizes.xs, + fontWeight: '700', + color: colors.primary.dark, + }, + timeText: { + fontSize: 9, + color: colors.text.hint, + fontWeight: '400', + textAlign: 'center', + lineHeight: 12, + }, + + // ── 日期列容器 ──────────────────────────────────────── + daysContainer: { + flexDirection: 'row', + }, + + // ── 单日列 ──────────────────────────────────────────── + dayColumn: { + borderRightWidth: StyleSheet.hairlineWidth, + borderRightColor: '#EBEBEB', + }, + gridCell: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#EBEBEB', + }, + + // ── 课程卡片 ────────────────────────────────────────── + courseCard: { + position: 'absolute', + borderRadius: borderRadius.xl, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.xs + 1, + ...shadows.md, + }, + courseName: { + fontSize: 10, + fontWeight: '700', + color: colors.text.inverse, + lineHeight: 13, + letterSpacing: 0.1, + }, + courseLocation: { + fontSize: 9, + color: 'rgba(255,255,255,0.80)', + marginTop: 3, + fontWeight: '500', + letterSpacing: 0.1, + }, + courseTeacher: { + fontSize: 9, + color: 'rgba(255,255,255,0.75)', + marginTop: 2, + fontWeight: '400', + }, + + // ── 添加课程弹窗 ────────────────────────────────────── + addModalMask: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.40)', + justifyContent: 'center', + paddingHorizontal: spacing.lg, + }, + addModalCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius['2xl'], + padding: spacing.xl, + ...shadows.lg, + }, + addModalTitle: { + fontSize: fontSizes.xl, + fontWeight: '700', + color: colors.text.primary, + letterSpacing: 0.3, + }, + addModalDesc: { + marginTop: spacing.xs, + marginBottom: spacing.lg, + fontSize: fontSizes.sm, + color: colors.text.secondary, + fontWeight: '500', + }, + addInput: { + height: 46, + borderWidth: 0, + borderRadius: borderRadius.lg, + paddingHorizontal: spacing.md, + marginBottom: spacing.sm, + fontSize: fontSizes.md, + color: colors.text.primary, + backgroundColor: colors.background.default, + }, + colorSection: { + marginBottom: spacing.sm, + }, + colorPresetRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm, + }, + colorPresetItem: { + width: 24, + height: 24, + borderRadius: borderRadius.full, + alignItems: 'center', + justifyContent: 'center', + }, + colorPresetItemActive: { + borderWidth: 2, + borderColor: colors.text.inverse, + }, + addActions: { + marginTop: spacing.md, + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + }, + addCancelBtn: { + height: 42, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.full, + backgroundColor: colors.background.default, + alignItems: 'center', + justifyContent: 'center', + }, + addCancelText: { + color: colors.text.secondary, + fontSize: fontSizes.md, + fontWeight: '600', + }, + addConfirmBtn: { + height: 42, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + }, + addConfirmBtnDisabled: { + backgroundColor: colors.primary.light, + opacity: 0.7, + }, + addConfirmText: { + color: colors.text.inverse, + fontSize: fontSizes.md, + fontWeight: '700', + letterSpacing: 0.3, + }, + + // ── 重复选项 ────────────────────────────────────────── + repeatSection: { + marginBottom: spacing.sm, + }, + repeatLabel: { + fontSize: fontSizes.sm, + fontWeight: '600', + color: colors.text.secondary, + marginBottom: spacing.xs, + letterSpacing: 0.2, + }, + repeatOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + }, + repeatChip: { + paddingHorizontal: spacing.md, + height: 32, + borderRadius: borderRadius.full, + borderWidth: 1.5, + borderColor: '#E8E8E8', + backgroundColor: colors.background.paper, + justifyContent: 'center', + alignItems: 'center', + }, + repeatChipActive: { + borderColor: colors.primary.main, + backgroundColor: `${colors.primary.main}15`, + }, + repeatChipText: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + fontWeight: '600', + }, + repeatChipTextActive: { + color: colors.primary.main, + fontWeight: '700', + }, + repeatHint: { + marginTop: spacing.xs, + fontSize: fontSizes.xs, + color: colors.text.hint, + fontWeight: '400', + }, + + // ── 周范围输入 ──────────────────────────────────────── + weekRangeRow: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.sm, + }, + weekRangeItem: { + flex: 1, + }, + weekRangeLabel: { + fontSize: fontSizes.sm, + fontWeight: '600', + color: colors.text.secondary, + marginBottom: spacing.xs, + letterSpacing: 0.2, + }, + weekRangeInput: { + height: 42, + borderWidth: 0, + borderRadius: borderRadius.lg, + paddingHorizontal: spacing.md, + fontSize: fontSizes.md, + color: colors.text.primary, + backgroundColor: colors.background.default, + }, +}); + +export default ScheduleScreen; diff --git a/src/screens/schedule/index.ts b/src/screens/schedule/index.ts new file mode 100644 index 0000000..95f6981 --- /dev/null +++ b/src/screens/schedule/index.ts @@ -0,0 +1,7 @@ +/** + * 课程表模块导出 + */ + +export { default as ScheduleScreen } from './ScheduleScreen'; +export { default as CourseDetailScreen } from './CourseDetailScreen'; +export { default } from './ScheduleScreen'; diff --git a/src/services/index.ts b/src/services/index.ts index 27ea301..53b2d34 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -88,6 +88,10 @@ export type { CreateAnnouncementRequest, } from './groupService'; +// 课表服务 +export { scheduleService } from './scheduleService'; +export type { CreateScheduleCourseRequest } from './scheduleService'; + // 后台保活服务 export { backgroundService, diff --git a/src/services/scheduleService.ts b/src/services/scheduleService.ts new file mode 100644 index 0000000..b273783 --- /dev/null +++ b/src/services/scheduleService.ts @@ -0,0 +1,70 @@ +import { api } from './api'; +import { Course } from '../types/schedule'; + +interface ScheduleCourseDTO { + id: string; + name: string; + teacher?: string; + location?: string; + day_of_week: number; + start_section: number; + end_section: number; + weeks: number[]; + color?: string; +} + +interface ScheduleCourseListResponse { + list: ScheduleCourseDTO[]; +} + +interface CreateScheduleCourseRequest { + name: string; + teacher?: string; + location?: string; + day_of_week: number; + start_section: number; + end_section: number; + weeks: number[]; + color?: string; +} + +interface CreateScheduleCourseResponse { + course: ScheduleCourseDTO; +} + +const toCourse = (dto: ScheduleCourseDTO): Course => ({ + 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 { + async getCourses(week?: number): Promise { + const params = week ? { week } : undefined; + const response = await api.get('/schedule/courses', params); + return response.data.list.map(toCourse); + } + + async createCourse(input: CreateScheduleCourseRequest): Promise { + const response = await api.post('/schedule/courses', input); + return toCourse(response.data.course); + } + + async updateCourse(courseId: string, input: CreateScheduleCourseRequest): Promise { + const response = await api.put(`/schedule/courses/${courseId}`, input); + return toCourse(response.data.course); + } + + async deleteCourse(courseId: string): Promise { + await api.delete(`/schedule/courses/${courseId}`); + } +} + +export const scheduleService = new ScheduleService(); +export type { CreateScheduleCourseRequest }; diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index 818797c..d3ebe39 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -231,6 +231,19 @@ class MessageManager { }); } + /** + * 按 message_id 合并消息并按 seq 排序。 + * 后者覆盖前者,可用来吸收更完整的同 ID 消息体(例如服务端回包/SSE)。 + */ + private mergeMessagesById(base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] { + if (incoming.length === 0) return base; + const merged = new Map(); + [...base, ...incoming].forEach(msg => { + merged.set(String(msg.id), msg); + }); + return Array.from(merged.values()).sort((a, b) => a.seq - b.seq); + } + private updateConversationList() { // 会话排序:置顶优先,再按最后消息时间排序 const list = Array.from(this.state.conversations.values()).sort((a, b) => { @@ -637,10 +650,10 @@ class MessageManager { // 2. 立即更新内存中的消息列表(关键:确保ChatScreen能立即看到) const existingMessages = this.state.messagesMap.get(normalizedConversationId) || []; - const messageExists = existingMessages.some(m => m.id === id); + const messageExists = existingMessages.some(m => String(m.id) === String(id)); if (!messageExists) { - const updatedMessages = [...existingMessages, newMessage].sort((a, b) => a.seq - b.seq); + const updatedMessages = this.mergeMessagesById(existingMessages, [newMessage]); this.state.messagesMap.set(normalizedConversationId, updatedMessages); // 3. 立即通知订阅者(关键:解决竞态条件) @@ -1115,12 +1128,7 @@ class MessageManager { try { const mergeMessages = (base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] => { - if (incoming.length === 0) return base; - const merged = new Map(); - [...base, ...incoming].forEach(msg => { - merged.set(String(msg.id), msg); - }); - return Array.from(merged.values()).sort((a, b) => a.seq - b.seq); + return this.mergeMessagesById(base, incoming); }; // 1. 先从本地数据库加载(确保立即有数据展示) @@ -1366,7 +1374,7 @@ class MessageManager { // 合并到现有消息 const existingMessages = this.state.messagesMap.get(conversationId) || []; - const mergedMessages = [...formattedMessages, ...existingMessages].sort((a, b) => a.seq - b.seq); + const mergedMessages = this.mergeMessagesById(existingMessages, formattedMessages); this.state.messagesMap.set(conversationId, mergedMessages); this.notifySubscribers({ @@ -1402,7 +1410,7 @@ class MessageManager { // 合并消息 const existingMessages = this.state.messagesMap.get(conversationId) || []; - const mergedMessages = [...serverMessages, ...existingMessages].sort((a, b) => a.seq - b.seq); + const mergedMessages = this.mergeMessagesById(existingMessages, serverMessages); this.state.messagesMap.set(conversationId, mergedMessages); this.notifySubscribers({ @@ -1481,7 +1489,7 @@ class MessageManager { status: 'normal', }; - const updatedMessages = [...existingMessages, newMessage].sort((a, b) => a.seq - b.seq); + const updatedMessages = this.mergeMessagesById(existingMessages, [newMessage]); this.state.messagesMap.set(conversationId, updatedMessages); // 关键:立即广播消息列表更新,确保 ChatScreen 立刻显示新消息 diff --git a/src/types/index.ts b/src/types/index.ts index 4e9ca6d..d1ee970 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ // 导出DTO类型作为主要类型 export * from './dto'; +export * from './schedule'; // 兼容旧类型(用于内部组件) import type { diff --git a/src/types/schedule.ts b/src/types/schedule.ts new file mode 100644 index 0000000..e448891 --- /dev/null +++ b/src/types/schedule.ts @@ -0,0 +1,125 @@ +/** + * 课程表类型定义 + */ + +// 课程数据接口 +export interface Course { + id: string; + name: string; + location?: string; + teacher?: string; + dayOfWeek: number; // 0-6 周一到周日 + startSection: number; // 开始节次 (1-12) + endSection: number; // 结束节次 (1-12) + weeks: number[]; // 上课周数 + color?: string; // 卡片颜色 +} + +// 时间段配置 +export interface TimeSlot { + section: number; // 节次 + startTime: string; // 如 "08:00" + endTime: string; // 如 "09:45" +} + +// 周配置 +export interface WeekConfig { + currentWeek: number; // 当前周 + totalWeeks: number; // 总周数 (通常16-20周) + semesterStartDate: string; // 学期开始日期 +} + +// 课程表状态 +export interface ScheduleState { + courses: Course[]; + currentWeek: number; + selectedWeek: number; + timeSlots: TimeSlot[]; + isLoading: boolean; + error: string | null; +} + +// 课程卡片颜色预设 +export const COURSE_COLORS = [ + '#FF6B6B', + '#FF8E72', + '#FFA552', + '#FFB347', + '#FFD166', + '#F6E05E', + '#D9ED92', + '#B5E48C', + '#99D98C', + '#76C893', + '#52B69A', + '#34A0A4', + '#4ECDC4', + '#2EC4B6', + '#45B7D1', + '#48CAE4', + '#4EA8DE', + '#5390D9', + '#4361EE', + '#3A0CA3', + '#5E60CE', + '#6930C3', + '#7400B8', + '#9D4EDD', + '#C77DFF', + '#DDA0DD', + '#E0AAFF', + '#F15BB5', + '#FF5D8F', + '#EF476F', + '#E76F51', + '#A8DADC', + '#8EC5FC', + '#98D8C8', + '#96CEB4', + '#85C1E2', + '#70D6FF', + '#7BDFF2', + '#B2F7EF', + '#F7D6E0', +]; + +// 默认时间段配置 (12节) +export const DEFAULT_TIME_SLOTS: TimeSlot[] = [ + { section: 1, startTime: '08:00', endTime: '08:45' }, + { section: 2, startTime: '08:55', endTime: '09:40' }, + { section: 3, startTime: '10:00', endTime: '10:45' }, + { section: 4, startTime: '10:55', endTime: '11:40' }, + { section: 5, startTime: '14:00', endTime: '14:45' }, + { section: 6, startTime: '14:55', endTime: '15:40' }, + { section: 7, startTime: '16:00', endTime: '16:45' }, + { section: 8, startTime: '16:55', endTime: '17:40' }, + { section: 9, startTime: '19:00', endTime: '19:45' }, + { section: 10, startTime: '19:55', endTime: '20:40' }, + { section: 11, startTime: '20:50', endTime: '21:35' }, + { section: 12, startTime: '21:45', endTime: '22:30' }, +]; + +// 星期名称 +export const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +// 获取当前是第几周 +export function getCurrentWeek(semesterStartDate: string, totalWeeks: number = 20): number { + const start = new Date(semesterStartDate); + const now = new Date(); + const diffTime = now.getTime() - start.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + const week = Math.floor(diffDays / 7) + 1; + return Math.min(Math.max(week, 1), totalWeeks); +} + +// 获取课程颜色 +export function getCourseColor(courseId: string, color?: string): string { + if (color) return color; + const hash = courseId.split('').reduce((a, b) => a + b.charCodeAt(0), 0); + return COURSE_COLORS[hash % COURSE_COLORS.length]; +} + +// 检查课程在某周是否有课 +export function hasCourseInWeek(course: Course, week: number): boolean { + return course.weeks.includes(week); +}