ui(grades): mobile course cards + inline add/edit forms (table desktop-only), band row range-pill redesign
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user