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 (
);
}
// ---------- 梯度设置 ----------
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)}
/>
) : (
setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
添加等级
)}
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 (
);
}
// ---------- 学期卡片 ----------
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} 学分
autoGpaMut.mutate()} disabled={autoGpaMut.isPending}>
自动绩点
setSemDialog(true)}>
}
/>
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => (
{h}
))}
{sem.courses.map(c => (
editingId === c.id
? setEditingId(null)} />
: (
{c.course_name}
{c.credit}
{c.score != null ? {c.score} : 未录入 }
{c.band ? {c.band.label} : — }
{c.grade_point ?? '—'}
setEditingId(c.id)}>
setDeleting(c)}>
)
))}
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)} />
addMut.mutate()}>
添加
);
}
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)} />
saveMut.mutate()} disabled={saveMut.isPending}>保存
取消
);
}
// ---------- 学期编辑 ----------
function AddSemesterButton() {
const [open, setOpen] = useState(false);
return (
<>
setOpen(true)}>
新增学期
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 && (
deleteMut?.mutate()} disabled={deleteMut?.isPending}>
删除学期
)}
取消
saveMut.mutate()} disabled={saveMut.isPending}>保存
>
}
>
);
}