feat(schedule): add academic system sync and course management improvements

- add educational system synchronization with username/password authentication
- implement long-press course deletion with single/all options
- add multi-lane course display for overlapping same-name courses
- refactor course detail screen with ScrollView and improved layout
- fix teacher display to show per-time-slot instructors instead of single field
- add settings modal with sync option and loading states
This commit is contained in:
2026-03-13 20:40:32 +08:00
parent 798dd7c9a0
commit e9d42fdb01
3 changed files with 391 additions and 44 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -64,8 +64,9 @@ const CourseDetailScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => navigation.goBack()}>
<TouchableOpacity activeOpacity={1} style={styles.card} onPress={e => e.stopPropagation()}>
<View style={styles.mask}>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
<View style={styles.card}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.iconBadge}>
@@ -78,39 +79,44 @@ const CourseDetailScreen: React.FC = () => {
</TouchableOpacity>
</View>
<View style={styles.row}>
<Text style={styles.label}></Text>
<Text style={styles.value} numberOfLines={2}>{course.name}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}></Text>
<Text style={styles.value}>{course.teacher || '未填写'}</Text>
</View>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.label}></Text>
<View style={styles.timeList}>
{timeLines.map((item, index) => (
<View key={`${item.id}-${index}`} style={styles.timeItemCard}>
<Text style={styles.timeItemMain}>
{formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]}
{getMergedSectionIndex(item.startSection)}
</Text>
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
</View>
))}
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentScrollContainer}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
>
<View style={styles.row}>
<Text style={styles.label}></Text>
<Text style={styles.value} numberOfLines={2}>{course.name}</Text>
</View>
</View>
<View style={styles.actionRow}>
<TouchableOpacity style={styles.deleteButton} onPress={handleDeleteCourse} activeOpacity={0.85}>
<MaterialCommunityIcons name="delete-outline" size={16} color={colors.error.main} />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
<View style={[styles.row, styles.rowLast]}>
<Text style={styles.label}></Text>
<View style={styles.timeList}>
{timeLines.map((item, index) => (
<View key={`${item.id}-${index}`} style={styles.timeItemCard}>
<Text style={styles.timeItemMain}>
{formatWeekRanges(item.weeks)} · {WEEKDAY_NAMES[item.dayOfWeek]}
{getMergedSectionIndex(item.startSection)}
</Text>
<Text style={styles.timeItemSub}>
{item.teacher || '未填写'}
</Text>
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
</View>
))}
</View>
</View>
<View style={styles.actionRow}>
<TouchableOpacity style={styles.deleteButton} onPress={handleDeleteCourse} activeOpacity={0.85}>
<MaterialCommunityIcons name="delete-outline" size={16} color={colors.error.main} />
<Text style={styles.deleteButtonText}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</SafeAreaView>
);
};
@@ -130,8 +136,15 @@ const styles = StyleSheet.create({
borderRadius: borderRadius.xl,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.lg,
maxHeight: '82%',
...shadows.lg,
},
contentScroll: {
maxHeight: '100%',
},
contentScrollContainer: {
paddingBottom: spacing.xs,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',

View File

@@ -15,6 +15,7 @@ import {
Modal,
TextInput,
Alert,
ActivityIndicator,
} from 'react-native';
import { PanGestureHandler, State } from 'react-native-gesture-handler';
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
@@ -71,6 +72,13 @@ 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) => {
@@ -137,6 +145,14 @@ export const ScheduleScreen: React.FC = () => {
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;
@@ -308,7 +324,10 @@ export const ScheduleScreen: React.FC = () => {
// 渲染周选择器
const renderWeekSelector = () => (
<View style={styles.weekSelector}>
<TouchableOpacity style={styles.settingsButton}>
<TouchableOpacity
style={styles.settingsButton}
onPress={() => setIsSettingsModalVisible(true)}
>
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" />
</TouchableOpacity>
<ScrollView
@@ -410,26 +429,120 @@ export const ScheduleScreen: React.FC = () => {
</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 = (course: Course) => {
const color = getCourseColor(course.id, course.color);
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={course.id}
key={key}
style={[
styles.courseCard,
{
top,
height,
backgroundColor: color,
left: 2,
right: 2,
left,
width: cardWidth,
},
]}
onPress={() =>
@@ -438,14 +551,16 @@ export const ScheduleScreen: React.FC = () => {
relatedCourses: getRelatedCourses(course),
})
}
onLongPress={() => handleCourseLongPress(course)}
delayLongPress={500}
activeOpacity={0.82}
>
<Text style={styles.courseName} numberOfLines={3}>
{formatCourseName(course.name)}
</Text>
{course.location ? (
{locationText ? (
<Text style={styles.courseLocation}>
@{course.location}
@{locationText}
</Text>
) : null}
</TouchableOpacity>
@@ -458,6 +573,7 @@ export const ScheduleScreen: React.FC = () => {
const dayCourses = currentWeekCourses.filter(
course => course.dayOfWeek === dayOfWeek
);
const displayCourses = buildDayDisplayCourses(dayCourses);
return (
<View
@@ -493,7 +609,7 @@ export const ScheduleScreen: React.FC = () => {
);
})}
{/* 课程卡片 */}
{dayCourses.map(renderCourseCard)}
{displayCourses.map(renderCourseCard)}
</View>
);
};
@@ -540,6 +656,154 @@ export const ScheduleScreen: React.FC = () => {
{/* 课程表主体 */}
{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
@@ -1060,6 +1324,58 @@ const styles = StyleSheet.create({
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;

View File

@@ -32,6 +32,19 @@ interface CreateScheduleCourseResponse {
course: ScheduleCourseDTO;
}
interface SyncScheduleRequest {
username: string;
password: string;
semester?: string;
}
interface SyncScheduleResponse {
success: boolean;
message: string;
synced_count: number;
error_message?: string;
}
const toCourse = (dto: ScheduleCourseDTO): Course => ({
id: dto.id,
name: dto.name,
@@ -64,7 +77,12 @@ class ScheduleService {
async deleteCourse(courseId: string): Promise<void> {
await api.delete(`/schedule/courses/${courseId}`);
}
async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> {
const response = await api.post<SyncScheduleResponse>('/schedule/sync', req);
return response.data;
}
}
export const scheduleService = new ScheduleService();
export type { CreateScheduleCourseRequest };
export type { CreateScheduleCourseRequest, SyncScheduleRequest };