test: demo seed now 18 footprints across two academic years
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
// 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>
|
||||
|
||||
Reference in New Issue
Block a user