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

1382 lines
44 KiB
TypeScript
Raw Normal View History

/**
*
* APP的设计风格
*/
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Dimensions,
StatusBar,
Modal,
TextInput,
Alert,
ActivityIndicator,
} from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { colors, fontSizes, spacing, borderRadius, shadows } from '../../theme';
import {
Course,
COURSE_COLORS,
TimeSlot,
getCourseColor,
hasCourseInWeek,
} from '../../types/schedule';
import type { ScheduleStackParamList } from '../../navigation/types';
import { scheduleService } from '../../services/scheduleService';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 时间段列宽度(按需求缩短)
const TIME_COLUMN_WIDTH = 38;
const VISIBLE_DAY_VALUES = [0, 1, 2, 3, 4, 5, 6]; // 周一到周日0=周一)
const VISIBLE_WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 星期列宽度(根据屏幕宽度自动计算)
const getDayColumnWidth = () => Math.max(46, (SCREEN_WIDTH - TIME_COLUMN_WIDTH - 2) / VISIBLE_DAY_VALUES.length);
// 每个大节(两小节)高度
const SECTION_HEIGHT = 105;
// 顶部周选择器高度
const WEEK_SELECTOR_HEIGHT = 44;
// 星期标题行高度
const HEADER_HEIGHT = 40;
// 底部Tab栏高度用于避让
const TAB_BAR_HEIGHT = 80;
// 大节时间配置(左侧显示 1-6
const GROUPED_TIME_SLOTS: TimeSlot[] = [
{ section: 1, startTime: '8:00', endTime: '9:45' },
{ section: 2, startTime: '10:05', endTime: '11:50' },
{ section: 3, startTime: '14:00', endTime: '15:45' },
{ section: 4, startTime: '16:05', endTime: '17:50' },
{ section: 5, startTime: '18:40', endTime: '20:25' },
{ section: 6, startTime: '20:45', endTime: '22:30' },
];
const getMergedSectionIndex = (section: number) => Math.ceil(section / 2);
const MAX_COURSE_NAME_CHARS = 11;
const formatCourseName = (name: string) =>
name.length > MAX_COURSE_NAME_CHARS ? `${name.slice(0, MAX_COURSE_NAME_CHARS)}` : name;
const MERGED_SECTION_COUNT = GROUPED_TIME_SLOTS.length;
const getMergedSectionStart = (mergedSection: number) => (mergedSection - 1) * 2 + 1;
const getMergedSectionEnd = (mergedSection: number) => mergedSection * 2;
const SWIPE_THRESHOLD = 18;
type RepeatMode = 'single' | 'weekly' | 'odd' | 'even';
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日
// 获取指定周的起始日期、每天的日期和月份
// weekIndex: 0表示第1周1表示第2周以此类推
const getWeekInfo = (weekIndex: number = 0) => {
// 计算该周的周一日期
const monday = new Date(FIRST_WEEK_START);
monday.setDate(monday.getDate() + weekIndex * 7);
const dates = [];
for (let i = 0; i < 7; i++) {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
dates.push(date.getDate());
}
// 获取该周周一所在的月份
const month = monday.getMonth() + 1;
return { dates, month };
};
// 为了向后兼容,保留 getWeekDates 函数
const getWeekDates = (weekOffset: number = 0) => {
return getWeekInfo(weekOffset).dates;
};
export const ScheduleScreen: React.FC = () => {
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
const [currentWeek, setCurrentWeek] = useState(1);
const [courses, setCourses] = useState<Course[]>([]);
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);
// 设置弹窗状态
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
// 同步教务系统弹窗状态
const [isSyncModalVisible, setIsSyncModalVisible] = useState(false);
const [syncUsername, setSyncUsername] = useState('');
const [syncPassword, setSyncPassword] = useState('');
const [isSyncing, setIsSyncing] = useState(false);
const todayColumnIndex = getTodayColumnIndex();
// currentWeek === 1 对应今天所在的真实周offset 0其他周不高亮今日列
const isViewingCurrentWeek = currentWeek === 1;
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
const dayColumnWidth = getDayColumnWidth();
const usedCourseColors = useMemo(
() => new Set(courses.map(item => normalizeHexColor(item.color || '')).filter(Boolean)),
[courses]
);
const suggestColor = useCallback(() => {
const firstAvailable = COURSE_COLORS.find(color => !usedCourseColors.has(normalizeHexColor(color)));
if (firstAvailable) return normalizeHexColor(firstAvailable);
return '';
}, [usedCourseColors]);
const loadCourses = useCallback(async () => {
try {
const remoteCourses = await scheduleService.getCourses();
setCourses(remoteCourses);
} catch (error) {
console.error('加载课表失败:', error);
Alert.alert('加载失败', '获取课表数据失败,请稍后重试');
}
}, []);
// 生成周数列表
const weeks = useMemo(() => {
return Array.from({ length: TOTAL_WEEKS }, (_, i) => i + 1);
}, []);
useEffect(() => {
const WEEK_ITEM_ESTIMATED_WIDTH = 92;
const centerOffset = SCREEN_WIDTH / 2 - WEEK_ITEM_ESTIMATED_WIDTH / 2 - 40;
const targetX = Math.max(0, (currentWeek - 1) * WEEK_ITEM_ESTIMATED_WIDTH - centerOffset);
weekScrollRef.current?.scrollTo({ x: targetX, animated: true });
}, [currentWeek]);
useFocusEffect(
useCallback(() => {
loadCourses();
}, [loadCourses])
);
// 获取当前周的课程
const getCurrentWeekCourses = useCallback(() => {
return courses.filter(course => hasCourseInWeek(course, currentWeek));
}, [courses, currentWeek]);
const getRelatedCourses = useCallback(
(targetCourse: Course) => {
return courses
.filter(course => course.name === targetCourse.name)
.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startSection - b.startSection;
});
},
[courses]
);
const handleWeekSwipe = useCallback(
({ nativeEvent }: PanGestureHandlerStateChangeEvent) => {
if (nativeEvent.oldState !== State.ACTIVE && nativeEvent.oldState !== State.BEGAN) return;
const { translationX, translationY } = nativeEvent;
if (Math.abs(translationX) < SWIPE_THRESHOLD) return;
if (Math.abs(translationX) <= Math.abs(translationY) * 0.8) return;
if (translationX < 0) {
setCurrentWeek(prev => Math.min(prev + 1, weeks.length));
} else {
setCurrentWeek(prev => Math.max(prev - 1, 1));
}
},
[weeks.length]
);
const isSlotOccupied = useCallback(
(dayOfWeek: number, mergedSection: number, weekCourses: Course[]) => {
return weekCourses.some(course => {
if (course.dayOfWeek !== dayOfWeek) return false;
const start = getMergedSectionIndex(course.startSection);
const end = getMergedSectionIndex(course.endSection);
return mergedSection >= start && mergedSection <= end;
});
},
[]
);
const openAddCourseModal = useCallback((dayOfWeek: number, mergedSection: number) => {
setPendingDay(dayOfWeek);
setPendingMergedSection(mergedSection);
setNewCourseName('');
setNewCourseTeacher('');
setNewCourseLocation('');
setNewCourseColor(suggestColor());
setRepeatMode('single');
setStartWeekInput(String(currentWeek));
setEndWeekInput(String(TOTAL_WEEKS));
setIsAddModalVisible(true);
}, [currentWeek, suggestColor]);
const closeAddCourseModal = useCallback(() => {
setIsAddModalVisible(false);
}, []);
const confirmAddCourse = useCallback(async () => {
const name = newCourseName.trim();
if (!name) return;
const startSection = getMergedSectionStart(pendingMergedSection);
const endSection = getMergedSectionEnd(pendingMergedSection);
const startWeek = parseWeekInput(startWeekInput, currentWeek);
const endWeek = parseWeekInput(endWeekInput, TOTAL_WEEKS);
const normalizedStartWeek = Math.min(startWeek, endWeek);
const normalizedEndWeek = Math.max(startWeek, endWeek);
const repeatWeeks = weeks.filter(week => {
if (week < normalizedStartWeek || week > normalizedEndWeek) return false;
if (repeatMode === 'single') return week === currentWeek;
if (repeatMode === 'weekly') return true;
if (repeatMode === 'odd') return week % 2 === 1;
return week % 2 === 0;
});
const normalizedColor = normalizeHexColor(newCourseColor);
if (!normalizedColor) {
Alert.alert('颜色无效', '请输入有效的颜色值,例如 #FF6B6B');
return;
}
if (usedCourseColors.has(normalizedColor)) {
Alert.alert('颜色重复', '该颜色已被其他课程使用,请更换颜色');
return;
}
try {
const created = await scheduleService.createCourse({
name,
teacher: newCourseTeacher.trim() || undefined,
location: newCourseLocation.trim() || undefined,
day_of_week: pendingDay,
start_section: startSection,
end_section: endSection,
weeks: repeatWeeks,
color: normalizedColor,
});
setCourses(prev => [...prev, created]);
closeAddCourseModal();
} catch (error) {
console.error('新增课程失败:', error);
Alert.alert('保存失败', '课程保存失败,请稍后重试');
}
}, [
closeAddCourseModal,
currentWeek,
newCourseLocation,
newCourseName,
newCourseTeacher,
newCourseColor,
pendingDay,
pendingMergedSection,
repeatMode,
startWeekInput,
usedCourseColors,
endWeekInput,
weeks,
]);
// 渲染周选择器
const renderWeekSelector = () => (
<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,
{
width: dayColumnWidth,
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: SECTION_HEIGHT },
]}
>
<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 {
// 删除所有相关课程
for (const relatedCourse of relatedCourses) {
await scheduleService.deleteCourse(relatedCourse.id);
}
// 从本地状态中移除
const relatedIds = new Set(relatedCourses.map(c => c.id));
setCourses(prev => prev.filter(c => !relatedIds.has(c.id)));
Alert.alert('成功', `已删除 ${relatedCourses.length} 节课程`);
} 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) * SECTION_HEIGHT;
const height = duration * SECTION_HEIGHT - 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={() =>
navigation.navigate('CourseDetail', {
course,
relatedCourses: getRelatedCourses(course),
})
}
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,
{
width: dayColumnWidth,
borderRightWidth: isLastColumn ? 0 : 1,
backgroundColor: columnIndex === activeTodayColumn ? `${colors.primary.main}08` : 'transparent',
},
]}
>
{/* 课程网格线 */}
{GROUPED_TIME_SLOTS.map(slot => {
const occupied = isSlotOccupied(dayOfWeek, slot.section, currentWeekCourses);
return (
<TouchableOpacity
key={slot.section}
style={[
styles.gridCell,
{ height: SECTION_HEIGHT },
]}
activeOpacity={1}
delayLongPress={280}
onLongPress={() => {
if (!occupied) {
openAddCourseModal(dayOfWeek, slot.section);
}
}}
/>
);
})}
{/* 课程卡片 */}
{displayCourses.map(renderCourseCard)}
</View>
);
};
// 渲染课程表主体
const renderScheduleBody = () => (
<PanGestureHandler
activeOffsetX={[-8, 8]}
failOffsetY={[-80, 80]}
onHandlerStateChange={handleWeekSwipe}
>
<View style={styles.scheduleBodyGestureLayer}>
<ScrollView
style={styles.scheduleBody}
showsVerticalScrollIndicator={true}
contentContainerStyle={styles.scheduleBodyContent}
>
{renderTimeColumn()}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
>
<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 (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);
Alert.alert('同步失败', '网络错误,请稍后重试');
} finally {
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: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
timeHeader: {
borderRightWidth: StyleSheet.hairlineWidth,
borderRightColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
},
monthText: {
fontSize: fontSizes.xs,
fontWeight: '700',
color: colors.primary.main,
letterSpacing: 0.3,
},
dayHeader: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 3,
},
dayHeaderText: {
fontSize: fontSizes.xs,
fontWeight: '500',
color: colors.text.secondary,
letterSpacing: 0.3,
},
dayHeaderTextActive: {
color: colors.primary.main,
fontWeight: '700',
},
todayDateBadge: {
width: 22,
height: 22,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
dateText: {
fontSize: fontSizes.xs,
color: colors.text.secondary,
marginTop: 2,
fontWeight: '400',
},
dateTextActive: {
color: colors.text.inverse,
fontWeight: '700',
marginTop: 0,
},
// ── 课程表主体 ─────────────────────────────────────────
scheduleBody: {
flex: 1,
backgroundColor: colors.background.default,
marginBottom: TAB_BAR_HEIGHT,
},
scheduleBodyGestureLayer: {
flex: 1,
},
scheduleBodyContent: {
flexDirection: 'row',
},
// ── 时间列 ────────────────────────────────────────────
timeColumn: {
backgroundColor: colors.background.paper,
borderRightWidth: StyleSheet.hairlineWidth,
borderRightColor: colors.divider,
},
timeCell: {
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#EBEBEB',
gap: 4,
},
timeSectionBadge: {
width: 22,
height: 22,
borderRadius: borderRadius.full,
backgroundColor: `${colors.primary.main}12`,
alignItems: 'center',
justifyContent: 'center',
},
timeSection: {
fontSize: fontSizes.xs,
fontWeight: '700',
color: colors.primary.dark,
},
timeText: {
fontSize: 9,
color: colors.text.hint,
fontWeight: '400',
textAlign: 'center',
lineHeight: 12,
},
// ── 日期列容器 ────────────────────────────────────────
daysContainer: {
flexDirection: 'row',
},
// ── 单日列 ────────────────────────────────────────────
dayColumn: {
borderRightWidth: StyleSheet.hairlineWidth,
borderRightColor: '#EBEBEB',
},
gridCell: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#EBEBEB',
},
// ── 课程卡片 ──────────────────────────────────────────
courseCard: {
position: 'absolute',
borderRadius: borderRadius.xl,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.xs + 1,
...shadows.md,
},
courseName: {
fontSize: 10,
fontWeight: '700',
color: colors.text.inverse,
lineHeight: 13,
letterSpacing: 0.1,
},
courseLocation: {
fontSize: 9,
color: 'rgba(255,255,255,0.80)',
marginTop: 3,
fontWeight: '500',
letterSpacing: 0.1,
},
courseTeacher: {
fontSize: 9,
color: 'rgba(255,255,255,0.75)',
marginTop: 2,
fontWeight: '400',
},
// ── 添加课程弹窗 ──────────────────────────────────────
addModalMask: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.40)',
justifyContent: 'center',
paddingHorizontal: spacing.lg,
},
addModalCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.lg,
},
addModalTitle: {
fontSize: fontSizes.xl,
fontWeight: '700',
color: colors.text.primary,
letterSpacing: 0.3,
},
addModalDesc: {
marginTop: spacing.xs,
marginBottom: spacing.lg,
fontSize: fontSizes.sm,
color: colors.text.secondary,
fontWeight: '500',
},
addInput: {
height: 46,
borderWidth: 0,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.md,
marginBottom: spacing.sm,
fontSize: fontSizes.md,
color: colors.text.primary,
backgroundColor: colors.background.default,
},
colorSection: {
marginBottom: spacing.sm,
},
colorPresetRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginBottom: spacing.sm,
},
colorPresetItem: {
width: 24,
height: 24,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
colorPresetItemActive: {
borderWidth: 2,
borderColor: colors.text.inverse,
},
addActions: {
marginTop: spacing.md,
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
},
addCancelBtn: {
height: 42,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
alignItems: 'center',
justifyContent: 'center',
},
addCancelText: {
color: colors.text.secondary,
fontSize: fontSizes.md,
fontWeight: '600',
},
addConfirmBtn: {
height: 42,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
addConfirmBtnDisabled: {
backgroundColor: colors.primary.light,
opacity: 0.7,
},
addConfirmText: {
color: colors.text.inverse,
fontSize: fontSizes.md,
fontWeight: '700',
letterSpacing: 0.3,
},
// ── 重复选项 ──────────────────────────────────────────
repeatSection: {
marginBottom: spacing.sm,
},
repeatLabel: {
fontSize: fontSizes.sm,
fontWeight: '600',
color: colors.text.secondary,
marginBottom: spacing.xs,
letterSpacing: 0.2,
},
repeatOptions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
},
repeatChip: {
paddingHorizontal: spacing.md,
height: 32,
borderRadius: borderRadius.full,
borderWidth: 1.5,
borderColor: '#E8E8E8',
backgroundColor: colors.background.paper,
justifyContent: 'center',
alignItems: 'center',
},
repeatChipActive: {
borderColor: colors.primary.main,
backgroundColor: `${colors.primary.main}15`,
},
repeatChipText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
fontWeight: '600',
},
repeatChipTextActive: {
color: colors.primary.main,
fontWeight: '700',
},
repeatHint: {
marginTop: spacing.xs,
fontSize: fontSizes.xs,
color: colors.text.hint,
fontWeight: '400',
},
// ── 周范围输入 ────────────────────────────────────────
weekRangeRow: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.sm,
},
weekRangeItem: {
flex: 1,
},
weekRangeLabel: {
fontSize: fontSizes.sm,
fontWeight: '600',
color: colors.text.secondary,
marginBottom: spacing.xs,
letterSpacing: 0.2,
},
weekRangeInput: {
height: 42,
borderWidth: 0,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.md,
fontSize: fontSizes.md,
color: colors.text.primary,
backgroundColor: colors.background.default,
},
// ── 设置弹窗 ──────────────────────────────────────────
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;