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:
root
2026-10-17 17:33:49 +08:00
parent c72474d123
commit 11cfeeb3a6
21 changed files with 311 additions and 156 deletions
+32 -19
View File
@@ -38,6 +38,12 @@ export interface ZcOverrides {
manual?: { title: string; section: string; score: number }[]; 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) // 学年('2024-2025')→ 统计时间窗 [2024-09-01, 2025-09-01)
export function yearRange(year: string): [string, string] | null { export function yearRange(year: string): [string, string] | null {
const m = /^(\d{4})-(\d{4})$/.exec(year); 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']; return [m[1] + '-09-01', m[2] + '-09-01'];
} }
// 学年 → 年级名(按个人信息入学年份推算:大一~大四) // 学年 → 年级名(按个人信息入学年份推算:大一~大四);无入学年份时退回学年名
export function gradeLabel(year: string, eduStart?: string): string { 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 ?? ''); 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 n = Number(m[1]) - Number(e[1]) + 1;
const names = ['大一', '大二', '大三', '大四', '大五']; const names = ['大一', '大二', '大三', '大四', '大五', '大六', '大七'];
return (names[n - 1] ?? n + '年级') + '(' + year + ' 学年)'; if (n < 1) return y + ' 学年';
return (names[n - 1] ?? n + '年级') + '(' + y + ' 学年)';
} }
function inRange(d: string | null | undefined, range: [string, string] | null): boolean { function inRange(d: string | null | undefined, range: [string, string] | null): boolean {
@@ -141,14 +149,8 @@ export function computeZongce(opts: {
])); ]));
// ---- 学业成绩(60):学年平均学分绩点 × 10 ---- // ---- 学业成绩(60):学年平均学分绩点 × 10 ----
const scopedCourses: CourseGrade[] = []; // 口径与「成绩」页统一:优先取学期卡展示的 semester.gpa;未存 gpa 的学期回退到
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;
const gpOf = (c: CourseGrade): number | null => { const gpOf = (c: CourseGrade): number | null => {
if (c.grade_point != null) return c.grade_point; if (c.grade_point != null) return c.grade_point;
if (bands && c.score != null) { if (bands && c.score != null) {
@@ -165,14 +167,25 @@ export function computeZongce(opts: {
return null; return null;
}; };
const wOf = (c: CourseGrade): number => (weights ? (weights[c.category ?? '其他'] ?? weights['其他'] ?? 1) : 1); 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); const yearKey = termYearOf(year);
if (gpCourses.length) { const scopedSemesters = semesters.filter(x => !yearKey || termYearOf(x.term) === yearKey);
const credits = gpCourses.reduce((a, c) => a + c.credit, 0); const scopedCourses: CourseGrade[] = [];
gpa = round2(gpCourses.reduce((a, c) => a + (c.grade_point as number) * wOf(c) * c.credit, 0) / credits); 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, [ 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) ---- // ---- 文体素质(10):体育 + 文体类加分(表2,cap 5) ----
-41
View File
@@ -1,41 +0,0 @@
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>';
}
+232 -49
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Download, Images, Printer, RotateCcw } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, FileText, Images, Printer, RotateCcw } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { attachmentList, attachmentThumb, 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 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'; import { Badge, Button, Dialog, Select, useToast } from '@/components/ui';
interface Evidence { recordId: number; title: string; meta: string; images: string[] } 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_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'; const W_TH = W_TD + ';background:#e8eaf0;font-weight:bold;font-family:SimHei;letter-spacing:1pt';
/** 综合素质测评表:A4 纵向、黑体标题、表格化分项、附图两列栅格 */ /** 评分细则参考(附在 Word 末尾,与页面「评分细则参考」同一数据源) */
function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<ProfileInfo>, evidence: Evidence[]): Blob { 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 f = (s: unknown) => esc(s);
const school = f(profile.school || '云川理工学院'); 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, half = false) => const infoCell = (v: unknown, label: string) =>
'<td style="' + W_TH + ';width:' + (half ? '16%' : '14%') + '">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>'; '<td style="' + W_TH + ';width:14%">' + label + '</td><td style="' + W_TD + '">' + f(v || '') + '</td>';
// 分板块明细表 // 分板块明细表
const body = result.sections.map(sec => { const body = result.sections.map(sec => {
@@ -69,7 +107,7 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
); );
}).join(''); }).join('');
// 证明材料附图:每条记录一个组块,图两列(约 200pt 宽) // 附 1:证明材料——每条有附件的活动 / 竞赛记录成组,图片纵向排布
const images: { name: string; dataUri: string }[] = []; const images: { name: string; dataUri: string }[] = [];
let figNo = 0; let figNo = 0;
const grids = evidence.map(ev => { const grids = evidence.map(ev => {
@@ -77,18 +115,26 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
figNo++; figNo++;
const name = 'ev' + ev.recordId + '_' + (images.length); const name = 'ev' + ev.recordId + '_' + (images.length);
images.push({ name, dataUri: uri }); 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>'; '<span style="font-size:9pt;color:#444">图 ' + figNo + ' ' + f(ev.title) + '</span></div>';
}).join(''); }).join('');
return '<tr><td style="' + W_TD + ';width:26%;vertical-align:top;background:#fafafa"><b style="font-size:10pt">' + f(ev.title) + '</b><br>' + 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>' + '<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(''); }).join('');
const annex = grids 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>' '<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 = const html =
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' + '<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><meta charset="utf-8"><title>学生综合素质测评表</title>' +
@@ -103,31 +149,41 @@ function zongceWordMhtml(termLabel: string, result: ZcResult, profile: Partial<P
'.num { font-family:Calibri,SimSun; }' + '.num { font-family:Calibri,SimSun; }' +
'</style></head>' + '</style></head>' +
'<body><div class="WordSection1">' + '<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>' + '<h1 class="title">学生综合素质测评表</h1>' +
'<p class="sub">测评学年:' + f(termLabel) + '</p>' + '<p class="sub">测评学年:' + f(termLabel) + '</p>' +
'<table style="border-collapse:collapse;width:100%;margin-top:10pt" border="1">' + info +
'<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>' +
body + body +
'<table style="border-collapse:collapse;width:100%;margin-top:8pt" border="1"><tr class="total">' + '<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:left;font-family:SimHei">综合素质测评总分</td>' +
'<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(result.total) + '</td></tr></table>' + '<td style="' + W_TD + ';text-align:right;width:64pt" class="num">' + f(result.total) + '</td></tr></table>' +
annex + annex +
rulesAnnexHtml(rules) +
'<table style="width:100%;border-collapse:collapse;margin-top:22pt;font-size:10.5pt"><tr>' + '<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 style="width:25%">班长签名:_____</td>' +
'<td style="width:25%">辅导员签名:_____</td>' + '<td style="width:25%">辅导员签名:_____</td>' +
'<td>日期:______</td></tr></table>' + '<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>'; '</div></body></html>';
return buildMhtml(html, images); 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() { export function AssessmentPage() {
const toast = useToast(); const toast = useToast();
const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet }); const profileQ = useQuery({ queryKey: ['profile'], queryFn: profileGet });
@@ -136,17 +192,24 @@ export function AssessmentPage() {
const stored = profileQ.data?.content; const stored = profileQ.data?.content;
const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) }; const profile: Partial<ProfileInfo> = { ...(stored?.info ?? {}) };
const rules: ZcRules = stored?.zongce?.rules ?? ZC_DEFAULT_RULES;
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({}); const [ovMap, setOvMap] = useState<Record<string, ZcOverrides>>({});
const [evidence, setEvidence] = useState<Evidence[]>([]); const [evidence, setEvidence] = useState<Evidence[]>([]);
const [initOpen, setInitOpen] = useState(false); 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); const [zoom, setZoom] = useState<{ src: string; title: string; index: number } | null>(null);
useEffect(() => { useEffect(() => {
if (!stored) return; if (!stored) return;
setOvMap(stored.zongce?.overrides ?? {}); 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]); if (!year && years.length) setYear(years[0]);
}, [stored, gradesQ.data]); }, [stored, gradesQ.data]);
@@ -162,23 +225,39 @@ export function AssessmentPage() {
[records, gradesQ.data, profile, rules, ov, year], [records, gradesQ.data, profile, rules, ov, year],
); );
// 证明材料:匹配条目对应记录的附件缩略图(每条最多 2 张) // 学年 → 大几:优先用个人信息「入学时间」,缺省时以最早学期推算
const matchedIds = useMemo(() => { const years = useMemo(() => Array.from(new Set((gradesQ.data?.semesters ?? []).map(x => termYearOf(x.term)).filter(Boolean) as string[])).sort(), [gradesQ.data]);
const ids: number[] = []; const eduStartEff = profile.eduStart || (years.length ? years[0] + '-09' : '');
result.sections.forEach(sec => sec.items.forEach(it => { if (it.fromRecord) ids.push(it.fromRecord); })); const termLabel = year === 'all' || !year ? '全部学年' : gradeLabel(year, eduStartEff);
return ids;
// 本学年范围内全部记录(证据画廊不再只看计分记录——有附件就展示)
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]); }, [result]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { (async () => {
const out: Evidence[] = []; const out: Evidence[] = [];
for (const rid of matchedIds) { for (const rid of scopeIds) {
const rec = records.find(r => r.id === rid); const rec = records.find(r => r.id === rid);
try { try {
const attList = await attachmentList(rid); const attList = await attachmentList(rid);
const images: string[] = []; 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); 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 (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); if (alive) setEvidence(out);
})(); })();
return () => { alive = false; }; return () => { alive = false; };
}, [matchedIds.join(',')]); }, [scopeIds.join(',')]);
// 全部放平的图序列(灯箱上一张/下一张用) // 全部放平的图序列(灯箱上一张/下一张用)
const flatImages = useMemo(() => { const flatImages = useMemo(() => {
@@ -200,19 +279,32 @@ export function AssessmentPage() {
return arr; return arr;
}, [evidence]); }, [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) => { const persistOv = (next: ZcOverrides) => {
setOvMap(m => ({ ...m, [year]: next })); 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 exportWord = () => {
const blob = zongceWordMhtml(termLabel, result, profile, evidence); const blob = zongceWordMhtml(termLabel, result, profile, evidence, rules);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click(); a.href = url; a.download = '综合素质测评_' + termLabel + '.doc'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 60000); 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; 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 className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h2 className="text-[15px] font-semibold text-ink-1">综合测评</h2> <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>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select className="w-[190px]" value={year} onChange={e => setYear(e.target.value)} <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={exportWord}><Download size={13} /> 导出 Word</Button>
<Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button> <Button variant="secondary" onClick={() => setInitOpen(true)}><RotateCcw size={13} /> 初始化</Button>
<Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button> <Button variant="primary" onClick={() => window.print()}><Printer size={13} /> 打印</Button>
@@ -242,20 +335,20 @@ export function AssessmentPage() {
</div> </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="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> <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> <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"> <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.studentId], ['姓 名', profile.name], ['性 别', profile.gender],
['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className], ['学 院', profile.college], ['专 业', profile.major], ['班 级', profile.className],
['政治面貌', profile.political], ['统计周期', termLabel], ['政治面貌', profile.political], ['统计周期', termLabel], ['电话号码', profile.phone],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label as string} className="flex bg-white"> <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="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>
))} ))}
</div> </div>
@@ -302,7 +395,7 @@ export function AssessmentPage() {
</div> </div>
))} ))}
{/* 证明材料:图片画廊 */} {/* 附:证明材料——本学年范围内每条有附件的活动 / 竞赛记录都展示 */}
{(evidence.length > 0 || totalImgs > 0) && ( {(evidence.length > 0 || totalImgs > 0) && (
<div className="mt-7"> <div className="mt-7">
<div className="flex items-center justify-between border-t border-neutral-200 pt-4"> <div className="flex items-center justify-between border-t border-neutral-200 pt-4">
@@ -316,10 +409,15 @@ export function AssessmentPage() {
{evidence.map(ev => ( {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 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="flex items-baseline justify-between gap-2">
<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 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 className="shrink-0 text-[10.5px] text-neutral-400">{ev.meta}</div>
</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) => ( {ev.images.map((src, i) => (
<button key={i} onClick={() => setZoom({ src, title: ev.title, index: flatImages.findIndex(x => x.src === src) })} <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"> className="group overflow-hidden rounded-md border border-neutral-200 bg-neutral-50">
@@ -333,9 +431,92 @@ export function AssessmentPage() {
</div> </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"> <div className="mt-6 border-t border-neutral-200 pt-3 text-[11px] leading-relaxed text-neutral-400">
计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。自动匹配条目均可手动修正分值; 计分依据:《学生综合素质测评评分细则》(默认值可在上方「评分细则参考」查看与修改)。
「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。 自动匹配条目均可手动修正分值;「初始化」清除本学年全部手动修正。导出 Word / 打印前请核对加分记录与证明材料一致。
</div> </div>
</div> </div>
@@ -344,7 +525,7 @@ export function AssessmentPage() {
open={!!zoom} open={!!zoom}
onClose={() => setZoom(null)} onClose={() => setZoom(null)}
width={920} width={920}
title={zoom?.title ?? '' + (zoom ? '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : '')} title={zoom ? zoom.title + '(' + (zoomIdx + 1) + '/' + totalImgs + ')' : ''}
footer={ footer={
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button> <Button variant="secondary" disabled={zoomIdx <= 0} onClick={() => gotoZoom(-1)}><ChevronLeft size={13} /> 上一张</Button>
@@ -366,13 +547,15 @@ export function AssessmentPage() {
const next = { ...ovMap }; const next = { ...ovMap };
delete next[year]; delete next[year];
setOvMap(next); 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> }}>恢复自动计算</Button>
</>}> </>}>
<div className="text-[12.5px] leading-relaxed text-ink-2"> <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> </div>
</Dialog> </Dialog>
</div> </div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as g,f as v,j as t,S as j,t as S}from"./index-Dplf5hVV.js";import{C as c,a as d,b as x}from"./card-C932WdWm.js";import{u as N,i as u,a as m}from"./useChart-D3ki8T58.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage}; import{r as g,f as v,j as t,S as j,t as S}from"./index-BhG5FvHJ.js";import{C as c,a as d,b as x}from"./card-BtFEK_ax.js";import{u as N,i as u,a as m}from"./useChart-BfaP0epD.js";import{u as k,i as w,a as C,b as L,c as R,d as $,e as z,f as T}from"./echarts-BIWefrIc.js";k([w,C,L,R,$,z,T]);function q(){const[l,n]=g.useState(""),i=N(),e=v({queryKey:["stats",l||void 0],queryFn:()=>S(l?Number(l):void 0)}).data,p=u("2"),r=u("3"),h=m(()=>({grid:{left:34,right:12,top:14,bottom:24},tooltip:{trigger:"axis"},xAxis:{type:"category",data:(e==null?void 0:e.monthly.map(a=>a.ym.slice(2)))??[],axisLine:{lineStyle:{color:r}},axisTick:{show:!1},axisLabel:{color:r,fontSize:10}},yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},series:[{type:"bar",data:(e==null?void 0:e.monthly.map(a=>a.count))??[],barMaxWidth:18,itemStyle:{color:"#3056d3",borderRadius:[3,3,0,0]}}]}),[e,i]),b=m(()=>{const a=((e==null?void 0:e.by_type)??[]).filter(s=>s.count>0);return{tooltip:{trigger:"item"},legend:{orient:"vertical",right:4,top:"middle",textStyle:{color:p,fontSize:11},itemWidth:10,itemHeight:10},series:[{type:"pie",radius:["48%","72%"],center:["34%","50%"],label:{show:!1},data:a.map(s=>({name:s.label,value:s.count,itemStyle:{color:s.color||"#64748b"}}))}]}},[e,i]),y=m(()=>{const a=((e==null?void 0:e.by_level)??[]).filter(s=>s.count>0).slice().reverse();return{grid:{left:64,right:24,top:8,bottom:22},tooltip:{trigger:"axis"},xAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(128,138,155,0.15)"}},axisLabel:{color:r,fontSize:10}},yAxis:{type:"category",data:a.map(s=>s.label),axisLine:{show:!1},axisTick:{show:!1},axisLabel:{color:p,fontSize:11}},series:[{type:"bar",data:a.map(s=>({value:s.count,itemStyle:{color:s.color||"#3056d3",borderRadius:[0,3,3,0]}})),barMaxWidth:14}]}},[e,i]),f=((e==null?void 0:e.metrics)??[]).slice(0,3);return t.jsxs("div",{className:"mx-auto max-w-[1080px] px-3 py-4 sm:px-6 sm:py-5",children:[t.jsxs("div",{className:"mb-4 flex flex-wrap items-center justify-between gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-3",children:[t.jsx(o,{label:"总记录",value:(e==null?void 0:e.total)??0}),t.jsx(o,{label:l?l+" 年新增":"本年新增",value:(e==null?void 0:e.total_this_year)??0}),t.jsx(o,{label:"附件总数",value:(e==null?void 0:e.attachment_total)??0}),f.map(a=>t.jsx(o,{label:a.label,value:a.value,unit:a.unit??""},a.key))]}),t.jsx(j,{className:"w-[120px]",value:l,onChange:a=>n(a.target.value),options:[{value:"",label:"全部年份"},...((e==null?void 0:e.years)??[]).map(a=>({value:String(a),label:a+" 年"}))]})]}),!(e!=null&&e.metrics.length)&&t.jsx("div",{className:"mb-4 rounded-md border border-dashed border-line px-4 py-2 text-[12px] text-ink-3",children:"提示:在「统计」配置中注册指标(如志愿时长、读书本数),带单位的数值字段即可在此量化展示。"}),t.jsxs("div",{className:"grid grid-cols-1 lg:grid-cols-2 gap-4",children:[t.jsxs(c,{children:[t.jsx(d,{title:"近 12 个月活跃度"}),t.jsx(x,{children:t.jsx("div",{ref:h,className:"h-[220px]"})})]}),t.jsxs(c,{children:[t.jsx(d,{title:"类型分布"}),t.jsx(x,{children:t.jsx("div",{ref:b,className:"h-[220px]"})})]}),t.jsxs(c,{className:"lg:col-span-2",children:[t.jsx(d,{title:"级别分布"}),t.jsx(x,{children:t.jsx("div",{ref:y,className:"h-[200px]"})})]})]})]})}function o({label:l,value:n,unit:i}){return t.jsxs("div",{className:"min-w-[120px] rounded-lg border border-line bg-surface px-4 py-3 shadow-card",children:[t.jsx("div",{className:"text-[12px] text-ink-3",children:l}),t.jsxs("div",{className:"num mt-0.5 text-[22px] font-semibold leading-tight text-ink-1",children:[n,i&&t.jsx("span",{className:"ml-1 text-[12px] font-normal text-ink-3",children:i})]})]})}export{q as DashboardPage};
@@ -1,4 +1,4 @@
import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-Dplf5hVV.js";import{C as w,a as G,b as F}from"./card-C932WdWm.js";import{u as ee,i as K,a as P}from"./useChart-D3ki8T58.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}from"./echarts-BIWefrIc.js";/** import{c as $,f as O,j as e,E as H,w as U,u as k,d as C,r as u,g as y,x as W,y as J,B as j,P as E,I as g,z as Y,C as A,A as V,D as B,F as X,m as S,l as Q,T as q,H as Z,J as z,K as M,S as T,M as D}from"./index-BhG5FvHJ.js";import{C as w,a as G,b as F}from"./card-BtFEK_ax.js";import{u as ee,i as K,a as P}from"./useChart-BfaP0epD.js";import{u as se,i as te,b as ae,a as re,c as ne,d as le,e as ie,f as oe}from"./echarts-BIWefrIc.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-Dplf5hVV.js";/** import{c as E,u as P,d as S,r as p,f as q,g as F,j as e,B as f,l as B,ac as L,H as z,J as k,S as M,I as Q,ad as _,ae as K,af as v,ag as T}from"./index-BhG5FvHJ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-Dplf5hVV.js";/** import{c as fe,j as e,a as N,r as s,b as he,u as ve,d as ge,e as je,f as L,g as ye,I as be,S as k,B as w,P as Q,h as te,L as se,G as ae,R as Ne,i as ne,k as ke,l as we,T as Ce,E as le,m as Ee,n as Se,o as _e,C as ze,p as qe,q as Le,s as Re,t as De,v as Ie}from"./index-BhG5FvHJ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1,4 +1,4 @@
import{c as B,d as ve,f as te,r as E,j as e,B as R,ah as Ne,af as L,J as U,S as se,ad as ce,P as q,H as we,a9 as Ce,T as ne,I as W,ag as Se,v as _e}from"./index-Dplf5hVV.js";import{C as ie,a as ae,b as re}from"./card-C932WdWm.js";import{R as le,P as ze,C as Re}from"./rotate-ccw-DFLp9voo.js";/** import{c as B,d as ve,f as te,r as E,j as e,B as R,ah as Ne,af as L,J as U,S as se,ad as ce,P as q,H as we,a9 as Ce,T as ne,I as W,ag as Se,v as _e}from"./index-BhG5FvHJ.js";import{C as ie,a as ae,b as re}from"./card-BtFEK_ax.js";import{R as le,P as ze,C as Re}from"./rotate-ccw-BDKjoLu6.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-Dplf5hVV.js";import{C as Y}from"./card-C932WdWm.js";/** import{c as D,u as K,d as I,f as L,r,g as q,j as e,B as p,P as F,N as O,m as _,l as H,T as P,C as R,O as G,H as J,J as i,I as j,Q as U,S as z,U as V,V as W,q as X}from"./index-BhG5FvHJ.js";import{C as Y}from"./card-BtFEK_ax.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{j as s,a}from"./index-Dplf5hVV.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b}; import{j as s,a}from"./index-BhG5FvHJ.js";function i({className:r,...e}){return s.jsx("div",{className:a("rounded-lg border border-line bg-surface shadow-card",r),...e})}function t({title:r,extra:e,className:n}){return s.jsxs("div",{className:a("flex min-h-11 flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-1.5",n),children:[s.jsx("h3",{className:"text-[13px] font-semibold text-ink-1",children:r}),e&&s.jsx("div",{className:"flex flex-wrap items-center gap-2",children:e})]})}function c({className:r,...e}){return s.jsx("div",{className:a("p-4",r),...e})}export{i as C,t as a,c as b};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{c as t}from"./index-Dplf5hVV.js";/** import{c as t}from"./index-BhG5FvHJ.js";/**
* @license lucide-react v0.475.0 - ISC * @license lucide-react v0.475.0 - ISC
* *
* This source code is licensed under the ISC license. * This source code is licensed under the ISC license.
@@ -1 +1 @@
import{r}from"./index-Dplf5hVV.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u}; import{r}from"./index-BhG5FvHJ.js";import{g as f}from"./echarts-BIWefrIc.js";function p(){const[n,o]=r.useState(0);return r.useEffect(()=>{const s=()=>o(t=>t+1);return window.addEventListener("fp-theme-change",s),()=>window.removeEventListener("fp-theme-change",s)},[]),n}function d(n){return getComputedStyle(document.documentElement).getPropertyValue("--text-"+n).trim()||"#454e5c"}function h(n,o){const s=r.useRef(null),[t,i]=r.useState(null),u=r.useRef(n);return u.current=n,r.useEffect(()=>{if(!t)return;const e=f(t);s.current=e,e.setOption(u.current(),{notMerge:!0});const c=new ResizeObserver(()=>e.resize());return c.observe(t),()=>{c.disconnect(),e.dispose(),s.current=null}},[t]),r.useEffect(()=>{var e;(e=s.current)==null||e.setOption(u.current(),{notMerge:!0})},[t,...o]),e=>i(e)}export{h as a,d as i,p as u};
+2 -2
View File
@@ -9,8 +9,8 @@
<link rel="icon" href="/icons/pwa-192.png" /> <link rel="icon" href="/icons/pwa-192.png" />
<link rel="apple-touch-icon" href="/icons/pwa-192.png" /> <link rel="apple-touch-icon" href="/icons/pwa-192.png" />
<title>足迹 Footprints</title> <title>足迹 Footprints</title>
<script type="module" crossorigin src="/assets/index-Dplf5hVV.js"></script> <script type="module" crossorigin src="/assets/index-BhG5FvHJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8H8oQlD.css"> <link rel="stylesheet" crossorigin href="/assets/index-CRJEsjf-.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>