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, '&').replace(//g, '>'); }
// ---------------- 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) =>
'' + shown +
'';
/** 评分细则参考(附在 Word 末尾,与页面「评分细则参考」同一数据源) */
function rulesAnnexHtml(r: ZcRules): string {
const basics =
'
' +
'| 基础项 | 分值 / 规则 |
' +
'| 政治面貌分 | ' + esc(Object.entries(r.politics).map(([k, v]) => k + ' ' + v + ' 分').join(';')) + ' |
' +
'| 操行考评 / 诚信分默认 | ' + esc(r.conductDefault) + ' 分 / ' + esc(r.integrityDefault) + ' 分 |
' +
'| 学业成绩 | 学年平均学分绩点 × ' + esc(r.gpaFactor) + '(上限 60 分) |
' +
'| 体育加分 | 体育课成绩之和 × ' + esc(r.sportsFactor) + '(上限 5 分) |
' +
'| 公益服务 | ' + esc(Object.entries(r.publicGood).map(([k, v]) => k + ' ' + v).join(';')) + ' |
' +
'
';
const t2 = (r.table2 as unknown as Record>);
const t4 = (r.table4 as unknown as Record>);
const rw = (r.rewards as unknown as Record>);
const levels = Object.keys(t2);
const grades = ['一等奖', '二等奖', '三等奖', '优秀奖'];
const matrix =
'' +
'| 获奖级别 | ' + grades.map(g => '' + g + ' | ').join('') + '
' +
levels.map(lv => '| ' + esc(lv) + ' | ' + grades.map(g => '' + esc(t2[lv]?.[g] ?? '—') + ' | ').join('') + '
').join('') +
'
';
const work =
'' +
'| 学生工作级别 | 岗位分值 |
' +
Object.keys(t4).map(lv => '| ' + esc(lv) + ' | ' + esc(Object.entries(t4[lv]).map(([k, v]) => k + ' ' + v).join(';')) + ' |
').join('') +
'
';
const others =
'' +
'| 类别 | 分值 |
' +
['荣誉称号', '英语等级', '计算机等级', '职业资格'].map(k => '| ' + esc(k) + ' | ' + esc(Object.entries(rw[k] ?? {}).map(([a, v]) => a + ' ' + v).join(';')) + ' |
').join('') +
'| 违纪扣分 | ' + esc(Object.entries(r.penalties).map(([k, v]) => k + ' -' + v).join(';')) + ' |
' +
'
';
return (
'附 2:评分细则参考(默认值,可自行调整)
' +
basics + matrix + work + others
);
}
/** 综合素质测评表:A4 纵向、宋体正文/黑体标题、统一表格版式、页脚页码、附1 材料图、附2 细则 */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial, evidence: Evidence[], rules: ZcRules): Blob {
const f = (s: unknown) => esc(s);
const schoolLine = profile.school ? '' + f(profile.school) + '
' : '';
const infoCell = (v: unknown, label: string) =>
'' + label + ' | ' + f(v || '') + ' | ';
// 统一分项表:各板块内嵌为整表的小节行 + 小计行,末尾总分行
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('| ' + f(cnNum[si] ?? si + 1) + '、' + f(sec.label) + '(上限 ' + f(sec.cap) + ' 分) |
');
for (const it of sec.items) {
rows.push(
'| ' + f(it.title) + (it.overridden ? '(已手动修正)' : '') + ' | ' +
'' + f(it.note || it.source) + ' | ' +
'' + f(it.score) + ' |
',
);
}
rows.push(
'| ' + f(sec.label) + '小计 | ' +
'' + f(subtotal) + ' |
',
);
});
rows.push(
'| 综合素质测评总分 | ' +
'' + f(result.total) + ' |
',
);
const specTable =
'' +
'| 考评项目 | ' +
'评分依据 | ' +
'分值 |
' +
rows.join('') +
'
';
// 附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 '
' +
'
图 ' + figNo + ' ' + f(ev.title) + ' ';
}).join('');
return '' + f(ev.title) + ' ' +
'' + f(ev.meta) + ' | ' +
'' + imgs + ' |
';
}).join('');
const annex = grids
? '附 1:加分证明材料
' +
'(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)
' +
''
: '';
// 信息栏 3×3:末格为电话号码
const info =
'' +
'' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '
' +
'' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '
' +
'' + infoCell(profile.political, '政治面貌') + infoCell(termLabel, '统计周期') + infoCell(profile.phone, '电话号码') + '
' +
'
';
const html =
'' +
'学生综合素质测评表' +
'' +
'' +
'' +
schoolLine +
'
学生综合素质测评表
' +
'
测评学年:' + f(termLabel) + '
' +
info +
specTable +
annex +
rulesAnnexHtml(rules) +
'
' +
'| 学生签名:_____ | ' +
'班长签名:_____ | ' +
'辅导员签名:_____ | ' +
'日期:______ |
' +
'
说明:本表依据《学生综合素质测评评分细则》自动生成(细则默认值见附 2,可自行调整);带「已手动修正」标记的分值为手动核定结果。导出前请核对加分记录与证明材料一致。
' +
'
' +
'
第 ' + W_FIELD('PAGE', '1') + ' 页(共 ' + W_FIELD('NUMPAGES', '1') + ' 页)
' +
'
' +
'
';
return buildMhtml(html, images);
}
// ---------------- 评分细则参考编辑 UI ----------------
const NumCell = ({ value, onChange, wide }: { value: number; onChange: (v: number) => void; wide?: boolean }) => (
onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
);
const MiniTable = ({ head, children }: { head: string[]; children: ReactNode }) => (
{head.map((h, i) => | {h} | )}
{children}
);
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 = { ...(stored?.info ?? {}) };
const [year, setYear] = useState('');
const [ovMap, setOvMap] = useState>({});
const [evidence, setEvidence] = useState([]);
const [initOpen, setInitOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const rulesRef = useRef(null);
const [rules, setRules] = useState(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();
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 }) =>
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 (
综合测评
按学年依据评分细则自动计算,可手动修正分值,支持导出 Word 测评表
{/* 评分细则参考:可修改,随档案保存;导出 Word 附同款参考表。置于页面顶部,点击按钮立即可见 */}
{rulesOpen && (
评分细则参考
默认值可自行调整,调整后即时生效并随档案保存
基础分值
{Object.entries(rules.politics).map(([k, v]) => (
| {k} |
editRule(r => { (r.politics as Record)[k] = v2; })} /> |
))}
| 操行考评默认分 |
editRule(r => { r.conductDefault = v2; })} /> |
| 诚信分默认 |
editRule(r => { r.integrityDefault = v2; })} /> |
| 学业成绩(绩点 ×) |
editRule(r => { r.gpaFactor = v2; })} /> |
| 体育加分(成绩 ×) |
editRule(r => { r.sportsFactor = v2; })} /> |
公益服务
{Object.entries(rules.publicGood).map(([k, v]) => (
| {k} |
editRule(r => { (r.publicGood as Record)[k] = v2; })} /> |
))}
违纪扣分
{Object.entries(rules.penalties).map(([k, v]) => (
| {k} |
editRule(r => { (r.penalties as Record)[k] = v2; })} /> |
))}
竞赛 / 文体奖项加分(级别 × 奖项)
| 级别 |
{['一等奖', '二等奖', '三等奖', '优秀奖'].map(g => {g} | )}
{Object.entries(rules.table2).map(([lv, grades2]) => (
| {lv} |
{(['一等奖', '二等奖', '三等奖', '优秀奖'] as const).map(g => (
)[g] ?? 0} onChange={v2 => editRule(r => { ((r.table2 as unknown as Record>)[lv])[g] = v2; })} />
|
))}
))}
学生工作
{Object.entries(rules.table4).flatMap(([lv, jobs]) =>
Object.entries(jobs as Record).map(([job, v]) => (
| {lv} · {job} |
editRule(r => { ((r.table4 as unknown as Record>)[lv])[job] = v2; })} /> |
)),
)}
荣誉与证照加分
{Object.entries(rules.rewards).flatMap(([cat, vals]) =>
Object.entries(vals as Record).map(([k, v]) => (
| {cat} · {k} |
editRule(r => { ((r.rewards as unknown as Record>)[cat])[k] = v2; }) } /> |
)),
)}
)}
{profile.school &&
{profile.school}
}
学生综合素质测评表
测评学年:{termLabel}
{/* 学生信息栏 3×3:末格为电话号码 */}
{[
['学 号', profile.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
['政治面貌', profile.political], ['统计周期', termLabel], ['电话号码', profile.phone],
].map(([label, val]) => (
{label}
{val || '—'}
))}
综合素质测评总分
{result.total}
/ 100 分
{result.sections.map(sec => (
{sec.label}
上限 {sec.cap} 分
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)}
{sec.items.map(it => (
))}
{!sec.items.length &&
暂无数据
}
))}
{/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
{(evidence.length > 0 || totalImgs > 0) && (
附:加分证明材料
共 {evidence.length} 项记录 · {totalImgs} 张图片(点击可放大)
{evidence.map(ev => (
{matchedSet.has(ev.recordId)
?
计分
:
未计分}
{ev.title}
{ev.meta}
1 ? 'grid grid-cols-2' : 'flex')}>
{ev.images.map((src, i) => (
))}
))}
)}
计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
{/* 图片灯箱 */}
);
}