feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
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-6 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 且 < 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">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user