229 lines
8.0 KiB
JavaScript
229 lines
8.0 KiB
JavaScript
const SUPABASE_URL = 'https://chixssrphfgxvqqigkzo.supabase.co';
|
||
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNoaXhzc3JwaGZneHZxcWlna3pvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ2OTE0OTEsImV4cCI6MjA5MDI2NzQ5MX0.Az_Ew2J2zdOMcSV0UNAjBS-LPqGpqhsaN4IyZ5R7iqU';
|
||
const sbClient = supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||
|
||
const examSelect = document.getElementById('examSelect');
|
||
const nameInput = document.getElementById('studentName');
|
||
const admissionInput = document.getElementById('admissionNo');
|
||
const searchButton = document.getElementById('searchBtn');
|
||
const resultsShell = document.getElementById('searchResults');
|
||
|
||
let loadedExams = [];
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[char]));
|
||
}
|
||
|
||
function normalize(value) {
|
||
return String(value ?? '').trim();
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return '-';
|
||
const date = new Date(`${value}T00:00:00`);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return date.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
});
|
||
}
|
||
|
||
function formatScore(value) {
|
||
const number = Number(value || 0);
|
||
return Number.isInteger(number) ? String(number) : number.toFixed(2);
|
||
}
|
||
|
||
function getScoreTotal(items) {
|
||
return (items || []).reduce((sum, item) => sum + Number(item.score || 0), 0);
|
||
}
|
||
|
||
function renderEmpty(message) {
|
||
resultsShell.innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
function renderError(message) {
|
||
resultsShell.innerHTML = `<div class="error-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
function renderLoading(message) {
|
||
resultsShell.innerHTML = `<div class="loading-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
function getSelectedExam() {
|
||
return loadedExams.find(exam => exam.id === examSelect.value) || null;
|
||
}
|
||
|
||
async function loadExams() {
|
||
examSelect.innerHTML = '<option value="">正在加载考试项目...</option>';
|
||
|
||
const { data, error } = await sbClient
|
||
.from('exam_exams')
|
||
.select('id, exam_code, exam_name, exam_date')
|
||
.eq('is_published', true)
|
||
.order('exam_date', { ascending: false });
|
||
|
||
if (error) throw error;
|
||
|
||
loadedExams = data || [];
|
||
|
||
if (loadedExams.length === 0) {
|
||
examSelect.innerHTML = '<option value="">暂无已发布考试项目</option>';
|
||
renderEmpty('暂无已发布考试项目,请先在 Supabase 的 exam_exams 表中新增考试,或执行 exam/schema.sql 中的示例插入语句。');
|
||
return;
|
||
}
|
||
|
||
examSelect.innerHTML = [
|
||
'<option value="">请选择考试项目</option>',
|
||
...loadedExams.map(exam => (
|
||
`<option value="${escapeHtml(exam.id)}">${escapeHtml(exam.exam_name)}(${escapeHtml(formatDate(exam.exam_date))})</option>`
|
||
))
|
||
].join('');
|
||
}
|
||
|
||
function renderResult(result, exam) {
|
||
const items = [...(result.exam_score_items || [])].sort((a, b) =>
|
||
Number(a.sort_order || 0) - Number(b.sort_order || 0) ||
|
||
String(a.item_name || '').localeCompare(String(b.item_name || ''), 'zh-CN')
|
||
);
|
||
const total = getScoreTotal(items);
|
||
const rows = items.map(item => {
|
||
const score = Number(item.score || 0);
|
||
const scoreClass = score < 0 ? 'score-value negative' : 'score-value';
|
||
const fullScore = item.full_score === null || item.full_score === undefined ? '-' : formatScore(item.full_score);
|
||
|
||
return `
|
||
<tr>
|
||
<td>${escapeHtml(item.item_name)}</td>
|
||
<td><span class="${scoreClass}">${escapeHtml(formatScore(item.score))}</span></td>
|
||
<td>${escapeHtml(fullScore)}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
resultsShell.innerHTML = `
|
||
<div class="candidate-card">
|
||
<div>
|
||
<h3 class="candidate-name">${escapeHtml(result.student_name)}</h3>
|
||
<div class="candidate-meta">
|
||
<span>准考证号:${escapeHtml(result.admission_no)}</span>
|
||
<span>班级:${escapeHtml(result.class_name || '-')}</span>
|
||
</div>
|
||
</div>
|
||
<span class="exam-id-pill">独立成绩单</span>
|
||
</div>
|
||
<div class="exam-list">
|
||
<article class="exam-block">
|
||
<div class="exam-block-header">
|
||
<div>
|
||
<h3 class="exam-title">${escapeHtml(exam.exam_name)}</h3>
|
||
<div class="exam-date">考试日期:${escapeHtml(formatDate(exam.exam_date))}</div>
|
||
</div>
|
||
<div class="exam-total">
|
||
本场总分<br><strong>${escapeHtml(formatScore(total))}</strong>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table class="score-table">
|
||
<thead>
|
||
<tr>
|
||
<th>成绩项目</th>
|
||
<th>得分</th>
|
||
<th>满分</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows || '<tr><td colspan="3">暂无成绩项目</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
<p class="exam-remark">备注:${escapeHtml(result.remark || '无')}</p>
|
||
</article>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function handleSearch() {
|
||
const exam = getSelectedExam();
|
||
const studentName = normalize(nameInput.value);
|
||
const admissionNo = normalize(admissionInput.value);
|
||
|
||
if (!exam || !studentName || !admissionNo) {
|
||
renderEmpty('请同时选择考试项目,并输入姓名和准考证号。');
|
||
return;
|
||
}
|
||
|
||
renderLoading('正在连接成绩数据库并执行查询...');
|
||
|
||
try {
|
||
const { data, error } = await sbClient
|
||
.from('exam_results')
|
||
.select(`
|
||
id,
|
||
student_name,
|
||
admission_no,
|
||
class_name,
|
||
remark,
|
||
exam_score_items (
|
||
id,
|
||
item_name,
|
||
score,
|
||
full_score,
|
||
sort_order
|
||
)
|
||
`)
|
||
.eq('exam_id', exam.id)
|
||
.eq('student_name', studentName)
|
||
.eq('admission_no', admissionNo)
|
||
.maybeSingle();
|
||
|
||
if (error) throw error;
|
||
|
||
if (!data) {
|
||
renderEmpty('未查询到该考试项目下的匹配成绩,请核对考试项目、姓名和准考证号。');
|
||
return;
|
||
}
|
||
|
||
renderResult(data, exam);
|
||
} catch (error) {
|
||
renderError(`查询失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
function applyQueryParams() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const presetExam = params.get('examId') || params.get('exam') || '';
|
||
const presetName = params.get('name') || '';
|
||
const presetAdmissionNo = params.get('admissionNo') || params.get('ticket') || '';
|
||
|
||
if (presetExam) {
|
||
const matchedExam = loadedExams.find(exam => exam.id === presetExam || exam.exam_code === presetExam);
|
||
if (matchedExam) examSelect.value = matchedExam.id;
|
||
}
|
||
if (presetName) nameInput.value = presetName;
|
||
if (presetAdmissionNo) admissionInput.value = presetAdmissionNo;
|
||
if (examSelect.value && presetName && presetAdmissionNo) handleSearch();
|
||
}
|
||
|
||
async function init() {
|
||
try {
|
||
await loadExams();
|
||
applyQueryParams();
|
||
} catch (error) {
|
||
examSelect.innerHTML = '<option value="">考试项目加载失败</option>';
|
||
renderError(`考试项目加载失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
searchButton.addEventListener('click', handleSearch);
|
||
[examSelect, nameInput, admissionInput].forEach(input => {
|
||
input.addEventListener('keydown', event => {
|
||
if (event.key === 'Enter') handleSearch();
|
||
});
|
||
});
|
||
init();
|