From 798dd7c9a0fee9b202e044932e19ec2b182d6ebc Mon Sep 17 00:00:00 2001 From: lan Date: Thu, 12 Mar 2026 08:37:08 +0800 Subject: [PATCH] feat(navigation): add schedule tab and stack navigator Add new ScheduleTab to main navigation with ScheduleStackNavigator for managing course schedule screens. Includes CourseDetailScreen with modal presentation. Also exports scheduleService and related types for the new schedule feature. fix(message): add group_id parameter to invite and request handlers Pass group_id from extra_data when calling respondInvite and reviewJoinRequest APIs, as the backend now requires this parameter. refactor(message): extract mergeMessagesById helper method Centralize message merging logic into a reusable private method to avoid duplication and ensure consistent message deduplication behavior. --- src/navigation/MainNavigator.tsx | 57 +- src/navigation/types.ts | 8 + .../message/GroupInviteDetailScreen.tsx | 7 +- .../message/GroupRequestDetailScreen.tsx | 9 +- src/screens/schedule/CourseDetailScreen.tsx | 239 ++++ src/screens/schedule/ScheduleScreen.tsx | 1065 +++++++++++++++++ src/screens/schedule/index.ts | 7 + src/services/index.ts | 4 + src/services/scheduleService.ts | 70 ++ src/stores/messageManager.ts | 30 +- src/types/index.ts | 1 + src/types/schedule.ts | 125 ++ 12 files changed, 1606 insertions(+), 16 deletions(-) create mode 100644 src/screens/schedule/CourseDetailScreen.tsx create mode 100644 src/screens/schedule/ScheduleScreen.tsx create mode 100644 src/screens/schedule/index.ts create mode 100644 src/services/scheduleService.ts create mode 100644 src/types/schedule.ts 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); +}