ui(grades): mobile course cards + inline add/edit forms (table desktop-only), band row range-pill redesign

This commit is contained in:
root
2026-10-17 11:55:24 +08:00
parent 07b3a0490e
commit 0905cebb02
15 changed files with 145 additions and 42 deletions
+124 -21
View File
@@ -221,27 +221,35 @@ function BandRow({ band, onSave, onDelete, onChange, onCancel, isNew }: {
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 className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2.5">
<div className="flex items-center gap-2">
<input type="color" value={value.color} onChange={e => set({ color: e.target.value })}
className="size-6 shrink-0 cursor-pointer rounded border border-line bg-surface" aria-label="颜色" />
<Input className="min-w-0 flex-1 sm:w-[96px] sm:flex-none" value={value.label} onChange={e => set({ label: e.target.value })} placeholder="等级名" />
</div>
)}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border border-line bg-surface-2 px-2.5 py-1">
<span className="text-[11px] text-ink-3">分数</span>
<Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0 text-right" value={String(value.min)} onChange={e => set({ min: Number(e.target.value) })} />
<span className="text-[11.5px] text-ink-3">—</span>
<Input type="number" className="num h-6 w-[64px] border-0 bg-transparent p-0" value={String(value.max)} onChange={e => set({ max: Number(e.target.value) })} />
</div>
<div className="flex items-center gap-1.5 sm:ml-auto">
{isNew ? (
<>
<Button size="sm" variant="primary" onClick={() => onSave?.(value)}>添加</Button>
<Button size="sm" variant="ghost" onClick={() => onCancel?.()}>取消</Button>
</>
) : (
<>
<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>
</div>
);
}
@@ -296,7 +304,7 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
}
/>
<CardBody className="p-0">
<div className="overflow-x-auto">
<div className="hidden overflow-x-auto md:block">
<div className="-mx-1 overflow-x-auto px-1"><table className="w-full border-collapse text-[13px]">
<thead>
<tr>
@@ -339,6 +347,34 @@ function SemesterCard({ sem }: { sem: SemesterGrades }) {
</div>
</CardBody>
{/* 手机端:课程卡片列表 + 添加表单(表格在窄屏不可用) */}
<div className="divide-y divide-line border-t border-line md:hidden">
{sem.courses.map(c => editingId === c.id ? (
<MobileCourseEdit key={'e' + c.id} sem={sem} course={c} onDone={() => setEditingId(null)} />
) : (
<div key={c.id} className="p-3">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-1">{c.course_name}</span>
{c.band && <Badge color={c.band.color}>{c.band.label}</Badge>}
</div>
<div className="num mt-1 flex flex-wrap gap-x-3 text-[11.5px] text-ink-3">
<span>学分 {c.credit}</span>
<span>分数 {c.score ?? '—'}</span>
<span>绩点 {c.grade_point ?? '—'}</span>
</div>
<div className="mt-2 flex gap-1.5">
<Button size="sm" variant="secondary" onClick={() => setEditingId(c.id)}>
<Pencil size={12} /> 编辑
</Button>
<Button size="sm" variant="ghost" className="text-ink-3 hover:text-danger" onClick={() => setDeleting(c)}>
<Trash2 size={12} /> 删除
</Button>
</div>
</div>
))}
<MobileCourseAdd sem={sem} />
</div>
<Confirm
open={!!deleting}
title="删除课程"
@@ -427,6 +463,73 @@ function CourseEditRow({ sem, course, onDone }: { sem: SemesterGrades; course: C
);
}
// ---------- 手机端课程表单 ----------
function MobileCourseAdd({ 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 saveMut = 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'] });
toast('success', '课程已添加');
},
onError: (e: Error) => toast('error', e.message)
});
return (
<div className="p-3">
<Input className="mb-2" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} />
<div className="mb-2 grid grid-cols-3 gap-2">
<Input type="number" step="0.5" placeholder="学分" value={credit} onChange={e => setCredit(e.target.value)} />
<Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} />
<Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} />
</div>
<Button size="sm" variant="primary" disabled={!name.trim() || saveMut.isPending} onClick={() => saveMut.mutate()}>
<Plus size={13} /> 添加课程
</Button>
</div>
);
}
function MobileCourseEdit({ 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 (
<div className="bg-brand-subtle/40 p-3">
<Input className="mb-2" placeholder="课程名称" value={name} onChange={e => setName(e.target.value)} />
<div className="mb-2 grid grid-cols-3 gap-2">
<Input type="number" step="0.5" placeholder="学分" value={credit} onChange={e => setCredit(e.target.value)} />
<Input type="number" placeholder="分数" value={score} onChange={e => setScore(e.target.value)} />
<Input type="number" step="0.1" placeholder="绩点" value={gp} onChange={e => setGp(e.target.value)} />
</div>
<div className="flex gap-1.5">
<Button size="sm" variant="primary" onClick={() => saveMut.mutate()}>保存</Button>
<Button size="sm" variant="ghost" onClick={onDone}>取消</Button>
</div>
</div>
);
}
// ---------- 学期编辑 ----------
function AddSemesterButton() {
const [open, setOpen] = useState(false);