feat(assessment): auto comprehensive assessment - rules engine from college scoring standard, per-semester stats, manual overrides, word export

This commit is contained in:
root
2026-10-17 15:40:07 +08:00
parent a0e2ded426
commit 49d8400722
2395 changed files with 1306906 additions and 94 deletions
+2
View File
@@ -12,6 +12,7 @@ const TaxonomyPage = lazy(() => import('@/pages/taxonomy/TaxonomyPage').then(m =
const AdminPage = lazy(() => import('@/pages/admin/AdminPage').then(m => ({ default: m.AdminPage })));
const ProfilePage = lazy(() => import('@/pages/profile/ProfilePage').then(m => ({ default: m.ProfilePage })));
const ResumePage = lazy(() => import('@/pages/resume/ResumePage').then(m => ({ default: m.ResumePage })));
const AssessmentPage = lazy(() => import('@/pages/assessment/AssessmentPage').then(m => ({ default: m.AssessmentPage })));
function PageLoading() {
return <div className="py-16 text-center text-[13px] text-ink-3">加载中…</div>;
@@ -31,6 +32,7 @@ export default function App() {
<Route path="/admin" element={<Suspense fallback={<PageLoading />}><AdminPage /></Suspense>} />
<Route path="/profile" element={<Suspense fallback={<PageLoading />}><ProfilePage /></Suspense>} />
<Route path="/resume" element={<Suspense fallback={<PageLoading />}><ResumePage /></Suspense>} />
<Route path="/assessment" element={<Suspense fallback={<PageLoading />}><AssessmentPage /></Suspense>} />
<Route path="*" element={<Navigate to="/timeline" replace />} />
</Route>
</Routes>
+2 -1
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText } from 'lucide-react';
import { CalendarDays, Rows3, ChartColumn, GraduationCap, Shapes, SlidersHorizontal, LogIn, LogOut, ShieldCheck, Cloud, User, ScrollText, ClipboardCheck } from 'lucide-react';
import { serverMe, clearToken, getToken } from '@/lib/api';
import { LoginDialog } from '@/components/auth/LoginDialog';
import { cn } from '@/lib/utils';
@@ -21,6 +21,7 @@ const NAV = [
items: [
{ to: '/profile', label: '个人信息', icon: User },
{ to: '/resume', label: '我的简历', icon: ScrollText },
{ to: '/assessment', label: '综合测评', icon: ClipboardCheck },
],
},
{
+4 -3
View File
@@ -1,8 +1,9 @@
import type {
ZcRules,
AppInfo, Attachment, Dimension, FootprintDetail, FootprintInput, FootprintQuery,
FootprintRow, FootprintType, FormField, GradeBand, GradesOverview, MetricValue,
StatsOverview, SyncStatus, TaxonomyItem, TimelineGroup,
ProfileInfo, ResumeConfig,
ProfileInfo, ResumeConfig, ZcOverrides,
} from './types';
@@ -223,8 +224,8 @@ export const gpaSettingsGet = () => call<{ weights: Record<string, number> }>('g
export const gpaSettingsUpsert = (weights: Record<string, number>) => call<{ ok: true; weights: Record<string, number> }>('gpa_settings_upsert', { weights });
// ---- 个人信息 / 简历 ----
export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig } }>('profile_get', {});
export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig }) =>
export const profileGet = () => call<{ content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig; zongce?: { rules?: ZcRules; overrides?: Record<string, ZcOverrides> } } }>('profile_get', {});
export const profileUpsert = (content: { info?: Partial<ProfileInfo>; resume?: ResumeConfig; zongce?: { rules?: unknown; overrides?: Record<string, ZcOverrides> } }) =>
call<{ ok: true }>('profile_upsert', { content });
export async function profilePhotoUpload(file: File): Promise<string> {
const dataB64 = await new Promise<string>((resolve, reject) => {
+20
View File
@@ -267,3 +267,23 @@ export interface ResumeConfig {
header?: { showPhoto?: boolean; showContact?: boolean; showSchool?: boolean };
sections: ResumeSectionCfg[];
}
export interface ZcOverrides {
conduct?: number;
integrity?: number;
itemOverrides?: Record<string, { score?: number; exclude?: boolean }>;
manual?: { title: string; section: string; score: number }[];
}
export interface ZcRules {
politics: Record<string, number>;
conductDefault: number;
integrityDefault: number;
sportsFactor: number;
gpaFactor: number;
table2: Record<string, Record<string, number>>;
table4: Record<string, Record<string, number>>;
publicGood: Record<string, number>;
rewards: Record<string, Record<string, number>>;
penalties: Record<string, number>;
}
+260
View File
@@ -0,0 +1,260 @@
// 综合素质测评引擎:规则默认值(源自学院评分细则)+ 自动匹配 + 手动覆盖
import type { CourseGrade, FootprintRow, GradeBand, ProfileInfo, ZcRules } from './types';
export type { ZcRules } from './types';
export const ZC_DEFAULT_RULES: ZcRules = {
politics: { 中共党员: 3, 中共预备党员: 2.5, 入党积极分子: 2, 共青团员: 1.5, 群众: 1 },
conductDefault: 8,
integrityDefault: 2,
sportsFactor: 0.025,
gpaFactor: 10,
table2: {
国家级: { 一等奖: 3, 二等奖: 2, 三等奖: 1, 优秀奖: 0.5 },
省市级: { 一等奖: 2, 二等奖: 1, 三等奖: 0.5, 优秀奖: 0.2 },
区县级: { 一等奖: 1, 二等奖: 0.5, 三等奖: 0.2, 优秀奖: 0.1 },
校级: { 一等奖: 0.5, 二等奖: 0.2, 三等奖: 0.1, 优秀奖: 0.05 },
二级学院: { 一等奖: 0.2, 二等奖: 0.1, 三等奖: 0.05, 优秀奖: 0 },
},
table4: {
校级: { 干部: 4, 干事: 2, 社团干部: 1, 学工助理: 2 },
院级: { 干部: 3, 干事: 1.5, 支部委员: 1.5, 支部组长: 1, 班长: 1, 团支书: 1, 班委: 0.5, 社团干部: 1, 学工助理: 2 },
},
publicGood: { 无偿献血: 0.5, 捐献骨髓: 3, 国家级: 1.5, 省市级: 1, 区县级: 0.5, 校级: 0.2, 二级学院: 0.1 },
rewards: {
荣誉称号: { 院级: 0.3, 校级: 0.5, 区级: 1, 市级: 1.5, 国家级: 2 },
英语等级: { 三级: 0.5, 四级: 1, 六级: 2 },
计算机等级: { 一级: 0.5, 二级: 1, 三级: 2 },
职业资格: { 从业资格: 0.5, 初级: 1, 中级: 2, 高级: 3 },
},
penalties: { 留校察看: 5, 记过: 4, 严重警告: 3, 警告: 2, 通报批评: 1 },
};
export interface ZcOverrides {
conduct?: number;
integrity?: number;
itemOverrides?: Record<string, { score?: number; exclude?: boolean }>;
manual?: { title: string; section: string; score: number }[];
}
// 学期 → 统计时间窗 [start, end)
export function termRange(term: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})-(1|2)$/.exec(term);
if (!m) return null;
const y1 = Number(m[1]);
return m[3] === '1' ? [y1 + '-09-01', (y1 + 1) + '-03-01'] : [(y1 + 1) + '-03-01', (y1 + 1) + '-09-01'];
}
function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
if (!range) return true;
const v = (d ?? '').slice(0, 10);
return v >= range[0] && v < range[1];
}
function normLevel(level?: string | null): keyof typeof ZC_DEFAULT_RULES.table2 | null {
const v = (level ?? '').trim();
if (!v) return null;
if (v.includes('国家')) return '国家级';
if (v.includes('省') || v.includes('市')) return '省市级';
if (v.includes('区')) return '区县级';
if (v.includes('院') || v.includes('二级')) return '二级学院';
if (v.includes('校')) return '校级';
return null;
}
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 (/优秀|优胜|第[7-9]名|第七名/.test(v)) return '优秀奖';
return null;
}
export interface ZcItem {
itemKey: string;
title: string;
source: string;
score: number;
overridden?: boolean;
note?: string;
}
export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number }
export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null }
const round2 = (n: number) => Math.round(n * 100) / 100;
function pickScore(r: ZcRules, levelKey: string | null, gradeKey: string | null): number | null {
if (!levelKey || !gradeKey) return null;
const v = (r.table2 as Record<string, Record<string, number>>)[levelKey]?.[gradeKey];
return v ?? null;
}
// 计算某学期(term,或 'all' 全年)的综测
export function computeZongce(opts: {
term: string;
records: FootprintRow[];
semesters: { term: string; gpa?: number | null; courses: CourseGrade[] }[];
bands?: GradeBand[];
weights?: Record<string, number>;
profile: Partial<ProfileInfo>;
rules: ZcRules;
ov: ZcOverrides;
}): ZcResult {
const { term, records, semesters, bands, weights, profile, rules: r, ov } = opts;
const range = term === 'all' ? null : termRange(term);
const scoped = records.filter(x => x.status !== 'draft' && inRange(x.start_date ?? x.created_at, range));
const ovItem = (k: string) => ov.itemOverrides?.[k];
const mk = (k: string, title: string, source: string, auto: number | null, note?: string): ZcItem => {
const o = ovItem(k);
const score = o?.score ?? (auto ?? 0);
return { itemKey: k, title, source, score, overridden: o?.score != null, note };
};
const sec = (key: string, label: string, cap: number, items: ZcItem[]): ZcSection => ({
key, label, cap, items,
subtotal: round2(items.reduce((a, x) => a + x.score, 0)),
});
const sections: ZcSection[] = [];
// ---- 品行素质(13):思想政治 + 操行 + 诚信 ----
const pol = profile.political ?? '';
const polKey = Object.keys(r.politics).find(k => pol.includes(k));
const polScore = polKey ? r.politics[polKey] : 1;
const conduct = ov.conduct ?? r.conductDefault;
const integrity = ov.integrity ?? r.integrityDefault;
sections.push(sec('conduct', '品行素质', 13, [
{ itemKey: 'politics', title: '思想政治基础分(' + (pol || '群众') + ')', source: '个人信息 · 政治面貌', score: polScore },
{ itemKey: 'conduct', title: '操行考评成绩', source: '辅导员考评(可修改)', score: conduct, overridden: ov.conduct != null },
{ itemKey: 'integrity', title: '诚信分', source: '默认满分(可修改)', score: integrity, overridden: ov.integrity != null },
]));
// ---- 学业成绩(60):学年平均学分绩点 × 10 ----
const scopedCourses: CourseGrade[] = [];
for (const x of semesters) {
if (!inRange(null, null)) continue;
const rng = term === 'all' ? null : termRange(x.term);
const hit = term === 'all' || (range && rng && !(rng[1] <= range[0] || rng[0] >= range[1]));
if (hit) scopedCourses.push(...x.courses);
}
let gpa: number | null = null;
const gpOf = (c: CourseGrade): number | null => {
if (c.grade_point != null) return c.grade_point;
if (bands && c.score != null) {
const b = bands.find(x => c.score! >= x.min && (c.score! < x.max || (x.max >= 100 && c.score! <= x.max)));
if (b && b.gp != null) {
if (b.gpTop != null) {
const topScore = b.max >= 100 ? b.max : b.max - 1;
const slope = (b.gpTop - b.gp) / Math.max(1, topScore - b.min);
return round2(b.gp + (c.score! - b.min) * slope);
}
return b.gp;
}
}
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 gpaScore = gpa != null ? Math.min(round2(gpa * r.gpaFactor), 60) : 0;
sections.push(sec('academic', '学业成绩', 60, [
{ itemKey: 'gpa', title: '平均学分绩点 ' + (gpa != null ? gpa.toFixed(2) : '—') + ' × 10', source: '成绩模块 · ' + scopedCourses.length + ' 门课程', score: gpaScore, note: gpa == null ? '该学期暂无课程成绩' : undefined },
]));
// ---- 文体素质(10):体育 + 文体类加分(表2,cap 5) ----
const sportCourses = scopedCourses.filter(c => /体育/.test(c.course_name));
const sportScore = round2(sportCourses.reduce((a, c) => a + (c.score ?? 0), 0) * r.sportsFactor);
const sportsItems: ZcItem[] = [];
if (sportCourses.length) sportsItems.push({ itemKey: 'sports', title: '体育成绩(' + sportCourses.length + ' 学期体育课)', source: '体育课成绩之和 × ' + r.sportsFactor, score: Math.min(sportScore, 5) });
const wItems: ZcItem[] = [];
for (const rec of scoped.filter(x => x.type_name === '文体活动')) {
const auto = pickScore(r, normLevel(rec.level), normGrade(rec.award_grade));
if (auto == null && !ovItem('w' + rec.id)?.score) continue;
wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto));
}
const wCap = round2(Math.min(wItems.reduce((a, x) => a + x.score, 0), 5));
sections.push(sec('sports', '文体素质', 10, [...sportsItems, { itemKey: 'wcap', title: '文体类加分(上限 5 分)', source: wItems.length + ' 条记录', score: wCap, note: wItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }]));
// ---- 拓展性素质(17):竞赛获奖(10) + 学生工作(4) + 公益(3) ----
const compItems: ZcItem[] = [];
for (const rec of scoped.filter(x => x.type_name === '竞赛' || x.type_name === '科研')) {
const auto = pickScore(r, normLevel(rec.level), normGrade(rec.award_grade));
if (auto == null && !ovItem('c' + rec.id)?.score) continue;
compItems.push(mk('c' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto));
}
const compCap = round2(Math.min(compItems.reduce((a, x) => a + x.score, 0), 10));
const workItems: ZcItem[] = [];
for (const rec of scoped.filter(x => x.type_name === '学生工作')) {
const lvl = normLevel(rec.level) ?? '院级';
const role = (rec.role ?? '') + ' ' + rec.title;
const tbl = (r.table4 as Record<string, Record<string, number>>)[lvl === '国家级' || lvl === '省市级' || lvl === '区县级' ? '校级' : lvl] ?? r.table4['院级'];
let auto: number | null = null;
for (const [k, v] of Object.entries(tbl)) { if (role.includes(k)) { auto = v; break; } }
if (auto == null && !ovItem('k' + rec.id)?.score) continue;
workItems.push(mk('k' + rec.id, rec.title, '学生工作 · ' + (rec.level ?? '院级') + ' ' + (rec.role ?? ''), auto, auto == null ? '请手动填写分值' : undefined));
}
const workCap = round2(Math.min(workItems.reduce((a, x) => a + x.score, 0), 4));
const pubItems: ZcItem[] = [];
for (const rec of scoped.filter(x => x.type_name === '志愿公益')) {
const lvl = normLevel(rec.level);
const blood = /献血/.test(rec.title);
let auto: number | null = blood ? r.publicGood['无偿献血'] : (lvl ? r.publicGood[lvl] ?? null : null);
if (auto == null && !ovItem('g' + rec.id)?.score) continue;
pubItems.push(mk('g' + rec.id, rec.title, '公益 · ' + (blood ? '无偿献血' : rec.level ?? '校级'), auto));
}
const pubCap = round2(Math.min(pubItems.reduce((a, x) => a + x.score, 0), 3));
sections.push(sec('expand', '拓展性素质', 17, [
{ itemKey: 'ccap', title: '竞赛获奖(上限 10 分)', source: compItems.length + ' 条记录', score: compCap, note: compItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' },
{ itemKey: 'kcap', title: '担任学生工作(上限 4 分)', source: workItems.length + ' 条记录', score: workCap, note: workItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' },
{ itemKey: 'gcap', title: '公益活动(上限 3 分)', source: pubItems.length + ' 条记录', score: pubCap, note: pubItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' },
]));
// ---- 奖惩分(±5) ----
const rwItems: ZcItem[] = [];
for (const rec of scoped.filter(x => x.type_name === '荣誉惩处' || x.type_name === '证书技能')) {
const o = ovItem('h' + rec.id);
const text = rec.title + ' ' + (rec.award_grade ?? '');
let auto: number | null = null;
let source = rec.type_name;
if (/留校察看/.test(text)) auto = -r.penalties['留校察看'];
else if (/记过/.test(text)) auto = -r.penalties['记过'];
else if (/严重警告/.test(text)) auto = -r.penalties['严重警告'];
else if (/警告/.test(text)) auto = -r.penalties['警告'];
else if (/通报批评/.test(text)) auto = -r.penalties['通报批评'];
else if (/六级|专八/.test(text)) auto = r.rewards['英语等级']['六级'];
else if (/四级|专四/.test(text)) auto = r.rewards['英语等级']['四级'];
else if (/三级/.test(text) && /英语/.test(text)) auto = r.rewards['英语等级']['三级'];
else if (/计算机/.test(text) && /三级/.test(text)) auto = r.rewards['计算机等级']['三级'];
else if (/计算机/.test(text) && /二级/.test(text)) auto = r.rewards['计算机等级']['二级'];
else if (/计算机/.test(text) && /一级/.test(text)) auto = r.rewards['计算机等级']['一级'];
else if (/高级/.test(text)) auto = r.rewards['职业资格']['高级'];
else if (/中级/.test(text)) auto = r.rewards['职业资格']['中级'];
else if (/初级/.test(text)) auto = r.rewards['职业资格']['初级'];
else if (/从业|资格证|教师资格|普通话|驾驶证|软考/.test(text)) auto = r.rewards['职业资格']['从业资格'];
else {
const lvl = normLevel(rec.level);
const lkey = lvl === '省市级' ? '市级' : lvl === '区县级' ? '区级' : lvl === '二级学院' ? '院级' : lvl;
if (lkey && r.rewards['荣誉称号'][lkey] != null) { auto = r.rewards['荣誉称号'][lkey]; source = '荣誉 · ' + lkey; }
}
if (auto == null && !o?.score) continue;
rwItems.push(mk('h' + rec.id, rec.title, source, auto));
}
const rwCap = round2(Math.max(Math.min(rwItems.reduce((a, x) => a + x.score, 0), 5), -5));
sections.push(sec('rewards', '奖惩分', 5, [{ itemKey: 'hcap', title: '奖惩合计(±5 分)', source: rwItems.length + ' 条记录', score: rwCap, note: rwItems.map(x => x.title + ' ' + x.score + '分').join(';') || '无匹配记录' }]));
// 手动条目
for (const m of ov.manual ?? []) {
const sec2 = sections.find(x => x.label === m.section) ?? sections[sections.length - 1];
sec2.items.push({ itemKey: 'm' + m.title + m.score, title: m.title, source: '手动添加', score: m.score });
sec2.subtotal = round2(sec2.items.reduce((a, x) => a + x.score, 0));
}
const total = round2(sections.reduce((a, x) => {
const cap = x.key === 'rewards' ? x.subtotal : Math.min(x.subtotal, x.cap);
return a + cap;
}, 0));
return { sections, total, gpa };
}
+41
View File
@@ -0,0 +1,41 @@
import type { ProfileInfo } from './types';
import type { ZcSection } from './zongce';
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>';
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Printer, RotateCcw } from 'lucide-react';
import { footprintList, gradesOverview, profileGet, profileUpsert } from '@/lib/api';
import type { FootprintRow, ProfileInfo, ZcOverrides } from '@/lib/types';
import { computeZongce, ZC_DEFAULT_RULES, type ZcRules } from '@/lib/zongce';
import { Button, Select, useToast } from '@/components/ui';
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<ProfileInfo> = { ...(stored?.info ?? {}) };
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
const [term, setTerm] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
const sems = (gradesQ.data?.semesters ?? []).map(x => x.term);
if (!term && sems.length) setTerm(sems[0]);
}, [stored, gradesQ.data]);
const records: FootprintRow[] = listQ.data?.items ?? [];
const ov: ZcOverrides = ovMap[term] ?? {};
const result = useMemo(
() => computeZongce({
term: term || 'all', records,
semesters: gradesQ.data?.semesters ?? [],
bands: gradesQ.data?.bands, weights: gradesQ.data?.weights,
profile, rules, ov,
}),
[records, gradesQ.data, profile, rules, ov, term]
);
const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [term]: next }));
void profileUpsert({ info: profile, zongce: { rules, overrides: { ...ovMap, [term]: next } } }).catch(e => toast('error', e.message));
};
const resetOverrides = () => {
const next = { ...ovMap };
delete next[term];
setOvMap(next);
void profileUpsert({ info: profile, zongce: { rules, overrides: next } }).then(() => toast('success', '已恢复自动计算')).catch(e => toast('error', e.message));
};
const termLabel = term === 'all' ? '全学年' : term;
const exportWord = () => {
void import('@/lib/zongceWord').then(({ zongceToWordHtml }) => {
const html = zongceToWordHtml(termLabel, result.sections, result.total, profile);
const blob = new Blob(['\ufeff' + html], { type: 'application/msword' });
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', '综测材料已导出');
});
};
return (
<div className="mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5">
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-[15px] font-semibold text-ink-1">综合测评(自动计算)</h2>
<div className="flex flex-wrap items-center gap-2">
<Select className="w-[150px]" value={term} onChange={e => setTerm(e.target.value)}
options={[...(gradesQ.data?.semesters ?? []).map(x => ({ value: x.term, label: x.term })), { value: 'all', label: '全学年' }]} />
<Button variant="secondary" onClick={resetOverrides}><RotateCcw size={13} /> 恢复自动</Button>
<Button variant="secondary" onClick={exportWord}><Download size={13} /> 导出材料</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
</div>
</div>
<div className="print-sheet mx-auto w-full max-w-[794px] rounded-lg border border-line bg-white p-5 shadow-card sm:p-8">
<h1 className="text-center text-[18px] font-bold text-neutral-900">学生综合素质测评</h1>
<div className="mt-1 text-center text-[12px] text-neutral-500">{profile.name ?? ''} · {profile.className ?? ''} · 统计周期:{termLabel}</div>
<div className="mt-5 rounded-md bg-neutral-100 px-4 py-2 text-center text-[15px] font-bold text-neutral-900">
综合素质总分:<span className="num">{result.total}</span> 分
</div>
{result.sections.map(sec => (
<div key={sec.key} className="mt-5">
<div className="flex items-baseline justify-between border-b-2 border-neutral-800 pb-1">
<span className="text-[13.5px] font-bold text-neutral-900">{sec.label}(上限 {sec.cap} 分)</span>
<span className="num text-[12.5px] text-neutral-600">
小计 {sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap)} 分
</span>
</div>
<div className="mt-2 flex flex-col gap-1.5">
{sec.items.map(it => (
<div key={it.itemKey} className="flex items-center gap-2 text-[12.5px]">
<span className="min-w-0 flex-1">
<span className="text-neutral-900">{it.title}</span>
<span className="ml-2 text-[11px] text-neutral-400">{it.source}</span>
{it.note && <div className="text-[11px] text-neutral-400">{it.note}</div>}
</span>
<input type="number" step="0.1" className="num w-[72px] rounded-sm border border-neutral-200 px-1 py-0.5 text-right text-[12px]"
value={it.score} onChange={e => {
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 });
}
}} />
</div>
))}
{!sec.items.length && <div className="text-[12px] text-neutral-400">暂无数据</div>}
</div>
</div>
))}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
「恢复自动」清除本学期全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
</div>
</div>
</div>
);
}