feat(assessment): auto comprehensive assessment - rules engine from college scoring standard, per-semester stats, manual overrides, word export
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ProfileInfo } from './types';
|
||||
import type { ZcSection } from './zongce';
|
||||
|
||||
const esc = (t: unknown) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
export function zongceToWordHtml(
|
||||
termLabel: string,
|
||||
sections: ZcSection[],
|
||||
total: number,
|
||||
profile: Partial<ProfileInfo>
|
||||
): string {
|
||||
const body = sections.map(sec => {
|
||||
const subtotal = sec.key === 'rewards' ? sec.subtotal : Math.min(sec.subtotal, sec.cap);
|
||||
const rows = sec.items.map(it =>
|
||||
'<tr>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999">' + esc(it.title) + '</td>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999;font-size:10pt;color:#555">' + esc(it.note || it.source) + '</td>' +
|
||||
'<td style="padding:3pt 6pt;border:0.75pt solid #999;text-align:right;width:70pt">' + esc(it.score) + '</td>' +
|
||||
'</tr>').join('');
|
||||
return '<h2 style="font-size:13pt;margin:12pt 0 5pt;color:#111">' + esc(sec.label) +
|
||||
'(上限 ' + esc(sec.cap) + ' 分,小计 ' + esc(subtotal) + ' 分)</h2>' +
|
||||
'<table style="width:100%;border-collapse:collapse;font-size:11pt">' + rows + '</table>';
|
||||
}).join('');
|
||||
|
||||
const head = '<div style="font-size:11pt;color:#555;margin-bottom:6pt">' +
|
||||
esc(profile.name ?? '') + ' · ' + esc(profile.className ?? '') + ' · 统计周期:' + esc(termLabel) + '</div>';
|
||||
|
||||
return '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">' +
|
||||
'<head><meta charset="utf-8"><title>综合素质测评</title>' +
|
||||
'<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]--></head>' +
|
||||
'<body style="font-family:SimSun,Calibri,sans-serif;margin:36pt;color:#222">' +
|
||||
'<h1 style="text-align:center;font-size:18pt;margin:0 0 6pt">学生综合素质测评</h1>' + head +
|
||||
'<p style="text-align:center;font-size:13pt;background:#f2f2f2;padding:6pt"><b>综合素质总分:' + esc(total) + ' 分</b></p>' +
|
||||
body +
|
||||
'<div style="margin-top:24pt;font-size:10.5pt;color:#555">计分依据:《计算机与网络安全学院学生综合素质测评评分细则》。以上自动匹配条目均经过人工核对。</div>' +
|
||||
'<table style="width:100%;border-collapse:collapse;margin-top:24pt;font-size:11pt"><tr>' +
|
||||
'<td style="width:33%">学生签名:______</td>' +
|
||||
'<td style="width:33%">辅导员签名:______</td>' +
|
||||
'<td>日期:______</td></tr></table>' +
|
||||
'</body></html>';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user