feat(assessment): auto comprehensive assessment - rules engine from college scoring standard, per-semester stats, manual overrides, word export
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Printer, RotateCcw } from 'lucide-react';
|
||||
import { footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
|
||||
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
|
||||
import { computeZongce, ZC_DEFAULT_RULES, type ZcRules } from '@/lib/zongce';
|
||||
import { Button, Select, useToast } from '@/components/ui';
|
||||
|
||||
export function AssessmentPage() {
|
||||
const toast = useToast();
|
||||
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
|
||||
const listQ = useQuery({ queryKey: ['zc-records'], queryFn: () => footprintList({ page: 1, page_size: 500 }) });
|
||||
const gradesQ = useQuery({ queryKey: ['grades'], queryFn: gradesOverview });
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) };
|
||||
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
|
||||
const [term, setTerm] = useState('');
|
||||
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!stored) return;
|
||||
setOvMap(stored.zongce?.overrides ?? {});
|
||||
const sems = (gradesQ.data?.semesters ?? []).map(x => x.term);
|
||||
if (!term && sems.length) setTerm(sems[0]);
|
||||
}, [stored, gradesQ.data]);
|
||||
|
||||
const records: FootprintRow[] = listQ.data?.items ?? [];
|
||||
const ov: ZcOverrides = ovMap[term] ?? {};
|
||||
const result = useMemo(
|
||||
() => computeZongce({
|
||||
term: term || 'all', records,
|
||||
semesters: gradesQ.data?.semesters ?? [],
|
||||
bands: gradesQ.data?.bands, weights: gradesQ.data?.weights,
|
||||
profile, rules, ov,
|
||||
}),
|
||||
[records, gradesQ.data, profile, rules, ov, term]
|
||||
);
|
||||
|
||||
const persistOv = (next: ZcOverrides) => {
|
||||
setOvMap(m => ({ ...m, [term]: next }));
|
||||
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [term]: next } } }).catch(e => toast('error', e.message));
|
||||
};
|
||||
|
||||
const resetOverrides = () => {
|
||||
const next = { ...ovMap };
|
||||
delete next[term];
|
||||
setOvMap(next);
|
||||
void profileUpsert({ info: profile, zongce: { rules, overrides: next } }).then(() => toast('success', '已恢复自动计算')).catch(e => toast('error', e.message));
|
||||
};
|
||||
|
||||
const termLabel = term === 'all' ? '全学年' : term;
|
||||
|
||||
const exportWord = () => {
|
||||
void import('@/lib/zongceWord').then(({ zongceToWordHtml }) => {
|
||||
const html = zongceToWordHtml(termLabel, result.sections, result.total, profile);
|
||||
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
toast('success', '综测材料已导出');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
|
||||
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-[15px] font-semibold text-ink-1">综合测评(自动计算)</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select className="w-[150px]" value={term} onChange={e => setTerm(e.target.value)}
|
||||
options={[...(gradesQ.data?.semesters ?? []).map(x => ({ value: x.term, label: x.term })), { value: 'all', label: '全学年' }]} />
|
||||
<Button variant="secondary" onClick={resetOverrides}><RotateCcw size={13} /> 恢复自动</Button>
|
||||
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出材料</Button>
|
||||
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-5 shadow-card sm:p-8">
|
||||
<h1 className="text-center text-[18px] font-bold text-neutral-900">学生综合素质测评</h1>
|
||||
<div className="mt-1 text-center text-[12px] text-neutral-500">{profile.name ?? ''} · {profile.className ?? ''} · 统计周期:{termLabel}</div>
|
||||
|
||||
<div className="mt-5 rounded-md bg-neutral-100 px-4 py-2 text-center text-[15px] font-bold text-neutral-900">
|
||||
综合素质总分:<span className="num">{result.total}</span> 分
|
||||
</div>
|
||||
|
||||
{result.sections.map(sec => (
|
||||
<div key={sec.key} className="mt-5">
|
||||
<div className="flex items-baseline justify-between border-b-2 border-neutral-800 pb-1">
|
||||
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}(上限 {sec.cap} 分)</span>
|
||||
<span className="num text-[12.5px] text-neutral-600">
|
||||
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)} 分
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{sec.items.map(it => (
|
||||
<div key={it.itemKey} className="flex items-center gap-2 text-[12.5px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-neutral-900">{it.title}</span>
|
||||
<span className="ml-2 text-[11px] text-neutral-400">{it.source}</span>
|
||||
{it.note && <div className="text-[11px] text-neutral-400">{it.note}</div>}
|
||||
</span>
|
||||
<input type="number" step="0.1" className="num w-[72px] rounded-sm border border-neutral-200 px-1 py-0.5 text-right text-[12px]"
|
||||
value={it.score} onChange={e => {
|
||||
if (it.itemKey === 'conduct' || it.itemKey === 'integrity') {
|
||||
persistOv({ ...ov, [it.itemKey]: Number(e.target.value) });
|
||||
} else {
|
||||
const io = { ...(ov.itemOverrides ?? {}), [it.itemKey]: { ...(ov.itemOverrides?.[it.itemKey] ?? {}), score: Number(e.target.value) } };
|
||||
persistOv({ ...ov, itemOverrides: io });
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
{!sec.items.length && <div className="text-[12px] text-neutral-400">暂无数据</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
|
||||
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
|
||||
「恢复自动」清除本学期全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user