Files
FootPrint/app/src/pages/grades/GradesPage.tsx
T

512 lines
22 KiB
TypeScript

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 <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
}
const semesters = [...(s?.semesters ?? [])].sort((a, b) => b.term.localeCompare(a.term));
return (
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
{/* 总览指标 */}
<div className="mb-4 flex flex-wrap gap-3">
<MetricCard label="总学分" value={s?.overall.credits ?? 0} />
<MetricCard label="加权平均分" value={s?.overall.avg != null ? s.overall.avg : '—'} />
<MetricCard label="已录课程" value={s?.overall.courses ?? 0} />
<MetricCard label="学期数" value={s?.semesters.length ?? 0} />
</div>
{/* 总览图表 */}
<div className="mb-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="单科成绩等级分布" />
<CardBody><div ref={tierRef} className="h-[220px]" /></CardBody>
</Card>
<Card>
<CardHeader title="学期趋势(加权平均分 / 绩点)" />
<CardBody><div ref={trendRef} className="h-[220px]" /></CardBody>
</Card>
</div>
{/* 成绩梯度设置 */}
<BandEditor bands={s?.bands ?? []} />
{/* 学年学科成绩 */}
<div className="mb-3 mt-5 flex items-center justify-between">
<h2 className="text-[14px] font-semibold text-ink-1">学年学科成绩</h2>
<AddSemesterButton />
</div>
{!semesters.length ? (
<Card><Empty
icon={<GraduationCap size={36} strokeWidth={1.5} />}
title="暂无学期成绩"
description="新增一个学期(如 2024-2025-1),再录入各科成绩。"
/></Card>
) : (
<div className="flex flex-col gap-5">
{groupByYear(semesters).map(([year, list]) => (
<section key={year}>
<div className="mb-2 text-[12px] font-medium text-ink-3">{year}</div>
<div className="flex flex-col gap-3">
{list.map(sem => <SemesterCard key={sem.id} sem={sem} />)}
</div>
</section>
))}
</div>
)}
</div>
);
}
function groupByYear(semesters: SemesterGrades[]): [string, SemesterGrades[]][] {
const map = new Map<string, SemesterGrades[]>();
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 (
<div className="min-w-[128px] flex-1 max-w-[220px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card">
<div className="text-[12px] text-ink-3">{label}</div>
<div className="num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1">{value}</div>
</div>
);
}
// ---------- 梯度设置 ----------
function BandEditor({ bands }: { bands: GradeBand[] }) {
const qc = useQueryClient();
const toast = useToast();
const [adding, setAdding] = useState<GradeBand | null>(null);
const [deleting, setDeleting] = useState<GradeBand | null>(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 (
<Card>
<CardHeader
title="成绩梯度"
extra={<span className="text-[11px] text-ink-3">按 分数 ≥ min 且 &lt; max 定级,满分档含上限</span>}
/>
<CardBody className="p-0">
<div className="divide-y divide-line">
{bands.map(b => (
<BandRow key={b.id} band={b} onSave={(next) => upMut.mutate(next)} onDelete={() => setDeleting(b)} />
))}
</div>
<div className="border-t border-line px-4 py-2.5">
{adding ? (
<BandRow
band={adding}
isNew
onChange={setAdding}
onSave={() => { upMut.mutate(adding); setAdding(null); }}
onCancel={() => setAdding(null)}
/>
) : (
<Button size="sm" variant="secondary" onClick={() => setAdding({ id: 0, label: '', color: '#3056d3', min: 0, max: 60, sort: (bands.at(-1)?.sort ?? 0) + 10 })}>
<Plus size={13} /> 添加等级
</Button>
)}
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除等级"
content={deleting ? '确定删除「' + deleting.label + '」吗?已录成绩将不再显示该等级。' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delMut.mutate(deleting.id)}
/>
</Card>
);
}
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<GradeBand>) => {
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 (
<div className="flex flex-wrap items-center gap-2 px-4 py-2">
<input type="color" value={value.color} onChange={e => set({ color: e.target.value })}
className="size-5 shrink-0 cursor-pointer rounded border border-line bg-surface" aria-label="颜色" />
<Input className="w-[96px]" value={value.label} onChange={e => set({ label: e.target.value })} placeholder="等级名" />
<span className="text-[12px] text-ink-3">分数</span>
<Input type="number" className="num w-[72px]" value={String(value.min)} onChange={e => set({ min: Number(e.target.value) })} />
<span className="text-[12px] text-ink-3">至</span>
<Input type="number" className="num w-[72px]" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
{isNew ? (
<>
<Button size="sm" variant="primary" onClick={() => onSave?.(value)}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => onCancel?.()}>取消</Button>
</>
) : (
<div className="ml-auto flex gap-1.5">
<Button size="sm" variant={dirty ? 'primary' : 'secondary'} disabled={!dirty} onClick={() => onSave?.(value)}>保存</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除等级" onClick={() => onDelete?.()}>
<Trash2 size={13} />
</Button>
</div>
)}
</div>
);
}
// ---------- 学期卡片 ----------
function SemesterCard({ sem }: { sem: SemesterGrades }) {
const qc = useQueryClient();
const toast = useToast();
const [editingId, setEditingId] = useState<number | null>(null);
const [deleting, setDeleting] = useState<CourseGrade | null>(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 (
<Card>
<CardHeader
title={sem.term}
extra={
<div className="flex items-center gap-2">
<Badge color="var(--brand-text)">绩点 {sem.gpa ?? '—'}</Badge>
{sem.rank != null && <Badge color="var(--success)">排名 {sem.rank}/{sem.total_students ?? '—'}</Badge>}
<Badge color="var(--text-3)">均分 {sem.stats.avg ?? '—'}</Badge>
<Badge color="var(--text-3)">{sem.stats.credits} 学分</Badge>
<Button size="sm" variant="ghost" onClick={() => autoGpaMut.mutate()} disabled={autoGpaMut.isPending}>
<Calculator size={13} /> 自动绩点
</Button>
<Button size="icon-sm" variant="ghost" aria-label="编辑学期" onClick={() => setSemDialog(true)}>
<Pencil size={13} />
</Button>
</div>
}
/>
<CardBody className="p-0">
<div className="overflow-x-auto">
<div className="-mx-1 overflow-x-auto px-1"><table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{['课程', '学分', '分数', '等级', '绩点', ''].map(h => (
<th key={h} className="border-b border-line bg-surface-2 px-3 py-2 text-left text-[12px] font-semibold text-ink-3">{h}</th>
))}
</tr>
</thead>
<tbody>
{sem.courses.map(c => (
editingId === c.id
? <CourseEditRow key={c.id} sem={sem} course={c} onDone={() => setEditingId(null)} />
: (
<tr key={c.id} className="transition-colors hover:bg-surface-2">
<td className="border-b border-line px-3 py-2 font-medium text-ink-1">{c.course_name}</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.credit}</td>
<td className="num border-b border-line px-3 py-2">
{c.score != null ? <span className="font-medium">{c.score}</span> : <span className="text-ink-3">未录入</span>}
</td>
<td className="border-b border-line px-3 py-2">
{c.band ? <Badge color={c.band.color}>{c.band.label}</Badge> : <span className="text-ink-3">—</span>}
</td>
<td className="num border-b border-line px-3 py-2 text-ink-2">{c.grade_point ?? '—'}</td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1">
<Button size="icon-sm" variant="ghost" aria-label="编辑课程" onClick={() => setEditingId(c.id)}>
<Pencil size={13} />
</Button>
<Button size="icon-sm" variant="ghost" aria-label="删除课程" className="text-ink-3 hover:text-danger" onClick={() => setDeleting(c)}>
<Trash2 size={13} />
</Button>
</div>
</td>
</tr>
)
))}
<CourseAddRow sem={sem} />
</tbody>
</table></div>
</div>
</CardBody>
<Confirm
open={!!deleting}
title="删除课程"
content={deleting ? '确定删除「' + deleting.course_name + '」吗?' : ''}
onCancel={() => setDeleting(null)}
onOk={() => deleting && delCourse.mutate(deleting.id)}
/>
<SemesterDialog
open={semDialog}
sem={sem}
onClose={() => setSemDialog(false)}
deleteMut={delSem}
/>
</Card>
);
}
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 (
<tr className="bg-surface-2">
<td className="px-3 py-2"><Input className="h-7 w-[160px]" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" placeholder="0" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="px-3 py-2"><Input type="number" className="num h-7 w-[72px]" placeholder="0-100" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="px-3 py-2 text-ink-3">—</td>
<td className="px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" placeholder="选填" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="px-3 py-2">
<Button size="sm" variant="secondary" disabled={!name.trim() || addMut.isPending} onClick={() => addMut.mutate()}>
<Plus size={13} /> 添加
</Button>
</td>
</tr>
);
}
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 (
<tr className="bg-brand-subtle/40">
<td className="border-b border-line px-3 py-2"><Input className="h-7 w-[160px]" value={name} onChange={e => setName(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.5" className="num h-7 w-[64px]" value={credit} onChange={e => setCredit(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2"><Input type="number" className="num h-7 w-[72px]" value={score} onChange={e => setScore(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2 text-[12px] text-ink-3">保存后按梯度定级</td>
<td className="border-b border-line px-3 py-2"><Input type="number" step="0.1" className="num h-7 w-[64px]" value={gp} onChange={e => setGp(e.target.value)} /></td>
<td className="border-b border-line px-3 py-2">
<div className="flex justify-end gap-1.5">
<Button size="sm" variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
<Button size="sm" variant="ghost" onClick={onDone}>取消</Button>
</div>
</td>
</tr>
);
}
// ---------- 学期编辑 ----------
function AddSemesterButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="primary" size="md" onClick={() => setOpen(true)}>
<Plus size={14} /> 新增学期
</Button>
<SemesterDialog open={open} sem={null} onClose={() => 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 (
<Dialog
open={open}
onClose={onClose}
width={480}
title={sem ? '编辑学期 · ' + sem.term : '新增学期'}
footer={
<>
{error && <span className="mr-auto text-[12px] text-danger">{error}</span>}
{sem && (
<Button variant="danger-ghost" className="mr-auto" onClick={() => deleteMut?.mutate()} disabled={deleteMut?.isPending}>
<Trash2 size={13} /> 删除学期
</Button>
)}
<Button variant="secondary" onClick={onClose}>取消</Button>
<Button variant="primary" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>保存</Button>
</>
}
>
<div className="flex flex-col gap-3.5">
<Field label="学期名称" required hint="如 2024-2025-1">
<Input value={term} onChange={e => setTerm(e.target.value)} placeholder="2024-2025-1" />
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="绩点">
<Input type="number" step="0.01" className="num" value={gpa} onChange={e => setGpa(e.target.value)} placeholder="选填" />
</Field>
<Field label="排名">
<Input type="number" className="num" value={rank} onChange={e => setRank(e.target.value)} placeholder="选填" />
</Field>
<Field label="年级人数">
<Input type="number" className="num" value={total} onChange={e => setTotal(e.target.value)} placeholder="选填" />
</Field>
</div>
<p className="flex items-start gap-1.5 rounded-md bg-surface-2 px-3 py-2 text-[12px] text-ink-3">
<Calculator size={13} className="mt-0.5 shrink-0" />
绩点可手动填写,也可在卡片上点「自动绩点」按课程学分加权计算。
</p>
</div>
</Dialog>
);
}