- 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.
1472 lines
48 KiB
TypeScript
1472 lines
48 KiB
TypeScript
/**
|
||
* 课程表主屏幕
|
||
* 参考哈课表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;
|