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
+29 -18
View File
@@ -38,12 +38,21 @@ export interface ZcOverrides {
manual?: { title: string; section: string; score: number }[];
}
// 学期 → 统计时间窗 [start, end)
export function termRange(term: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})-(1|2)$/.exec(term);
// 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01)
export function yearRange(year: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})$/.exec(year);
if (!m) return null;
const y1 = Number(m[1]);
return m[3] === '1' ? [y1 + '-09-01', (y1 + 1) + '-03-01'] : [(y1 + 1) + '-03-01', (y1 + 1) + '-09-01'];
return [m[1] + '-09-01', m[2] + '-09-01'];
}
// 学年 → 年级名(按个人信息入学年份推算:大一~大四)
export function gradeLabel(year: string, eduStart?: string): string {
const m = /^(\d{4})-/.exec(year);
const e = /^(\d{4})/.exec(eduStart ?? '');
if (!m || !e) return year + ' 学年';
const n = Number(m[1]) - Number(e[1]) + 1;
const names = ['大一', '大二', '大三', '大四', '大五'];
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)';
}
function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
@@ -80,6 +89,7 @@ export interface ZcItem {
score: number;
overridden?: boolean;
note?: string;
fromRecord?: number;
}
export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number }
export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null }
@@ -94,7 +104,7 @@ function pickScore(r: ZcRules, levelKey: string | null, gradeKey: string | null)
// 计算某学期(term,或 'all' 全年)的综测
export function computeZongce(opts: {
term: string;
year: string;
records: FootprintRow[];
semesters: { term: string; gpa?: number | null; courses: CourseGrade[] }[];
bands?: GradeBand[];
@@ -103,14 +113,14 @@ export function computeZongce(opts: {
rules: ZcRules;
ov: ZcOverrides;
}): ZcResult {
const { term, records, semesters, bands, weights, profile, rules: r, ov } = opts;
const range = term === 'all' ? null : termRange(term);
const { year, records, semesters, bands, weights, profile, rules: r, ov } = opts;
const range = year === 'all' ? null : yearRange(year);
const scoped = records.filter(x => x.status !== 'draft' && inRange(x.start_date ?? x.created_at, range));
const ovItem = (k: string) => ov.itemOverrides?.[k];
const mk = (k: string, title: string, source: string, auto: number | null, note?: string): ZcItem => {
const mk = (k: string, title: string, source: string, auto: number | null, note?: string, fromRecord?: number): ZcItem => {
const o = ovItem(k);
const score = o?.score ?? (auto ?? 0);
return { itemKey: k, title, source, score, overridden: o?.score != null, note };
return { itemKey: k, title, source, score, overridden: o?.score != null, note, fromRecord };
};
const sec = (key: string, label: string, cap: number, items: ZcItem[]): ZcSection => ({
key, label, cap, items,
@@ -133,9 +143,9 @@ export function computeZongce(opts: {
// ---- 学业成绩(60):学年平均学分绩点 × 10 ----
const scopedCourses: CourseGrade[] = [];
for (const x of semesters) {
if (!inRange(null, null)) continue;
const rng = term === 'all' ? null : termRange(x.term);
const hit = term === 'all' || (range && rng && !(rng[1] <= range[0] || rng[0] >= range[1]));
if (range && !yearRange(x.term)) continue;
const rng = yearRange(x.term);
const hit = range === null || (rng && !(rng[1] <= range[0] || rng[0] >= range[1]));
if (hit) scopedCourses.push(...x.courses);
}
let gpa: number | null = null;
@@ -174,7 +184,7 @@ export function computeZongce(opts: {
for (const rec of scoped.filter(x => x.type_name === '文体活动')) {
const auto = pickScore(r, normLevel(rec.level), normGrade(rec.award_grade));
if (auto == null && !ovItem('w' + rec.id)?.score) continue;
wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto));
wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id));
}
const wCap = round2(Math.min(wItems.reduce((a, x) => a + x.score, 0), 5));
sections.push(sec('sports', '文体素质', 10, [...sportsItems, { itemKey: 'wcap', title: '文体类加分(上限 5 分)', source: wItems.length + ' 条记录', score: wCap, note: wItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }]));
@@ -184,7 +194,7 @@ export function computeZongce(opts: {
for (const rec of scoped.filter(x => x.type_name === '竞赛' || x.type_name === '科研')) {
const auto = pickScore(r, normLevel(rec.level), normGrade(rec.award_grade));
if (auto == null && !ovItem('c' + rec.id)?.score) continue;
compItems.push(mk('c' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto));
compItems.push(mk('c' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id));
}
const compCap = round2(Math.min(compItems.reduce((a, x) => a + x.score, 0), 10));
const workItems: ZcItem[] = [];
@@ -195,7 +205,7 @@ export function computeZongce(opts: {
let auto: number | null = null;
for (const [k, v] of Object.entries(tbl)) { if (role.includes(k)) { auto = v; break; } }
if (auto == null && !ovItem('k' + rec.id)?.score) continue;
workItems.push(mk('k' + rec.id, rec.title, '学生工作 · ' + (rec.level ?? '院级') + ' ' + (rec.role ?? ''), auto, auto == null ? '请手动填写分值' : undefined));
workItems.push(mk('k' + rec.id, rec.title, '学生工作 · ' + (rec.level ?? '院级') + ' ' + (rec.role ?? ''), auto, auto == null ? '请手动填写分值' : undefined, rec.id));
}
const workCap = round2(Math.min(workItems.reduce((a, x) => a + x.score, 0), 4));
const pubItems: ZcItem[] = [];
@@ -204,7 +214,7 @@ export function computeZongce(opts: {
const blood = /献血/.test(rec.title);
let auto: number | null = blood ? r.publicGood['无偿献血'] : (lvl ? r.publicGood[lvl] ?? null : null);
if (auto == null && !ovItem('g' + rec.id)?.score) continue;
pubItems.push(mk('g' + rec.id, rec.title, '公益 · ' + (blood ? '无偿献血' : rec.level ?? '校级'), auto));
pubItems.push(mk('g' + rec.id, rec.title, '公益 · ' + (blood ? '无偿献血' : rec.level ?? '校级'), auto, undefined, rec.id));
}
const pubCap = round2(Math.min(pubItems.reduce((a, x) => a + x.score, 0), 3));
sections.push(sec('expand', '拓展性素质', 17, [
@@ -218,6 +228,7 @@ export function computeZongce(opts: {
for (const rec of scoped.filter(x => x.type_name === '荣誉惩处' || x.type_name === '证书技能')) {
const o = ovItem('h' + rec.id);
const text = rec.title + ' ' + (rec.award_grade ?? '');
if (/综合测评奖学金/.test(text)) continue; // 细则:综合测评奖学金不加分
let auto: number | null = null;
let source = rec.type_name;
if (/留校察看/.test(text)) auto = -r.penalties['留校察看'];
@@ -241,7 +252,7 @@ export function computeZongce(opts: {
if (lkey && r.rewards['荣誉称号'][lkey] != null) { auto = r.rewards['荣誉称号'][lkey]; source = '荣誉 · ' + lkey; }
}
if (auto == null && !o?.score) continue;
rwItems.push(mk('h' + rec.id, rec.title, source, auto));
rwItems.push(mk('h' + rec.id, rec.title, source, auto, undefined, rec.id));
}
const rwCap = round2(Math.max(Math.min(rwItems.reduce((a, x) => a + x.score, 0), 5), -5));
sections.push(sec('rewards', '奖惩分', 5, [{ itemKey: 'hcap', title: '奖惩合计(±5 分)', source: rwItems.length + ' 条记录', score: rwCap, note: rwItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }]));
+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>