Files
FootPrint/app/src/pages/assessment/AssessmentPage.tsx
T
root ba448946c9 fix(assessment): clickable rules panel at top, print-layout word doc polish
- scoring rules panel moved above the sheet and auto-scrolls into view on toggle (was rendered at the very bottom, felt unresponsive)
- word export: unified single spec table with numbered section rows (one/two/three...), subtotal and total rows, editorial heading fonts
- standardized fonts and metrics: body SimSun 10.5pt, headings SimHei, numerals Times New Roman, line-height, A4 margins 72/78/66pt
- footer with PAGE/NUMPAGES fields via mso-element footer, Word opens in Print layout view
- evidence captions and rules annex restyled to match; www rebuilt
2026-10-17 17:48:16 +08:00

585 lines
35 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Download, FileText, Images, Printer, RotateCcw } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
import { computeZongce, gradeLabel, termYearOf, yearRange, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
import { Badge, Button, Dialog, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; meta: string; images: string[] }
function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// ---------------- Word 导出(MHTML 单文件 .doc,图片内嵌) ----------------
function buildMhtml(html: string, images: { name: string; dataUri: string }[]): Blob {
const boundary = '----=_fp_zongce';
const parts: string[] = [];
parts.push(
'MIME-Version: 1.0\r\n' +
'Content-Type: multipart/related; type="text/html"; boundary="' + boundary + '"\r\n\r\n' +
'--' + boundary + '\r\n' +
'Content-Location: document.html\r\n' +
'Content-Type: text/html; charset="utf-8"\r\n' +
'Content-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\n' +
'Content-Location: ' + img.name + '\r\n' +
'Content-Type: ' + mime + '\r\n' +
'Content-Transfer-Encoding: base64\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 + '"' });
}
const W_TD = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:10.5pt;vertical-align:middle';
const W_TH = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:10.5pt;font-family:"黑体",SimHei;letter-spacing:1pt;vertical-align:middle;background:#eef1f6';
const W_SEC = 'border:0.75pt solid #3a3a3a;padding:5pt 8pt;font-size:11pt;font-family:"黑体",SimHei;letter-spacing:1.5pt;background:#f5f6f9;text-align:left';
// Word 域代码(页码等):条件注释 + field-begin/separator/end,Word 打开时自动计算
const W_FIELD = (name: string, shown: string) =>
'<!--[if supportFields]><span style="mso-element:field-begin"></span>' + name +
'<span style="mso-element:field-separator"></span><![endif]-->' + shown +
'<!--[if supportFields]><span style="mso-element:field-end"></span><![endif]-->';
/** 评分细则参考(附在 Word 末尾,与页面「评分细则参考」同一数据源) */
function rulesAnnexHtml(r: ZcRules): string {
const basics =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:30%">基础项</td><td style="' + W_TH + '">分值 / 规则</td></tr>' +
'<tr><td style="' + W_TD + '">政治面貌分</td><td style="' + W_TD + '">' + esc(Object.entries(r.politics).map(([k, v]) => k + ' ' + v + ' 分').join(';')) + '</td></tr>' +
'<tr><td style="' + W_TD + '">操行考评 / 诚信分默认</td><td style="' + W_TD + '">' + esc(r.conductDefault) + ' 分 / ' + esc(r.integrityDefault) + ' 分</td></tr>' +
'<tr><td style="' + W_TD + '">学业成绩</td><td style="' + W_TD + '">学年平均学分绩点 × ' + esc(r.gpaFactor) + '(上限 60 分)</td></tr>' +
'<tr><td style="' + W_TD + '">体育加分</td><td style="' + W_TD + '">体育课成绩之和 × ' + esc(r.sportsFactor) + '(上限 5 分)</td></tr>' +
'<tr><td style="' + W_TD + '">公益服务</td><td style="' + W_TD + '">' + esc(Object.entries(r.publicGood).map(([k, v]) => k + ' ' + v).join(';')) + '</td></tr>' +
'</table>';
const t2 = (r.table2 as unknown as Record<string, Record<string, number>>);
const t4 = (r.table4 as unknown as Record<string, Record<string, number>>);
const rw = (r.rewards as unknown as Record<string, Record<string, number>>);
const levels = Object.keys(t2);
const grades = ['一等奖', '二等奖', '三等奖', '优秀奖'];
const matrix =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">获奖级别</td>' + grades.map(g => '<td style="' + W_TH + '">' + g + '</td>').join('') + '</tr>' +
levels.map(lv => '<tr><td style="' + W_TD + '">' + esc(lv) + '</td>' + grades.map(g => '<td style="' + W_TD + ';text-align:right" class="num">' + esc(t2[lv]?.[g] ?? '—') + '</td>').join('') + '</tr>').join('') +
'</table>';
const work =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">学生工作级别</td><td style="' + W_TH + '">岗位分值</td></tr>' +
Object.keys(t4).map(lv => '<tr><td style="' + W_TD + '">' + esc(lv) + '</td><td style="' + W_TD + '">' + esc(Object.entries(t4[lv]).map(([k, v]) => k + ' ' + v).join(';')) + '</td></tr>').join('') +
'</table>';
const others =
'<table style="border-collapse:collapse;width:100%;margin:4pt 0" border="1">' +
'<tr><td style="' + W_TH + ';width:20%">类别</td><td style="' + W_TH + '">分值</td></tr>' +
['荣誉称号', '英语等级', '计算机等级', '职业资格'].map(k => '<tr><td style="' + W_TD + '">' + esc(k) + '</td><td style="' + W_TD + '">' + esc(Object.entries(rw[k] ?? {}).map(([a, v]) => a + ' ' + v).join(';')) + '</td></tr>').join('') +
'<tr><td style="' + W_TD + '">违纪扣分</td><td style="' + W_TD + '">' + esc(Object.entries(r.penalties).map(([k, v]) => k + ' -' + v).join(';')) + '</td></tr>' +
'</table>';
return (
'<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附 2:评分细则参考(默认值,可自行调整)</div>' +
basics + matrix + work + others
);
}
/** 综合素质测评表:A4 纵向、宋体正文/黑体标题、统一表格版式、页脚页码、附1 材料图、附2 细则 */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[], rules: ZcRules): Blob {
const f = (s: unknown) => esc(s);
const schoolLine = profile.school ? '<div style="text-align:center;font-size:10.5pt;letter-spacing:2pt;color:#222">' + f(profile.school) + '</div>' : '';
const infoCell = (v: unknown, label: string) =>
'<td style="' + W_TH + ';width:16%;text-align:center">' + label + '</td><td style="' + W_TD + ';text-align:center">' + f(v || '') + '</td>';
// 统一分项表:各板块内嵌为整表的小节行 + 小计行,末尾总分行
const cnNum = ['一', '二', '三', '四', '五', '六', '七', '八'];
const rows: string[] = [];
result.sections.forEach((sec, si) => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
rows.push('<tr><td colspan="3" style="' + W_SEC + '">' + f(cnNum[si] ?? si + 1) + '、' + f(sec.label) + '(上限 ' + f(sec.cap) + ' 分)</td></tr>');
for (const it of sec.items) {
rows.push(
'<tr><td style="' + W_TD + '">' + f(it.title) + (it.overridden ? '<span style="color:#8a6d3b">(已手动修正)</span>' : '') + '</td>' +
'<td style="' + W_TD + ';color:#444;font-size:9.5pt;border-left-width:0.75pt">' + f(it.note || it.source) + '</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(it.score) + '</td></tr>',
);
}
rows.push(
'<tr><td colspan="2" style="' + W_TD + ';text-align:right;background:#fafbfc">' + f(sec.label) + '小计</td>' +
'<td style="' + W_TD + ';text-align:right;background:#fafbfc" class="num"><b>' + f(subtotal) + '</b></td></tr>',
);
});
rows.push(
'<tr><td colspan="2" style="' + W_TD + ';text-align:center;font-family:"黑体",SimHei;font-size:12pt;letter-spacing:2pt;background:#eef1f6">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;background:#eef1f6" class="num"><b style="font-size:12pt">' + f(result.total) + '</b></td></tr>',
);
const specTable =
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" border="1">' +
'<tr><td style="' + W_TH + ';width:34%;text-align:center">考评项目</td>' +
'<td style="' + W_TH + ';text-align:center">评分依据</td>' +
'<td style="' + W_TH + ';width:64pt;text-align:center">分值</td></tr>' +
rows.join('') +
'</table>';
// 附1:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布并编图号
const images: { name: string; dataUri: string }[] = [];
let figNo = 0;
const grids = evidence.map(ev => {
const imgs = ev.images.map(uri => {
figNo++;
const name = 'ev' + ev.recordId + '_' + (images.length);
images.push({ name, dataUri: uri });
return '<div style="text-align:center;margin:3pt 0"><img src="' + name + '" style="width:200pt;border:0.75pt solid #5a5a5a" alt="附图"><br>' +
'<span style="font-size:9pt;color:#333;font-family:"宋体",SimSun">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
}).join('');
return '<tr><td style="' + W_TD + ';width:26%;vertical-align:top;background:#fafafa"><b style="font-size:10pt">' + f(ev.title) + '</b><br>' +
'<span style="font-size:9pt;color:#555">' + f(ev.meta) + '</span></td>' +
'<td style="' + W_TD + '">' + imgs + '</td></tr>';
}).join('');
const annex = grids
? '<div style="font-family:"黑体",SimHei;font-size:12pt;margin:16pt 0 4pt;letter-spacing:1pt">附 1:加分证明材料</div>' +
'<div style="font-size:9.5pt;color:#555;margin:0 0 4pt">(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)</div>' +
'<table style="border-collapse:collapse;width:100%" border="1">' + grids + '</table>'
: '';
// 信息栏 3×3:末格为电话号码
const info =
'<table style="border-collapse:collapse;width:100%;margin-top:12pt" border="1">' +
'<tr>' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '</tr>' +
'<tr>' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '</tr>' +
'<tr>' + infoCell(profile.political, '政治面貌') + infoCell(termLabel, '统计周期') + infoCell(profile.phone, '电话号码') + '</tr>' +
'</table>';
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>' +
'<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:TrackChanges>0</w:TrackChanges></w:WordDocument></xml><![endif]-->' +
'<style>' +
'@page WordSection1 { size:595.3pt 841.9pt; margin:72pt 78pt 66pt; mso-footer: f1; }' +
'div.WordSection1 { page:WordSection1; }' +
'body { font-family:"宋体",SimSun,serif; font-size:10.5pt; color:#1a1a1a; line-height:1.5; }' +
'h1.title { font-family:"黑体",SimHei; font-size:18pt; text-align:center; letter-spacing:4pt; margin:2pt 0 6pt; font-weight:bold; }' +
'p.sub { text-align:center; font-size:10pt; color:#333; margin:0 0 2pt; }' +
'table { border-collapse:collapse; }' +
'.num { font-family:"Times New Roman",SimSun; }' +
'</style></head>' +
'<body><div class="WordSection1">' +
schoolLine +
'<h1 class="title">学生综合素质测评表</h1>' +
'<p class="sub">测评学年:' + f(termLabel) + '</p>' +
info +
specTable +
annex +
rulesAnnexHtml(rules) +
'<table style="width:100%;border-collapse:collapse;margin-top:26pt;font-size:10.5pt"><tr>' +
'<td style="width:25%">学生签名:_____</td>' +
'<td style="width:25%">班长签名:_____</td>' +
'<td style="width:25%">辅导员签名:_____</td>' +
'<td>日期:______</td></tr></table>' +
'<p style="font-size:9pt;color:#555;margin-top:12pt">说明:本表依据《学生综合素质测评评分细则》自动生成(细则默认值见附 2,可自行调整);带「已手动修正」标记的分值为手动核定结果。导出前请核对加分记录与证明材料一致。</p>' +
'<div style="mso-element:footer" id="f1">' +
'<p style="text-align:center;font-size:9pt;font-family:"宋体",SimSun;margin:0">第 ' + W_FIELD('PAGE', '1') + ' 页(共 ' + W_FIELD('NUMPAGES', '1') + ' 页)</p>' +
'</div>' +
'</div></body></html>';
return buildMhtml(html, images);
}
// ---------------- 评分细则参考编辑 UI ----------------
const NumCell = ({ value, onChange, wide }: { value: number; onChange: (v: number) => void; wide?: boolean }) => (
<input type="number" step="0.05"
className={'num rounded-md border border-neutral-200 bg-white px-1 py-0.5 text-right text-[11.5px] focus:border-brand focus:outline-none ' + (wide ? 'w-[76px]' : 'w-[58px]')}
value={value}
onChange={e => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
);
const MiniTable = ({ head, children }: { head: string[]; children: ReactNode }) => (
<table className="w-full border border-neutral-200 text-[11.5px]">
<thead><tr>{head.map((h, i) => <th key={i} className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">{h}</th>)}</tr></thead>
<tbody>{children}</tbody>
</table>
);
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 [year, setYear] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]);
const [initOpen, setInitOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const rulesRef = useRef<HTMLDivElement>(null);
const [rules, setRules] = useState<ZcRules>(ZC_DEFAULT_RULES);
const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null);
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
if (stored.zongce?.rules) setRules(stored.zongce.rules);
}, [stored]);
const records: FootprintRow[] = listQ.data?.items ?? [];
const termYears = useMemo(() => Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => termYearOf(x.term)).filter(Boolean) as string[])).sort(), [gradesQ.data]);
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
if (!year && termYears.length) setYear(termYears[0]);
}, [stored, gradesQ.data]);
const ov: ZcOverrides = ovMap[year] ?? {};
const result = useMemo(
() => computeZongce({
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, year],
);
// 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
const eduStartEff = profile.eduStart || (termYears.length ? termYears[0] + '-09' : '');
const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
// 本学年范围内全部记录(证据画廊不再只看计分记录——有附件就展示)
const scopeIds = useMemo(() => {
const range = year && year !== 'all' ? yearRange(year) : null;
return records.filter(x => {
if (x.status === 'draft') return false;
if (!range) return true;
const d = (x.start_date ?? x.created_at ?? '').slice(0, 10);
return d >= range[0] && d < range[1];
}).map(x => x.id);
}, [records, year]);
// 参与计分的记录 id(画廊中标注「计分」)
const matchedSet = useMemo(() => {
const s = new Set<number>();
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) s.add(it.fromRecord); }));
return s;
}, [result]);
useEffect(() => {
let alive = true;
(async () => {
const out: Evidence[] = [];
for (const rid of scopeIds) {
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 && (x.mime ?? '').startsWith('image/'))) {
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) {
const meta = [rec?.type_name, rec?.level, rec?.award_grade].filter(Boolean).join(' · ');
out.push({ recordId: rid, title: rec?.title ?? '记录 ' + rid, meta, images });
}
} catch {}
}
if (alive) setEvidence(out);
})();
return () => { alive = false; };
}, [scopeIds.join(',')]);
// 全部放平的图序列(灯箱上一张/下一张用)
const flatImages = useMemo(() => {
const arr: { src: string; title: string }[] = [];
for (const ev of evidence) for (const src of ev.images) arr.push({ src, title: ev.title });
return arr;
}, [evidence]);
const persist = (p2: { rules?: ZcRules; overrides?: Record<string, ZcOverrides> }) =>
void profileUpsert({ info: profile, zongce: { rules: p2.rules ?? rules, overrides: p2.overrides ?? ovMap } }).catch(e => toast('error', e.message));
const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [year]: next }));
persist({ overrides: { ...ovMap, [year]: next } });
};
const editRule = (fn: (r: ZcRules) => void) => {
const next: ZcRules = JSON.parse(JSON.stringify(rules));
fn(next);
setRules(next);
persist({ rules: next });
};
const resetRules = () => {
const d: ZcRules = JSON.parse(JSON.stringify(ZC_DEFAULT_RULES));
setRules(d);
persist({ rules: d });
toast('success', '评分细则已恢复默认');
};
const exportWord = () => {
const blob = zongceWordMhtml(termLabel, result, profile, evidence, rules);
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', '综测表已导出(.doc,附证明材料图 ' + flatImages.length + ' 张 + 细则参考)');
};
const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1;
const gotoZoom = (delta: number) => {
const next = zoomIdx + delta;
if (next >= 0 && next < flatImages.length) {
setZoom({ src: flatImages[next].src, title: flatImages[next].title, index: next });
}
};
const totalImgs = flatImages.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">
<div>
<h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2>
<p className="mt-0.5 text-[11.5px] text-ink-3">按学年依据评分细则自动计算,可手动修正分值,支持导出 Word 测评表</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...termYears.map(y => ({ value: y, label: gradeLabel(y, eduStartEff) })), { value: 'all', label: '全部学年' }]} />
<Button variant="secondary" onClick={() => {
const next = !rulesOpen;
setRulesOpen(next);
if (next) setTimeout(() => rulesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
}}><FileText size={13} /> 评分细则参考</Button>
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出 Word</Button>
<Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
</div>
</div>
{/* 评分细则参考:可修改,随档案保存;导出 Word 附同款参考表。置于页面顶部,点击按钮立即可见 */}
{rulesOpen && (
<div ref={rulesRef} className="no-print mb-4 scroll-mt-16 rounded-xl border border-line bg-white p-4 shadow-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText size={15} className="text-neutral-500" />
<span className="text-[13.5px] font-bold text-neutral-900">评分细则参考</span>
<span className="text-[11.5px] text-neutral-400">默认值可自行调整,调整后即时生效并随档案保存</span>
</div>
<Button variant="ghost" onClick={resetRules}><RotateCcw size={13} /> 恢复默认</Button>
</div>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1.5 text-[12px] font-semibold text-neutral-600">基础分值</div>
<MiniTable head={['项目', '分值']}>
{Object.entries(rules.politics).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.politics as Record<string, number>)[k] = v2; })} /></td></tr>
))}
<tr><td className="border-b border-neutral-100 px-2 py-1">操行考评默认分</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.conductDefault} onChange={v2 => editRule(r => { r.conductDefault = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">诚信分默认</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.integrityDefault} onChange={v2 => editRule(r => { r.integrityDefault = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">学业成绩(绩点 ×)</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.gpaFactor} onChange={v2 => editRule(r => { r.gpaFactor = v2; })} /></td></tr>
<tr><td className="border-b border-neutral-100 px-2 py-1">体育加分(成绩 ×)</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={rules.sportsFactor} onChange={v2 => editRule(r => { r.sportsFactor = v2; })} /></td></tr>
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">公益服务</div>
<MiniTable head={['项目', '分值']}>
{Object.entries(rules.publicGood).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.publicGood as Record<string, number>)[k] = v2; })} /></td></tr>
))}
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">违纪扣分</div>
<MiniTable head={['项目', '扣分']}>
{Object.entries(rules.penalties).map(([k, v]) => (
<tr key={k}><td className="border-b border-neutral-100 px-2 py-1">{k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { (r.penalties as Record<string, number>)[k] = v2; })} /></td></tr>
))}
</MiniTable>
</div>
<div>
<div className="mb-1.5 text-[12px] font-semibold text-neutral-600">竞赛 / 文体奖项加分(级别 × 奖项)</div>
<table className="w-full border border-neutral-200 text-[11.5px]">
<thead><tr><th className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">级别</th>
{['一等奖', '二等奖', '三等奖', '优秀奖'].map(g => <th key={g} className="border-b border-neutral-200 bg-neutral-50 px-2 py-1 text-left font-medium text-neutral-500">{g}</th>)}</tr></thead>
<tbody>
{Object.entries(rules.table2).map(([lv, grades2]) => (
<tr key={lv}><td className="border-b border-neutral-100 px-2 py-1 font-medium text-neutral-700">{lv}</td>
{(['一等奖', '二等奖', '三等奖', '优秀奖'] as const).map(g => (
<td key={g} className="border-b border-neutral-100 px-1 py-1">
<NumCell value={(grades2 as Record<string, number>)[g] ?? 0} onChange={v2 => editRule(r => { ((r.table2 as unknown as Record<string, Record<string, number>>)[lv])[g] = v2; })} />
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">学生工作</div>
<MiniTable head={['级别 / 岗位', '分值']}>
{Object.entries(rules.table4).flatMap(([lv, jobs]) =>
Object.entries(jobs as Record<string, number>).map(([job, v]) => (
<tr key={lv + job}><td className="border-b border-neutral-100 px-2 py-1">{lv} · {job}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { ((r.table4 as unknown as Record<string, Record<string, number>>)[lv])[job] = v2; })} /></td></tr>
)),
)}
</MiniTable>
<div className="mb-1.5 mt-3 text-[12px] font-semibold text-neutral-600">荣誉与证照加分</div>
<MiniTable head={['类别 / 等级', '分值']}>
{Object.entries(rules.rewards).flatMap(([cat, vals]) =>
Object.entries(vals as Record<string, number>).map(([k, v]) => (
<tr key={cat + k}><td className="border-b border-neutral-100 px-2 py-1">{cat} · {k}</td>
<td className="border-b border-neutral-100 px-2 py-1"><NumCell value={v} onChange={v2 => editRule(r => { ((r.rewards as unknown as Record<string, Record<string, number>>)[cat])[k] = v2; }) } /></td></tr>
)),
)}
</MiniTable>
</div>
</div>
</div>
)}
<div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8">
{profile.school && <div className="text-center text-[11px] tracking-widest text-neutral-500">{profile.school}</div>}
<h1 className="mt-0.5 text-center text-[19px] font-bold tracking-wide text-neutral-900">学生综合素质测评表</h1>
<div className="mt-1 text-center text-[12px] text-neutral-500">测评学年:{termLabel}</div>
{/* 学生信息栏 3×3:末格为电话号码 */}
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-neutral-300 bg-neutral-300 text-[12.5px] sm:grid-cols-3">
{[
['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
['政治面貌', profile.political], ['统计周期', termLabel], ['电话号码', profile.phone],
].map(([label, val]) => (
<div key={label as string} className="flex bg-white">
<span className="w-[62px] shrink-0 bg-neutral-50 px-2 py-1.5 font-medium text-neutral-500">{label}</span>
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val || '—'}</span>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-center rounded-lg bg-gradient-to-r from-neutral-900 to-neutral-700 px-4 py-2.5 text-white">
<span className="text-[12px] opacity-80">综合素质测评总分</span>
<span className="num ml-3 text-[26px] font-bold leading-none">{result.total}</span>
<span className="ml-1.5 text-[12px] opacity-80">/ 100 分</span>
</div>
{result.sections.map(sec => (
<div key={sec.key} className="mt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded-full bg-brand"></span>
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}</span>
<Badge className="no-print" color="var(--text-3)">上限 {sec.cap} 分</Badge>
</div>
<span className="num rounded-md bg-neutral-100 px-2 py-0.5 text-[12px] font-semibold text-neutral-700">
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)}
</span>
</div>
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-neutral-200 bg-neutral-50/60 p-3">
{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] leading-relaxed text-neutral-400">{it.note}</div>}
</span>
<input type="number" step="0.1" className="num w-[72px] rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 text-right text-[12px] focus:border-brand focus:outline-none"
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>
))}
{/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
{(evidence.length > 0 || totalImgs > 0) && (
<div className="mt-7">
<div className="flex items-center justify-between border-t border-neutral-200 pt-4">
<div className="flex items-center gap-2">
<Images size={15} className="text-neutral-500" />
<span className="text-[13.5px] font-bold text-neutral-900">附:加分证明材料</span>
<span className="text-[11.5px] text-neutral-400">共 {evidence.length} 项记录 · {totalImgs} 张图片(点击可放大)</span>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{evidence.map(ev => (
<div key={ev.recordId} className="rounded-lg border border-neutral-200 bg-white p-2.5 transition-shadow hover:shadow-md">
<div className="flex items-baseline justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{matchedSet.has(ev.recordId)
? <Badge color="var(--brand-text)">计分</Badge>
: <Badge className="no-print" color="var(--text-3)">未计分</Badge>}
<div className="min-w-0 truncate text-[12.5px] font-semibold text-neutral-800">{ev.title}</div>
</div>
<div className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div>
</div>
<div className={'mt-2 gap-2 ' + (ev.images.length > 1 ? 'grid grid-cols-2' : 'flex')}>
{ev.images.map((src, i) => (
<button key={i} onClick={() => setZoom({ src, title: ev.title, index: flatImages.findIndex(x => x.src === src) })}
className="group overflow-hidden rounded-md border border-neutral-200 bg-neutral-50">
<img src={src} alt={'证明 ' + (i + 1)} className="aspect-[4/3] w-full object-cover transition-transform group-hover:scale-[1.03]" />
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
</div>
</div>
{/* 图片灯箱 */}
<Dialog
open={!!zoom}
onClose={() => setZoom(null)}
width={920}
title={zoom ? zoom.title + '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : ''}
footer={
<div className="flex w-full items-center justify-between">
<Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button>
<span className="num text-[12px] text-ink-3">{zoom ? (zoomIdx + 1) + ' / ' + totalImgs : ''}</span>
<Button variant="primary" disabled={zoomIdx >= totalImgs - 1} onClick={() => gotoZoom(1)}>下一张 <ChevronRight size={13} /></Button>
</div>
}>
{zoom && <img src={zoom.src} alt={zoom.title} className="mx-auto max-h-[74vh] rounded-md" />}
</Dialog>
<Dialog
open={initOpen}
onClose={() => setInitOpen(false)}
width={440}
title="初始化综测"
footer={<>
<Button variant="ghost" onClick={() => setInitOpen(false)}>取消</Button>
<Button variant="primary" onClick={() => {
const next = { ...ovMap };
delete next[year];
setOvMap(next);
persist({ overrides: next });
setInitOpen(false);
toast('success', '已恢复自动计算');
}}>恢复自动计算</Button>
</>}>
<div className="text-[12.5px] leading-relaxed text-ink-2">
将清除 <b>{year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff)}</b> 的全部手动修正
(分值覆盖、操行/诚信分修改),恢复为按细则规则的自动计算结果。
<div className="mt-2 text-ink-3">记录数据、个人信息与评分细则不受影响。</div>
</div>
</Dialog>
</div>
);
}