2026-03-12 08:37:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 课程表主屏幕
|
|
|
|
|
|
* 参考哈课表APP的设计风格
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
ScrollView,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
Dimensions,
|
|
|
|
|
|
StatusBar,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
TextInput,
|
|
|
|
|
|
Alert,
|
2026-03-13 20:40:32 +08:00
|
|
|
|
ActivityIndicator,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
|
|
|
|
|
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
2026-03-21 03:22:28 +08:00
|
|
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
2026-03-12 08:37:08 +08:00
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { useFocusEffect } from '@react-navigation/native';
|
|
|
|
|
|
import { useRouter } from 'expo-router';
|
2026-03-12 08:37:08 +08:00
|
|
|
|
import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme';
|
2026-03-18 00:25:46 +08:00
|
|
|
|
import { useResponsive } from '../../hooks/useResponsive';
|
2026-03-12 08:37:08 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Course,
|
|
|
|
|
|
COURSE_COLORS,
|
|
|
|
|
|
TimeSlot,
|
|
|
|
|
|
getCourseColor,
|
|
|
|
|
|
hasCourseInWeek,
|
|
|
|
|
|
} from '../../types/schedule';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import * as hrefs from '../../navigation/hrefs';
|
|
|
|
|
|
import { routePayloadCache } from '../../stores/routePayloadCache';
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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;
|
2026-03-12 08:37:08 +08:00
|
|
|
|
// 顶部周选择器高度
|
|
|
|
|
|
const WEEK_SELECTOR_HEIGHT = 44;
|
|
|
|
|
|
// 星期标题行高度
|
|
|
|
|
|
const HEADER_HEIGHT = 40;
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 悬浮TabBar高度(TabBar高度64 + 浮动间距12*2 = 88)
|
|
|
|
|
|
const FLOATING_TAB_BAR_HEIGHT = 88;
|
2026-03-12 08:37:08 +08:00
|
|
|
|
|
|
|
|
|
|
// 大节时间配置(左侧显示 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';
|
2026-03-13 20:40:32 +08:00
|
|
|
|
type CourseDisplayItem = {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
course: Course;
|
|
|
|
|
|
locationText: string;
|
|
|
|
|
|
laneIndex: number;
|
|
|
|
|
|
laneCount: number;
|
|
|
|
|
|
};
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 08:37:08 +08:00
|
|
|
|
// 获取指定周的起始日期、每天的日期和月份
|
|
|
|
|
|
// 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
|
|
|
|
|
2026-03-12 08:37:08 +08:00
|
|
|
|
export const ScheduleScreen: React.FC = () => {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-18 00:25:46 +08:00
|
|
|
|
// 使用响应式 hook 检测屏幕尺寸
|
|
|
|
|
|
const { width: screenWidth, height: screenHeight, isMobile, platform } = useResponsive();
|
2026-03-21 03:22:28 +08:00
|
|
|
|
const insets = useSafeAreaInsets();
|
2026-03-18 00:25:46 +08:00
|
|
|
|
const isWeb = platform.isWeb;
|
|
|
|
|
|
const [currentWeek, setCurrentWeek] = useState(INITIAL_WEEK);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
|
|
// 动态计算每节课的高度
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 手机端:第六节课底部刚好在悬浮TabBar上方(可用高度 = 屏幕高度 - 周选择器 - 星期标题 - 悬浮TabBar - 安全区域)
|
2026-03-18 00:25:46 +08:00
|
|
|
|
// 电脑端:第六节课刚好填到底部(可用高度 = 屏幕高度 - 周选择器 - 星期标题)
|
|
|
|
|
|
const sectionHeight = useMemo((): number => {
|
|
|
|
|
|
const totalHeaderHeight = WEEK_SELECTOR_HEIGHT + HEADER_HEIGHT;
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// 悬浮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);
|
2026-03-21 03:22:28 +08:00
|
|
|
|
}, [screenHeight, isMobile, insets.bottom]);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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);
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 组件卸载状态标记,用于防止组件卸载后仍调用 setState
|
|
|
|
|
|
const isMountedRef = useRef(true);
|
2026-03-13 20:40:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 设置弹窗状态
|
|
|
|
|
|
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
|
|
|
|
|
|
// 同步教务系统弹窗状态
|
|
|
|
|
|
const [isSyncModalVisible, setIsSyncModalVisible] = useState(false);
|
|
|
|
|
|
const [syncUsername, setSyncUsername] = useState('');
|
|
|
|
|
|
const [syncPassword, setSyncPassword] = useState('');
|
|
|
|
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
2026-03-15 02:25:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 组件卸载时设置标记
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
isMountedRef.current = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
const todayColumnIndex = getTodayColumnIndex();
|
2026-03-18 00:25:46 +08:00
|
|
|
|
// 当前正在查看的周是否为真实的当前周
|
|
|
|
|
|
const isViewingCurrentWeek = currentWeek === INITIAL_WEEK;
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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}>
|
2026-03-13 20:40:32 +08:00
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.settingsButton}
|
|
|
|
|
|
onPress={() => setIsSettingsModalVisible(true)}
|
|
|
|
|
|
>
|
2026-03-12 08:37:08 +08:00
|
|
|
|
<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,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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 },
|
2026-03-12 08:37:08 +08:00
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-13 20:40:32 +08:00
|
|
|
|
// 处理长按课程删除
|
|
|
|
|
|
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 {
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 使用 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} 节课程`);
|
2026-03-13 20:40:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
} 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;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-12 08:37:08 +08:00
|
|
|
|
// 渲染课程卡片
|
2026-03-13 20:40:32 +08:00
|
|
|
|
const renderCourseCard = ({ key, course, locationText, laneIndex, laneCount }: CourseDisplayItem) => {
|
|
|
|
|
|
// 颜色仅按课程名计算,确保同名课在 UI 上颜色一致
|
|
|
|
|
|
const color = getCourseColor(course.name);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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;
|
2026-03-13 20:40:32 +08:00
|
|
|
|
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);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
2026-03-13 20:40:32 +08:00
|
|
|
|
key={key}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
style={[
|
|
|
|
|
|
styles.courseCard,
|
|
|
|
|
|
{
|
|
|
|
|
|
top,
|
|
|
|
|
|
height,
|
|
|
|
|
|
backgroundColor: color,
|
2026-03-13 20:40:32 +08:00
|
|
|
|
left,
|
|
|
|
|
|
width: cardWidth,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
},
|
|
|
|
|
|
]}
|
2026-03-24 14:21:31 +08:00
|
|
|
|
onPress={() => {
|
|
|
|
|
|
const related = getRelatedCourses(course);
|
|
|
|
|
|
routePayloadCache.stashCourseDetail(course, related);
|
|
|
|
|
|
router.push(hrefs.hrefScheduleCourse(String(course.id)));
|
|
|
|
|
|
}}
|
2026-03-13 20:40:32 +08:00
|
|
|
|
onLongPress={() => handleCourseLongPress(course)}
|
|
|
|
|
|
delayLongPress={500}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
activeOpacity={0.82}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text style={styles.courseName} numberOfLines={3}>
|
|
|
|
|
|
{formatCourseName(course.name)}
|
|
|
|
|
|
</Text>
|
2026-03-13 20:40:32 +08:00
|
|
|
|
{locationText ? (
|
2026-03-12 08:37:08 +08:00
|
|
|
|
<Text style={styles.courseLocation}>
|
2026-03-13 20:40:32 +08:00
|
|
|
|
@{locationText}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
</Text>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染单日课程列
|
|
|
|
|
|
const renderDayColumn = (dayOfWeek: number, columnIndex: number, isLastColumn: boolean) => {
|
|
|
|
|
|
const currentWeekCourses = getCurrentWeekCourses();
|
|
|
|
|
|
const dayCourses = currentWeekCourses.filter(
|
|
|
|
|
|
course => course.dayOfWeek === dayOfWeek
|
|
|
|
|
|
);
|
2026-03-13 20:40:32 +08:00
|
|
|
|
const displayCourses = buildDayDisplayCourses(dayCourses);
|
2026-03-12 08:37:08 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 课程网格线 */}
|
|
|
|
|
|
{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 },
|
2026-03-12 08:37:08 +08:00
|
|
|
|
]}
|
|
|
|
|
|
activeOpacity={1}
|
|
|
|
|
|
delayLongPress={280}
|
|
|
|
|
|
onLongPress={() => {
|
|
|
|
|
|
if (!occupied) {
|
|
|
|
|
|
openAddCourseModal(dayOfWeek, slot.section);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{/* 课程卡片 */}
|
2026-03-13 20:40:32 +08:00
|
|
|
|
{displayCourses.map(renderCourseCard)}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染课程表主体
|
2026-03-18 00:25:46 +08:00
|
|
|
|
const renderScheduleBody = () => {
|
|
|
|
|
|
// 移动端和电脑端都不添加额外底部间距,让内容填满整个区域
|
|
|
|
|
|
// 动态计算的高度已经确保第六节课显示在正确位置
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-12 08:37:08 +08:00
|
|
|
|
<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}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
contentContainerStyle={styles.scheduleBodyContent}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderTimeColumn()}
|
|
|
|
|
|
<ScrollView
|
|
|
|
|
|
horizontal
|
|
|
|
|
|
showsHorizontalScrollIndicator={false}
|
2026-03-18 00:25:46 +08:00
|
|
|
|
contentContainerStyle={{ flexGrow: 1 }}
|
2026-03-12 08:37:08 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-03-12 08:37:08 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-21 03:22:28 +08:00
|
|
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
2026-03-12 08:37:08 +08:00
|
|
|
|
<StatusBar barStyle="light-content" backgroundColor={colors.primary.main} />
|
|
|
|
|
|
|
|
|
|
|
|
{/* 周选择器 */}
|
|
|
|
|
|
{renderWeekSelector()}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 星期标题 */}
|
|
|
|
|
|
{renderHeader()}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 课程表主体 */}
|
|
|
|
|
|
{renderScheduleBody()}
|
|
|
|
|
|
|
2026-03-13 20:40:32 +08:00
|
|
|
|
{/* 设置弹窗 */}
|
|
|
|
|
|
<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;
|
2026-03-15 02:25:55 +08:00
|
|
|
|
|
2026-03-13 20:40:32 +08:00
|
|
|
|
setIsSyncing(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await scheduleService.syncSchedule({
|
|
|
|
|
|
username: syncUsername.trim(),
|
|
|
|
|
|
password: syncPassword.trim(),
|
|
|
|
|
|
});
|
2026-03-15 02:25:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查组件是否已卸载
|
|
|
|
|
|
if (!isMountedRef.current) return;
|
|
|
|
|
|
|
2026-03-13 20:40:32 +08:00
|
|
|
|
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);
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 检查组件是否已卸载
|
|
|
|
|
|
if (isMountedRef.current) {
|
|
|
|
|
|
Alert.alert('同步失败', '网络错误,请稍后重试');
|
|
|
|
|
|
}
|
2026-03-13 20:40:32 +08:00
|
|
|
|
} finally {
|
2026-03-15 02:25:55 +08:00
|
|
|
|
// 检查组件是否已卸载
|
|
|
|
|
|
if (isMountedRef.current) {
|
|
|
|
|
|
setIsSyncing(false);
|
|
|
|
|
|
}
|
2026-03-13 20:40:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
|
disabled={!syncUsername.trim() || !syncPassword.trim() || isSyncing}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text style={styles.addConfirmText}>
|
|
|
|
|
|
{isSyncing ? '同步中...' : '开始同步'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
|
2026-03-12 08:37:08 +08:00
|
|
|
|
<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,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
borderBottomColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
timeHeader: {
|
2026-03-18 00:25:46 +08:00
|
|
|
|
borderRightWidth: 1,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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, // 确保最小宽度
|
2026-03-12 08:37:08 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── 单日列 ────────────────────────────────────────────
|
|
|
|
|
|
dayColumn: {
|
2026-03-18 00:25:46 +08:00
|
|
|
|
flex: 1,
|
|
|
|
|
|
backgroundColor: colors.background.default, // 使用主题默认背景色
|
|
|
|
|
|
borderRightWidth: 1,
|
2026-03-12 08:37:08 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-03-13 20:40:32 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 设置弹窗 ──────────────────────────────────────────
|
|
|
|
|
|
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',
|
|
|
|
|
|
},
|
2026-03-12 08:37:08 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
export default ScheduleScreen;
|