Files
my_site/front/src/pages/School.tsx
2026-03-14 22:55:07 +08:00

597 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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) {
if (!startTime || !endTime) {
return '节次待定';
}
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';
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<CourseResponse[]>(initialCachedResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(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';
}
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; refresh?: 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,
refresh: options.refresh ? 'true' : 'false',
}).toString();
const [scheduleData, gradeData] = await Promise.all([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/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(() => {
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);
}
loadInitialSchoolData().catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
const handleQuery = async (event: React.FormEvent) => {
event.preventDefault();
await loadSchoolData(studentId, semester, { refresh: true });
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleQuery} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input value={studentId} onChange={(event) => setStudentId(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="pl-9 bg-black/20" required />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<select
value={semester}
onChange={(event) => 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]"
>
<option value="2025-spring">2025 </option>
<option value="2024-fall">2024 </option>
<option value="2024-spring">2024 </option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? '查询中...' : '查询课表'}
</Button>
<Button
type="submit"
variant="outline"
disabled={loading}
className="w-full"
onClick={() => setActiveTab('grades')}
>
{loading ? '查询中...' : '查询成绩'}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DatabaseIcon className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{queried ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryItem label="当前账号" value={studentId} icon={User} />
<SummaryItem label="当前学期" value={semester} icon={Calendar} />
<SummaryItem label="平均成绩" value={`${averageGrade}`} icon={Award} />
</div>
) : (
<div className="h-40 flex flex-col items-center justify-center text-slate-500 space-y-3 border border-dashed border-white/10 rounded-xl bg-white/[0.01]">
<Search className="w-8 h-8 opacity-50" />
<p className="text-sm"></p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
<button
onClick={() => setActiveTab('schedule')}
className={cn(
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'schedule' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white',
)}
>
</button>
<button
onClick={() => setActiveTab('grades')}
className={cn(
'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'grades' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white',
)}
>
</button>
</div>
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'schedule' ? <ScheduleView queried={queried} schedule={schedule} /> : <GradesView queried={queried} grades={grades} />}
</motion.div>
</div>
);
}
function DatabaseIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function SummaryItem({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-[#336EFF]/10 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-[#336EFF]" />
</div>
<div>
<p className="text-xs text-slate-400 mb-0.5">{label}</p>
<p className="text-sm font-medium text-white">{value}</p>
</div>
</div>
);
}
function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) {
if (!queried) {
return (
<Card>
<CardContent className="flex h-64 flex-col items-center justify-center text-slate-500">
<BookOpen className="mb-4 h-12 w-12 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
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 (
<Card className="overflow-hidden border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))]">
<CardHeader className="border-b border-white/8 bg-white/[0.02]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="flex flex-wrap gap-2 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 1-4 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 5 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 6-9 </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"> 10-12 </span>
</div>
</div>
</CardHeader>
<CardContent className="p-4 md:p-5">
<div className="overflow-x-auto">
<div
className="grid min-w-[1180px] gap-2"
style={{
gridTemplateColumns: '88px 96px repeat(7, minmax(138px, 1fr))',
gridTemplateRows: '48px repeat(12, 96px)',
}}
>
<div className="rounded-2xl bg-white/[0.04] px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
</div>
<div className="rounded-2xl bg-white/[0.04] px-3 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
</div>
{days.map((day) => (
<div key={day} className="rounded-2xl bg-white/[0.04] px-3 py-3 text-center text-sm font-medium text-slate-200">
{day}
</div>
))}
{periodOrder.map((period, index) => (
<div
key={period}
style={{
gridColumn: 1,
gridRow:
period === 'morning'
? '2 / span 4'
: period === 'noon'
? '6 / span 1'
: period === 'afternoon'
? '7 / span 4'
: '11 / span 3',
}}
className="flex h-full rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-4"
>
<div className="flex flex-1 items-center justify-center rounded-xl bg-black/20 text-sm font-semibold tracking-[0.25em] text-slate-300 [writing-mode:vertical-rl]">
{periodLabels[period]}
</div>
</div>
))}
{rows.map((row) => (
<div
key={`section-${row.section}`}
style={{ gridColumn: 2, gridRow: row.section + 1 }}
className="flex h-full flex-col justify-center rounded-2xl border border-white/8 bg-white/[0.03] px-3"
>
<span className="text-[11px] uppercase tracking-[0.22em] text-slate-500">Section</span>
<span className="mt-1 text-lg font-semibold text-white">{row.section}</span>
</div>
))}
{rows.flatMap((row) =>
row.slots.map((slot, columnIndex) => {
if (slot.type !== 'empty') {
return null;
}
return (
<div
key={`empty-${row.section}-${columnIndex}`}
style={{ gridColumn: columnIndex + 3, gridRow: row.section + 1 }}
className="rounded-2xl border border-dashed border-white/8 bg-white/[0.015]"
/>
);
}),
)}
{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 (
<div
key={`course-${row.section}-${columnIndex}`}
style={{ gridColumn: columnIndex + 3, gridRow: `${row.section + 1} / span ${rowSpan}` }}
className={cn(
'group relative z-10 flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border p-3 transition duration-200 hover:-translate-y-0.5 hover:brightness-110',
theme.panel,
theme.border,
theme.shadow,
)}
>
<div className={cn('absolute inset-x-0 top-0 h-1.5', theme.accent)} />
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm font-semibold leading-5', theme.title)}>
{slot.course?.courseName}
</p>
<span className={cn('shrink-0 rounded-full px-2 py-1 text-[10px] font-medium', theme.badge)}>
{formatSections(slot.course?.startTime, slot.course?.endTime)}
</span>
</div>
<div className="mt-3 space-y-2">
<p className={cn('flex items-center gap-1.5 text-xs', theme.meta)}>
<MapPin className="h-3.5 w-3.5" />
<span>{slot.course?.classroom ?? '教室待定'}</span>
</p>
<p className={cn('flex items-center gap-1.5 text-xs', theme.meta)}>
<User className="h-3.5 w-3.5" />
<span>{slot.course?.teacher ?? '教师待定'}</span>
</p>
</div>
</div>
);
}),
)}
</div>
</div>
</CardContent>
</Card>
);
}
function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) {
if (!queried) {
return (
<Card>
<CardContent className="h-64 flex flex-col items-center justify-center text-slate-500">
<Award className="w-12 h-12 mb-4 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
const terms = grades.reduce<Record<string, number[]>>((accumulator, grade) => {
const semester = grade.semester ?? '未分类';
if (!accumulator[semester]) {
accumulator[semester] = [];
}
accumulator[semester].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 (
<Card className="bg-[#0f172a]/80 backdrop-blur-sm border-slate-800/50">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-medium text-white"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{Object.entries(terms).map(([term, scores]) => (
<div key={term} className="flex flex-col">
<h3 className="mb-4 border-b border-white/5 pb-3 text-sm font-bold text-white">{term}</h3>
<div className="flex flex-col gap-2">
{scores.map((score, index) => (
<div
key={`${term}-${index}`}
className={cn(
'w-full rounded-full py-1.5 text-center text-xs font-mono font-medium transition-colors',
getScoreStyle(score),
)}
>
{score}
</div>
))}
</div>
</div>
))}
{Object.keys(terms).length === 0 ? <div className="text-sm text-slate-500"></div> : null}
</div>
</CardContent>
</Card>
);
}