test: demo seed now 18 footprints across two academic years

This commit is contained in:
root
2026-10-17 16:05:34 +08:00
parent 952a07fe6e
commit caa5cac7b7
24 changed files with 247 additions and 113 deletions
+122 -27
View File
@@ -1,11 +1,65 @@
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 { attachmentList, attachmentThumb, 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 { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
import { Button, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; images: string[] }
function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// MHTML:把图片内嵌进 Word(.doc)的可靠方式
function buildMhtml(html: string, images: { name: string; dataUri: string }[]): Blob {
const boundary = '----=_fp_zongce';
const parts: string[] = [];
parts.push('--' + boundary + '\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n' + html + '\r\n');
for (const img of images) {
const idx = img.dataUri.indexOf(',');
const meta = img.dataUri.slice(0, idx);
const b64 = img.dataUri.slice(idx + 1);
const mime = /data:([^;]+)/.exec(meta)?.[1] ?? 'image/png';
parts.push('--' + boundary + '\r\nContent-Type: ' + mime + '\r\nContent-Transfer-Encoding: base64\r\nContent-Location: ' + img.name + '\r\n\r\n' + b64 + '\r\n');
}
parts.push('--' + boundary + '--\r\n');
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
}
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob {
const body = result.sections.map(sec => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
const rows = sec.items.map(it =>
'<tr>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999">' + esc(it.title) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;font-size:10pt;color:#555">' + esc(it.note || it.source) + '</td>' +
'<td style="padding:3pt 6pt;border:0.75pt solid #999;text-align:right;width:70pt">' + esc(it.score) + '</td></tr>').join('');
return '<h2 style="font-size:13pt;margin:12pt 0 5pt">' + esc(sec.label) + '(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
}).join('');
const images: { name: string; dataUri: string }[] = [];
const annex = evidence.map(ev => {
let imgs = '';
ev.images.forEach((uri, i) => {
const name = 'ev' + ev.recordId + '_' + i;
images.push({ name, dataUri: uri });
imgs += '<div style="font-size:10pt;color:#555">图 ' + (i + 1) + '</div><img src="' + name + '" style="width:300pt">';
});
return '<div style="margin-bottom:10pt"><b style="font-size:11pt">' + esc(ev.title) + '</b>' + imgs + '</div>';
}).join('');
const html = '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
'<head><meta charset="utf-8"><title>综合素质测评</title></head>' +
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
'<h1 style="text-align:center;font-size:18pt;margin:0 0 6pt">学生综合素质测评</h1>' +
'<div style="text-align:center;color:#555;font-size:11pt">' + esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>' +
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(result.total) + ' 分</b></p>' +
body + (annex ? '<h2 style="font-size:13pt;margin:16pt 0 6pt">附:证明材料</h2>' + annex : '') +
'<table style="width:100%;border-collapse:collapse;margin-top:24pt;font-size:11pt"><tr>' +
'<td style="width:33%">学生签名:______</td><td style="width:33%">辅导员签名:______</td><td>日期:______</td></tr></table>' +
'</body></html>';
return buildMhtml(html, images);
}
export function AssessmentPage() {
const toast = useToast();
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
@@ -15,61 +69,86 @@ export function AssessmentPage() {
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 [year, setYear] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]);
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
const sems = (gradesQ.data?.semesters ?? []).map(x => x.term);
if (!term && sems.length) setTerm(sems[0]);
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7))));
if (!year && years.length) setYear(years[0]);
}, [stored, gradesQ.data]);
const records: FootprintRow[] = listQ.data?.items ?? [];
const ov: ZcOverrides = ovMap[term] ?? {};
const ov: ZcOverrides = ovMap[year] ?? {};
const result = useMemo(
() => computeZongce({
term: term || 'all', records,
year: year || 'all', records,
semesters: gradesQ.data?.semesters ?? [],
bands: gradesQ.data?.bands, weights: gradesQ.data?.weights,
profile, rules, ov,
}),
[records, gradesQ.data, profile, rules, ov, term]
[records, gradesQ.data, profile, rules, ov, year]
);
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 matchedIds = useMemo(() => {
const ids: number[] = [];
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); }));
return ids;
}, [result]);
useEffect(() => {
let alive = true;
(async () => {
const out: Evidence[] = [];
for (const rid of matchedIds) {
const rec = records.find(r => r.id === rid);
try {
const attList = await attachmentList(rid);
const images: string[] = [];
for (const att of attList.items.filter(x => x.id).slice(0, 3)) {
const th = await attachmentThumb(att.id);
if (th.data_b64) images.push('data:' + (att.mime === 'image/jpeg' ? 'image/jpeg' : 'image/webp') + ';base64,' + th.data_b64);
}
if (images.length) out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, images });
} catch {}
}
if (alive) setEvidence(out);
})();
return () => { alive = false; };
}, [matchedIds.join(',')]);
const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [year]: next }));
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [year]: next } } }).catch(e => toast('error', e.message));
};
const resetOverrides = () => {
const next = { ...ovMap };
delete next[term];
delete next[year];
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 termLabel = year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart);
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', '综测材料已导出');
});
const blob = zongceWordMhtml(termLabel, result, profile, evidence);
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', '综测材料已导出' + (evidence.length ? '(含 ' + evidence.length + ' 条证明材料图片)' : ''));
};
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>
<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: '全学年' }]} />
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7)))).map(y => ({ value: y, label: gradeLabel(y, profile.eduStart) })), { 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>
@@ -116,9 +195,25 @@ export function AssessmentPage() {
</div>
))}
{evidence.length > 0 && (
<div className="mt-6 border-t border-neutral-300 pt-4">
<div className="mb-3 text-[13.5px] font-bold text-neutral-900">附:证明材料</div>
{evidence.map(ev => (
<div key={ev.recordId} className="mb-4">
<div className="text-[12px] font-semibold text-neutral-800">{ev.title}</div>
<div className="mt-1.5 flex flex-wrap gap-3">
{ev.images.map((src, i) => (
<img key={i} src={src} alt={'证明 ' + (i + 1)} className="max-h-[180px] rounded border border-neutral-200" />
))}
</div>
</div>
))}
</div>
)}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
「恢复自动」清除本学期全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
「恢复自动」清除本学年全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
</div>
</div>
</div>