grade_query / src /templates /index.html
jzyg123's picture
Upload 6 files
18ffd74 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>成绩查询 | Student Portal</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--brand: #0f172a;
--brand-light: #334155;
--accent: #3b82f6;
--accent-hover: #2563eb;
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--text-main: #0f172a;
--text-muted: #64748b;
--text-light: #94a3b8;
--positive: #10b981;
--negative: #ef4444;
--radius-sm: 12px;
--radius-md: 16px;
--radius-lg: 24px;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
body {
font-family: var(--font-sans);
background-color: var(--bg);
color: var(--text-main);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px 80px;
}
header {
text-align: center;
margin-bottom: 32px;
animation: slideDown 0.6s ease-out;
}
.logo-area {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.logo-icon {
background: linear-gradient(135deg, var(--brand), var(--brand-light));
color: white;
padding: 10px;
border-radius: 14px;
box-shadow: var(--shadow-md);
display: flex;
}
.logo-area h1 {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--brand);
}
header p {
color: var(--text-muted);
font-size: 1rem;
max-width: 500px;
margin: 0 auto;
}
.nav-wrapper {
display: flex;
justify-content: center;
margin-bottom: 32px;
animation: fadeIn 0.8s ease-out;
}
.nav-strip {
display: inline-flex;
background: var(--surface);
padding: 6px;
border-radius: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
gap: 8px;
}
.nav-item {
padding: 10px 24px;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-muted);
text-decoration: none;
cursor: pointer;
border-radius: 14px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 6px;
}
.nav-item:hover {
color: var(--text-main);
background: var(--bg);
}
.nav-item.active {
background: var(--brand);
color: white;
box-shadow: var(--shadow-md);
}
.compare-link {
color: var(--accent);
background: rgba(59, 130, 246, 0.08);
}
.compare-link:hover {
background: rgba(59, 130, 246, 0.15);
color: var(--accent-hover);
}
.panel {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 32px;
border: 1px solid var(--border-light);
margin-bottom: 32px;
animation: slideUp 0.6s ease-out;
}
.search-section { position: relative; }
.input-wrapper { position: relative; }
.input-wrapper i {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
color: var(--text-light);
font-size: 22px;
}
.minimal-input {
width: 100%;
padding: 18px 20px 18px 56px;
font-size: 1.05rem;
font-weight: 500;
border: 2px solid var(--border-light);
border-radius: var(--radius-md);
outline: none;
background: var(--bg);
transition: all 0.2s ease;
font-family: inherit;
color: var(--text-main);
}
.minimal-input:focus {
background: var(--surface);
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
}
.minimal-input::placeholder { color: var(--text-light); }
.dropdown {
position: absolute;
top: calc(100% + 12px);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
display: none;
overflow: hidden;
animation: fadeIn 0.2s ease-out;
}
.drop-item {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
transition: background 0.2s ease;
}
.drop-item:last-child { border-bottom: none; }
.drop-item:hover { background: var(--bg); }
.id-hint { font-size: 0.8rem; color: var(--accent); font-weight: 600; }
#results-area {
animation: slideUp 0.8s ease-out;
padding: 0;
background: transparent;
box-shadow: none;
border: none;
margin-top: 0;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.15rem;
font-weight: 800;
margin: 32px 0 20px;
color: var(--brand);
}
.section-title i { color: var(--accent); font-size: 1.4rem; }
.insight-grid, .detail-grid, .record-analysis {
display: grid;
gap: 16px;
}
.insight-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.detail-grid { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.record-analysis { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-top: 24px;
}
.card, .chart-box, .table-shell {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.card { padding: 20px; display: flex; flex-direction: column; }
.card-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.card-value {
font-size: 1.5rem;
font-weight: 800;
color: var(--brand);
line-height: 1.2;
margin-bottom: auto;
}
.card-note {
margin-top: 10px;
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
}
.capsule-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 24px;
}
.capsule {
padding: 10px 20px;
border-radius: 999px;
border: 1px solid var(--border-light);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
background: var(--surface);
color: var(--text-muted);
white-space: nowrap;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.capsule:hover {
border-color: var(--border);
color: var(--brand);
background: var(--bg);
}
.capsule.active {
background: var(--brand);
color: #fff;
border-color: var(--brand);
box-shadow: var(--shadow-md);
}
.table-shell { padding: 8px 0; overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.data-table th, .data-table td {
padding: 16px 24px;
border-bottom: 1px solid var(--border-light);
}
.data-table th {
background: var(--surface);
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr { transition: background 0.2s ease; }
.data-table tbody tr:hover { background: var(--bg); }
.data-table tbody tr:last-child td { border-bottom: none; }
.val { font-weight: 700; color: var(--brand); font-size: 1.05rem; }
.chart-box { padding: 24px; }
.chart-header {
font-size: 1.05rem;
font-weight: 800;
margin-bottom: 20px;
color: var(--brand);
display: flex;
align-items: center;
gap: 8px;
}
.chart-header i { color: var(--accent); }
.chart-wrap {
height: 300px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.chart-empty {
display: none;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-muted);
font-weight: 500;
background: var(--bg);
border-radius: var(--radius-md);
}
.chart-empty.show { display: flex; }
.error-box {
padding: 20px;
border-radius: var(--radius-md);
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
font-weight: 600;
display: flex;
align-items: flex-start;
gap: 12px;
box-shadow: var(--shadow-sm);
}
.error-box i { margin-top: 2px; }
.error-content {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.inline-action {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 12px;
background: var(--brand);
color: #fff;
text-decoration: none;
font-size: 0.95rem;
font-weight: 700;
width: fit-content;
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.inline-action:hover {
background: var(--brand-light);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.loader-overlay {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(248, 250, 252, 0.8);
backdrop-filter: blur(4px);
z-index: 9999;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spinner {
width: 36px; height: 36px;
border: 4px solid var(--border);
border-top-color: var(--brand);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes slideDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 768px) {
.container { padding: 24px 16px 60px; }
header { margin-bottom: 24px; }
.logo-area h1 { font-size: 1.8rem; }
header p { font-size: 0.95rem; }
.nav-wrapper { margin-bottom: 24px; }
.nav-strip {
width: 100%;
justify-content: space-between;
overflow-x: auto;
padding: 6px;
}
.nav-item { padding: 10px 12px; flex: 1; justify-content: center; font-size: 0.9rem; }
.nav-item i { font-size: 18px; margin-right: 2px; }
.nav-item span { display: inline-block; }
.panel { padding: 20px; margin-bottom: 24px; }
.minimal-input { padding: 16px 16px 16px 48px; font-size: 1rem; }
.capsule-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px !important;
margin-bottom: 16px !important;
overflow: visible !important;
width: 100% !important;
}
.capsule {
flex: 0 0 auto !important;
}
.insight-grid, .detail-grid, .record-analysis {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.card { padding: 16px; }
.card-value { font-size: 1.25rem; }
.card-label { font-size: 0.7rem; }
.data-table thead { display: none; }
.data-table, .data-table tbody, .data-table tr, .data-table td {
display: block;
width: 100%;
}
.data-table tr {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
}
.data-table td {
padding: 8px 0;
border: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.data-table td::before {
content: attr(data-label);
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 600;
}
.chart-wrap { height: 260px; }
}
@media (max-width: 480px) {
.insight-grid { grid-template-columns: 1fr; }
.nav-item span { display: none; }
.nav-item i { margin-right: 0; display: block; }
.nav-item::after { content: attr(data-text); font-size: 0.85rem; margin-left: 6px;}
}
</style>
</head>
<body>
<div id="loader" class="loader-overlay">
<div class="spinner"></div>
</div>
<div class="container">
<header>
<div class="logo-area">
<div class="logo-icon">
<i class="material-icons-outlined">analytics</i>
</div>
<h1>成绩查询</h1>
</div>
<p>输入姓名、拼音或身份证号,实时获取考试报告与趋势分析。</p>
</header>
<div class="nav-wrapper">
<nav class="nav-strip">
<div class="nav-item active">
<i class="material-icons-outlined">person_search</i><span>智能查询</span>
</div>
<a href="/compare" class="nav-item compare-link" data-text="对比模式">
<i class="material-icons-outlined">compare_arrows</i><span>对比模式</span>
</a>
</nav>
</div>
<section class="panel">
<div class="search-section">
<div class="input-wrapper">
<i class="material-icons-outlined">search</i>
<input type="text" id="smart-input" class="minimal-input" placeholder="输入姓名 / 拼音首字母 / 身份证号" autocomplete="off">
</div>
<div id="dropdown" class="dropdown"></div>
</div>
<form action="/query" method="post" id="queryForm" style="display:none;">
<input type="hidden" name="id_number" id="hidden-id">
</form>
</section>
{% if result %}
<section class="panel" id="results-area">
{% if result.error_message %}
<div class="error-box">
<i class="material-icons-outlined">error_outline</i>
<div class="error-content">
<span>{{ result.error_message }}</span>
{% if result.login_required and result.login_url %}
<a href="{{ result.login_url }}" class="inline-action">
<i class="material-icons-outlined">login</i>
<span>打开登录页</span>
</a>
{% endif %}
</div>
</div>
{% elif result.scores_data %}
<div class="section-title"><i class="material-icons-outlined">insights</i> 历史分析</div>
<div class="insight-grid">
<div class="card">
<span class="card-label">平均排名</span>
<div class="card-value" id="avg-rank">-</div>
<div class="card-note">所有有效年级排名</div>
</div>
<div class="card">
<span class="card-label">历史最高排名</span>
<div class="card-value" id="best-rank">-</div>
<div class="card-note">数字越小越好</div>
</div>
<div class="card">
<span class="card-label">历史最高总分</span>
<div class="card-value" id="best-score">-</div>
<div class="card-note">总分峰值</div>
</div>
<div class="card">
<span class="card-label">学情波动率</span>
<div class="card-value" id="volatility">-</div>
<div class="card-note">按排名计算</div>
</div>
<div class="card">
<span class="card-label">最近排名变化</span>
<div class="card-value" id="recent-rank-change">-</div>
<div class="card-note" id="recent-rank-note">-</div>
</div>
<div class="card">
<span class="card-label">优势学科</span>
<div class="card-value" id="best-subject">-</div>
<div class="card-note">平均校次最好</div>
</div>
</div>
<div class="section-title"><i class="material-icons-outlined">event_available</i> 选择考试场次</div>
<div class="capsule-group" id="exam-tabs"></div>
<div class="detail-grid">
<div class="card">
<span class="card-label">当前考试总分</span>
<div class="card-value" id="current-total">-</div>
<div class="card-note" id="current-total-note">-</div>
</div>
<div class="card">
<span class="card-label">当前年级排名</span>
<div class="card-value" id="current-rank">-</div>
<div class="card-note" id="current-rank-note">-</div>
</div>
<div class="card">
<span class="card-label">考试时间</span>
<div class="card-value" id="current-time" style="font-size: 1.1rem; line-height: 1.5; margin-top: 5px;">-</div>
<div class="card-note">记录时间</div>
</div>
<div class="card">
<span class="card-label">有效科目数</span>
<div class="card-value" id="current-coverage">-</div>
<div class="card-note">有分数或排名</div>
</div>
</div>
<div class="record-analysis" style="margin-top:16px;">
<div class="card">
<span class="card-label">单科最高分</span>
<div class="card-value" id="record-best-score-subject">-</div>
<div class="card-note" id="record-best-score-note">-</div>
</div>
<div class="card">
<span class="card-label">位次最佳学科</span>
<div class="card-value" id="record-best-rank-subject">-</div>
<div class="card-note" id="record-best-rank-note">-</div>
</div>
<div class="card">
<span class="card-label">待关注学科</span>
<div class="card-value" id="record-watch-subject">-</div>
<div class="card-note" id="record-watch-note">-</div>
</div>
</div>
<div class="table-shell" style="margin-top: 24px;">
<table class="data-table">
<thead>
<tr><th>考试科目</th><th>得分</th><th>排名</th></tr>
</thead>
<tbody id="subject-table-body"></tbody>
</table>
</div>
<div class="chart-grid">
<div class="chart-box">
<div class="chart-header"><i class="material-icons-outlined">radar</i> 单次考试雷达图</div>
<div class="chart-wrap">
<canvas id="record-radar"></canvas>
<div id="record-radar-empty" class="chart-empty">这场考试暂无足够学科分数用于雷达图展示。</div>
</div>
</div>
{% if result.scores_data|length > 1 %}
<div class="chart-box">
<div class="chart-header"><i class="material-icons-outlined">trending_up</i> 历史成长曲线</div>
<div id="trend-filters" class="capsule-group" style="margin-bottom:14px;"></div>
<div class="chart-wrap">
<canvas id="trend-canvas"></canvas>
<div id="trend-empty" class="chart-empty">暂无足够历史数据用于趋势分析。</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
</section>
{% endif %}
</div>
<script>
const SUBJECTS = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '政治', '地理'];
const TREND_SERIES = ['大类排名', ...SUBJECTS, '总分'];
const SERIES_COLORS = {
'总分': '#0f172a',
'大类排名': '#475569',
'语文': '#f97316',
'数学': '#3b82f6',
'英语': '#14b8a6',
'物理': '#8b5cf6',
'化学': '#10b981',
'生物': '#84cc16',
'历史': '#b45309',
'政治': '#ec4899',
'地理': '#06b6d4'
};
const records = {% if result and result.scores_data %}{{ result.scores_data|tojson|safe }}{% else %}[]{% endif %};
const charts = {};
let activeRecordKey = 0;
let activeTrendSeries = '总分';
document.addEventListener('DOMContentLoaded', () => {
initSmartSearch();
if (records.length) {
renderInsights();
renderExamTabs();
selectRecord(0);
renderTrendControls();
}
});
function parseNumber(value) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text || text === '无数据' || text === '-') return null;
const match = text.match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : null;
}
function mean(values) {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
}
function stddev(values) {
if (!values.length) return null;
const avg = mean(values);
const variance = mean(values.map(value => Math.pow(value - avg, 2)));
return variance === null ? null : Math.sqrt(variance);
}
function formatNumber(value, digits = 1) {
return value === null || Number.isNaN(value) ? '-' : Number(value).toFixed(digits).replace(/\.0$/, '');
}
function initSmartSearch() {
const dropdown = document.getElementById('dropdown');
const smartInput = document.getElementById('smart-input');
const loader = document.getElementById('loader');
const hiddenId = document.getElementById('hidden-id');
const queryForm = document.getElementById('queryForm');
if (!dropdown || !smartInput) return;
let timer;
function submitQuery(id) {
loader.style.display = 'flex';
hiddenId.value = id;
queryForm.submit();
}
smartInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const firstItem = dropdown.querySelector('.drop-item');
if (firstItem && dropdown.style.display !== 'none') {
firstItem.click();
} else {
const val = smartInput.value.trim().replace(/'/g, '');
if (/^\d{17}[\dXx]$/.test(val)) submitQuery(val);
}
}
});
smartInput.addEventListener('input', () => {
clearTimeout(timer);
const keyword = smartInput.value.trim();
const cleanKeyword = keyword.replace(/'/g, '');
if (cleanKeyword.length < 1) {
dropdown.style.display = 'none';
return;
}
timer = setTimeout(async () => {
const formData = new FormData();
formData.append('name', cleanKeyword);
try {
const response = await fetch('/search_student', { method: 'POST', body: formData });
const data = await response.json();
let html = '';
if (data.success && data.students && data.students.length > 0) {
html = data.students.map(student => `
<div class="drop-item student-item" data-id="${student['身份证号']}">
<div style="font-weight:700; font-size: 1.05rem;">${student['姓名']}</div>
<div style="font-size:0.8rem; color:var(--text-light); font-family:monospace;">${student['身份证号']}</div>
</div>
`).join('');
}
if (cleanKeyword.length === 18) {
const idHtml = `
<div class="drop-item direct-id" data-id="${cleanKeyword}">
<div><span style="font-weight:700;">直接查询号码:</span> <span style="font-family:monospace;">${cleanKeyword}</span></div>
<div class="id-hint">识别为身份证号</div>
</div>
`;
html = idHtml + html;
}
if (html) {
dropdown.innerHTML = html;
dropdown.style.display = 'block';
dropdown.querySelectorAll('.drop-item').forEach(item => {
item.onclick = () => submitQuery(item.dataset.id);
});
} else {
dropdown.style.display = 'none';
}
} catch (error) {
dropdown.style.display = 'none';
}
}, 250);
});
document.addEventListener('click', e => {
if (!document.querySelector('.search-section').contains(e.target)) {
dropdown.style.display = 'none';
}
});
}
function getOrderedRecords(direction = 'desc') {
const sorted = [...records].sort((a, b) => (parseNumber(a.sort_index) ?? 0) - (parseNumber(b.sort_index) ?? 0));
return direction === 'desc' ? sorted.reverse() : sorted;
}
function getTrendField(series) {
if (series === '大类排名') return '大类排名';
if (series === '总分') return '组合排名';
return `${series}校次`;
}
function getTrendValue(record, series) {
if (!record) return null;
return parseNumber(record[getTrendField(series)]);
}
function renderInsights() {
const ranks = records.map(record => parseNumber(record['大类排名'])).filter(value => value !== null);
const scores = records.map(record => parseNumber(record['总分'])).filter(value => value !== null);
const avgRank = mean(ranks);
const bestRank = ranks.length ? Math.min(...ranks) : null;
const bestScore = scores.length ? Math.max(...scores) : null;
const rankVolatility = stddev(ranks);
const latestRecord = getOrderedRecords('desc')[0] || null;
const rankTrend = latestRecord ? latestRecord['rank_trend'] || '无变化' : '-';
const subjectRankAverages = SUBJECTS.map(subject => {
const values = records.map(record => parseNumber(record[`${subject}校次`])).filter(value => value !== null);
return { subject, avgRank: mean(values), count: values.length };
}).filter(item => item.count > 0 && item.avgRank !== null)
.sort((a, b) => a.avgRank - b.avgRank);
document.getElementById('avg-rank').textContent = avgRank === null ? '-' : `#${formatNumber(avgRank)}`;
document.getElementById('best-rank').textContent = bestRank === null ? '-' : `#${formatNumber(bestRank, 0)}`;
document.getElementById('best-score').textContent = bestScore === null ? '-' : formatNumber(bestScore);
const volatilityText = rankVolatility === null ? '-' :
rankVolatility > 80 ? `较高 (${formatNumber(rankVolatility)})` :
rankVolatility > 30 ? `中等 (${formatNumber(rankVolatility)})` :
`平稳 (${formatNumber(rankVolatility)})`;
document.getElementById('volatility').textContent = volatilityText;
document.getElementById('recent-rank-change').textContent = rankTrend;
document.getElementById('recent-rank-note').textContent = latestRecord ? latestRecord['考试名称'] : '暂无最近考试';
document.getElementById('best-subject').textContent = subjectRankAverages[0]?.subject || '-';
}
function getAggregateSnapshot() {
const totalScores = records.map(record => parseNumber(record['总分'])).filter(value => value !== null);
const totalRanks = records.map(record => parseNumber(record['大类排名'])).filter(value => value !== null);
const subjectStats = SUBJECTS.map(subject => {
const scoreValues = records.map(record => parseNumber(record[subject])).filter(value => value !== null);
const rankValues = records.map(record => parseNumber(record[`${subject}校次`])).filter(value => value !== null);
return {
subject,
avgScore: mean(scoreValues),
avgRank: mean(rankValues),
scoreCount: scoreValues.length,
rankCount: rankValues.length
};
});
const scoredSubjects = subjectStats.filter(item => item.avgScore !== null).sort((a, b) => b.avgScore - a.avgScore);
const rankedSubjects = subjectStats.filter(item => item.avgRank !== null).sort((a, b) => a.avgRank - b.avgRank);
return {
examCount: records.length,
totalAvg: mean(totalScores),
totalCount: totalScores.length,
rankAvg: mean(totalRanks),
rankCount: totalRanks.length,
subjectStats,
validSubjectCount: subjectStats.filter(item => item.avgScore !== null || item.avgRank !== null).length,
bestScoreSubject: scoredSubjects[0] || null,
bestRankSubject: rankedSubjects[0] || null,
watchSubject: rankedSubjects.length ? rankedSubjects[rankedSubjects.length - 1] : null
};
}
function renderExamTabs() {
const container = document.getElementById('exam-tabs');
if (!container) return;
const ordered = getOrderedRecords('desc');
container.innerHTML = '';
ordered.forEach((record, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `capsule ${index === activeRecordKey ? 'active' : ''}`;
button.textContent = record['考试名称'] || `考试 ${index + 1}`;
button.addEventListener('click', () => selectRecord(index));
container.appendChild(button);
});
const aggregateButton = document.createElement('button');
aggregateButton.type = 'button';
aggregateButton.className = `capsule ${activeRecordKey === 'AGGREGATE' ? 'active' : ''}`;
aggregateButton.textContent = '历史汇总';
aggregateButton.addEventListener('click', () => selectRecord('AGGREGATE'));
container.appendChild(aggregateButton);
}
function selectRecord(key) {
activeRecordKey = key;
const ordered = getOrderedRecords('desc');
document.querySelectorAll('#exam-tabs .capsule').forEach((button, buttonIndex) => {
const isAggregateButton = buttonIndex === ordered.length;
button.classList.toggle('active', isAggregateButton ? key === 'AGGREGATE' : buttonIndex === key);
});
if (key === 'AGGREGATE') {
const snapshot = getAggregateSnapshot();
renderAggregateRecordSummary(snapshot);
renderAggregateSubjectTable(snapshot);
renderAggregateRadar(snapshot);
return;
}
const record = ordered[key];
if (!record) return;
renderRecordSummary(record);
renderSubjectTable(record);
renderRadar(record);
}
function renderAggregateRecordSummary(snapshot) {
document.getElementById('current-total').textContent = snapshot.totalAvg === null ? '-' : formatNumber(snapshot.totalAvg);
document.getElementById('current-total-note').textContent = snapshot.totalCount ? `共 ${snapshot.totalCount} 场有效总分记录` : '暂无总分数据';
document.getElementById('current-rank').textContent = snapshot.rankAvg === null ? '-' : `#${formatNumber(snapshot.rankAvg, 0)}`;
document.getElementById('current-rank-note').textContent = snapshot.rankCount ? `共 ${snapshot.rankCount} 场有效排名记录` : '暂无排名数据';
document.getElementById('current-time').textContent = `历史汇总 / ${snapshot.examCount} 场考试`;
document.getElementById('current-coverage').textContent = `${snapshot.validSubjectCount} / ${SUBJECTS.length}`;
document.getElementById('record-best-score-subject').textContent = snapshot.bestScoreSubject?.subject || '-';
document.getElementById('record-best-score-note').textContent = snapshot.bestScoreSubject ? `平均分 ${formatNumber(snapshot.bestScoreSubject.avgScore)}` : '暂无科目分数';
document.getElementById('record-best-rank-subject').textContent = snapshot.bestRankSubject?.subject || '-';
document.getElementById('record-best-rank-note').textContent = snapshot.bestRankSubject ? `平均校次 #${formatNumber(snapshot.bestRankSubject.avgRank, 0)}` : '暂无科目位次';
document.getElementById('record-watch-subject').textContent = snapshot.watchSubject?.subject || '-';
document.getElementById('record-watch-note').textContent = snapshot.watchSubject ? `平均校次 #${formatNumber(snapshot.watchSubject.avgRank, 0)}` : '暂无待关注项';
}
function renderRecordSummary(record) {
const total = parseNumber(record['总分']);
const rank = parseNumber(record['大类排名']);
const validSubjects = SUBJECTS.filter(subject => parseNumber(record[subject]) !== null || parseNumber(record[`${subject}校次`]) !== null);
document.getElementById('current-total').textContent = total === null ? '-' : formatNumber(total);
document.getElementById('current-total-note').textContent = record['score_trend'] || '暂无分数趋势';
document.getElementById('current-rank').textContent = rank === null ? '-' : `#${formatNumber(rank, 0)}`;
document.getElementById('current-rank-note').textContent = record['rank_trend'] || '暂无排名趋势';
document.getElementById('current-time').textContent = record['考试时间'] || record['考试名称'] || '-';
document.getElementById('current-coverage').textContent = `${validSubjects.length} / ${SUBJECTS.length}`;
const scoreSubjects = SUBJECTS.map(subject => ({ subject, score: parseNumber(record[subject]) }))
.filter(item => item.score !== null)
.sort((a, b) => b.score - a.score);
const rankSubjects = SUBJECTS.map(subject => ({ subject, rank: parseNumber(record[`${subject}校次`]) }))
.filter(item => item.rank !== null)
.sort((a, b) => a.rank - b.rank);
document.getElementById('record-best-score-subject').textContent = scoreSubjects[0]?.subject || '-';
document.getElementById('record-best-score-note').textContent = scoreSubjects[0] ? `得分 ${formatNumber(scoreSubjects[0].score)}` : '暂无科目分数';
document.getElementById('record-best-rank-subject').textContent = rankSubjects[0]?.subject || '-';
document.getElementById('record-best-rank-note').textContent = rankSubjects[0] ? `校次 #${formatNumber(rankSubjects[0].rank, 0)}` : '暂无科目位次';
document.getElementById('record-watch-subject').textContent = rankSubjects.length ? rankSubjects[rankSubjects.length - 1].subject : '-';
document.getElementById('record-watch-note').textContent = rankSubjects.length ? `当前校次 #${formatNumber(rankSubjects[rankSubjects.length - 1].rank, 0)}` : '暂无待关注项';
}
function renderAggregateSubjectTable(snapshot) {
const tbody = document.getElementById('subject-table-body');
if (!tbody) return;
const rows = snapshot.subjectStats.filter(item => item.avgScore !== null || item.avgRank !== null);
tbody.innerHTML = rows.map(item => `
<tr>
<td data-label="考试科目">${item.subject}</td>
<td data-label="得分" class="val">${item.avgScore === null ? '-' : formatNumber(item.avgScore)}</td>
<td data-label="排名">${item.avgRank === null ? '-' : `#${formatNumber(item.avgRank, 0)}`}</td>
</tr>
`).join('');
}
function renderSubjectTable(record) {
const tbody = document.getElementById('subject-table-body');
if (!tbody) return;
const rows = SUBJECTS.filter(subject => parseNumber(record[subject]) !== null || parseNumber(record[`${subject}校次`]) !== null);
tbody.innerHTML = rows.map(subject => `
<tr>
<td data-label="考试科目">${subject}</td>
<td data-label="得分" class="val">${record[subject] && record[subject] !== '无数据' ? record[subject] : '-'}</td>
<td data-label="排名">${parseNumber(record[`${subject}校次`]) !== null ? `#${formatNumber(parseNumber(record[`${subject}校次`]), 0)}` : '-'}</td>
</tr>
`).join('');
}
function toggleChartEmpty(canvasId, emptyId, showEmpty) {
const canvas = document.getElementById(canvasId);
const empty = document.getElementById(emptyId);
if (!canvas || !empty) return;
canvas.style.display = showEmpty ? 'none' : 'block';
empty.classList.toggle('show', showEmpty);
}
function renderRadar(record) {
const labels = SUBJECTS.filter(subject => parseNumber(record[subject]) !== null);
if (charts.radar) charts.radar.destroy();
if (!labels.length) {
toggleChartEmpty('record-radar', 'record-radar-empty', true);
return;
}
toggleChartEmpty('record-radar', 'record-radar-empty', false);
charts.radar = new Chart(document.getElementById('record-radar'), {
type: 'radar',
data: {
labels,
datasets: [{
label: record['考试名称'] || '当前考试',
data: labels.map(subject => parseNumber(record[subject])),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: '#3b82f6',
pointBorderColor: '#fff',
pointBorderWidth: 2,
spanGaps: true
}]
},
options: {
maintainAspectRatio: false,
layout: { padding: 12 },
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 10, font: { weight: '600' } }
}
},
scales: {
r: {
beginAtZero: true,
ticks: { backdropColor: 'transparent', font: { size: 10 }, display: false },
grid: { color: '#e2e8f0' },
angleLines: { color: '#e2e8f0' },
pointLabels: { font: { size: 12, weight: '700' }, color: '#475569' }
}
}
}
});
}
function renderAggregateRadar(snapshot) {
const labels = snapshot.subjectStats.filter(item => item.avgScore !== null).map(item => item.subject);
if (charts.radar) charts.radar.destroy();
if (!labels.length) {
toggleChartEmpty('record-radar', 'record-radar-empty', true);
return;
}
toggleChartEmpty('record-radar', 'record-radar-empty', false);
charts.radar = new Chart(document.getElementById('record-radar'), {
type: 'radar',
data: {
labels,
datasets: [{
label: '历史汇总',
data: labels.map(subject => snapshot.subjectStats.find(item => item.subject === subject)?.avgScore ?? null),
borderColor: '#0f172a',
backgroundColor: 'rgba(15, 23, 42, 0.12)',
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: '#0f172a',
pointBorderColor: '#fff',
pointBorderWidth: 2,
spanGaps: true
}]
},
options: {
maintainAspectRatio: false,
layout: { padding: 12 },
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 10, font: { weight: '600' } }
}
},
scales: {
r: {
beginAtZero: true,
ticks: { backdropColor: 'transparent', font: { size: 10 }, display: false },
grid: { color: '#e2e8f0' },
angleLines: { color: '#e2e8f0' },
pointLabels: { font: { size: 12, weight: '700' }, color: '#475569' }
}
}
}
});
}
function renderTrendControls() {
const container = document.getElementById('trend-filters');
if (!container) return;
const ordered = getOrderedRecords('asc');
const availableSeries = TREND_SERIES.filter(series => ordered.some(record => getTrendValue(record, series) !== null));
activeTrendSeries = availableSeries[0] || '大类排名';
container.innerHTML = '';
availableSeries.forEach(series => {
const button = document.createElement('button');
button.type = 'button';
button.className = `capsule ${series === activeTrendSeries ? 'active' : ''}`;
button.textContent = series;
button.addEventListener('click', () => {
activeTrendSeries = series;
document.querySelectorAll('#trend-filters .capsule').forEach(item => {
item.classList.toggle('active', item.textContent === series);
});
renderTrend();
});
container.appendChild(button);
});
renderTrend();
}
function renderTrend() {
const canvas = document.getElementById('trend-canvas');
if (!canvas) return;
if (charts.trend) charts.trend.destroy();
const ordered = getOrderedRecords('asc');
const values = ordered.map(record => getTrendValue(record, activeTrendSeries));
const hasValues = values.some(value => value !== null);
if (!hasValues) {
toggleChartEmpty('trend-canvas', 'trend-empty', true);
return;
}
toggleChartEmpty('trend-canvas', 'trend-empty', false);
const color = SERIES_COLORS[activeTrendSeries] || '#3b82f6';
charts.trend = new Chart(canvas, {
type: 'line',
data: {
labels: ordered.map(record => record['考试名称'] || '考试'),
datasets: [{
label: activeTrendSeries,
data: values,
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 3,
tension: 0.4,
pointRadius: 5,
pointHoverRadius: 6,
pointBackgroundColor: '#fff',
pointBorderColor: color,
pointBorderWidth: 2,
spanGaps: true
}]
},
options: {
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 10, font: { weight: '600' } }
}
},
scales: {
x: {
grid: { display: false },
ticks: { maxRotation: 0, color: '#64748b' }
},
y: {
reverse: true,
grid: { color: '#f1f5f9' },
ticks: { color: '#64748b' }
}
}
}
});
}
</script>
</body>
</html>