test: demo seed now 18 footprints across two academic years

This commit is contained in:
root
2026-10-17 16:05:34 +08:00
parent 952a07fe6e
commit caa5cac7b7
24 changed files with 247 additions and 113 deletions
+60 -43
View File
@@ -359,51 +359,59 @@ function openDb(dbPath) {
// ---------- 演示数据(demo 账号播种 / 本地 seed 共用) ----------
// 行格式: [类型, 标题, 角色, 级别, 获奖等级, 主办方, 开始, 结束, 状态, 描述, 结果, 标签, 维度, extras, metrics]
export const DEMO_ROWS = [
['竞赛', '2025 全国大学生数学建模竞赛', '主导', '国家级', '省市级二等奖', '中国工业与应用数学学会', '2025-09-05', '2025-09-08', 'finished',
'担任队长,负责模型建立与论文主笔;协调三人分工与时间安排', '重庆赛区二等奖', ['建模', '团队协作'], ['学业深造'],
{ my_work: '模型建立与论文主笔', deliverable: '参赛论文《基于优化模型的共享单车调度研究》', team_members: '张三、李四、王五' }, {}],
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '省市级二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-20', 'finished',
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展', '学业深造'],
{ my_work: '独立参赛', deliverable: '省赛获奖证书' }, {}],
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', null, '教育部高等学校网络空间安全专业教学指导委员会', '2025-05-01', '2025-08-15', 'submitted',
'负责 Web 漏洞挖掘与利用脚本编写,参与作品报告撰写', '已提交作品,等待国赛评审', ['安全', 'CTF'], ['职业发展'],
{ my_work: 'Web 漏洞挖掘与利用脚本编写', deliverable: '参赛作品与报告' }, {}],
['志愿公益', '校图书馆迎新志愿服务', '参加', '校级', null, '校图书馆', '2025-09-01', '2025-09-05', 'finished',
'新生入馆引导与借阅咨询,共服务 5 天', '获馆方感谢信', ['志愿'], ['综合素养'],
{ hours: 24, contact: '李老师' }, { volunteer_hours: 24 }],
// ---- 大一(2023-2024 学年):学生工作 / 志愿 / 文体 / 证书 ----
['学生工作', '院学生会宣传部干事', '干事', '院级', null, '院学生会', '2023-09-01', '2024-06-30', 'finished',
'负责活动推文撰写与海报设计,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
{ duties: '推文撰写、海报设计、活动现场摄影', term: '2023.09 - 2024.06' }, {}],
['志愿公益', '校迎新志愿服务', '参加', '校级', null, '校青年志愿者联合会', '2023-09-01', '2023-09-03', 'finished',
'新生入馆引导与借阅咨询', '获馆方感谢信', ['志愿'], ['综合素养'],
{ hours: 24 }, { volunteer_hours: 24 }],
['志愿公益', '无偿献血', '参加', null, null, '市中心血站', '2024-03-15', '2024-03-15', 'finished',
'全血献血 400ml', '', ['公益'], ['综合素养'],
{ hours: 2 }, { volunteer_hours: 2 }],
['文体活动', '校运动会男子3000米', '参加', '校级', '三等奖', '校体育运动委员会', '2024-04-18', '2024-04-20', 'finished',
'坚持训练一个月,决赛第三名', '', ['文体'], ['兴趣生活'],
{ participation: '男子3000米决赛' }, {}],
['证书技能', '大学英语四级(CET-4)', '参加', '国家级', null, '教育部教育考试院', '2024-06-15', '2024-08-20', 'finished',
'笔试 562 分', 'CET-4 562 分', ['英语'], ['学业深造', '职业发展'],
{ score: '562' }, {}],
// ---- 大二(2024-2025 学年):竞赛 / 科研 / 荣誉 / 证书 ----
['竞赛', '全国大学生数学建模竞赛', '主导', '省市级', '一等奖', '中国工业与应用数学学会', '2024-09-05', '2024-09-08', 'finished',
'担任队长,负责模型建立与论文主笔;协调三人分工', '重庆赛区一等奖', ['建模', '团队协作'], ['学业深造'],
{ my_work: '模型建立与论文主笔', team_members: '张三、李四' }, {}],
['竞赛', '第十六届蓝桥杯程序设计大赛', '参加', '省市级', '二等奖', '工业和信息化部人才交流中心', '2025-04-12', '2025-06-01', 'finished',
'备战算法与数据结构,完成省赛', '重庆赛区 C/C++ B 组二等奖', ['算法'], ['职业发展'],
{ my_work: '独立参赛' }, {}],
['竞赛', '全国大学生信息安全竞赛', '核心', '国家级', '三等奖', '网络空间安全专业教指委', '2025-05-01', '2025-08-15', 'finished',
'负责 Web 漏洞挖掘与利用脚本编写', '全国总决赛三等奖', ['安全', 'CTF'], ['职业发展'],
{ my_work: 'Web 漏洞挖掘与利用脚本' }, {}],
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造'],
{ authors: '主持人', outcome: '研究报告' }, {}],
['文体活动', '校园十佳歌手大赛', '参加', '校级', '二等奖', '校团委', '2024-12-01', '2024-12-15', 'finished',
'初赛复赛决赛三轮', '校园十佳歌手', ['文体'], ['兴趣生活'],
{}, {}],
['志愿公益', '社区敬老院慰问活动', '参加', '区县级', null, '所在社区居委会', '2025-03-15', '2025-03-15', 'finished',
'陪伴老人、协助整理活动室', '', ['志愿', '公益'], ['综合素养'],
{ hours: 6 }, { volunteer_hours: 6 }],
['学生工作', '院学生会宣传部干事', '参加', '院级', null, '院学生会', '2024-09-01', '2025-06-30', 'finished',
'负责活动推文撰写与排版,全年产出推文 20 篇', '获优秀干事', ['宣传'], ['综合素养'],
{ duties: '推文撰写与排版、活动现场摄影', term: '2024.09 - 2025.06' }, {}],
['学生工作', '院学生会宣传部副部长', '主导', '院级', null, '院学生会', '2025-09-01', null, 'ongoing',
'统筹部门工作,管理 8 名干事,主导院级活动宣传方案', '', ['管理'], ['综合素养', '职业发展'],
{ duties: '部门统筹、活动宣传方案设计', term: '2025.09 - 至今' }, {}],
['荣誉惩处', '校级二等奖学金', '参加', '校级', null, '云川理工学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列', '二等奖学金', ['奖学金'], ['学业深造'],
{ issued_by: '云川理工学院' }, {}],
['学生工作', '校学生会学习部干事', '干事', '校级', null, '校学生会', '2024-09-01', null, 'ongoing',
'负责学风建设活动组织与学业帮扶配对', '', ['学风'], ['综合素养'],
{ duties: '学风活动组织、学业帮扶配对', term: '2024.09 - 至今' }, {}],
['荣誉惩处', '优秀共青团员', '参加', '校级', null, '校团委', '2025-05-04', '2025-05-04', 'finished',
'五四表彰', '优秀共青团员', ['表彰'], ['综合素养'],
{ issued_by: '共青团重庆城市科技学院委员会' }, {}],
['证书技能', '大学英语六级', '参加', '国家级', null, '教育部教育考试院', '2025-06-14', '2025-08-20', 'finished',
'笔试 528 分', 'CET-6 528 分', ['英语'], ['学业深造', '职业发展'],
{ cert_no: '510001251200123', score: '528' }, {}],
['证书技能', '计算机技术与软件专业技术资格(中级)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师', '', ['软考'], ['职业发展'],
{ score: '' }, {}],
['学业', '2024-2025 学年秋季学期', '参加', null, null, null, '2024-09-01', '2025-01-15', 'finished',
'学期绩点 3.72,专业排名 12/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-1' }, {}],
['学业', '2024-2025 学年春季学期', '参加', null, null, null, '2025-02-24', '2025-07-05', 'finished',
'学期绩点 3.85,专业排名 9/156', '', ['绩点'], ['学业深造'],
{ semester_label: '2024-2025-2' }, {}],
['文体活动', '校运动会开幕式方阵', '参加', '校级', null, '校体育运动委员会', '2025-04-18', '2025-04-20', 'finished',
'学院方阵队员,参与排练与开幕式表演', '', ['文体'], ['兴趣生活'],
{ participation: '方阵队员' }, {}],
['科研', '大学生创新创业训练计划项目', '主导', '校级', null, '校创新创业学院', '2025-03-01', null, 'ongoing',
'主持校级大创项目「基于流量特征的入侵检测研究」,带 2 名成员', '中期检查通过', ['科研', '安全'], ['学业深造', '职业发展'],
{ authors: '主持人', outcome: '研究报告' }, {}],
{}, {}],
['荣誉惩处', '综合测评奖学金', '参加', '校级', null, '云川理工学院', '2025-10-15', '2025-10-15', 'finished',
'2024-2025 学年综合测评前列(细则规定综测奖学金不加分,用于验证排除逻辑)', '二等奖学金', ['奖学金'], ['学业深造'],
{}, {}],
['证书技能', '全国计算机等级考试二级', '参加', '国家级', null, '教育部考试中心', '2025-03-22', '2025-05-10', 'finished',
'MS Office 高级应用', '二级合格证书', ['计算机'], ['职业发展'],
{}, {}],
['证书技能', '机动车驾驶证(C1)', '参加', null, null, '市公安局交通管理局', '2024-07-01', '2024-08-10', 'finished',
'科目一到科目四一次通过', 'C1 驾驶证', ['驾照'], ['生活技能'],
{}, {}],
['证书技能', '计算机技术与软件专业技术资格(软件设计师)', '参加', '国家级', null, '工业和信息化部', '2025-11-08', null, 'ongoing',
'备考软件设计师(中级)', '', ['软考'], ['职业发展'],
{}, {}],
['实习实践', '某安全公司渗透测试实习生', '参加', null, null, '某网络安全科技有限公司', '2025-07-01', '2025-08-25', 'finished',
'参与企业授权渗透测试项目 3 个,编写测试报告', '获实习优秀评价', ['实习', '安全'], ['职业发展'],
{ position: '渗透测试实习生', mentor: '陈工' }, {}]
@@ -421,7 +429,10 @@ export const DEMO_PROFILE = {
}
};
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
// 演示附件用真实 PNG(1x1,可在预览查看并进入综测证明材料)
const DEMO_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点, 类别][] }
export const DEMO_SEMESTERS = [
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
['高等数学(上)', 5, 78, 3.0, '必修课'], ['大学英语(一)', 3, 85, 3.7, '必修课'], ['程序设计基础', 4, 91, 4.0, '必修课'],
@@ -433,7 +444,7 @@ export const DEMO_SEMESTERS = [
]},
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
['概率论与数理统计', 3, 88, 3.9, '必修课'], ['操作系统', 4, 92, 4.0, '必修课'], ['计算机网络', 4, 86, 3.8, '必修课'],
['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课']
['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课'], ['体育(三)', 1, 88, 3.9, '必修课']
]},
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.0, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
@@ -1225,13 +1236,19 @@ function listFootprints(d, query) {
for (const [t, title, role, level, grade, organizer, start, end, status, desc, result, tags, dimNames, extras, metrics] of DEMO_ROWS) {
const typeId = typeIds[t];
if (!typeId) continue;
handleCommand('footprint_upsert', { item: {
const fr = handleCommand('footprint_upsert', { item: {
type_id: typeId, title, role, level, award_grade: grade, organizer,
start_date: start, end_date: end, status, description: desc, result,
tags, metrics, extras,
dimension_ids: (dimNames || []).map((nm) => dimIds[nm]).filter(Boolean),
}});
n++;
// 演示附件:竞赛/荣誉记录附带证书图片(可预览、可进综测证明材料)
if (/竞赛|优秀共青团员/.test(String(title)) && status === 'finished') {
try {
handleCommand('attachment_add', { owner_id: fr.item.id, file_name: '获奖证书.png', mime: 'image/png', size: DEMO_PNG.length, data_b64: DEMO_PNG, thumb_b64: DEMO_PNG });
} catch {}
}
}
let sems = 0;
for (const s of DEMO_SEMESTERS) {
+29 -18
View File
@@ -38,12 +38,21 @@ export interface ZcOverrides {
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);
// 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01)
export function yearRange(year: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})$/.exec(year);
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'];
return [m[1] + '-09-01', m[2] + '-09-01'];
}
// 学年 → 年级名(按个人信息入学年份推算:大一~大四)
export function gradeLabel(year: string, eduStart?: string): string {
const m = /^(\d{4})-/.exec(year);
const e = /^(\d{4})/.exec(eduStart ?? '');
if (!m || !e) return year + ' 学年';
const n = Number(m[1]) - Number(e[1]) + 1;
const names = ['大一', '大二', '大三', '大四', '大五'];
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)';
}
function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
@@ -80,6 +89,7 @@ export interface ZcItem {
score: number;
overridden?: boolean;
note?: string;
fromRecord?: number;
}
export interface ZcSection { key: string; label: string; cap: number; items: ZcItem[]; subtotal: number }
export interface ZcResult { sections: ZcSection[]; total: number; gpa: number | null }
@@ -94,7 +104,7 @@ function pickScore(r: ZcRules, levelKey: string | null, gradeKey: string | null)
// 计算某学期(term,或 'all' 全年)的综测
export function computeZongce(opts: {
term: string;
year: string;
records: FootprintRow[];
semesters: { term: string; gpa?: number | null; courses: CourseGrade[] }[];
bands?: GradeBand[];
@@ -103,14 +113,14 @@ export function computeZongce(opts: {
rules: ZcRules;
ov: ZcOverrides;
}): ZcResult {
const { term, records, semesters, bands, weights, profile, rules: r, ov } = opts;
const range = term === 'all' ? null : termRange(term);
const { year, records, semesters, bands, weights, profile, rules: r, ov } = opts;
const range = year === 'all' ? null : yearRange(year);
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 mk = (k: string, title: string, source: string, auto: number | null, note?: string, fromRecord?: number): ZcItem => {
const o = ovItem(k);
const score = o?.score ?? (auto ?? 0);
return { itemKey: k, title, source, score, overridden: o?.score != null, note };
return { itemKey: k, title, source, score, overridden: o?.score != null, note, fromRecord };
};
const sec = (key: string, label: string, cap: number, items: ZcItem[]): ZcSection => ({
key, label, cap, items,
@@ -133,9 +143,9 @@ export function computeZongce(opts: {
// ---- 学业成绩(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 (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;
@@ -174,7 +184,7 @@ export function computeZongce(opts: {
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));
wItems.push(mk('w' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id));
}
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(';') || '无匹配记录' }]));
@@ -184,7 +194,7 @@ export function computeZongce(opts: {
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));
compItems.push(mk('c' + rec.id, rec.title, rec.type_name + ' · ' + (rec.level ?? '') + ' ' + (rec.award_grade ?? ''), auto, undefined, rec.id));
}
const compCap = round2(Math.min(compItems.reduce((a, x) => a + x.score, 0), 10));
const workItems: ZcItem[] = [];
@@ -195,7 +205,7 @@ export function computeZongce(opts: {
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));
workItems.push(mk('k' + rec.id, rec.title, '学生工作 · ' + (rec.level ?? '院级') + ' ' + (rec.role ?? ''), auto, auto == null ? '请手动填写分值' : undefined, rec.id));
}
const workCap = round2(Math.min(workItems.reduce((a, x) => a + x.score, 0), 4));
const pubItems: ZcItem[] = [];
@@ -204,7 +214,7 @@ export function computeZongce(opts: {
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));
pubItems.push(mk('g' + rec.id, rec.title, '公益 · ' + (blood ? '无偿献血' : rec.level ?? '校级'), auto, undefined, rec.id));
}
const pubCap = round2(Math.min(pubItems.reduce((a, x) => a + x.score, 0), 3));
sections.push(sec('expand', '拓展性素质', 17, [
@@ -218,6 +228,7 @@ export function computeZongce(opts: {
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 ?? '');
if (/综合测评奖学金/.test(text)) continue; // 细则:综合测评奖学金不加分
let auto: number | null = null;
let source = rec.type_name;
if (/留校察看/.test(text)) auto = -r.penalties['留校察看'];
@@ -241,7 +252,7 @@ export function computeZongce(opts: {
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));
rwItems.push(mk('h' + rec.id, rec.title, source, auto, undefined, rec.id));
}
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(';') || '无匹配记录' }]));
+122 -27
View File
@@ -1,11 +1,65 @@
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 { attachmentList, attachmentThumb, 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 { computeZongce, gradeLabel, ZC_DEFAULT_RULES, type ZcRules, type ZcResult } from '@/lib/zongce';
import { Button, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; images: string[] }
function esc(t: unknown) { return String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// MHTML:把图片内嵌进 Word(.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');
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');
return new Blob([parts.join('')], { type: 'multipart/related; type="text/html"; boundary="' + boundary + '"' });
}
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob {
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 =>
'<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">' + esc(sec.label) + '(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
}).join('');
const images: { name: string; dataUri: string }[] = [];
const annex = evidence.map(ev => {
let imgs = '';
ev.images.forEach((uri, i) => {
const name = 'ev' + ev.recordId + '_' + i;
images.push({ name, dataUri: uri });
imgs += '<div style="font-size:10pt;color:#555">图 ' + (i + 1) + '</div><img src="' + name + '" style="width:300pt">';
});
return '<div style="margin-bottom:10pt"><b style="font-size:11pt">' + esc(ev.title) + '</b>' + imgs + '</div>';
}).join('');
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></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>' +
'<div style="text-align:center;color:#555;font-size:11pt">' + esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>' +
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(result.total) + ' 分</b></p>' +
body + (annex ? '<h2 style="font-size:13pt;margin:16pt 0 6pt">附:证明材料</h2>' + annex : '') +
'<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>';
return buildMhtml(html, images);
}
export function AssessmentPage() {
const toast = useToast();
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
@@ -15,61 +69,86 @@ export function AssessmentPage() {
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 [year, setYear] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]);
useEffect(() => {
if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {});
const sems = (gradesQ.data?.semesters ?? []).map(x => x.term);
if (!term && sems.length) setTerm(sems[0]);
const years = Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7))));
if (!year && years.length) setYear(years[0]);
}, [stored, gradesQ.data]);
const records: FootprintRow[] = listQ.data?.items ?? [];
const ov: ZcOverrides = ovMap[term] ?? {};
const ov: ZcOverrides = ovMap[year] ?? {};
const result = useMemo(
() => computeZongce({
term: term || 'all', records,
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, term]
[records, gradesQ.data, profile, rules, ov, year]
);
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 matchedIds = useMemo(() => {
const ids: number[] = [];
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); }));
return ids;
}, [result]);
useEffect(() => {
let alive = true;
(async () => {
const out: Evidence[] = [];
for (const rid of matchedIds) {
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, 3)) {
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 });
} catch {}
}
if (alive) setEvidence(out);
})();
return () => { alive = false; };
}, [matchedIds.join(',')]);
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));
};
const resetOverrides = () => {
const next = { ...ovMap };
delete next[term];
delete next[year];
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 termLabel = year === 'all' ? '全部学年' : gradeLabel(year, profile.eduStart);
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', '综测材料已导出');
});
const blob = zongceWordMhtml(termLabel, result, profile, evidence);
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', '综测材料已导出' + (evidence.length ? '(含 ' + evidence.length + ' 条证明材料图片)' : ''));
};
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>
<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: '全学年' }]} />
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)}
options={[...Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => x.term.slice(0, 7)))).map(y => ({ value: y, label: gradeLabel(y, profile.eduStart) })), { 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>
@@ -116,9 +195,25 @@ export function AssessmentPage() {
</div>
))}
{evidence.length > 0 && (
<div className="mt-6 border-t border-neutral-300 pt-4">
<div className="mb-3 text-[13.5px] font-bold text-neutral-900">附:证明材料</div>
{evidence.map(ev => (
<div key={ev.recordId} className="mb-4">
<div className="text-[12px] font-semibold text-neutral-800">{ev.title}</div>
<div className="mt-1.5 flex flex-wrap gap-3">
{ev.images.map((src, i) => (
<img key={i} src={src} alt={'证明 ' + (i + 1)} className="max-h-[180px] rounded border border-neutral-200" />
))}
</div>
</div>
))}
</div>
)}
<div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值;
「恢复自动」清除本学期全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
「恢复自动」清除本学年全部手动修正。导出材料前请核对每条加分记录与证明材料一致。
</div>
</div>
</div>