Files
frontend/src/screens/schedule/ScheduleScreen.tsx
lafay 2ddb9cadd8
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m27s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h16m45s
refactor(App, navigation): migrate to Expo Router and clean up navigation structure
- Updated App entry point to utilize Expo Router, simplifying the navigation setup.
- Removed legacy navigation components and services to streamline the codebase.
- Adjusted package.json to reflect the new entry point and updated dependencies for compatibility.
- Enhanced overall application structure by consolidating navigation logic and improving maintainability.
2026-03-24 14:21:31 +08:00

1472 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 课程表主屏幕
* 参考哈课表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';
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);
// 每个大节(两小节)高度 - 默认值,会在组件中动态调整
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日
// 计算当前日期对应的周数返回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;
};
// 获取初始周数(仅在组件初始化时计算一次)
const INITIAL_WEEK = getCurrentWeekNumber();
export const ScheduleScreen: React.FC = () => {
const router = useRouter();
// 使用响应式 hook 检测屏幕尺寸
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
const insets = useSafeAreaInsets();
const isWeb = platform.isWeb;
const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK);
const [courses, setCourses] = useState<Course[]>([]);
// 动态计算日列宽度:移动端填满屏幕宽度,电脑端也填满
// 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 - 安全区域)
// 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题)
const sectionHeight = useMemo((): number => {
const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT;
// 悬浮TabBar高度 + 安全区域底部
const bottomOffset = isMobile ? FLOATING_TAB_BAR_HEIGHT + insets.bottom : 0;
// 可用高度 = 屏幕高度 - 顶部区域 - 底部偏移
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();
// 当前正在查看的周是否为真实的当前周
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,
{
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,
{ 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;
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,
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,
{ height: sectionHeight },
]}
activeOpacity={1}
delayLongPress={280}
onLongPress={() => {
if (!occupied) {
openAddCourseModal(dayOfWeek, slot.section);
}
}}
/>
);
})}
{/* 课程卡片 */}
{displayCourses.map(renderCourseCard)}
</View>
);
};
// 渲染课程表主体
const renderScheduleBody = () => {
// 移动端和电脑端都不添加额外底部间距,让内容填满整个区域
// 动态计算的高度已经确保第六节课显示在正确位置
return (
<PanGestureHandler
activeOffsetX={[-8, 8]}
failOffsetY={[-80, 80]}
onHandlerStateChange={handleWeekSwipe}
>
<View style={styles.scheduleBodyGestureLayer}>
<ScrollView
style={styles.scheduleBody}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scheduleBodyContent}
>
{renderTimeColumn()}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
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>
);
};
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,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
timeHeader: {
borderRightWidth: 1,
borderRightColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
},
monthText: {
fontSize: fontSizes.xs,
fontWeight: '700',
color: colors.primary.main,
letterSpacing: 0.3,
},
dayHeader: {
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',
flex: 1,
backgroundColor: colors.background.default,
minWidth: TIME_COLUMN_WIDTH, // 确保最小宽度
},
// ── 单日列 ────────────────────────────────────────────
dayColumn: {
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;