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 => '').join('') + '' + levels.map(lv => '' + grades.map(g => '').join('') + '').join('') + '
获奖级别' + g + '
' + esc(lv) + '' + esc(t2[lv]?.[g] ?? '—') + '
'; const work = '' + '' + Object.keys(t4).map(lv => '').join('') + '
学生工作级别岗位分值
' + esc(lv) + '' + esc(Object.entries(t4[lv]).map(([k, v]) => k + ' ' + v).join(';')) + '
'; const others = '' + '' + ['荣誉称号', '英语等级', '计算机等级', '职业资格'].map(k => '').join('') + '' + '
类别分值
' + esc(k) + '' + esc(Object.entries(rw[k] ?? {}).map(([a, v]) => a + ' ' + v).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) + ' 张材料图)
' + '' + grids + '
' : ''; // 信息栏 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) => )}{children}
{h}
); 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 测评表

{ 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 }); } }} />
))} {!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 / 打印前请核对加分记录与证明材料一致。
{/* 图片灯箱 */} setZoom(null)} width={920} title={zoom ? zoom.title + '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : ''} footer={
{zoom ? (zoomIdx + 1) + ' / ' + totalImgs : ''}
}> {zoom && {zoom.title}}
setInitOpen(false)} width={440} title="初始化综测" footer={<> }>
将清除 {year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff)} 的全部手动修正 (分值覆盖、操行/诚信分修改),恢复为按细则规则的自动计算结果。
记录数据、个人信息与评分细则不受影响。
); }