Files
frontend/src/screens/schedule/ScheduleScreen.tsx

1472 lines
48 KiB
TypeScript
Raw Normal View History

/**
*
* APP的设计风格
*/
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Dimensions,
StatusBar,
Modal,
TextInput,
Alert,
ActivityIndicator,
} from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme';
2026-03-18 00:25:46 +08:00
import { useResponsive } from '../../hooks/useResponsive';
import {
Course,
COURSE_COLORS,
TimeSlot,
getCourseColor,
hasCourseInWeek,
} from '../../types/schedule';
import * as hrefs from '../../navigation/hrefs';
import { routePayloadCache } from '../../stores/routePayloadCache';
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);
2026-03-18 00:25:46 +08:00
// 每个大节(两小节)高度 - 默认值,会在组件中动态调整
const DEFAULT_SECTION_HEIGHT = 105;
// 顶部周选择器高度
const WEEK_SELECTOR_HEIGHT = 44;
// 星期标题行高度
const HEADER_HEIGHT = 40;
// 悬浮TabBar高度TabBar高度64 + 浮动间距12*2 = 88
const FLOATING_TAB_BAR_HEIGHT = 88;
// 大节时间配置(左侧显示 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';
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) => {
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日
2026-03-18 00:25:46 +08:00
// 计算当前日期对应的周数返回1-20
const getCurrentWeekNumber = (): number => {
const now = new Date();
// 计算当前日期与第一周周一的差距天数
const diffTime = now.getTime() - FIRST_WEEK_START.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
// 计算周数从1开始
const weekNumber = Math.floor(diffDays / 7) + 1;
// 确保周数在有效范围内
return Math.max(1, Math.min(TOTAL_WEEKS, weekNumber));
};
// 获取指定周的起始日期、每天的日期和月份
// 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;
};
2026-03-18 00:25:46 +08:00
// 获取初始周数(仅在组件初始化时计算一次)
const INITIAL_WEEK = getCurrentWeekNumber();
2026-03-17 12:29:04 +08:00
export const ScheduleScreen: React.FC = () => {
const router = useRouter();
2026-03-18 00:25:46 +08:00
// 使用响应式 hook 检测屏幕尺寸
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
const insets = useSafeAreaInsets();
2026-03-18 00:25:46 +08:00
const isWeb = platform.isWeb;
const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK);
const [courses, setCourses] = useState<Course[]>([]);
2026-03-18 00:25:46 +08:00
// 动态计算日列宽度:移动端填满屏幕宽度,电脑端也填满
// Web端需要特殊处理确保周一到周日刚好横向填满
const dayColumnWidth = useMemo(() => {
if (isWeb) {
// Web端使用更大的宽度确保填满
return Math.max(80, (screenWidth - TIME_COLUMN_WIDTH) / VISIBLE_DAY_VALUES.length);
}
// 移动端和原生桌面端
return Math.max(46, (screenWidth - TIME_COLUMN_WIDTH - 2) / VISIBLE_DAY_VALUES.length);
}, [screenWidth, isWeb]);
// 动态计算每节课的高度
// 手机端第六节课底部刚好在悬浮TabBar上方可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 悬浮TabBar - 安全区域)
2026-03-18 00:25:46 +08:00
// 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题)
const sectionHeight = useMemo((): number => {
const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT;
// 悬浮TabBar高度 + 安全区域底部
const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0;
2026-03-18 00:25:46 +08:00
// 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移
const availableHeight = screenHeight - totalHeaderHeight - bottomOffset;
// 6节课每节课高度 = 可用高度 / 6
const calculatedHeight = Math.floor(availableHeight / 6);
// 确保高度不低于默认值
return Math.max(DEFAULT_SECTION_HEIGHT, calculatedHeight);
}, [screenHeight, isMobile, insets.bottom]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [pendingDay, setPendingDay] = useState<number>(0);
const [pendingMergedSection, setPendingMergedSection] = useState<number>(1);
const [newCourseName, setNewCourseName] = useState('');
const [newCourseTeacher, setNewCourseTeacher] = useState('');
const [newCourseLocation, setNewCourseLocation] = useState('');
const [newCourseColor, setNewCourseColor] = useState('');
const [repeatMode, setRepeatMode] = useState<RepeatMode>('single');
const [startWeekInput, setStartWeekInput] = useState(String(currentWeek));
const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS));
const weekScrollRef = useRef<ScrollView | null>(null);
// 组件卸载状态标记,用于防止组件卸载后仍调用 setState
const isMountedRef = useRef(true);
// 设置弹窗状态
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
// 同步教务系统弹窗状态
const [isSyncModalVisible, setIsSyncModalVisible] = useState(false);
const [syncUsername, setSyncUsername] = useState('');
const [syncPassword, setSyncPassword] = useState('');
const [isSyncing, setIsSyncing] = useState(false);
// 组件卸载时设置标记
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const todayColumnIndex = getTodayColumnIndex();
2026-03-18 00:25:46 +08:00
// 当前正在查看的周是否为真实的当前周
const isViewingCurrentWeek = currentWeek === INITIAL_WEEK;
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
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 = () => (
<View style={styles.weekSelector}>
<TouchableOpacity
style={styles.settingsButton}
onPress={() => setIsSettingsModalVisible(true)}
>
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" />
</TouchableOpacity>
<ScrollView
ref={weekScrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.weekList}
>
{weeks.map(week => (
<TouchableOpacity
key={week}
style={[
styles.weekItem,
currentWeek === week && styles.weekItemActive,
]}
onPress={() => setCurrentWeek(week)}
>
<Text
style={[
styles.weekItemText,
currentWeek === week && styles.weekItemTextActive,
]}
>
{week}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
// 渲染星期标题行
const renderHeader = () => {
const { month: currentMonth, dates: weekDates } = getWeekInfo(currentWeek - 1);
return (
<View style={styles.header}>
{/* 左上角月份显示 */}
<View style={[styles.timeHeader, { width: TIME_COLUMN_WIDTH }]}>
<Text style={styles.monthText}>{currentMonth}</Text>
</View>
{/* 星期标题 */}
{VISIBLE_WEEKDAY_NAMES.map((day, index) => (
<View
key={day}
style={[
styles.dayHeader,
{
2026-03-18 00:25:46 +08:00
flex: 1,
backgroundColor: index === activeTodayColumn ? `${colors.primary.main}10` : 'transparent',
},
]}
>
<Text
style={[
styles.dayHeaderText,
index === activeTodayColumn && styles.dayHeaderTextActive,
]}
>
{day}
</Text>
<View style={index === activeTodayColumn ? styles.todayDateBadge : undefined}>
<Text
style={[
styles.dateText,
index === activeTodayColumn && styles.dateTextActive,
]}
>
{weekDates[index]}
</Text>
</View>
</View>
))}
</View>
);
};
// 渲染时间段列
const renderTimeColumn = () => (
<View style={[styles.timeColumn, { width: TIME_COLUMN_WIDTH }]}>
{GROUPED_TIME_SLOTS.map(slot => (
<View
key={slot.section}
style={[
styles.timeCell,
2026-03-18 00:25:46 +08:00
{ height: sectionHeight },
]}
>
<View style={styles.timeSectionBadge}>
<Text style={styles.timeSection}>{slot.section}</Text>
</View>
<Text style={styles.timeText}>
{slot.startTime}
{'\n\n'}
{slot.endTime}
</Text>
</View>
))}
</View>
);
// 处理长按课程删除
const handleCourseLongPress = useCallback((course: Course) => {
const relatedCourses = getRelatedCourses(course);
const hasMultiple = relatedCourses.length > 1;
Alert.alert(
'删除课程',
`课程:${course.name}`,
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除本节',
style: 'destructive',
onPress: async () => {
try {
await scheduleService.deleteCourse(course.id);
setCourses(prev => prev.filter(c => c.id !== course.id));
Alert.alert('成功', '已删除本节课程');
} catch (error) {
console.error('删除课程失败:', error);
Alert.alert('删除失败', '请稍后重试');
}
},
},
...(hasMultiple
? [
{
text: '删除全部',
style: 'destructive' as const,
onPress: async () => {
try {
// 使用 Promise.allSettled 批量删除,即使部分失败也能继续
const results = await Promise.allSettled(
relatedCourses.map(course => scheduleService.deleteCourse(course.id))
);
// 统计成功和失败的数量
const fulfilledCount = results.filter(r => r.status === 'fulfilled').length;
const rejectedCount = results.filter(r => r.status === 'rejected').length;
// 只移除成功删除的课程
const successfullyDeletedIds = new Set(
relatedCourses
.filter((_, index) => results[index].status === 'fulfilled')
.map(c => c.id)
);
setCourses(prev => prev.filter(c => !successfullyDeletedIds.has(c.id)));
if (rejectedCount > 0) {
Alert.alert(
'部分删除成功',
`成功删除 ${fulfilledCount} 节课程,${rejectedCount} 节删除失败,请重试`
);
} else {
Alert.alert('成功', `已删除 ${fulfilledCount} 节课程`);
}
} catch (error) {
console.error('批量删除课程失败:', error);
Alert.alert('删除失败', '请稍后重试');
}
},
},
]
: []),
],
{ cancelable: true }
);
}, [getRelatedCourses]);
const buildDayDisplayCourses = useCallback((dayCourses: Course[]): CourseDisplayItem[] => {
const groupedByNameAndSlot = new Map<string, Course[]>();
dayCourses.forEach(course => {
// 同名 + 同时间段分组;若出现多条记录,则拆分并排展示
const key = `${course.name}|${course.dayOfWeek}|${course.startSection}|${course.endSection}`;
const group = groupedByNameAndSlot.get(key);
if (group) {
group.push(course);
} else {
groupedByNameAndSlot.set(key, [course]);
}
});
const items: CourseDisplayItem[] = [];
groupedByNameAndSlot.forEach((groupCourses, groupKey) => {
groupCourses.forEach((course, index) => {
items.push({
key: `${groupKey}|${course.id}|${index}`,
course,
locationText: course.location?.trim() ?? '',
laneIndex: index,
laneCount: groupCourses.length,
});
});
});
return items;
}, []);
// 渲染课程卡片
const renderCourseCard = ({ 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;
2026-03-18 00:25:46 +08:00
const top = (startMergedSection - 1) * sectionHeight;
const height = duration * sectionHeight - 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 (
<TouchableOpacity
key={key}
style={[
styles.courseCard,
{
top,
height,
backgroundColor: color,
left,
width: cardWidth,
},
]}
onPress={() => {
const related = getRelatedCourses(course);
routePayloadCache.stashCourseDetail(course, related);
router.push(hrefs.hrefScheduleCourse(String(course.id)));
}}
onLongPress={() => handleCourseLongPress(course)}
delayLongPress={500}
activeOpacity={0.82}
>
<Text style={styles.courseName} numberOfLines={3}>
{formatCourseName(course.name)}
</Text>
{locationText ? (
<Text style={styles.courseLocation}>
@{locationText}
</Text>
) : null}
</TouchableOpacity>
);
};
// 渲染单日课程列
const renderDayColumn = (dayOfWeek: number, columnIndex: number, isLastColumn: boolean) => {
const currentWeekCourses = getCurrentWeekCourses();
const dayCourses = currentWeekCourses.filter(
course => course.dayOfWeek === dayOfWeek
);
const displayCourses = buildDayDisplayCourses(dayCourses);
return (
<View
key={dayOfWeek}
style={[
styles.dayColumn,
{
borderRightWidth: isLastColumn ? 0 : 1,
2026-03-18 00:25:46 +08:00
backgroundColor: columnIndex === activeTodayColumn ? `${colors.primary.main}08` : colors.background.default,
},
]}
>
{/* 课程网格线 */}
{GROUPED_TIME_SLOTS.map(slot => {
const occupied = isSlotOccupied(dayOfWeek, slot.section, currentWeekCourses);
return (
<TouchableOpacity
key={slot.section}
style={[
styles.gridCell,
2026-03-18 00:25:46 +08:00
{ height: sectionHeight },
]}
activeOpacity={1}
delayLongPress={280}
onLongPress={() => {
if (!occupied) {
openAddCourseModal(dayOfWeek, slot.section);
}
}}
/>
);
})}
{/* 课程卡片 */}
{displayCourses.map(renderCourseCard)}
</View>
);
};
// 渲染课程表主体
2026-03-18 00:25:46 +08:00
const renderScheduleBody = () => {
// 移动端和电脑端都不添加额外底部间距,让内容填满整个区域
// 动态计算的高度已经确保第六节课显示在正确位置
return (
<PanGestureHandler
activeOffsetX={[-8, 8]}
failOffsetY={[-80, 80]}
onHandlerStateChange={handleWeekSwipe}
>
<View style={styles.scheduleBodyGestureLayer}>
<ScrollView
style={styles.scheduleBody}
2026-03-18 00:25:46 +08:00
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scheduleBodyContent}
>
{renderTimeColumn()}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
2026-03-18 00:25:46 +08:00
contentContainerStyle={{ flexGrow: 1 }}
>
<View style={styles.daysContainer}>
{VISIBLE_DAY_VALUES.map((dayOfWeek, index) =>
renderDayColumn(dayOfWeek, index, index === VISIBLE_DAY_VALUES.length - 1)
)}
</View>
</ScrollView>
</ScrollView>
</View>
</PanGestureHandler>
2026-03-18 00:25:46 +08:00
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="light-content" backgroundColor={colors.primary.main} />
{/* 周选择器 */}
{renderWeekSelector()}
{/* 星期标题 */}
{renderHeader()}
{/* 课程表主体 */}
{renderScheduleBody()}
{/* 设置弹窗 */}
<Modal
visible={isSettingsModalVisible}
transparent
animationType="fade"
onRequestClose={() => setIsSettingsModalVisible(false)}
>
<View style={styles.addModalMask}>
<View style={styles.settingsModalCard}>
<Text style={styles.addModalTitle}></Text>
<TouchableOpacity
style={styles.settingsItem}
onPress={() => {
setIsSettingsModalVisible(false);
setIsSyncModalVisible(true);
}}
>
<MaterialCommunityIcons name="cloud-sync" size={22} color={colors.primary.main} />
<Text style={styles.settingsItemText}></Text>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</TouchableOpacity>
<TouchableOpacity
style={styles.settingsCloseBtn}
onPress={() => setIsSettingsModalVisible(false)}
>
<Text style={styles.settingsCloseText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
{/* 同步教务系统弹窗 */}
<Modal
visible={isSyncModalVisible}
transparent
animationType="fade"
onRequestClose={() => {
if (!isSyncing) {
setIsSyncModalVisible(false);
}
}}
>
<View style={styles.addModalMask}>
<View style={styles.addModalCard}>
<Text style={styles.addModalTitle}></Text>
<Text style={styles.addModalDesc}>
</Text>
<TextInput
value={syncUsername}
onChangeText={setSyncUsername}
placeholder="学号/工号"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
autoCapitalize="none"
editable={!isSyncing}
/>
<TextInput
value={syncPassword}
onChangeText={setSyncPassword}
placeholder="密码"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
secureTextEntry
editable={!isSyncing}
/>
{isSyncing && (
<View style={styles.syncingContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text style={styles.syncingText}>...</Text>
</View>
)}
<View style={styles.addActions}>
<TouchableOpacity
style={[styles.addCancelBtn, isSyncing && styles.addConfirmBtnDisabled]}
onPress={() => {
if (!isSyncing) {
setIsSyncModalVisible(false);
setSyncUsername('');
setSyncPassword('');
}
}}
activeOpacity={0.8}
disabled={isSyncing}
>
<Text style={styles.addCancelText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.addConfirmBtn,
(!syncUsername.trim() || !syncPassword.trim() || isSyncing) && styles.addConfirmBtnDisabled,
]}
onPress={async () => {
if (!syncUsername.trim() || !syncPassword.trim()) return;
setIsSyncing(true);
try {
const result = await scheduleService.syncSchedule({
username: syncUsername.trim(),
password: syncPassword.trim(),
});
// 检查组件是否已卸载
if (!isMountedRef.current) return;
if (result.success) {
Alert.alert(
'同步成功',
`成功同步 ${result.synced_count} 门课程`,
[
{
text: '确定',
onPress: () => {
setIsSyncModalVisible(false);
setSyncUsername('');
setSyncPassword('');
loadCourses(); // 刷新课表
},
},
]
);
} else {
Alert.alert(
'同步失败',
result.error_message || result.message || '请检查账号密码是否正确'
);
}
} catch (error) {
console.error('同步课表失败:', error);
// 检查组件是否已卸载
if (isMountedRef.current) {
Alert.alert('同步失败', '网络错误,请稍后重试');
}
} finally {
// 检查组件是否已卸载
if (isMountedRef.current) {
setIsSyncing(false);
}
}
}}
activeOpacity={0.8}
disabled={!syncUsername.trim() || !syncPassword.trim() || isSyncing}
>
<Text style={styles.addConfirmText}>
{isSyncing ? '同步中...' : '开始同步'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
<Modal
visible={isAddModalVisible}
transparent
animationType="fade"
onRequestClose={closeAddCourseModal}
>
<View style={styles.addModalMask}>
<View style={styles.addModalCard}>
<Text style={styles.addModalTitle}></Text>
<Text style={styles.addModalDesc}>
{currentWeek} · {VISIBLE_WEEKDAY_NAMES[pendingDay]} · {pendingMergedSection}
</Text>
<TextInput
value={newCourseName}
onChangeText={setNewCourseName}
placeholder="课程名称(必填)"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
/>
<TextInput
value={newCourseTeacher}
onChangeText={setNewCourseTeacher}
placeholder="教师(选填)"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
/>
<TextInput
value={newCourseLocation}
onChangeText={setNewCourseLocation}
placeholder="地点(选填)"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
/>
<View style={styles.colorSection}>
<Text style={styles.repeatLabel}></Text>
<View style={styles.colorPresetRow}>
{COURSE_COLORS.filter(color => !usedCourseColors.has(normalizeHexColor(color))).map(color => {
const normalized = normalizeHexColor(color);
const selected = normalizeHexColor(newCourseColor) === normalized;
return (
<TouchableOpacity
key={color}
style={[
styles.colorPresetItem,
{ backgroundColor: normalized || color },
selected && styles.colorPresetItemActive,
]}
onPress={() => setNewCourseColor(normalized)}
activeOpacity={0.85}
>
{selected ? <MaterialCommunityIcons name="check" size={12} color={colors.text.inverse} /> : null}
</TouchableOpacity>
);
})}
</View>
<TextInput
value={newCourseColor}
onChangeText={setNewCourseColor}
placeholder="#FF6B6B支持自定义"
placeholderTextColor={colors.text.hint}
style={styles.addInput}
autoCapitalize="characters"
maxLength={7}
/>
</View>
{repeatMode !== 'single' ? (
<View style={styles.weekRangeRow}>
<View style={styles.weekRangeItem}>
<Text style={styles.weekRangeLabel}></Text>
<TextInput
value={startWeekInput}
onChangeText={setStartWeekInput}
placeholder={String(currentWeek)}
placeholderTextColor={colors.text.hint}
style={styles.weekRangeInput}
keyboardType="number-pad"
maxLength={2}
/>
</View>
<View style={styles.weekRangeItem}>
<Text style={styles.weekRangeLabel}></Text>
<TextInput
value={endWeekInput}
onChangeText={setEndWeekInput}
placeholder={String(TOTAL_WEEKS)}
placeholderTextColor={colors.text.hint}
style={styles.weekRangeInput}
keyboardType="number-pad"
maxLength={2}
/>
</View>
</View>
) : null}
<View style={styles.repeatSection}>
<Text style={styles.repeatLabel}></Text>
<View style={styles.repeatOptions}>
<TouchableOpacity
style={[styles.repeatChip, repeatMode === 'single' && styles.repeatChipActive]}
onPress={() => setRepeatMode('single')}
activeOpacity={0.8}
>
<Text style={[styles.repeatChipText, repeatMode === 'single' && styles.repeatChipTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.repeatChip, repeatMode === 'weekly' && styles.repeatChipActive]}
onPress={() => setRepeatMode('weekly')}
activeOpacity={0.8}
>
<Text style={[styles.repeatChipText, repeatMode === 'weekly' && styles.repeatChipTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.repeatChip, repeatMode === 'odd' && styles.repeatChipActive]}
onPress={() => setRepeatMode('odd')}
activeOpacity={0.8}
>
<Text style={[styles.repeatChipText, repeatMode === 'odd' && styles.repeatChipTextActive]}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.repeatChip, repeatMode === 'even' && styles.repeatChipActive]}
onPress={() => setRepeatMode('even')}
activeOpacity={0.8}
>
<Text style={[styles.repeatChipText, repeatMode === 'even' && styles.repeatChipTextActive]}>
</Text>
</TouchableOpacity>
</View>
{repeatMode !== 'single' ? (
<Text style={styles.repeatHint}>
{parseWeekInput(startWeekInput, currentWeek)}{parseWeekInput(endWeekInput, TOTAL_WEEKS)}
</Text>
) : null}
</View>
<View style={styles.addActions}>
<TouchableOpacity style={styles.addCancelBtn} onPress={closeAddCourseModal} activeOpacity={0.8}>
<Text style={styles.addCancelText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.addConfirmBtn, !newCourseName.trim() && styles.addConfirmBtnDisabled]}
onPress={confirmAddCourse}
activeOpacity={0.8}
disabled={!newCourseName.trim()}
>
<Text style={styles.addConfirmText}></Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
};
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,
2026-03-18 00:25:46 +08:00
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
timeHeader: {
2026-03-18 00:25:46 +08:00
borderRightWidth: 1,
borderRightColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
},
monthText: {
fontSize: fontSizes.xs,
fontWeight: '700',
color: colors.primary.main,
letterSpacing: 0.3,
},
dayHeader: {
2026-03-18 00:25:46 +08:00
flex: 1,
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,
},
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',
2026-03-18 00:25:46 +08:00
flex: 1,
backgroundColor: colors.background.default,
minWidth: TIME_COLUMN_WIDTH, // 确保最小宽度
},
// ── 单日列 ────────────────────────────────────────────
dayColumn: {
2026-03-18 00:25:46 +08:00
flex: 1,
backgroundColor: colors.background.default, // 使用主题默认背景色
borderRightWidth: 1,
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,
},
// ── 设置弹窗 ──────────────────────────────────────────
settingsModalCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.lg,
minWidth: 280,
},
settingsItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
marginTop: spacing.md,
},
settingsItemText: {
flex: 1,
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.md,
},
settingsCloseBtn: {
marginTop: spacing.lg,
height: 46,
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
alignItems: 'center',
justifyContent: 'center',
},
settingsCloseText: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.secondary,
},
// ── 同步状态 ──────────────────────────────────────────
syncingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
gap: spacing.sm,
},
syncingText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
fontWeight: '500',
},
});
export default ScheduleScreen;