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
This commit is contained in:
root
2026-10-17 17:48:16 +08:00
parent 11cfeeb3a6
commit ba448946c9
19 changed files with 200 additions and 179 deletions
+154 -133
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
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';
@@ -38,8 +38,14 @@ function buildMhtml(html: string, images: { name: string; dataUri: string }[]):
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
}
const W_TD = 'border:0.75pt solid #4a4a4a;padding:4pt 7pt;font-size:10.5pt;vertical-align:middle';
const W_TH = W_TD + ';background:#e8eaf0;font-weight:bold;font-family:SimHei;letter-spacing:1pt';
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 {
@@ -79,35 +85,44 @@ function rulesAnnexHtml(r: ZcRules): string {
);
}
/** 综合素质测评表:A4 纵向、黑体标题、表格化分项、附图栅格 + 细则参考附表 */
/** 综合素质测评表: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:1pt;color:#333">' + f(profile.school) + '</div>' : '';
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:14%">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>';
'<td style="' + W_TH + ';width:16%;text-align:center">' + label + '</td><td style="' + W_TD + ';text-align:center">' + f(v || '') + '</td>';
// 分板块明细表
const body = result.sections.map(sec => {
// 统一分项表:各板块内嵌为整表的小节行 + 小计行,末尾总分行
const cnNum = ['一', '二', '三', '四', '五', '六', '七', '八'];
const rows: string[] = [];
result.sections.forEach((sec, si) => {
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
const rows = sec.items.map(it =>
'<tr>' +
'<td style="' + W_TD + '">' + f(it.title) + (it.overridden ? '(已手动修正)' : '') + '</td>' +
'<td style="' + W_TD + ';color:#555;font-size:9.5pt">' + f(it.note || it.source) + '</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(it.score) + '</td></tr>',
).join('');
return (
'<table style="border-collapse:collapse;width:100%;margin:6pt 0 0" border="1">' +
'<tr><td style="' + W_TH + ';width:34%">考评项目</td>' +
'<td style="' + W_TH + '">评分依据</td>' +
'<td style="' + W_TH + ';width:64pt;text-align:center">分值</td></tr>' +
rows +
'<tr><td colspan="2" style="' + W_TD + ';text-align:right;background:#f4f5f7"><b>' + f(sec.label) + '小计</b>(板块上限 ' + f(sec.cap) + ' 分)</td>' +
'<td style="' + W_TD + ';text-align:right;background:#f4f5f7"><b class="num">' + f(subtotal) + '</b></td></tr>' +
'</table>'
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>',
);
}).join('');
});
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:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布
// 附1:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布并编图号
const images: { name: string; dataUri: string }[] = [];
let figNo = 0;
const grids = evidence.map(ev => {
@@ -115,21 +130,22 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
figNo++;
const name = 'ev' + ev.recordId + '_' + (images.length);
images.push({ name, dataUri: uri });
return '<div style="text-align:center;margin:2pt 0"><img src="' + name + '" style="width:196pt;border:0.75pt solid #8a8a8a" alt="附图"><br>' +
'<span style="font-size:9pt;color:#444">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
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:#666">' + f(ev.meta) + '</span></td>' +
'<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:14pt 0 4pt">附 1:加分证明材料 — ' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图</div>' +
? '<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:10pt" border="1">' +
'<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>' +
@@ -138,37 +154,38 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
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:56.7pt 62pt 62pt; mso-footer: f1; }' +
'@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; }' +
'h1.title { font-family:SimHei; font-size:16pt; text-align:center; letter-spacing:2pt; margin:0 0 4pt; }' +
'p.sub { text-align:center; font-size:10pt; color:#333; margin:0; }' +
'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; }' +
'.total td { background:#eef1f6; font-family:SimHei; font-size:12pt; }' +
'.num { font-family:Calibri,SimSun; }' +
'.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 +
body +
'<table style="border-collapse:collapse;width:100%;margin-top:8pt" border="1"><tr class="total">' +
'<td style="' + W_TD + ';text-align:left;font-family:SimHei">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(result.total) + '</td></tr></table>' +
specTable +
annex +
rulesAnnexHtml(rules) +
'<table style="width:100%;border-collapse:collapse;margin-top:22pt;font-size:10.5pt"><tr>' +
'<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:10pt">说明:本表依据《学生综合素质测评评分细则》自动生成(细则参考默认值见附 2,可在综合测评页自行调整);带「已手动修正」标记的分值为手动核定结果。导出前请核对加分记录与证明材料一致。</p>' +
'<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"
@@ -197,6 +214,7 @@ export function AssessmentPage() {
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);
@@ -206,14 +224,14 @@ export function AssessmentPage() {
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 ?? {});
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => termYearOf(x.term)).filter(Boolean))) as string[];
if (!year && years.length) setYear(years[0]);
if (!year && termYears.length) setYear(termYears[0]);
}, [stored, gradesQ.data]);
const records: FootprintRow[] = listQ.data?.items ?? [];
const ov: ZcOverrides = ovMap[year] ?? {};
const result = useMemo(
() => computeZongce({
@@ -226,9 +244,8 @@ export function AssessmentPage() {
);
// 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
const years = useMemo(() => Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => termYearOf(x.term)).filter(Boolean) as string[])).sort(), [gradesQ.data]);
const eduStartEff = profile.eduStart || (years.length ? years[0] + '-09' : '');
const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
const eduStartEff = profile.eduStart || (termYears.length ? termYears[0] + '-09' : '');
const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
// 本学年范围内全部记录(证据画廊不再只看计分记录——有附件就展示)
const scopeIds = useMemo(() => {
@@ -326,14 +343,101 @@ export function AssessmentPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...years.map(y => ({ value: y, label: gradeLabel(y, eduStartEff) })), { value: 'all', label: '全部学年' }]} />
<Button variant="secondary" onClick={() => setRulesOpen(v => !v)}><FileText size={13} /> 评分细则参考</Button>
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>
@@ -431,89 +535,6 @@ export function AssessmentPage() {
</div>
)}
{/* 评分细则参考:可修改,随档案保存;导出 Word 会附同款参考表 */}
{rulesOpen && (
<div className="no-print mt-7 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="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。