feat(assessment): unified GPA source, full evidence gallery, editable scoring rules, 3x3 info with phone
- fix per-year computation: termYearOf parses semester terms (2023-2024-1) so year Scots no longer skipped the semester filter (previous bug made yearly score empty) - academic score now uses semester.gpa shown on grades page (single source of truth); fallback recalculates with the same band-linear + category weights formula - gradeLabel infers grade year (freshman/sophomore) from eduStart, falls back to earliest semester year - info grid 3x3 with phone number as the 9th cell (page + word export) - evidence gallery shows every activity/competition record with attachments in scope (no 2-image cap), records marked scored/unscored - scoring rules reference panel: default rules editable and persisted with profile; restore-default button; no college/school names in citations - word export mirrors all of the above, adds rules annex table; removed dead zongceWord.ts with school citation - sync-server/www rebuilt
This commit is contained in:
+32
-19
@@ -38,6 +38,12 @@ export interface ZcOverrides {
|
||||
manual?: { title: string; section: string; score: number }[];
|
||||
}
|
||||
|
||||
// 学期名('2023-2024-1')或学年名('2023-2024')→ 学年名;无法解析返回 null
|
||||
export function termYearOf(term: string): string | null {
|
||||
const m = /^(\d{4})-(\d{4})/.exec(String(term ?? '').trim());
|
||||
return m ? m[1] + '-' + m[2] : null;
|
||||
}
|
||||
|
||||
// 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01)
|
||||
export function yearRange(year: string): [string, string] | null {
|
||||
const m = /^(\d{4})-(\d{4})$/.exec(year);
|
||||
@@ -45,14 +51,16 @@ export function yearRange(year: string): [string, string] | null {
|
||||
return [m[1] + '-09-01', m[2] + '-09-01'];
|
||||
}
|
||||
|
||||
// 学年 → 年级名(按个人信息入学年份推算:大一~大四)
|
||||
// 学年 → 年级名(按个人信息入学年份推算:大一~大四);无入学年份时退回学年名
|
||||
export function gradeLabel(year: string, eduStart?: string): string {
|
||||
const m = /^(\d{4})-/.exec(year);
|
||||
const y = termYearOf(year) ?? year;
|
||||
const m = /^(\d{4})/.exec(y);
|
||||
const e = /^(\d{4})/.exec(eduStart ?? '');
|
||||
if (!m || !e) return year + ' 学年';
|
||||
if (!m || !e) return y + ' 学年';
|
||||
const n = Number(m[1]) - Number(e[1]) + 1;
|
||||
const names = ['大一', '大二', '大三', '大四', '大五'];
|
||||
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)';
|
||||
const names = ['大一', '大二', '大三', '大四', '大五', '大六', '大七'];
|
||||
if (n < 1) return y + ' 学年';
|
||||
return (names[n - 1] ?? n + '年级') + '(' + y + ' 学年)';
|
||||
}
|
||||
|
||||
function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
|
||||
@@ -141,14 +149,8 @@ export function computeZongce(opts: {
|
||||
]));
|
||||
|
||||
// ---- 学业成绩(60):学年平均学分绩点 × 10 ----
|
||||
const scopedCourses: CourseGrade[] = [];
|
||||
for (const x of semesters) {
|
||||
if (range && !yearRange(x.term)) continue;
|
||||
const rng = yearRange(x.term);
|
||||
const hit = range === null || (rng && !(rng[1] <= range[0] || rng[0] >= range[1]));
|
||||
if (hit) scopedCourses.push(...x.courses);
|
||||
}
|
||||
let gpa: number | null = null;
|
||||
// 口径与「成绩」页统一:优先取学期卡展示的 semester.gpa;未存 gpa 的学期回退到
|
||||
// 与「自动绩点」按钮相同的推算公式(档内线性折算 × 分类权重),保证两处数字一致。
|
||||
const gpOf = (c: CourseGrade): number | null => {
|
||||
if (c.grade_point != null) return c.grade_point;
|
||||
if (bands && c.score != null) {
|
||||
@@ -165,14 +167,25 @@ export function computeZongce(opts: {
|
||||
return null;
|
||||
};
|
||||
const wOf = (c: CourseGrade): number => (weights ? (weights[c.category ?? '其他'] ?? weights['其他'] ?? 1) : 1);
|
||||
const gpCourses = scopedCourses.map(c => ({ ...c, grade_point: gpOf(c) })).filter(c => c.grade_point != null && c.credit > 0);
|
||||
if (gpCourses.length) {
|
||||
const credits = gpCourses.reduce((a, c) => a + c.credit, 0);
|
||||
gpa = round2(gpCourses.reduce((a, c) => a + (c.grade_point as number) * wOf(c) * c.credit, 0) / credits);
|
||||
const yearKey = termYearOf(year);
|
||||
const scopedSemesters = semesters.filter(x => !yearKey || termYearOf(x.term) === yearKey);
|
||||
const scopedCourses: CourseGrade[] = [];
|
||||
const semGpas: { term: string; gpa: number; fallback: boolean }[] = [];
|
||||
for (const s of scopedSemesters) {
|
||||
scopedCourses.push(...(s.courses ?? []));
|
||||
if (s.gpa != null) { semGpas.push({ term: s.term, gpa: s.gpa, fallback: false }); continue; }
|
||||
const withGp = (s.courses ?? []).map(x => ({ ...x, grade_point: gpOf(x) })).filter(x => x.grade_point != null && x.credit > 0);
|
||||
if (!withGp.length) continue;
|
||||
const credits = withGp.reduce((a, x) => a + x.credit, 0);
|
||||
semGpas.push({ term: s.term, gpa: round2(withGp.reduce((a, x) => a + (x.grade_point as number) * wOf(x) * x.credit, 0) / credits), fallback: true });
|
||||
}
|
||||
const gpaScore = gpa != null ? Math.min(round2(gpa * r.gpaFactor), 60) : 0;
|
||||
const gpa = semGpas.length ? round2(semGpas.reduce((a, x) => a + x.gpa, 0) / semGpas.length) : null;
|
||||
const gpaNote = gpa == null
|
||||
? '该学年暂无学期绩点数据(先在「成绩」页录入学期或课程)'
|
||||
: semGpas.some(x => x.fallback) ? '部分学期绩点未录入,按课程推算' : undefined;
|
||||
const gpaScore = gpa != null ? round2(gpa * r.gpaFactor) : 0;
|
||||
sections.push(sec('academic', '学业成绩', 60, [
|
||||
{ itemKey: 'gpa', title: '平均学分绩点 ' + (gpa != null ? gpa.toFixed(2) : '—') + ' × 10', source: '成绩模块 · ' + scopedCourses.length + ' 门课程', score: gpaScore, note: gpa == null ? '该学期暂无课程成绩' : undefined },
|
||||
{ itemKey: 'gpa', title: '学年平均学分绩点 ' + (gpa != null ? gpa.toFixed(2) : '—') + ' × ' + r.gpaFactor, source: '成绩模块 · ' + scopedSemesters.length + ' 学期' + (semGpas.length ? '(绩点均值)' : ''), score: gpaScore, note: gpaNote || (gpa == null ? undefined : scopedCourses.length + ' 门课程') },
|
||||
]));
|
||||
|
||||
// ---- 文体素质(10):体育 + 文体类加分(表2,cap 5) ----
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ProfileInfo } from './types';
|
||||
import type { ZcSection } from './zongce';
|
||||
|
||||
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
export function zongceToWordHtml(
|
||||
termLabel: string,
|
||||
sections: ZcSection[],
|
||||
total: number,
|
||||
profile: Partial<ProfileInfo>
|
||||
): string {
|
||||
const body = sections.map(sec => {
|
||||
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
|
||||
const rows = sec.items.map(it =>
|
||||
'<tr>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999">' + esc(it.title) + '</td>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999;font-size:10pt;color:#555">' + esc(it.note || it.source) + '</td>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999;text-align:right;width:70pt">' + esc(it.score) + '</td>' +
|
||||
'</tr>').join('');
|
||||
return '<h2 style="font-size:13pt;margin:12pt 0 5pt;color:#111">' + esc(sec.label) +
|
||||
'(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
|
||||
}).join('');
|
||||
|
||||
const head = '<div style="font-size:11pt;color:#555;margin-bottom:6pt">' +
|
||||
esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>';
|
||||
|
||||
return '<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:WordDocument></xml><![endif]--></head>' +
|
||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
|
||||
'<h1 style="text-align:center;font-size:18pt;margin:0 0 6pt">学生综合素质测评</h1>' + head +
|
||||
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(total) + ' 分</b></p>' +
|
||||
body +
|
||||
'<div style="margin-top:24pt;font-size:10.5pt;color:#555">计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。以上自动匹配条目均经过人工核对。</div>' +
|
||||
'<table style="width:100%;border-collapse:collapse;margin-top:24pt;font-size:11pt"><tr>' +
|
||||
'<td style="width:33%">学生签名:______</td>' +
|
||||
'<td style="width:33%">辅导员签名:______</td>' +
|
||||
'<td>日期:______</td></tr></table>' +
|
||||
'</body></html>';
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Download, Images, Printer, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, 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, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
|
||||
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[] }
|
||||
@@ -41,12 +41,50 @@ function buildMhtml(html: string, images: { name: string; dataUri: string }[]):
|
||||
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<ProfileInfo>, evidence: Evidence[]): Blob {
|
||||
/** 评分细则参考(附在 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 纵向、黑体标题、表格化分项、附图栅格 + 细则参考附表 */
|
||||
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[], rules: ZcRules): Blob {
|
||||
const f = (s: unknown) => esc(s);
|
||||
const school = f(profile.school || '云川理工学院');
|
||||
const infoCell = (v: unknown, label: string, half = false) =>
|
||||
'<td style="' + W_TH + ';width:' + (half ? '16%' : '14%') + '">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>';
|
||||
const schoolLine = profile.school ? '<div style="text-align:center;font-size:10.5pt;letter-spacing:1pt;color:#333">' + 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>';
|
||||
|
||||
// 分板块明细表
|
||||
const body = result.sections.map(sec => {
|
||||
@@ -69,7 +107,7 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
|
||||
);
|
||||
}).join('');
|
||||
|
||||
// 证明材料附图:每条记录一个组块,图两列(约 200pt 宽)
|
||||
// 附 1:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布
|
||||
const images: { name: string; dataUri: string }[] = [];
|
||||
let figNo = 0;
|
||||
const grids = evidence.map(ev => {
|
||||
@@ -77,18 +115,26 @@ 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"><img src="' + name + '" style="width:196pt;border:0.75pt solid #8a8a8a" alt="附图"><br>' +
|
||||
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>';
|
||||
}).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>' +
|
||||
'<td style="' + W_TD + '"><table style="width:100%;border-collapse:collapse"><tr><td style="padding:2pt">' + imgs + '</td></tr></table></td></tr>';
|
||||
'<td style="' + W_TD + '">' + imgs + '</td></tr>';
|
||||
}).join('');
|
||||
const annex = grids
|
||||
? '<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附:加分证明材料(' + f(evidence.length) + ' 项记录 · ' + f(figNo) + ' 张材料图)</div>' +
|
||||
? '<div style="font-family:SimHei;font-size:12pt;margin:14pt 0 4pt">附 1:加分证明材料 — ' + 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">' +
|
||||
'<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>' +
|
||||
@@ -103,31 +149,41 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
|
||||
'.num { font-family:Calibri,SimSun; }' +
|
||||
'</style></head>' +
|
||||
'<body><div class="WordSection1">' +
|
||||
'<div style="text-align:center;font-size:10.5pt;letter-spacing:1pt;color:#333">' + school + '</div>' +
|
||||
schoolLine +
|
||||
'<h1 class="title">学生综合素质测评表</h1>' +
|
||||
'<p class="sub">测评学年:' + f(termLabel) + '</p>' +
|
||||
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" 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, '政治面貌') +
|
||||
'<td style="' + W_TH + '">测评学年</td><td style="' + W_TD + '">' + f(termLabel) + '</td>' +
|
||||
'<td style="' + W_TH + '">测评总分</td><td style="' + W_TD + '"><b class="num" style="font-size:12pt">' + f(result.total) + '</b> / 100 分</td></tr>' +
|
||||
'</table>' +
|
||||
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>' +
|
||||
annex +
|
||||
rulesAnnexHtml(rules) +
|
||||
'<table style="width:100%;border-collapse:collapse;margin-top:22pt;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">说明:本表依据《计算机与网络安全学院学生综合素质测评评分细则》自动生成;带「已手动修正」标记的分值为辅导员核准后的手动结果。导出前请核对加分记录与证明材料一致。</p>' +
|
||||
'<p style="font-size:9pt;color:#555;margin-top:10pt">说明:本表依据《学生综合素质测评评分细则》自动生成(细则参考默认值见附 2,可在综合测评页自行调整);带「已手动修正」标记的分值为手动核定结果。导出前请核对加分记录与证明材料一致。</p>' +
|
||||
'</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 });
|
||||
@@ -136,17 +192,24 @@ export function AssessmentPage() {
|
||||
|
||||
const stored = profileQ.data?.content;
|
||||
const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) };
|
||||
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
|
||||
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 [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 ?? {});
|
||||
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7))));
|
||||
if (stored.zongce?.rules) setRules(stored.zongce.rules);
|
||||
}, [stored]);
|
||||
|
||||
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]);
|
||||
}, [stored, gradesQ.data]);
|
||||
|
||||
@@ -162,23 +225,39 @@ export function AssessmentPage() {
|
||||
[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); }));
|
||||
return ids;
|
||||
// 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
|
||||
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 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 matchedIds) {
|
||||
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).slice(0, 2)) {
|
||||
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);
|
||||
}
|
||||
@@ -191,7 +270,7 @@ export function AssessmentPage() {
|
||||
if (alive) setEvidence(out);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [matchedIds.join(',')]);
|
||||
}, [scopeIds.join(',')]);
|
||||
|
||||
// 全部放平的图序列(灯箱上一张/下一张用)
|
||||
const flatImages = useMemo(() => {
|
||||
@@ -200,19 +279,32 @@ export function AssessmentPage() {
|
||||
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 }));
|
||||
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [year]: next } } }).catch(e => toast('error', e.message));
|
||||
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 termLabel = year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart);
|
||||
|
||||
const exportWord = () => {
|
||||
const blob = zongceWordMhtml(termLabel, result, profile, evidence);
|
||||
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 + ' 张)');
|
||||
toast('success', '综测表已导出(.doc,附证明材料图 ' + flatImages.length + ' 张 + 细则参考)');
|
||||
};
|
||||
|
||||
const zoomIdx = zoom ? flatImages.findIndex(x => x.src === zoom.src) : -1;
|
||||
@@ -230,11 +322,12 @@ export function AssessmentPage() {
|
||||
<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>
|
||||
<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={[...Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.split('-').slice(0, 2).join('-')))).map(y => ({ value: y, label: gradeLabel(y, profile.eduStart) })), { value: 'all', label: '全部学年' }]} />
|
||||
options={[...years.map(y => ({ value: y, label: gradeLabel(y, eduStartEff) })), { value: 'all', label: '全部学年' }]} />
|
||||
<Button variant="secondary" onClick={() => setRulesOpen(v => !v)}><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>
|
||||
@@ -242,20 +335,20 @@ export function AssessmentPage() {
|
||||
</div>
|
||||
|
||||
<div className="print-sheet mx-auto w-full max-w-[820px] rounded-xl border border-line bg-white shadow-card sm:p-8">
|
||||
<div className="text-center text-[11px] tracking-widest text-neutral-500">{profile.school || '云川理工学院'}</div>
|
||||
{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.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>
|
||||
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-neutral-900">{val || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -302,7 +395,7 @@ export function AssessmentPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 证明材料:图片画廊 */}
|
||||
{/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
|
||||
{(evidence.length > 0 || totalImgs > 0) && (
|
||||
<div className="mt-7">
|
||||
<div className="flex items-center justify-between border-t border-neutral-200 pt-4">
|
||||
@@ -316,10 +409,15 @@ export function AssessmentPage() {
|
||||
{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="min-w-0 truncate text-[12.5px] font-semibold text-neutral-800">{ev.title}</div>
|
||||
<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 grid grid-cols-2 gap-2">
|
||||
<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">
|
||||
@@ -333,9 +431,92 @@ 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 / 打印前请核对加分记录与证明材料一致。
|
||||
计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
|
||||
自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,7 +525,7 @@ export function AssessmentPage() {
|
||||
open={!!zoom}
|
||||
onClose={() => setZoom(null)}
|
||||
width={920}
|
||||
title={zoom?.title ?? '' + (zoom ? '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : '')}
|
||||
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>
|
||||
@@ -366,13 +547,15 @@ export function AssessmentPage() {
|
||||
const next = { ...ovMap };
|
||||
delete next[year];
|
||||
setOvMap(next);
|
||||
void profileUpsert({ info: profile, zongce: { rules, overrides: next } }).then(() => { setInitOpen(false); toast('success', '已恢复自动计算'); }).catch(e => toast('error', e.message));
|
||||
persist({ overrides: next });
|
||||
setInitOpen(false);
|
||||
toast('success', '已恢复自动计算');
|
||||
}}>恢复自动计算</Button>
|
||||
</>}>
|
||||
<div className="text-[12.5px] leading-relaxed text-ink-2">
|
||||
将清除 <b>{year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart)}</b> 的全部手动修正
|
||||
将清除 <b>{year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff)}</b> 的全部手动修正
|
||||
(分值覆盖、操行/诚信分修改),恢复为按细则规则的自动计算结果。
|
||||
<div className="mt-2 text-ink-3">记录数据与个人信息不受影响。</div>
|
||||
<div className="mt-2 text-ink-3">记录数据、个人信息与评分细则不受影响。</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user