diff --git a/app/src/lib/zongce.ts b/app/src/lib/zongce.ts index 71517fe..842c8d4 100644 --- a/app/src/lib/zongce.ts +++ b/app/src/lib/zongce.ts @@ -75,9 +75,9 @@ function normLevel(level?: string | null): keyof typeof ZC_DEFAULT_RULES.table2 function normGrade(grade?: string | null): keyof typeof ZC_DEFAULT_RULES.table2['国家级'] | null { const v = (grade ?? '').trim(); if (!v) return null; - if (/特等|一等|冠军|第1名|第一名/.test(v)) return '一等奖'; - if (/二等|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖'; - if (/三等|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖'; + if (/特等|一等|金(色)?奖|冠军|第1名|第一名/.test(v)) return '一等奖'; + if (/二等|银(色)?奖|亚军|第2名|第3名|第二名|第三名/.test(v)) return '二等奖'; + if (/三等|铜(色)?奖|第[4-6]名|第四名|第五名|第六名/.test(v)) return '三等奖'; if (/优秀|优胜|第[7-9]名|第七名/.test(v)) return '优秀奖'; return null; } diff --git a/app/src/pages/assessment/AssessmentPage.tsx b/app/src/pages/assessment/AssessmentPage.tsx index 48199a0..cfdfdaf 100644 --- a/app/src/pages/assessment/AssessmentPage.tsx +++ b/app/src/pages/assessment/AssessmentPage.tsx @@ -1,62 +1,130 @@ import { useEffect, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight, Download, Images, Printer, RotateCcw } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; -import { Download, Printer, RotateCcw } from 'lucide-react'; import { attachmentList, attachmentThumb, footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api'; import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types'; import { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce'; -import { Button, Dialog, Select, useToast } from '@/components/ui'; +import { Badge, Button, Dialog, Select, useToast } from '@/components/ui'; -interface Evidence { recordId: number; title: string; images: string[] } +interface Evidence { recordId: number; title: string; meta: string; images: string[] } function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&').replace(//g, '>'); } -// MHTML:把图片内嵌进 Word(.doc)的可靠方式 +// ---------------- Word 导出(MHTML 单文件 .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'); + 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\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' + + '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 #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'; + +/** 综合素质测评表:A4 纵向、黑体标题、表格化分项、附图两列栅格 */ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial, evidence: Evidence[]): Blob { + const f = (s: unknown) => esc(s); + const school = f(profile.school || '云川理工学院'); + const infoCell = (v: unknown, label: string, half = false) => + '' + label + '' + f(v || '') + ''; + + // 分板块明细表 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 => '' + - '' + esc(it.title) + '' + - '' + esc(it.note || it.source) + '' + - '' + esc(it.score) + '').join(''); - return '

' + esc(sec.label) + '(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)

' + - '' + rows + '
'; + '' + f(it.title) + (it.overridden ? '(已手动修正)' : '') + '' + + '' + f(it.note || it.source) + '' + + '' + f(it.score) + '', + ).join(''); + return ( + '' + + '' + + '' + + '' + + rows + + '' + + '' + + '
考评项目评分依据分值
' + f(sec.label) + '小计(板块上限 ' + f(sec.cap) + ' 分)' + f(subtotal) + '
' + ); }).join(''); + + // 证明材料附图:每条记录一个组块,图两列(约 200pt 宽) const images: { name: string; dataUri: string }[] = []; - const annex = evidence.map(ev => { - let imgs = ''; - ev.images.forEach((uri, i) => { - const name = 'ev' + ev.recordId + '_' + i; + 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 }); - imgs += '
图 ' + (i + 1) + '
'; - }); - return '
' + esc(ev.title) + '' + imgs + '
'; + return '
附图
' + + '图 ' + figNo + ' ' + f(ev.title) + '
'; + }).join(''); + return '' + f(ev.title) + '
' + + '' + f(ev.meta) + '' + + '
' + imgs + '
'; }).join(''); - const html = '' + - '综合素质测评' + - '' + - '

学生综合素质测评

' + - '
' + esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '
' + - '

综合素质总分:' + esc(result.total) + ' 分

' + - body + (annex ? '

附:证明材料

' + annex : '') + - '' + - '
学生签名:______辅导员签名:______日期:______
' + - ''; + const annex = grids + ? '
附:加分证明材料(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)
' + + '' + grids + '
' + : ''; + + const html = + '' + + '学生综合素质测评表' + + '' + + '
' + + '
' + school + '
' + + '

学生综合素质测评表

' + + '

测评学年:' + f(termLabel) + '

' + + '' + + '' + infoCell(profile.studentId, '学 号') + infoCell(profile.name, '姓 名') + infoCell(profile.gender, '性 别') + '' + + '' + infoCell(profile.college, '学 院') + infoCell(profile.major, '专 业') + infoCell(profile.className, '班 级') + '' + + '' + infoCell(profile.political, '政治面貌') + + '' + + '' + + '
测评学年' + f(termLabel) + '测评总分' + f(result.total) + ' / 100 分
' + + body + + '' + + '' + + '
综合素质测评总分' + f(result.total) + '
' + + annex + + '' + + '' + + '' + + '' + + '
学生签名:_____班长签名:_____辅导员签名:_____日期:______
' + + '

说明:本表依据《计算机与网络安全学院学生综合素质测评评分细则》自动生成;带「已手动修正」标记的分值为辅导员核准后的手动结果。导出前请核对加分记录与证明材料一致。

' + + '
'; return buildMhtml(html, images); } @@ -73,6 +141,7 @@ export function AssessmentPage() { const [ovMap, setOvMap] = useState>({}); const [evidence, setEvidence] = useState([]); const [initOpen, setInitOpen] = useState(false); + const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null); useEffect(() => { if (!stored) return; @@ -90,10 +159,10 @@ export function AssessmentPage() { bands: gradesQ.data?.bands, weights: gradesQ.data?.weights, profile, rules, ov, }), - [records, gradesQ.data, profile, rules, ov, year] + [records, gradesQ.data, profile, rules, ov, year], ); - // 证明材料:匹配条目对应记录的附件缩略图 + // 证明材料:匹配条目对应记录的附件缩略图(每条最多 2 张) const matchedIds = useMemo(() => { const ids: number[] = []; result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); })); @@ -109,11 +178,14 @@ export function AssessmentPage() { try { const attList = await attachmentList(rid); const images: string[] = []; - for (const att of attList.items.filter(x => x.id).slice(0, 3)) { + for (const att of attList.items.filter(x => x.id).slice(0, 2)) { 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 }); + 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); @@ -121,6 +193,13 @@ export function AssessmentPage() { return () => { alive = false; }; }, [matchedIds.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 persistOv = (next: ZcOverrides) => { setOvMap(m => ({ ...m, [year]: next })); void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [year]: next } } }).catch(e => toast('error', e.message)); @@ -133,47 +212,81 @@ export function AssessmentPage() { 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 + ' 条证明材料图片)' : '')); + 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) }); @@ -189,27 +302,59 @@ export function AssessmentPage() {
))} - {evidence.length > 0 && ( -
-
附:证明材料
- {evidence.map(ev => ( -
-
{ev.title}
-
- {ev.images.map((src, i) => ( - {'证明 - ))} -
+ {/* 证明材料:图片画廊 */} + {(evidence.length > 0 || totalImgs > 0) && ( +
+
+
+ + 附:加分证明材料 + 共 {evidence.length} 项记录 · {totalImgs} 张图片(点击可放大)
- ))} +
+
+ {evidence.map(ev => ( +
+
+
{ev.title}
+
{ev.meta}
+
+
+ {ev.images.map((src, i) => ( + + ))} +
+
+ ))} +
)}
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值; - 「恢复自动」清除本学年全部手动修正。导出材料前请核对每条加分记录与证明材料一致。 + 「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
+ + {/* 图片灯箱 */} + setZoom(null)} + width={920} + title={zoom?.title ?? '' + (zoom ? '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : '')} + footer={ +
+ + {zoom ? (zoomIdx + 1) + ' / ' + totalImgs : ''} + +
+ }> + {zoom && {zoom.title}} +
+ setInitOpen(false)}