import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import * as echarts from 'echarts/core'; import { BarChart, LineChart, PieChart } from 'echarts/charts'; import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; import { Plus, Trash2, Pencil, Calculator, GraduationCap } from 'lucide-react'; import { bandDelete, bandUpsert, courseDelete, courseUpsert, gradesOverview, semesterDelete, semesterUpsert } from '@/lib/api'; import type { CourseGrade, GradeBand, SemesterGrades } from '@/lib/types'; import { Badge, Button, Card, CardBody, CardHeader, Confirm, Dialog, Empty, Field, Input, useToast } from '@/components/ui'; import { useChart, inkColor, useThemeTick } from '@/hooks/useChart'; echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); function termYear(term: string): string { const m = /^(\d{4})-(\d{4})/.exec(term); return m ? m[1] + '~' + m[2] + ' 学年' : '其他学期'; } function autoGpa(courses: CourseGrade[]): number | null { const withGp = courses.filter(c => c.grade_point != null && c.credit > 0); if (!withGp.length) return null; const credits = withGp.reduce((a, c) => a + c.credit, 0); const weighted = withGp.reduce((a, c) => a + (c.grade_point as number) * c.credit, 0); return Math.round((weighted / credits) * 100) / 100; } export function GradesPage() { const themeTick = useThemeTick(); const overviewQ = useQuery({ queryKey: ['grades'], queryFn: gradesOverview }); const s = overviewQ.data; const ink2 = inkColor('2'); const ink3 = inkColor('3'); const tierRef = useChart(() => { const items = (s?.overall.tier_dist ?? []).filter(t => t.count > 0); return { tooltip: { trigger: 'item' }, legend: { orient: 'vertical', right: 8, top: 'middle', itemGap: 10, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 10, itemHeight: 10 }, series: [{ type: 'pie', radius: ['48%', '72%'], center: ['34%', '50%'], label: { show: false }, data: items.map(t => ({ name: t.label, value: t.count, itemStyle: { color: t.color } })) }] }; }, [s, themeTick]); const trendRef = useChart(() => { const t = s?.overall.gpa_trend ?? []; return { grid: { left: 8, right: 8, top: 36, bottom: 6, containLabel: true }, tooltip: { trigger: 'axis' }, legend: { top: 0, left: 'center', itemGap: 16, textStyle: { color: ink2, fontSize: 11 }, itemWidth: 14, itemHeight: 8 }, xAxis: { type: 'category', data: t.map(x => x.term), axisLine: { lineStyle: { color: ink3 } }, axisTick: { show: false }, axisLabel: { color: ink3, fontSize: 10 } }, yAxis: [ { type: 'value', min: 0, max: 100, splitLine: { lineStyle: { color: 'rgba(128,138,155,0.15)' } }, axisLabel: { color: ink3, fontSize: 10 } }, { type: 'value', min: 0, max: 5, splitLine: { show: false }, axisLabel: { color: ink3, fontSize: 10 } } ], series: [ { name: '加权平均分', type: 'bar', barMaxWidth: 18, itemStyle: { color: '#3056d3', borderRadius: [3, 3, 0, 0] }, data: t.map(x => x.avg) }, { name: '绩点', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, itemStyle: { color: '#d97706' }, lineStyle: { width: 2 }, data: t.map(x => x.gpa) } ] }; }, [s, themeTick]); if (overviewQ.isLoading) { return
加载中…
; } const semesters = [...(s?.semesters ?? [])].sort((a, b) => b.term.localeCompare(a.term)); return (
{/* 总览指标 */}
{/* 总览图表 */}
{/* 成绩梯度设置 */} {/* 学年学科成绩 */}

学年学科成绩

{!semesters.length ? ( } title="暂无学期成绩" description="新增一个学期(如 2024-2025-1),再录入各科成绩。" /> ) : (
{groupByYear(semesters).map(([year, list]) => (
{year}
{list.map(sem => )}
))}
)}
); } function groupByYear(semesters: SemesterGrades[]): [string, SemesterGrades[]][] { const map = new Map(); for (const s of semesters) { const y = termYear(s.term); if (!map.has(y)) map.set(y, []); map.get(y)!.push(s); } return [...map.entries()]; } function MetricCard({ label, value }: { label: string; value: number | string }) { return (
{label}
{value}
); } // ---------- 梯度设置 ---------- function BandEditor({ bands }: { bands: GradeBand[] }) { const qc = useQueryClient(); const toast = useToast(); const [adding, setAdding] = useState(null); const [deleting, setDeleting] = useState(null); const upMut = useMutation({ mutationFn: bandUpsert, onSuccess: () => { toast('success', '梯度已保存'); void qc.invalidateQueries({ queryKey: ['grades'] }); }, onError: (e: Error) => toast('error', e.message) }); const delMut = useMutation({ mutationFn: bandDelete, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); setDeleting(null); } }); return ( 按 分数 ≥ min 且 < max 定级,满分档含上限} />
{bands.map(b => ( upMut.mutate(next)} onDelete={() => setDeleting(b)} /> ))}
{adding ? ( { upMut.mutate(adding); setAdding(null); }} onCancel={() => setAdding(null)} /> ) : ( )}
setDeleting(null)} onOk={() => deleting && delMut.mutate(deleting.id)} />
); } function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: { band: GradeBand; isNew?: boolean; onSave?: (b: GradeBand) => void; onDelete?: () => void; onChange?: (b: GradeBand) => void; onCancel?: () => void; }) { const [draft, setDraft] = useState(band); const value = isNew && onChange ? draft : band; const set = (patch: Partial) => { const next = { ...value, ...patch }; if (onChange) onChange(next); else setDraft(next); }; const dirty = isNew || JSON.stringify({ ...value, id: 0 }) !== JSON.stringify({ ...band, id: 0 }); return (
set({ color: e.target.value })} className="size-5 shrink-0 cursor-pointer rounded border border-line bg-surface" aria-label="颜色" /> set({ label: e.target.value })} placeholder="等级名" /> 分数 set({ min: Number(e.target.value) })} /> 至 set({ max: Number(e.target.value) })} /> {isNew ? ( <> ) : (
)}
); } // ---------- 学期卡片 ---------- function SemesterCard({ sem }: { sem: SemesterGrades }) { const qc = useQueryClient(); const toast = useToast(); const [editingId, setEditingId] = useState(null); const [deleting, setDeleting] = useState(null); const [semDialog, setSemDialog] = useState(false); const invalidate = () => void qc.invalidateQueries({ queryKey: ['grades'] }); const delCourse = useMutation({ mutationFn: courseDelete, onSuccess: () => { invalidate(); setDeleting(null); } }); const autoGpaMut = useMutation({ mutationFn: () => { const gpa = autoGpa(sem.courses); if (gpa == null) throw new Error('请先为课程填写绩点'); return semesterUpsert({ id: sem.id, term: sem.term, gpa, rank: sem.rank ?? null, total_students: sem.total_students ?? null }); }, onSuccess: () => { toast('success', '绩点已按课程自动计算'); invalidate(); }, onError: (e: Error) => toast('error', e.message) }); const delSem = useMutation({ mutationFn: () => semesterDelete(sem.id), onSuccess: () => { invalidate(); setSemDialog(false); toast('success', '学期已删除'); } }); return ( 绩点 {sem.gpa ?? '—'} {sem.rank != null && 排名 {sem.rank}/{sem.total_students ?? '—'}} 均分 {sem.stats.avg ?? '—'} {sem.stats.credits} 学分
} />
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => ( ))} {sem.courses.map(c => ( editingId === c.id ? setEditingId(null)} /> : ( ) ))}
{h}
{c.course_name} {c.credit} {c.score != null ? {c.score} : 未录入} {c.band ? {c.band.label} : —} {c.grade_point ?? '—'}
setDeleting(null)} onOk={() => deleting && delCourse.mutate(deleting.id)} /> setSemDialog(false)} deleteMut={delSem} /> ); } function CourseAddRow({ sem }: { sem: SemesterGrades }) { const qc = useQueryClient(); const toast = useToast(); const [name, setName] = useState(''); const [credit, setCredit] = useState(''); const [score, setScore] = useState(''); const [gp, setGp] = useState(''); const addMut = useMutation({ mutationFn: () => courseUpsert({ semester_id: sem.id, course_name: name, credit: Number(credit) || 0, score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp) }), onSuccess: () => { setName(''); setCredit(''); setScore(''); setGp(''); void qc.invalidateQueries({ queryKey: ['grades'] }); }, onError: (e: Error) => toast('error', e.message) }); return ( setName(e.target.value)} /> setCredit(e.target.value)} /> setScore(e.target.value)} /> — setGp(e.target.value)} /> ); } function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: CourseGrade; onDone: () => void }) { const qc = useQueryClient(); const toast = useToast(); const [name, setName] = useState(course.course_name); const [credit, setCredit] = useState(String(course.credit)); const [score, setScore] = useState(course.score != null ? String(course.score) : ''); const [gp, setGp] = useState(course.grade_point != null ? String(course.grade_point) : ''); const saveMut = useMutation({ mutationFn: () => courseUpsert({ id: course.id, semester_id: sem.id, course_name: name, credit: Number(credit) || 0, score: score === '' ? null : Number(score), grade_point: gp === '' ? null : Number(gp) }), onSuccess: () => { void qc.invalidateQueries({ queryKey: ['grades'] }); onDone(); }, onError: (e: Error) => toast('error', e.message) }); return ( setName(e.target.value)} /> setCredit(e.target.value)} /> setScore(e.target.value)} /> 保存后按梯度定级 setGp(e.target.value)} />
); } // ---------- 学期编辑 ---------- function AddSemesterButton() { const [open, setOpen] = useState(false); return ( <> setOpen(false)} /> ); } function SemesterDialog({ open, sem, onClose, deleteMut }: { open: boolean; sem: SemesterGrades | null; onClose: () => void; deleteMut?: { mutate: () => void; isPending: boolean }; }) { const qc = useQueryClient(); const toast = useToast(); const [term, setTerm] = useState(sem?.term ?? ''); const [gpa, setGpa] = useState(sem?.gpa != null ? String(sem.gpa) : ''); const [rank, setRank] = useState(sem?.rank != null ? String(sem.rank) : ''); const [total, setTotal] = useState(sem?.total_students != null ? String(sem.total_students) : ''); const [error, setError] = useState(''); const saveMut = useMutation({ mutationFn: () => semesterUpsert({ id: sem?.id, term, gpa: gpa === '' ? null : Number(gpa), rank: rank === '' ? null : Number(rank), total_students: total === '' ? null : Number(total) }), onSuccess: () => { toast('success', sem ? '学期已更新' : '学期已新增'); void qc.invalidateQueries({ queryKey: ['grades'] }); onClose(); }, onError: (e: Error) => setError(e.message) }); return ( {error && {error}} {sem && ( )} } >
setTerm(e.target.value)} placeholder="2024-2025-1" />
setGpa(e.target.value)} placeholder="选填" /> setRank(e.target.value)} placeholder="选填" /> setTotal(e.target.value)} placeholder="选填" />

绩点可手动填写,也可在卡片上点「自动绩点」按课程学分加权计算。

); }