diff --git a/.history/front/src/pages/School_20260314124227.tsx b/.history/front/src/pages/School_20260314124227.tsx new file mode 100644 index 0000000..4b99a94 --- /dev/null +++ b/.history/front/src/pages/School_20260314124227.tsx @@ -0,0 +1,430 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { Award, BookOpen, Calendar, ChevronRight, GraduationCap, Lock, Search, User } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import { buildScheduleTable } from '@/src/lib/schedule-table'; +import type { CourseResponse, GradeResponse } from '@/src/lib/types'; +import { cn } from '@/src/lib/utils'; + +function formatSections(startTime?: number | null, endTime?: number | null) { + if (!startTime || !endTime) { + return '节次待定'; + } + + return `第 ${startTime}-${endTime} 节`; +} + +export default function School() { + const storedQuery = readStoredSchoolQuery(); + const initialStudentId = storedQuery?.studentId ?? '2023123456'; + const initialSemester = storedQuery?.semester ?? '2025-spring'; + const initialCachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); + const [studentId, setStudentId] = useState(initialStudentId); + const [password, setPassword] = useState('password123'); + const [semester, setSemester] = useState(initialSemester); + const [loading, setLoading] = useState(false); + const [queried, setQueried] = useState(initialCachedResults?.queried ?? false); + const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); + const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + + const averageGrade = useMemo(() => { + if (grades.length === 0) { + return '0.0'; + } + + const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0); + return (sum / grades.length).toFixed(1); + }, [grades]); + + const loadSchoolData = async ( + nextStudentId: string, + nextSemester: string, + options: { background?: boolean } = {}, + ) => { + const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); + const cachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(cacheKey); + + if (!options.background) { + setLoading(true); + } + + writeStoredSchoolQuery({ + studentId: nextStudentId, + semester: nextSemester, + }); + + try { + const queryString = new URLSearchParams({ + studentId: nextStudentId, + semester: nextSemester, + }).toString(); + + const [scheduleData, gradeData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + + setQueried(true); + setSchedule(scheduleData); + setGrades(gradeData); + writeCachedValue(cacheKey, { + queried: true, + studentId: nextStudentId, + semester: nextSemester, + schedule: scheduleData, + grades: gradeData, + }); + } catch { + if (!cachedResults) { + setQueried(false); + setSchedule([]); + setGrades([]); + } + } finally { + if (!options.background) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (!storedQuery) { + return; + } + + loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }).catch(() => undefined); + }, []); + + const handleQuery = async (event: React.FormEvent) => { + event.preventDefault(); + await loadSchoolData(studentId, semester); + }; + + return ( +
+
+ + + + + 教务查询 + + 输入学号、密码和学期后同步课表与成绩。 + + +
+
+ +
+ + setStudentId(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ +
+ + setPassword(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + 数据摘要 + + 展示当前缓存或最近一次查询结果。 + + + {queried ? ( +
+ + + +
+ ) : ( +
+ +

暂无缓存数据,请先执行查询。

+
+ )} +
+
+
+ +
+ + +
+ + + {activeTab === 'schedule' ? : } + +
+ ); +} + +function DatabaseIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + +function SummaryItem({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} + +function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { + if (!queried) { + return ( + + + +

请先查询课表

+
+
+ ); + } + + const days = ['周一', '周二', '周三', '周四', '周五']; + const periodLabels: Record<'morning' | 'afternoon' | 'evening', string> = { + morning: '上午', + afternoon: '下午', + evening: '晚上', + }; + const rows = buildScheduleTable(schedule); + + return ( + + + 本周课表 + 上午 4 节、下午 4 节、晚上 4 节。没有课的格子保持为空。 + + +
+ + + + + {days.map((day) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.slots.map((slot, index) => { + if (slot.type === 'covered') { + return null; + } + + if (slot.type === 'empty') { + return ( + + ); + })} + + ))} + +
+ 节次 + + {day} +
+
+

{periodLabels[row.period]}

+

第 {row.section} 节

+
+
+ ); + } + + return ( + +
+

+ {formatSections(slot.course?.startTime, slot.course?.endTime)} +

+

+ {slot.course?.courseName} +

+

+ {slot.course?.classroom ?? '教室待定'} +

+

+ {slot.course?.teacher ?? '任课教师待定'} +

+
+
+
+
+
+ ); +} + +function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { + if (!queried) { + return ( + + + +

请先查询成绩

+
+
+ ); + } + + const terms = grades.reduce>((accumulator, grade) => { + const term = grade.semester ?? '未分类'; + if (!accumulator[term]) { + accumulator[term] = []; + } + accumulator[term].push(grade.grade ?? 0); + return accumulator; + }, {}); + + const getScoreStyle = (score: number) => { + if (score >= 95) return 'bg-[#336EFF]/50 text-white'; + if (score >= 90) return 'bg-[#336EFF]/40 text-white/90'; + if (score >= 85) return 'bg-[#336EFF]/30 text-white/80'; + if (score >= 80) return 'bg-slate-700/60 text-white/70'; + if (score >= 75) return 'bg-slate-700/40 text-white/60'; + return 'bg-slate-800/60 text-white/50'; + }; + + return ( + + + 成绩热力图 + + +
+ {Object.entries(terms).map(([term, scores]) => ( +
+

{term}

+
+ {scores.map((score, index) => ( +
+ {score} +
+ ))} +
+
+ ))} + {Object.keys(terms).length === 0 &&
暂无成绩数据
} +
+
+
+ ); +} diff --git a/.history/front/src/pages/School_20260314213832.tsx b/.history/front/src/pages/School_20260314213832.tsx new file mode 100644 index 0000000..2269f12 --- /dev/null +++ b/.history/front/src/pages/School_20260314213832.tsx @@ -0,0 +1,430 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { Award, BookOpen, Calendar, ChevronRight, GraduationCap, Lock, MapPin, Search, User } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import { buildScheduleTable } from '@/src/lib/schedule-table'; +import type { CourseResponse, GradeResponse } from '@/src/lib/types'; +import { cn } from '@/src/lib/utils'; + +function formatSections(startTime?: number | null, endTime?: number | null) { + if (!startTime || !endTime) { + return '节次待定'; + } + + return `第 ${startTime}-${endTime} 节`; +} + +export default function School() { + const storedQuery = readStoredSchoolQuery(); + const initialStudentId = storedQuery?.studentId ?? '2023123456'; + const initialSemester = storedQuery?.semester ?? '2025-spring'; + const initialCachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); + const [studentId, setStudentId] = useState(initialStudentId); + const [password, setPassword] = useState('password123'); + const [semester, setSemester] = useState(initialSemester); + const [loading, setLoading] = useState(false); + const [queried, setQueried] = useState(initialCachedResults?.queried ?? false); + const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); + const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + + const averageGrade = useMemo(() => { + if (grades.length === 0) { + return '0.0'; + } + + const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0); + return (sum / grades.length).toFixed(1); + }, [grades]); + + const loadSchoolData = async ( + nextStudentId: string, + nextSemester: string, + options: { background?: boolean } = {}, + ) => { + const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); + const cachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(cacheKey); + + if (!options.background) { + setLoading(true); + } + + writeStoredSchoolQuery({ + studentId: nextStudentId, + semester: nextSemester, + }); + + try { + const queryString = new URLSearchParams({ + studentId: nextStudentId, + semester: nextSemester, + }).toString(); + + const [scheduleData, gradeData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + + setQueried(true); + setSchedule(scheduleData); + setGrades(gradeData); + writeCachedValue(cacheKey, { + queried: true, + studentId: nextStudentId, + semester: nextSemester, + schedule: scheduleData, + grades: gradeData, + }); + } catch { + if (!cachedResults) { + setQueried(false); + setSchedule([]); + setGrades([]); + } + } finally { + if (!options.background) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (!storedQuery) { + return; + } + + loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }).catch(() => undefined); + }, []); + + const handleQuery = async (event: React.FormEvent) => { + event.preventDefault(); + await loadSchoolData(studentId, semester); + }; + + return ( +
+
+ + + + + 教务查询 + + 输入学号、密码和学期后同步课表与成绩。 + + +
+
+ +
+ + setStudentId(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ +
+ + setPassword(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + 数据摘要 + + 展示当前缓存或最近一次查询结果。 + + + {queried ? ( +
+ + + +
+ ) : ( +
+ +

暂无缓存数据,请先执行查询。

+
+ )} +
+
+
+ +
+ + +
+ + + {activeTab === 'schedule' ? : } + +
+ ); +} + +function DatabaseIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + +function SummaryItem({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} + +function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { + if (!queried) { + return ( + + + +

请先查询课表

+
+
+ ); + } + + const days = ['周一', '周二', '周三', '周四', '周五']; + const periodLabels: Record<'morning' | 'afternoon' | 'evening', string> = { + morning: '上午', + afternoon: '下午', + evening: '晚上', + }; + const rows = buildScheduleTable(schedule); + + return ( + + + 本周课表 + 上午 4 节、下午 4 节、晚上 4 节。没有课的格子保持为空。 + + +
+ + + + + {days.map((day) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.slots.map((slot, index) => { + if (slot.type === 'covered') { + return null; + } + + if (slot.type === 'empty') { + return ( + + ); + })} + + ))} + +
+ 节次 + + {day} +
+
+

{periodLabels[row.period]}

+

第 {row.section} 节

+
+
+ ); + } + + return ( + +
+

+ {formatSections(slot.course?.startTime, slot.course?.endTime)} +

+

+ {slot.course?.courseName} +

+

+ {slot.course?.classroom ?? '教室待定'} +

+

+ {slot.course?.teacher ?? '任课教师待定'} +

+
+
+
+
+
+ ); +} + +function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { + if (!queried) { + return ( + + + +

请先查询成绩

+
+
+ ); + } + + const terms = grades.reduce>((accumulator, grade) => { + const term = grade.semester ?? '未分类'; + if (!accumulator[term]) { + accumulator[term] = []; + } + accumulator[term].push(grade.grade ?? 0); + return accumulator; + }, {}); + + const getScoreStyle = (score: number) => { + if (score >= 95) return 'bg-[#336EFF]/50 text-white'; + if (score >= 90) return 'bg-[#336EFF]/40 text-white/90'; + if (score >= 85) return 'bg-[#336EFF]/30 text-white/80'; + if (score >= 80) return 'bg-slate-700/60 text-white/70'; + if (score >= 75) return 'bg-slate-700/40 text-white/60'; + return 'bg-slate-800/60 text-white/50'; + }; + + return ( + + + 成绩热力图 + + +
+ {Object.entries(terms).map(([term, scores]) => ( +
+

{term}

+
+ {scores.map((score, index) => ( +
+ {score} +
+ ))} +
+
+ ))} + {Object.keys(terms).length === 0 &&
暂无成绩数据
} +
+
+
+ ); +} diff --git a/.history/front/src/pages/School_20260314213920.tsx b/.history/front/src/pages/School_20260314213920.tsx new file mode 100644 index 0000000..315c262 --- /dev/null +++ b/.history/front/src/pages/School_20260314213920.tsx @@ -0,0 +1,512 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { Award, BookOpen, Calendar, ChevronRight, GraduationCap, Lock, MapPin, Search, User } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import { buildScheduleTable } from '@/src/lib/schedule-table'; +import type { CourseResponse, GradeResponse } from '@/src/lib/types'; +import { cn } from '@/src/lib/utils'; + +function formatSections(startTime?: number | null, endTime?: number | null) { + if (!startTime || !endTime) { + return '节次待定'; + } + + return `第 ${startTime}-${endTime} 节`; +} + +export default function School() { + const storedQuery = readStoredSchoolQuery(); + const initialStudentId = storedQuery?.studentId ?? '2023123456'; + const initialSemester = storedQuery?.semester ?? '2025-spring'; + const initialCachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); + const [studentId, setStudentId] = useState(initialStudentId); + const [password, setPassword] = useState('password123'); + const [semester, setSemester] = useState(initialSemester); + const [loading, setLoading] = useState(false); + const [queried, setQueried] = useState(initialCachedResults?.queried ?? false); + const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); + const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + + const averageGrade = useMemo(() => { + if (grades.length === 0) { + return '0.0'; + } + + const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0); + return (sum / grades.length).toFixed(1); + }, [grades]); + + const loadSchoolData = async ( + nextStudentId: string, + nextSemester: string, + options: { background?: boolean } = {}, + ) => { + const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); + const cachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(cacheKey); + + if (!options.background) { + setLoading(true); + } + + writeStoredSchoolQuery({ + studentId: nextStudentId, + semester: nextSemester, + }); + + try { + const queryString = new URLSearchParams({ + studentId: nextStudentId, + semester: nextSemester, + }).toString(); + + const [scheduleData, gradeData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + + setQueried(true); + setSchedule(scheduleData); + setGrades(gradeData); + writeCachedValue(cacheKey, { + queried: true, + studentId: nextStudentId, + semester: nextSemester, + schedule: scheduleData, + grades: gradeData, + }); + } catch { + if (!cachedResults) { + setQueried(false); + setSchedule([]); + setGrades([]); + } + } finally { + if (!options.background) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (!storedQuery) { + return; + } + + loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }).catch(() => undefined); + }, []); + + const handleQuery = async (event: React.FormEvent) => { + event.preventDefault(); + await loadSchoolData(studentId, semester); + }; + + return ( +
+
+ + + + + 教务查询 + + 输入学号、密码和学期后同步课表与成绩。 + + +
+
+ +
+ + setStudentId(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ +
+ + setPassword(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + 数据摘要 + + 展示当前缓存或最近一次查询结果。 + + + {queried ? ( +
+ + + +
+ ) : ( +
+ +

暂无缓存数据,请先执行查询。

+
+ )} +
+
+
+ +
+ + +
+ + + {activeTab === 'schedule' ? : } + +
+ ); +} + +function DatabaseIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + +function SummaryItem({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} + +function getCourseTheme(courseName?: string) { + if (!courseName) { + return { + bg: 'bg-slate-500/10', + border: 'border-slate-500/20', + text: 'text-slate-300', + }; + } + + const themes = [ + { + bg: 'bg-blue-500/20 hover:bg-blue-500/30', + border: 'border-blue-400/30', + text: 'text-blue-100', + }, + { + bg: 'bg-indigo-500/20 hover:bg-indigo-500/30', + border: 'border-indigo-400/30', + text: 'text-indigo-100', + }, + { + bg: 'bg-cyan-500/20 hover:bg-cyan-500/30', + border: 'border-cyan-400/30', + text: 'text-cyan-100', + }, + { + bg: 'bg-sky-500/20 hover:bg-sky-500/30', + border: 'border-sky-400/30', + text: 'text-sky-100', + }, + { + bg: 'bg-violet-500/20 hover:bg-violet-500/30', + border: 'border-violet-400/30', + text: 'text-violet-100', + }, + { + bg: 'bg-teal-500/20 hover:bg-teal-500/30', + border: 'border-teal-400/30', + text: 'text-teal-100', + }, + ]; + + let hash = 0; + for (let i = 0; i < courseName.length; i += 1) { + hash = courseName.charCodeAt(i) + ((hash << 5) - hash); + } + return themes[Math.abs(hash) % themes.length]; +} + +function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { + if (!queried) { + return ( + + + +

请先查询课表

+
+
+ ); + } + + const days = ['周一', '周二', '周三', '周四', '周五']; + const rows = buildScheduleTable(schedule); + + return ( + + +
+
+ 我的课表 + 2025 春季学期 +
+
+ + + {' '} + 必修 + + + + {' '} + 选修 + +
+
+
+ +
+
+ + + + + ))} + + + + {rows.map((row, index) => ( + + {/* Morning/Afternoon label */} + {index % 4 === 0 && ( + + )} + + {/* Section Number */} + + + {/* Cells */} + {row.slots.map((slot, colIdx) => { + if (slot.type === 'covered') return null; + if (slot.type === 'empty') { + return ( + + ); + })} + + ))} + +
+ + {days.map((d) => ( + + {d} +
+
+ {row.section <= 4 ? '上午' : row.section <= 8 ? '下午' : '晚上'} +
+
+
{row.section}
+
+ ); + } + + const theme = getCourseTheme(slot.course?.courseName ?? ''); + return ( + +
+
+

+ {slot.course?.courseName} +

+

+ + {' '} + {slot.course?.classroom ?? '未知'} +

+
+
+ + {slot.course?.teacher ?? '老师'} + +
+
+
+
+
+
+
+ ); +} + +function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { + if (!queried) { + return ( + + + +

请先查询成绩

+
+
+ ); + } + + const terms = grades.reduce>((accumulator, grade) => { + const term = grade.semester ?? '未分类'; + if (!accumulator[term]) { + accumulator[term] = []; + } + accumulator[term].push(grade.grade ?? 0); + return accumulator; + }, {}); + + const getScoreStyle = (score: number) => { + if (score >= 95) return 'bg-[#336EFF]/50 text-white'; + if (score >= 90) return 'bg-[#336EFF]/40 text-white/90'; + if (score >= 85) return 'bg-[#336EFF]/30 text-white/80'; + if (score >= 80) return 'bg-slate-700/60 text-white/70'; + if (score >= 75) return 'bg-slate-700/40 text-white/60'; + return 'bg-slate-800/60 text-white/50'; + }; + + return ( + + + 成绩热力图 + + +
+ {Object.entries(terms).map(([term, scores]) => ( +
+

{term}

+
+ {scores.map((score, index) => ( +
+ {score} +
+ ))} +
+
+ ))} + {Object.keys(terms).length === 0 &&
暂无成绩数据
} +
+
+
+ ); +} diff --git a/.history/front/src/pages/School_20260314213942.tsx b/.history/front/src/pages/School_20260314213942.tsx new file mode 100644 index 0000000..f4c1e9d --- /dev/null +++ b/.history/front/src/pages/School_20260314213942.tsx @@ -0,0 +1,504 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { motion } from 'motion/react'; +import { Award, BookOpen, Calendar, Lock, MapPin, Search, User } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import { buildScheduleTable } from '@/src/lib/schedule-table'; +import type { CourseResponse, GradeResponse } from '@/src/lib/types'; +import { cn } from '@/src/lib/utils'; + +export default function School() { + const storedQuery = readStoredSchoolQuery(); + const initialStudentId = storedQuery?.studentId ?? '2023123456'; + const initialSemester = storedQuery?.semester ?? '2025-spring'; + const initialCachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); + const [studentId, setStudentId] = useState(initialStudentId); + const [password, setPassword] = useState('password123'); + const [semester, setSemester] = useState(initialSemester); + const [loading, setLoading] = useState(false); + const [queried, setQueried] = useState(initialCachedResults?.queried ?? false); + const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); + const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + + const averageGrade = useMemo(() => { + if (grades.length === 0) { + return '0.0'; + } + + const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0); + return (sum / grades.length).toFixed(1); + }, [grades]); + + const loadSchoolData = async ( + nextStudentId: string, + nextSemester: string, + options: { background?: boolean } = {}, + ) => { + const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); + const cachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(cacheKey); + + if (!options.background) { + setLoading(true); + } + + writeStoredSchoolQuery({ + studentId: nextStudentId, + semester: nextSemester, + }); + + try { + const queryString = new URLSearchParams({ + studentId: nextStudentId, + semester: nextSemester, + }).toString(); + + const [scheduleData, gradeData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + + setQueried(true); + setSchedule(scheduleData); + setGrades(gradeData); + writeCachedValue(cacheKey, { + queried: true, + studentId: nextStudentId, + semester: nextSemester, + schedule: scheduleData, + grades: gradeData, + }); + } catch { + if (!cachedResults) { + setQueried(false); + setSchedule([]); + setGrades([]); + } + } finally { + if (!options.background) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (!storedQuery) { + return; + } + + loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }).catch(() => undefined); + }, []); + + const handleQuery = async (event: React.FormEvent) => { + event.preventDefault(); + await loadSchoolData(studentId, semester); + }; + + return ( +
+
+ + + + + 教务查询 + + 输入学号、密码和学期后同步课表与成绩。 + + +
+
+ +
+ + setStudentId(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ +
+ + setPassword(event.target.value)} className="pl-9 bg-black/20" required /> +
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + 数据摘要 + + 展示当前缓存或最近一次查询结果。 + + + {queried ? ( +
+ + + +
+ ) : ( +
+ +

暂无缓存数据,请先执行查询。

+
+ )} +
+
+
+ +
+ + +
+ + + {activeTab === 'schedule' ? : } + +
+ ); +} + +function DatabaseIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + +function SummaryItem({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} + +function getCourseTheme(courseName?: string) { + if (!courseName) { + return { + bg: 'bg-slate-500/10', + border: 'border-slate-500/20', + text: 'text-slate-300', + }; + } + + const themes = [ + { + bg: 'bg-blue-500/20 hover:bg-blue-500/30', + border: 'border-blue-400/30', + text: 'text-blue-100', + }, + { + bg: 'bg-indigo-500/20 hover:bg-indigo-500/30', + border: 'border-indigo-400/30', + text: 'text-indigo-100', + }, + { + bg: 'bg-cyan-500/20 hover:bg-cyan-500/30', + border: 'border-cyan-400/30', + text: 'text-cyan-100', + }, + { + bg: 'bg-sky-500/20 hover:bg-sky-500/30', + border: 'border-sky-400/30', + text: 'text-sky-100', + }, + { + bg: 'bg-violet-500/20 hover:bg-violet-500/30', + border: 'border-violet-400/30', + text: 'text-violet-100', + }, + { + bg: 'bg-teal-500/20 hover:bg-teal-500/30', + border: 'border-teal-400/30', + text: 'text-teal-100', + }, + ]; + + let hash = 0; + for (let i = 0; i < courseName.length; i += 1) { + hash = courseName.charCodeAt(i) + ((hash << 5) - hash); + } + return themes[Math.abs(hash) % themes.length]; +} + +function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { + if (!queried) { + return ( + + + +

请先查询课表

+
+
+ ); + } + + const days = ['周一', '周二', '周三', '周四', '周五']; + const rows = buildScheduleTable(schedule); + + return ( + + +
+
+ 我的课表 + 2025 春季学期 +
+
+ + + {' '} + 必修 + + + + {' '} + 选修 + +
+
+
+ +
+
+ + + + + ))} + + + + {rows.map((row, index) => ( + + {/* Morning/Afternoon label */} + {index % 4 === 0 && ( + + )} + + {/* Section Number */} + + + {/* Cells */} + {row.slots.map((slot, colIdx) => { + if (slot.type === 'covered') return null; + if (slot.type === 'empty') { + return ( + + ); + })} + + ))} + +
+ + {days.map((d) => ( + + {d} +
+
+ {row.section <= 4 ? '上午' : row.section <= 8 ? '下午' : '晚上'} +
+
+
{row.section}
+
+ ); + } + + const theme = getCourseTheme(slot.course?.courseName ?? ''); + return ( + +
+
+

+ {slot.course?.courseName} +

+

+ + {' '} + {slot.course?.classroom ?? '未知'} +

+
+
+ + {slot.course?.teacher ?? '老师'} + +
+
+
+
+
+
+
+ ); +} + +function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { + if (!queried) { + return ( + + + +

请先查询成绩

+
+
+ ); + } + + const terms = grades.reduce>((accumulator, grade) => { + const term = grade.semester ?? '未分类'; + if (!accumulator[term]) { + accumulator[term] = []; + } + accumulator[term].push(grade.grade ?? 0); + return accumulator; + }, {}); + + const getScoreStyle = (score: number) => { + if (score >= 95) return 'bg-[#336EFF]/50 text-white'; + if (score >= 90) return 'bg-[#336EFF]/40 text-white/90'; + if (score >= 85) return 'bg-[#336EFF]/30 text-white/80'; + if (score >= 80) return 'bg-slate-700/60 text-white/70'; + if (score >= 75) return 'bg-slate-700/40 text-white/60'; + return 'bg-slate-800/60 text-white/50'; + }; + + return ( + + + 成绩热力图 + + +
+ {Object.entries(terms).map(([term, scores]) => ( +
+

{term}

+
+ {scores.map((score, index) => ( +
+ {score} +
+ ))} +
+
+ ))} + {Object.keys(terms).length === 0 &&
暂无成绩数据
} +
+
+
+ ); +} diff --git a/.history/开发测试账号_20260314122451.md b/.history/开发测试账号_20260314122451.md new file mode 100644 index 0000000..a0c463a --- /dev/null +++ b/.history/开发测试账号_20260314122451.md @@ -0,0 +1,18 @@ +# 开发测试账号 + +以下账号会在后端以 `dev` profile 启动时自动初始化。 + +## 门户账号 + +| 门户用户名 | 门户密码 | 教务学号 | 教务密码 | 查询学期 | 网盘示例文件 | +| --- | --- | --- | --- | --- | --- | +| `portal-demo` | `portal123456` | `2023123456` | `portal123456` | `2025-spring` | `迎新资料.txt`、`课程规划.md`、`campus-shot.png` | +| `portal-study` | `study123456` | `2022456789` | `study123456` | `2024-fall` | `实验数据.csv`、`论文草稿.md`、`data-chart.png` | +| `portal-design` | `design123456` | `2021789012` | `design123456` | `2024-spring` | `素材清单.txt`、`作品说明.md`、`ui-mockup.png` | + +## 使用说明 + +- 先用上表中的“门户用户名 / 门户密码”登录站点。 +- 登录后进入网盘页,每个用户都会看到自己的 `下载 / 文档 / 图片` 目录,以及各自不同的样例文件。 +- 进入教务页后,填入对应的“教务学号 / 教务密码 / 查询学期”即可看到该用户对应的 mock 教务数据。 +- 当前开发环境的教务密码字段仅用于前端占位,后端主要依据登录态、学号和学期返回该用户的 mock 数据。为避免混淆,直接填表中的教务密码即可。 diff --git a/.history/开发测试账号_20260314225231.md b/.history/开发测试账号_20260314225231.md new file mode 100644 index 0000000..2773819 --- /dev/null +++ b/.history/开发测试账号_20260314225231.md @@ -0,0 +1,18 @@ +# 开发测试账号 + +以下账号会在后端以 `dev` profile 启动时自动初始化。 + +## 门户账号 + +| 门户用户名 | 门户密码 | 教务学号 | 教务密码 | 查询学期 | 网盘示例文件 | +| --- | --- | --- | --- | --- | --- | +| `CCC` | `portal123456` | `2023123456` | `portal123456` | `2025-spring` | `迎新资料.txt`、`课程规划.md`、`campus-shot.png` | +| `portal-study` | `study123456` | `2022456789` | `study123456` | `2024-fall` | `实验数据.csv`、`论文草稿.md`、`data-chart.png` | +| `portal-design` | `design123456` | `2021789012` | `design123456` | `2024-spring` | `素材清单.txt`、`作品说明.md`、`ui-mockup.png` | + +## 使用说明 + +- 先用上表中的“门户用户名 / 门户密码”登录站点。 +- 登录后进入网盘页,每个用户都会看到自己的 `下载 / 文档 / 图片` 目录,以及各自不同的样例文件。 +- 进入教务页后,填入对应的“教务学号 / 教务密码 / 查询学期”即可看到该用户对应的 mock 教务数据。 +- 当前开发环境的教务密码字段仅用于前端占位,后端主要依据登录态、学号和学期返回该用户的 mock 数据。为避免混淆,直接填表中的教务密码即可。 diff --git a/.history/开发测试账号_20260314225233.md b/.history/开发测试账号_20260314225233.md new file mode 100644 index 0000000..a0c463a --- /dev/null +++ b/.history/开发测试账号_20260314225233.md @@ -0,0 +1,18 @@ +# 开发测试账号 + +以下账号会在后端以 `dev` profile 启动时自动初始化。 + +## 门户账号 + +| 门户用户名 | 门户密码 | 教务学号 | 教务密码 | 查询学期 | 网盘示例文件 | +| --- | --- | --- | --- | --- | --- | +| `portal-demo` | `portal123456` | `2023123456` | `portal123456` | `2025-spring` | `迎新资料.txt`、`课程规划.md`、`campus-shot.png` | +| `portal-study` | `study123456` | `2022456789` | `study123456` | `2024-fall` | `实验数据.csv`、`论文草稿.md`、`data-chart.png` | +| `portal-design` | `design123456` | `2021789012` | `design123456` | `2024-spring` | `素材清单.txt`、`作品说明.md`、`ui-mockup.png` | + +## 使用说明 + +- 先用上表中的“门户用户名 / 门户密码”登录站点。 +- 登录后进入网盘页,每个用户都会看到自己的 `下载 / 文档 / 图片` 目录,以及各自不同的样例文件。 +- 进入教务页后,填入对应的“教务学号 / 教务密码 / 查询学期”即可看到该用户对应的 mock 教务数据。 +- 当前开发环境的教务密码字段仅用于前端占位,后端主要依据登录态、学号和学期返回该用户的 mock 数据。为避免混淆,直接填表中的教务密码即可。 diff --git a/backend/src/main/java/com/yoyuzh/auth/User.java b/backend/src/main/java/com/yoyuzh/auth/User.java index 2cc905c..b45fa91 100644 --- a/backend/src/main/java/com/yoyuzh/auth/User.java +++ b/backend/src/main/java/com/yoyuzh/auth/User.java @@ -35,6 +35,12 @@ public class User { @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; + @Column(name = "last_school_student_id", length = 64) + private String lastSchoolStudentId; + + @Column(name = "last_school_semester", length = 64) + private String lastSchoolSemester; + @PrePersist public void prePersist() { if (createdAt == null) { @@ -81,4 +87,20 @@ public class User { public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public String getLastSchoolStudentId() { + return lastSchoolStudentId; + } + + public void setLastSchoolStudentId(String lastSchoolStudentId) { + this.lastSchoolStudentId = lastSchoolStudentId; + } + + public String getLastSchoolSemester() { + return lastSchoolSemester; + } + + public void setLastSchoolSemester(String lastSchoolSemester) { + this.lastSchoolSemester = lastSchoolSemester; + } } diff --git a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java b/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java index 5510b5d..adf7b1e 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java @@ -3,9 +3,12 @@ package com.yoyuzh.cqu; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface CourseRepository extends JpaRepository { List findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester); + Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); + void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); } diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquController.java b/backend/src/main/java/com/yoyuzh/cqu/CquController.java index dc7645f..5e41d85 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CquController.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CquController.java @@ -26,16 +26,24 @@ public class CquController { @GetMapping("/schedule") public ApiResponse> schedule(@AuthenticationPrincipal UserDetails userDetails, @RequestParam String semester, - @RequestParam String studentId) { - return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId)); + @RequestParam String studentId, + @RequestParam(defaultValue = "false") boolean refresh) { + return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId, refresh)); } @Operation(summary = "获取成绩") @GetMapping("/grades") public ApiResponse> grades(@AuthenticationPrincipal UserDetails userDetails, @RequestParam String semester, - @RequestParam String studentId) { - return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId)); + @RequestParam String studentId, + @RequestParam(defaultValue = "false") boolean refresh) { + return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId, refresh)); + } + + @Operation(summary = "获取最近一次教务数据") + @GetMapping("/latest") + public ApiResponse latest(@AuthenticationPrincipal UserDetails userDetails) { + return ApiResponse.success(cquDataService.getLatest(resolveUser(userDetails))); } private User resolveUser(UserDetails userDetails) { diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java b/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java index 40188ff..cfa1e20 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java @@ -1,5 +1,6 @@ package com.yoyuzh.cqu; +import com.yoyuzh.auth.UserRepository; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; @@ -10,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -18,47 +20,82 @@ public class CquDataService { private final CquApiClient cquApiClient; private final CourseRepository courseRepository; private final GradeRepository gradeRepository; + private final UserRepository userRepository; private final CquApiProperties cquApiProperties; @Transactional public List getSchedule(User user, String semester, String studentId) { + return getSchedule(user, semester, studentId, false); + } + + @Transactional + public List getSchedule(User user, String semester, String studentId, boolean refresh) { requireLoginIfNecessary(user); + if (user != null && !refresh) { + List stored = readSavedSchedule(user.getId(), studentId, semester); + if (!stored.isEmpty()) { + rememberLastSchoolQuery(user, studentId, semester); + return stored; + } + } + List responses = cquApiClient.fetchSchedule(semester, studentId).stream() .map(this::toCourseResponse) .toList(); if (user != null) { saveCourses(user, semester, studentId, responses); - return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc( - user.getId(), studentId, semester) - .stream() - .map(item -> new CourseResponse( - item.getCourseName(), - item.getTeacher(), - item.getClassroom(), - item.getDayOfWeek(), - item.getStartTime(), - item.getEndTime())) - .toList(); + rememberLastSchoolQuery(user, studentId, semester); + return readSavedSchedule(user.getId(), studentId, semester); } return responses; } @Transactional public List getGrades(User user, String semester, String studentId) { + return getGrades(user, semester, studentId, false); + } + + @Transactional + public List getGrades(User user, String semester, String studentId, boolean refresh) { requireLoginIfNecessary(user); + if (user != null && !refresh + && gradeRepository.existsByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester)) { + rememberLastSchoolQuery(user, studentId, semester); + return readSavedGrades(user.getId(), studentId); + } + List responses = cquApiClient.fetchGrades(semester, studentId).stream() .map(this::toGradeResponse) .toList(); if (user != null) { saveGrades(user, semester, studentId, responses); - return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), studentId) - .stream() - .map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester())) - .toList(); + rememberLastSchoolQuery(user, studentId, semester); + return readSavedGrades(user.getId(), studentId); } return responses; } + @Transactional + public LatestSchoolDataResponse getLatest(User user) { + requireLoginIfNecessary(user); + if (user == null) { + return null; + } + + QueryContext context = resolveLatestContext(user); + if (context == null) { + return null; + } + + List schedule = readSavedSchedule(user.getId(), context.studentId(), context.semester()); + List grades = readSavedGrades(user.getId(), context.studentId()); + if (schedule.isEmpty() && grades.isEmpty()) { + return null; + } + + return new LatestSchoolDataResponse(context.studentId(), context.semester(), schedule, grades); + } + private void requireLoginIfNecessary(User user) { if (cquApiProperties.isRequireLogin() && user == null) { throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "该接口需要登录后访问"); @@ -97,6 +134,77 @@ public class CquDataService { }).toList()); } + private List readSavedSchedule(Long userId, String studentId, String semester) { + return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc( + userId, studentId, semester) + .stream() + .map(item -> new CourseResponse( + item.getCourseName(), + item.getTeacher(), + item.getClassroom(), + item.getDayOfWeek(), + item.getStartTime(), + item.getEndTime())) + .toList(); + } + + private List readSavedGrades(Long userId, String studentId) { + return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(userId, studentId) + .stream() + .map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester())) + .toList(); + } + + private void rememberLastSchoolQuery(User user, String studentId, String semester) { + boolean changed = false; + if (!semester.equals(user.getLastSchoolSemester())) { + user.setLastSchoolSemester(semester); + changed = true; + } + if (!studentId.equals(user.getLastSchoolStudentId())) { + user.setLastSchoolStudentId(studentId); + changed = true; + } + if (changed) { + userRepository.save(user); + } + } + + private QueryContext resolveLatestContext(User user) { + if (hasText(user.getLastSchoolStudentId()) && hasText(user.getLastSchoolSemester())) { + return new QueryContext(user.getLastSchoolStudentId(), user.getLastSchoolSemester()); + } + + Optional latestCourse = courseRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()); + Optional latestGrade = gradeRepository.findTopByUserIdOrderByCreatedAtDesc(user.getId()); + if (latestCourse.isEmpty() && latestGrade.isEmpty()) { + return null; + } + + QueryContext context; + if (latestGrade.isEmpty()) { + context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester()); + } else if (latestCourse.isEmpty()) { + context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester()); + } else if (latestCourse.get().getCreatedAt().isAfter(latestGrade.get().getCreatedAt())) { + context = new QueryContext(latestCourse.get().getStudentId(), latestCourse.get().getSemester()); + } else { + context = new QueryContext(latestGrade.get().getStudentId(), latestGrade.get().getSemester()); + } + + if (hasText(context.studentId()) && hasText(context.semester())) { + user.setLastSchoolStudentId(context.studentId()); + user.setLastSchoolSemester(context.semester()); + userRepository.save(user); + return context; + } + return null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private CourseResponse toCourseResponse(Map source) { return new CourseResponse( stringValue(source, "courseName"), @@ -128,4 +236,7 @@ public class CquDataService { Object value = source.get(key); return value == null ? null : Double.parseDouble(value.toString()); } + + private record QueryContext(String studentId, String semester) { + } } diff --git a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java b/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java index 6314a7e..54be404 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java +++ b/backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java @@ -3,9 +3,14 @@ package com.yoyuzh.cqu; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface GradeRepository extends JpaRepository { List findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId); + boolean existsByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); + + Optional findTopByUserIdOrderByCreatedAtDesc(Long userId); + void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester); } diff --git a/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java b/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java new file mode 100644 index 0000000..924fc00 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/cqu/LatestSchoolDataResponse.java @@ -0,0 +1,11 @@ +package com.yoyuzh.cqu; + +import java.util.List; + +public record LatestSchoolDataResponse( + String studentId, + String semester, + List schedule, + List grades +) { +} diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java index 37b56ad..646f9bf 100644 --- a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java @@ -1,6 +1,7 @@ package com.yoyuzh.cqu; import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; import com.yoyuzh.config.CquApiProperties; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,9 +12,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -28,6 +31,9 @@ class CquDataServiceTest { @Mock private GradeRepository gradeRepository; + @Mock + private UserRepository userRepository; + @InjectMocks private CquDataService cquDataService; @@ -35,7 +41,7 @@ class CquDataServiceTest { void shouldNormalizeScheduleFromRemoteApi() { CquApiProperties properties = new CquApiProperties(); properties.setRequireLogin(false); - cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties); + cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties); when(cquApiClient.fetchSchedule("2025-2026-1", "20230001")).thenReturn(List.of(Map.of( "courseName", "Java", "teacher", "Zhang", @@ -56,7 +62,7 @@ class CquDataServiceTest { void shouldPersistGradesForLoggedInUserWhenAvailable() { CquApiProperties properties = new CquApiProperties(); properties.setRequireLogin(true); - cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties); + cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties); User user = new User(); user.setId(1L); user.setUsername("alice"); @@ -83,4 +89,114 @@ class CquDataServiceTest { assertThat(response).hasSize(1); assertThat(response.get(0).grade()).isEqualTo(95D); } + + @Test + void shouldReturnPersistedScheduleWithoutCallingRemoteApiWhenRefreshIsDisabled() { + CquApiProperties properties = new CquApiProperties(); + properties.setRequireLogin(true); + cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties); + + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + + Course persisted = new Course(); + persisted.setUser(user); + persisted.setCourseName("Java"); + persisted.setTeacher("Zhang"); + persisted.setClassroom("A101"); + persisted.setDayOfWeek(1); + persisted.setStartTime(1); + persisted.setEndTime(2); + persisted.setSemester("2025-spring"); + persisted.setStudentId("20230001"); + + when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring")) + .thenReturn(List.of(persisted)); + + List response = cquDataService.getSchedule(user, "2025-spring", "20230001", false); + + assertThat(response).extracting(CourseResponse::courseName).containsExactly("Java"); + verifyNoInteractions(cquApiClient); + } + + @Test + void shouldReturnLatestStoredSchoolDataFromPersistedUserContext() { + CquApiProperties properties = new CquApiProperties(); + properties.setRequireLogin(true); + cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties); + + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setLastSchoolStudentId("20230001"); + user.setLastSchoolSemester("2025-spring"); + + Course course = new Course(); + course.setUser(user); + course.setCourseName("Java"); + course.setTeacher("Zhang"); + course.setClassroom("A101"); + course.setDayOfWeek(1); + course.setStartTime(1); + course.setEndTime(2); + course.setSemester("2025-spring"); + course.setStudentId("20230001"); + + Grade grade = new Grade(); + grade.setUser(user); + grade.setCourseName("Java"); + grade.setGrade(95D); + grade.setSemester("2025-spring"); + grade.setStudentId("20230001"); + + when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring")) + .thenReturn(List.of(course)); + when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001")) + .thenReturn(List.of(grade)); + + LatestSchoolDataResponse response = cquDataService.getLatest(user); + + assertThat(response.studentId()).isEqualTo("20230001"); + assertThat(response.semester()).isEqualTo("2025-spring"); + assertThat(response.schedule()).extracting(CourseResponse::courseName).containsExactly("Java"); + assertThat(response.grades()).extracting(GradeResponse::courseName).containsExactly("Java"); + } + + @Test + void shouldFallbackToMostRecentStoredSchoolDataWhenUserContextIsEmpty() { + CquApiProperties properties = new CquApiProperties(); + properties.setRequireLogin(true); + cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties); + + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + + Course latestCourse = new Course(); + latestCourse.setUser(user); + latestCourse.setCourseName("Java"); + latestCourse.setTeacher("Zhang"); + latestCourse.setClassroom("A101"); + latestCourse.setDayOfWeek(1); + latestCourse.setStartTime(1); + latestCourse.setEndTime(2); + latestCourse.setSemester("2025-spring"); + latestCourse.setStudentId("20230001"); + latestCourse.setCreatedAt(LocalDateTime.now()); + + when(courseRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(latestCourse)); + when(gradeRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.empty()); + when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring")) + .thenReturn(List.of(latestCourse)); + when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001")) + .thenReturn(List.of()); + + LatestSchoolDataResponse response = cquDataService.getLatest(user); + + assertThat(response.studentId()).isEqualTo("20230001"); + assertThat(response.semester()).isEqualTo("2025-spring"); + assertThat(user.getLastSchoolStudentId()).isEqualTo("20230001"); + assertThat(user.getLastSchoolSemester()).isEqualTo("2025-spring"); + } } diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java index 62dd34c..4841839 100644 --- a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java +++ b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java @@ -64,7 +64,7 @@ class CquDataServiceTransactionTest { ) )); - List response = cquDataService.getGrades(user, "2025-spring", "2023123456"); + List response = cquDataService.getGrades(user, "2025-spring", "2023123456", true); assertThat(response).hasSize(1); assertThat(response.get(0).courseName()).isEqualTo("Java"); @@ -74,5 +74,9 @@ class CquDataServiceTransactionTest { .first() .extracting(Grade::getCourseName) .isEqualTo("Java"); + assertThat(userRepository.findById(user.getId())) + .get() + .extracting(User::getLastSchoolStudentId, User::getLastSchoolSemester) + .containsExactly("2023123456", "2025-spring"); } } diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index 7bd3542..a540060 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -30,7 +30,7 @@ export function Layout() { {/* Top Navigation */} -
+
{/* Brand */}
@@ -85,7 +85,7 @@ export function Layout() {
{/* Main Content */} -
+
diff --git a/front/src/lib/schedule-table.test.ts b/front/src/lib/schedule-table.test.ts new file mode 100644 index 0000000..2f0b0de --- /dev/null +++ b/front/src/lib/schedule-table.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import type { CourseResponse } from './types'; +import { buildScheduleTable, getScheduleCellHeight, getScheduleDividerOffsets } from './schedule-table'; + +test('buildScheduleTable creates 12 sections with empty slots preserved', () => { + const schedule: CourseResponse[] = [ + { + courseName: 'Advanced Java', + teacher: 'Li', + classroom: 'A101', + dayOfWeek: 1, + startTime: 1, + endTime: 2, + }, + { + courseName: 'Networks', + teacher: 'Wang', + classroom: 'B202', + dayOfWeek: 3, + startTime: 5, + endTime: 6, + }, + ]; + + const table = buildScheduleTable(schedule); + + assert.equal(table.length, 12); + assert.equal(table[0].slots.length, 7); + assert.equal(table[0].section, 1); + assert.equal(table[11].section, 12); + assert.equal(table[0].period, 'morning'); + assert.equal(table[4].period, 'noon'); + assert.equal(table[5].period, 'afternoon'); + assert.equal(table[9].period, 'evening'); + assert.equal(table[0].slots[0]?.course?.courseName, 'Advanced Java'); + assert.equal(table[1].slots[0]?.type, 'covered'); + assert.equal(table[2].slots[0]?.type, 'empty'); + assert.equal(table[4].slots[2]?.course?.courseName, 'Networks'); + assert.equal(table[5].slots[2]?.type, 'covered'); + assert.equal(table[8].slots[4]?.type, 'empty'); + assert.equal(table[8].slots[6]?.type, 'empty'); +}); + +test('buildScheduleTable clamps invalid section ranges safely', () => { + const schedule: CourseResponse[] = [ + { + courseName: 'Night Studio', + teacher: 'Xu', + classroom: 'C303', + dayOfWeek: 5, + startTime: 11, + endTime: 14, + }, + ]; + + const table = buildScheduleTable(schedule); + + assert.equal(table[10].slots[4]?.rowSpan, 2); + assert.equal(table[11].slots[4]?.type, 'covered'); +}); + +test('getScheduleCellHeight returns merged visual height for rowspan cells', () => { + assert.equal(getScheduleCellHeight(1), 96); + assert.equal(getScheduleCellHeight(2), 200); + assert.equal(getScheduleCellHeight(4), 408); +}); + +test('getScheduleDividerOffsets returns internal section boundaries for merged cells', () => { + assert.deepEqual(getScheduleDividerOffsets(1), []); + assert.deepEqual(getScheduleDividerOffsets(2), [100]); + assert.deepEqual(getScheduleDividerOffsets(4), [100, 204, 308]); +}); diff --git a/front/src/lib/schedule-table.ts b/front/src/lib/schedule-table.ts new file mode 100644 index 0000000..87bc63d --- /dev/null +++ b/front/src/lib/schedule-table.ts @@ -0,0 +1,77 @@ +import type { CourseResponse } from './types'; + +export interface ScheduleSlot { + type: 'empty' | 'course' | 'covered'; + course?: CourseResponse; + rowSpan?: number; +} + +export interface ScheduleRow { + section: number; + period: 'morning' | 'noon' | 'afternoon' | 'evening'; + slots: ScheduleSlot[]; +} + +const SECTION_COUNT = 12; +const WEEKDAY_COUNT = 7; +const SECTION_CELL_HEIGHT = 96; +const SECTION_CELL_GAP = 8; + +function getPeriod(section: number): ScheduleRow['period'] { + if (section <= 4) { + return 'morning'; + } + if (section === 5) { + return 'noon'; + } + if (section <= 8) { + return 'afternoon'; + } + return 'evening'; +} + +export function buildScheduleTable(schedule: CourseResponse[]) { + const rows: ScheduleRow[] = Array.from({ length: SECTION_COUNT }, (_, index) => ({ + section: index + 1, + period: getPeriod(index + 1), + slots: Array.from({ length: WEEKDAY_COUNT }, () => ({ type: 'empty' as const })), + })); + + for (const course of schedule) { + const dayIndex = (course.dayOfWeek ?? 0) - 1; + if (dayIndex < 0 || dayIndex >= WEEKDAY_COUNT) { + continue; + } + + const startSection = Math.max(1, Math.min(SECTION_COUNT, course.startTime ?? 1)); + const endSection = Math.max(startSection, Math.min(SECTION_COUNT, course.endTime ?? startSection)); + const rowSpan = endSection - startSection + 1; + const startRowIndex = startSection - 1; + + rows[startRowIndex].slots[dayIndex] = { + type: 'course', + course, + rowSpan, + }; + + for (let section = startSection + 1; section <= endSection; section += 1) { + rows[section - 1].slots[dayIndex] = { + type: 'covered', + }; + } + } + + return rows; +} + +export function getScheduleCellHeight(rowSpan: number) { + const safeRowSpan = Math.max(1, rowSpan); + return safeRowSpan * SECTION_CELL_HEIGHT + (safeRowSpan - 1) * SECTION_CELL_GAP; +} + +export function getScheduleDividerOffsets(rowSpan: number) { + const safeRowSpan = Math.max(1, rowSpan); + return Array.from({ length: safeRowSpan - 1 }, (_, index) => + (index + 1) * SECTION_CELL_HEIGHT + index * SECTION_CELL_GAP + SECTION_CELL_GAP / 2, + ); +} diff --git a/front/src/lib/school.ts b/front/src/lib/school.ts new file mode 100644 index 0000000..5c65363 --- /dev/null +++ b/front/src/lib/school.ts @@ -0,0 +1,22 @@ +import { apiRequest } from './api'; +import { writeCachedValue } from './cache'; +import { getSchoolResultsCacheKey, writeStoredSchoolQuery } from './page-cache'; +import type { LatestSchoolDataResponse } from './types'; + +export async function fetchLatestSchoolData() { + return apiRequest('/cqu/latest'); +} + +export function cacheLatestSchoolData(latest: LatestSchoolDataResponse) { + writeStoredSchoolQuery({ + studentId: latest.studentId, + semester: latest.semester, + }); + writeCachedValue(getSchoolResultsCacheKey(latest.studentId, latest.semester), { + queried: true, + studentId: latest.studentId, + semester: latest.semester, + schedule: latest.schedule, + grades: latest.grades, + }); +} diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts index 4d36663..a035870 100644 --- a/front/src/lib/types.ts +++ b/front/src/lib/types.ts @@ -46,3 +46,10 @@ export interface GradeResponse { grade: number | null; semester: string | null; } + +export interface LatestSchoolDataResponse { + studentId: string; + semester: string; + schedule: CourseResponse[]; + grades: GradeResponse[]; +} diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index 3a95526..f476bc1 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -18,7 +18,8 @@ import { Button } from '@/src/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; import { apiRequest } from '@/src/lib/api'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; -import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery } from '@/src/lib/page-cache'; +import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school'; import { readStoredSession } from '@/src/lib/session'; import type { CourseResponse, FileMetadata, GradeResponse, PageResponse, UserProfile } from '@/src/lib/types'; @@ -107,28 +108,33 @@ export default function Overview() { setRecentFiles(filesRecent); setRootFiles(filesRoot.items); + let scheduleData: CourseResponse[] = []; + let gradesData: GradeResponse[] = []; const schoolQuery = readStoredSchoolQuery(); - if (!schoolQuery?.studentId || !schoolQuery?.semester) { - writeCachedValue(getOverviewCacheKey(), { - profile: user, - recentFiles: filesRecent, - rootFiles: filesRoot.items, - schedule: [], - grades: [], - }); - return; + + if (schoolQuery?.studentId && schoolQuery?.semester) { + const queryString = new URLSearchParams({ + studentId: schoolQuery.studentId, + semester: schoolQuery.semester, + }).toString(); + + [scheduleData, gradesData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + } else { + const latest = await fetchLatestSchoolData(); + if (latest) { + cacheLatestSchoolData(latest); + writeStoredSchoolQuery({ + studentId: latest.studentId, + semester: latest.semester, + }); + scheduleData = latest.schedule; + gradesData = latest.grades; + } } - const queryString = new URLSearchParams({ - studentId: schoolQuery.studentId, - semester: schoolQuery.semester, - }).toString(); - - const [scheduleData, gradesData] = await Promise.all([ - apiRequest(`/cqu/schedule?${queryString}`), - apiRequest(`/cqu/grades?${queryString}`), - ]); - if (!cancelled) { setSchedule(scheduleData); setGrades(gradesData); diff --git a/front/src/pages/School.tsx b/front/src/pages/School.tsx index c7bb25e..43e8fd4 100644 --- a/front/src/pages/School.tsx +++ b/front/src/pages/School.tsx @@ -1,14 +1,16 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'motion/react'; -import { GraduationCap, Calendar, User, Lock, Search, BookOpen, ChevronRight, Award } from 'lucide-react'; +import { Award, BookOpen, Calendar, Lock, MapPin, Search, User } from 'lucide-react'; import { Button } from '@/src/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/src/components/ui/card'; import { Input } from '@/src/components/ui/input'; import { apiRequest } from '@/src/lib/api'; import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; -import type { CourseResponse, GradeResponse } from '@/src/lib/types'; +import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school'; +import { buildScheduleTable } from '@/src/lib/schedule-table'; +import type { CourseResponse, GradeResponse, LatestSchoolDataResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; function formatSections(startTime?: number | null, endTime?: number | null) { @@ -19,6 +21,76 @@ function formatSections(startTime?: number | null, endTime?: number | null) { return `第 ${startTime}-${endTime} 节`; } +function getCourseTheme(courseName?: string) { + const themes = [ + { + panel: 'bg-gradient-to-br from-[#336EFF]/26 via-[#4D7FFF]/18 to-[#7AA2FF]/12', + border: 'border-[#5E88FF]/45', + accent: 'bg-[#5D8BFF]', + title: 'text-blue-50', + meta: 'text-blue-100/80', + badge: 'bg-[#336EFF]/22 text-blue-100', + shadow: 'shadow-[0_10px_30px_rgba(51,110,255,0.18)]', + }, + { + panel: 'bg-gradient-to-br from-cyan-500/24 via-sky-500/18 to-blue-500/10', + border: 'border-cyan-400/40', + accent: 'bg-cyan-400', + title: 'text-cyan-50', + meta: 'text-cyan-100/80', + badge: 'bg-cyan-500/18 text-cyan-100', + shadow: 'shadow-[0_10px_30px_rgba(34,211,238,0.16)]', + }, + { + panel: 'bg-gradient-to-br from-indigo-500/24 via-blue-500/18 to-slate-500/8', + border: 'border-indigo-400/40', + accent: 'bg-indigo-400', + title: 'text-indigo-50', + meta: 'text-indigo-100/80', + badge: 'bg-indigo-500/18 text-indigo-100', + shadow: 'shadow-[0_10px_30px_rgba(99,102,241,0.16)]', + }, + { + panel: 'bg-gradient-to-br from-sky-500/24 via-blue-500/16 to-violet-500/10', + border: 'border-sky-400/40', + accent: 'bg-sky-400', + title: 'text-sky-50', + meta: 'text-sky-100/80', + badge: 'bg-sky-500/18 text-sky-100', + shadow: 'shadow-[0_10px_30px_rgba(14,165,233,0.16)]', + }, + { + panel: 'bg-gradient-to-br from-violet-500/22 via-indigo-500/16 to-blue-500/10', + border: 'border-violet-400/38', + accent: 'bg-violet-400', + title: 'text-violet-50', + meta: 'text-violet-100/80', + badge: 'bg-violet-500/18 text-violet-100', + shadow: 'shadow-[0_10px_30px_rgba(139,92,246,0.14)]', + }, + { + panel: 'bg-gradient-to-br from-teal-500/22 via-cyan-500/16 to-sky-500/10', + border: 'border-teal-400/38', + accent: 'bg-teal-400', + title: 'text-teal-50', + meta: 'text-teal-100/80', + badge: 'bg-teal-500/18 text-teal-100', + shadow: 'shadow-[0_10px_30px_rgba(45,212,191,0.14)]', + }, + ]; + + if (!courseName) { + return themes[0]; + } + + let hash = 0; + for (let index = 0; index < courseName.length; index += 1) { + hash = courseName.charCodeAt(index) + ((hash << 5) - hash); + } + + return themes[Math.abs(hash) % themes.length]; +} + export default function School() { const storedQuery = readStoredSchoolQuery(); const initialStudentId = storedQuery?.studentId ?? '2023123456'; @@ -28,6 +100,7 @@ export default function School() { schedule: CourseResponse[]; grades: GradeResponse[]; }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); const [studentId, setStudentId] = useState(initialStudentId); const [password, setPassword] = useState('password123'); @@ -37,6 +110,15 @@ export default function School() { const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + const applySchoolResults = (results: LatestSchoolDataResponse) => { + setStudentId(results.studentId); + setSemester(results.semester); + setQueried(true); + setSchedule(results.schedule); + setGrades(results.grades); + cacheLatestSchoolData(results); + }; + const averageGrade = useMemo(() => { if (grades.length === 0) { return '0.0'; @@ -49,7 +131,7 @@ export default function School() { const loadSchoolData = async ( nextStudentId: string, nextSemester: string, - options: { background?: boolean } = {} + options: { background?: boolean; refresh?: boolean } = {}, ) => { const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); const cachedResults = readCachedValue<{ @@ -71,6 +153,7 @@ export default function School() { const queryString = new URLSearchParams({ studentId: nextStudentId, semester: nextSemester, + refresh: options.refresh ? 'true' : 'false', }).toString(); const [scheduleData, gradeData] = await Promise.all([ @@ -102,31 +185,45 @@ export default function School() { }; useEffect(() => { - if (!storedQuery) { - return; + let cancelled = false; + + async function loadInitialSchoolData() { + if (storedQuery) { + await loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }); + return; + } + + const latest = await fetchLatestSchoolData(); + if (!latest || cancelled) { + return; + } + applySchoolResults(latest); } - loadSchoolData(storedQuery.studentId, storedQuery.semester, { - background: true, - }).catch(() => undefined); + loadInitialSchoolData().catch(() => undefined); + + return () => { + cancelled = true; + }; }, []); - const handleQuery = async (e: React.FormEvent) => { - e.preventDefault(); - await loadSchoolData(studentId, semester); + const handleQuery = async (event: React.FormEvent) => { + event.preventDefault(); + await loadSchoolData(studentId, semester, { refresh: true }); }; return (
- {/* Query Form */} 教务查询 - 输入教务系统账号密码以同步数据 + 输入学号、密码和学期后同步课表与成绩。
@@ -146,7 +243,11 @@ export default function School() {
- setSemester(event.target.value)} + className="flex h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF]" + > @@ -157,7 +258,13 @@ export default function School() { -
@@ -165,55 +272,52 @@ export default function School() { - {/* Data Summary */} 数据摘要 - 当前缓存或最近一次查询结果 + 展示当前缓存或最近一次查询结果。 {queried ? (
- - - + + +
) : (
-

暂无缓存数据,请先执行查询

+

暂无缓存数据,请先执行查询。

)}
- {/* View Toggle */}
- {/* Content Area */} ) { return ( ; +}) { return (
@@ -265,49 +377,154 @@ function ScheduleView({ queried, schedule }: { queried: boolean; schedule: Cours if (!queried) { return ( - - + +

请先查询课表

); } - const days = ['周一', '周二', '周三', '周四', '周五']; + const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + const periodLabels: Record<'morning' | 'noon' | 'afternoon' | 'evening', string> = { + morning: '上午', + noon: '中午', + afternoon: '下午', + evening: '晚上', + }; + const periodOrder = ['morning', 'noon', 'afternoon', 'evening'] as const; + const rows = buildScheduleTable(schedule); return ( - - - 本周课表 + + +
+
+ 本周课表 + 周一到周日完整展示,空白节次保持固定格子,跨节课程会按节数占满网格。 +
+
+ 上午 1-4 节 + 中午 5 节 + 下午 6-9 节 + 晚上 10-12 节 +
+
- -
- {days.map((day, index) => { - const dayCourses = schedule.filter((item) => (item.dayOfWeek ?? 0) - 1 === index); - return ( -
-
- {day} -
-
- {dayCourses.map((course, i) => ( -
-

{formatSections(course.startTime, course.endTime)}

-

{course.courseName}

-

- {course.classroom ?? '教室待定'} -

-
- ))} - {dayCourses.length === 0 && ( -
- 无课程 -
- )} + +
+
+
+ 时段 +
+
+ 节次 +
+ {days.map((day) => ( +
+ {day} +
+ ))} + + {periodOrder.map((period, index) => ( +
+
+ {periodLabels[period]}
- ); - })} + ))} + + {rows.map((row) => ( +
+ Section + {row.section} +
+ ))} + + {rows.flatMap((row) => + row.slots.map((slot, columnIndex) => { + if (slot.type !== 'empty') { + return null; + } + + return ( +
+ ); + }), + )} + + {rows.flatMap((row) => + row.slots.map((slot, columnIndex) => { + if (slot.type !== 'course') { + return null; + } + + const theme = getCourseTheme(slot.course?.courseName); + const rowSpan = slot.rowSpan ?? 1; + + return ( +
+
+
+

+ {slot.course?.courseName} +

+ + {formatSections(slot.course?.startTime, slot.course?.endTime)} + +
+
+

+ + {slot.course?.classroom ?? '教室待定'} +

+

+ + {slot.course?.teacher ?? '教师待定'} +

+
+
+ ); + }), + )} +
@@ -350,17 +567,17 @@ function GradesView({ queried, grades }: { queried: boolean; grades: GradeRespon 成绩热力图 -
- {Object.entries(terms).map(([term, scores], i) => ( -
-

{term}

+
+ {Object.entries(terms).map(([term, scores]) => ( +
+

{term}

- {scores.map((score, j) => ( + {scores.map((score, index) => (
{score} @@ -369,9 +586,11 @@ function GradesView({ queried, grades }: { queried: boolean; grades: GradeRespon
))} - {Object.keys(terms).length === 0 &&
暂无成绩数据
} + {Object.keys(terms).length === 0 ?
暂无成绩数据
: null}
); } + +