Spaces:
Running
Running
| <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 ; | |
| flex-wrap: wrap ; | |
| gap: 8px ; | |
| margin-bottom: 16px ; | |
| overflow: visible ; | |
| width: 100% ; | |
| } | |
| .capsule { | |
| flex: 0 0 auto ; | |
| } | |
| .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> | |