first commit
This commit is contained in:
536
templates/exam_detail.html
Normal file
536
templates/exam_detail.html
Normal file
@@ -0,0 +1,536 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ exam.title }} - 智联青云{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto">
|
||||
{% if need_password %}
|
||||
<div class="bg-white shadow-sm rounded-lg p-8 border border-slate-200 max-w-md mx-auto mt-12">
|
||||
<div class="text-center mb-6">
|
||||
<svg class="w-16 h-16 mx-auto text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
<h2 class="text-xl font-bold text-slate-900">{{ exam.title }}</h2>
|
||||
<p class="text-sm text-slate-500 mt-1">该考试需要输入密码才能进入</p>
|
||||
</div>
|
||||
<div id="password-form">
|
||||
<input type="password" id="exam-pwd" placeholder="请输入考试密码"
|
||||
class="w-full px-4 py-3 border border-slate-300 rounded-md text-center text-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
onkeydown="if(event.key==='Enter')verifyPassword()">
|
||||
<p id="pwd-error" class="text-red-500 text-sm text-center mt-2 hidden">密码错误,请重试</p>
|
||||
<button onclick="verifyPassword()" class="w-full mt-4 px-4 py-3 bg-primary text-white rounded-md font-medium hover:bg-blue-700">
|
||||
验证并进入考试
|
||||
</button>
|
||||
<a href="/exams" class="block text-center mt-3 text-sm text-slate-500 hover:text-slate-700">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function verifyPassword() {
|
||||
const pwd = document.getElementById('exam-pwd').value;
|
||||
if (!pwd) { document.getElementById('pwd-error').textContent = '请输入密码'; document.getElementById('pwd-error').classList.remove('hidden'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/exams/{{ exam.id }}/verify-password', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({password: pwd})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) { location.reload(); }
|
||||
else { document.getElementById('pwd-error').textContent = data.message || '密码错误'; document.getElementById('pwd-error').classList.remove('hidden'); }
|
||||
} catch(e) { document.getElementById('pwd-error').textContent = '验证失败,请重试'; document.getElementById('pwd-error').classList.remove('hidden'); }
|
||||
}
|
||||
</script>
|
||||
{% elif existing_submission %}
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
||||
<p class="text-yellow-800 font-medium">您已提交过该试卷</p>
|
||||
<a href="/exams/{{ exam.id }}/result" class="mt-3 inline-block px-4 py-2 bg-primary text-white rounded-md text-sm">查看结果</a>
|
||||
</div>
|
||||
{% elif exam.status == 'closed' %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<p class="text-red-800 font-medium">该考试已关闭</p>
|
||||
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
|
||||
</div>
|
||||
{% elif schedule_status == 'not_started' %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 text-center">
|
||||
<p class="text-blue-800 font-medium text-xl mb-2">⏰ 考试尚未开始</p>
|
||||
<p class="text-blue-600">预定开始时间:{{ exam.scheduled_start.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if exam.scheduled_end %}
|
||||
<p class="text-blue-600">预定结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-blue-500 mb-2">距离开考还有:</p>
|
||||
<div id="countdown" class="text-3xl font-bold text-blue-700"></div>
|
||||
</div>
|
||||
<a href="/exams" class="mt-4 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
const startTime = new Date('{{ exam.scheduled_start.strftime("%Y-%m-%dT%H:%M:%S") }}').getTime();
|
||||
function updateCountdown() {
|
||||
const now = Date.now();
|
||||
const diff = Math.max(0, Math.floor((startTime - now) / 1000));
|
||||
if (diff <= 0) { location.reload(); return; }
|
||||
const d = Math.floor(diff / 86400);
|
||||
const h = Math.floor((diff % 86400) / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
let text = '';
|
||||
if (d > 0) text += d + '天 ';
|
||||
text += String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
|
||||
document.getElementById('countdown').textContent = text;
|
||||
setTimeout(updateCountdown, 1000);
|
||||
}
|
||||
updateCountdown();
|
||||
})();
|
||||
</script>
|
||||
{% elif schedule_status == 'ended' %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
|
||||
<p class="text-gray-800 font-medium">该考试已结束</p>
|
||||
<p class="text-gray-600 text-sm mt-1">结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- 顶部信息栏 -->
|
||||
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-0 z-20">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-slate-900">{{ exam.title }}</h1>
|
||||
<div class="mt-1 text-sm text-slate-500">
|
||||
{{ exam.subject }} · {{ exam.duration }}分钟 · 满分{{ exam.total_score }}分
|
||||
{% if exam.scheduled_end %}
|
||||
· 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-slate-500">
|
||||
<span id="progress-text">0</span>/{{ questions|length }} 已答
|
||||
</div>
|
||||
<div class="flex items-center text-red-600 font-medium">
|
||||
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span id="timer">--:--:--</span>
|
||||
</div>
|
||||
<div id="tab-warning" class="hidden text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded">
|
||||
切屏 <span id="tab-count">0</span> 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div class="mt-3 w-full bg-slate-100 rounded-full h-1.5">
|
||||
<div id="progress-bar" class="bg-primary h-1.5 rounded-full transition-all duration-300" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-4">
|
||||
<!-- 题号导航面板 -->
|
||||
<div class="hidden lg:block w-48 flex-shrink-0">
|
||||
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-24">
|
||||
<div class="text-sm font-medium text-slate-700 mb-3">题目导航</div>
|
||||
<div class="grid grid-cols-5 gap-2" id="nav-panel">
|
||||
{% for q in questions %}
|
||||
<button onclick="goToQuestion({{ loop.index0 }})" id="nav-{{ loop.index0 }}"
|
||||
class="w-8 h-8 rounded-md text-xs font-medium border border-slate-200 bg-slate-50 text-slate-600 hover:border-primary hover:text-primary transition-colors flex items-center justify-center">
|
||||
{{ loop.index }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-4 space-y-1 text-xs text-slate-500">
|
||||
<div class="flex items-center"><span class="w-3 h-3 rounded bg-primary mr-2"></span>当前题</div>
|
||||
<div class="flex items-center"><span class="w-3 h-3 rounded bg-green-500 mr-2"></span>已答</div>
|
||||
<div class="flex items-center"><span class="w-3 h-3 rounded bg-slate-200 mr-2"></span>未答</div>
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-slate-400" id="save-status">自动保存中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主答题区 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 移动端题号导航 -->
|
||||
<div class="lg:hidden mb-4 bg-white shadow-sm rounded-lg p-3 border border-slate-200">
|
||||
<div class="flex flex-wrap gap-1.5" id="nav-panel-mobile">
|
||||
{% for q in questions %}
|
||||
<button onclick="goToQuestion({{ loop.index0 }})" id="nav-m-{{ loop.index0 }}"
|
||||
class="w-7 h-7 rounded text-xs font-medium border border-slate-200 bg-slate-50 text-slate-600 flex items-center justify-center">
|
||||
{{ loop.index }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="exam-form" onsubmit="handleSubmit(event)">
|
||||
{% for q in questions %}
|
||||
<div class="question-card bg-white shadow-sm rounded-lg p-6 border border-slate-200 mb-4" data-index="{{ loop.index0 }}" style="{% if loop.index0 != 0 %}display:none{% endif %}">
|
||||
<div class="flex items-start space-x-4">
|
||||
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span>
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded
|
||||
{% if q.type == 'choice' %}bg-blue-50 text-blue-700
|
||||
{% elif q.type == 'fill' %}bg-green-50 text-green-700
|
||||
{% else %}bg-purple-50 text-purple-700{% endif %}">
|
||||
{% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %}
|
||||
</span>
|
||||
<p class="mt-2 text-lg text-slate-900">{{ q.content }}</p>
|
||||
{% if q.get('images') %}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{% for img in q.images %}
|
||||
<img src="{{ img }}" class="max-h-48 rounded border border-slate-200 cursor-pointer" onclick="window.open(this.src)" alt="题目图片">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">({{ q.score }}分)</span>
|
||||
</div>
|
||||
{% if q.type == 'choice' %}
|
||||
<div class="space-y-3">
|
||||
{% for opt in q.options %}
|
||||
<label class="flex items-center space-x-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer transition-colors option-label" data-qid="{{ q.id }}">
|
||||
<input type="radio" name="q-{{ q.id }}" value="{{ ['A','B','C','D'][loop.index0] }}" class="h-4 w-4 text-primary border-slate-300 focus:ring-primary answer-input" data-qid="{{ q.id }}" onchange="onAnswerChange({{ q.id }})">
|
||||
<span class="text-slate-700">{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif q.type == 'fill' %}
|
||||
<input type="text" name="q-{{ q.id }}" class="w-full px-3 py-2 border border-slate-300 rounded-md answer-input" placeholder="请输入答案" data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})">
|
||||
{% else %}
|
||||
<textarea name="q-{{ q.id }}" rows="6" class="w-full rounded-lg border-slate-300 shadow-sm focus:border-primary focus:ring-primary border px-3 py-2 answer-input" placeholder="请输入您的答案..." data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})"></textarea>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<button type="button" onclick="examUpload({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📷 上传图片</button>
|
||||
<button type="button" onclick="examCamera({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📸 拍照上传</button>
|
||||
</div>
|
||||
<div id="img-preview-{{ q.id }}" class="flex flex-wrap gap-2 mt-2"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="flex justify-between items-center mt-4 mb-8">
|
||||
<button type="button" id="prev-btn" onclick="prevQuestion()" class="px-5 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed" disabled>
|
||||
← 上一题
|
||||
</button>
|
||||
<span class="text-sm text-slate-500">
|
||||
第 <span id="current-num">1</span> / {{ questions|length }} 题
|
||||
</span>
|
||||
<button type="button" id="next-btn" onclick="nextQuestion()" class="px-5 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-blue-700">
|
||||
下一题 →
|
||||
</button>
|
||||
<button type="submit" id="submit-btn" class="px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 hidden">
|
||||
提交试卷
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% if not existing_submission and exam.status != 'closed' and schedule_status == 'available' %}
|
||||
<script>
|
||||
const EXAM_ID = {{ exam.id }};
|
||||
const TOTAL_Q = {{ questions|length }};
|
||||
const DURATION = {{ exam.duration }};
|
||||
const STORAGE_KEY = `exam_${EXAM_ID}_answers`;
|
||||
const TIMER_KEY = `exam_${EXAM_ID}_start`;
|
||||
{% if exam.scheduled_end %}
|
||||
const SCHEDULED_END = new Date('{{ exam.scheduled_end.strftime("%Y-%m-%dT%H:%M:%S") }}').getTime();
|
||||
{% else %}
|
||||
const SCHEDULED_END = null;
|
||||
{% endif %}
|
||||
|
||||
let currentIndex = 0;
|
||||
let answers = {};
|
||||
let tabSwitchCount = 0;
|
||||
|
||||
// ===== 图片上传 =====
|
||||
function examUpload(qid) {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
|
||||
inp.onchange = () => { if (inp.files.length) examUploadFiles(inp.files, qid); };
|
||||
inp.click();
|
||||
}
|
||||
function examCamera(qid) {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'environment';
|
||||
inp.onchange = () => { if (inp.files.length) examUploadFiles(inp.files, qid); };
|
||||
inp.click();
|
||||
}
|
||||
async function examUploadFiles(files, qid) {
|
||||
for (const file of files) {
|
||||
if (file.size > 10*1024*1024) { alert('文件不能超过10MB'); continue; }
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/upload', {method:'POST', body: fd});
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
const ta = document.querySelector(`[name="q-${qid}"]`);
|
||||
if (ta) {
|
||||
const tag = `\n[img:${d.url}]\n`;
|
||||
ta.value += tag;
|
||||
onAnswerChange(qid);
|
||||
}
|
||||
const preview = document.getElementById('img-preview-' + qid);
|
||||
if (preview) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative group';
|
||||
div.innerHTML = `<img src="${d.url}" class="w-16 h-16 object-cover rounded border border-slate-200"><button type="button" onclick="this.parentElement.remove()" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
|
||||
preview.appendChild(div);
|
||||
}
|
||||
} else { alert(d.message); }
|
||||
} catch(e) { alert('上传失败'); }
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化:从服务器草稿或localStorage恢复答案
|
||||
function initAnswers() {
|
||||
// 优先从服务器草稿恢复
|
||||
{% if draft %}
|
||||
const serverDraft = {{ draft.answers | tojson }};
|
||||
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
|
||||
answers = serverDraft;
|
||||
restoreAnswersToForm();
|
||||
saveToLocal();
|
||||
updateNavStatus();
|
||||
return;
|
||||
}
|
||||
{% endif %}
|
||||
// 其次从localStorage恢复
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
answers = JSON.parse(saved);
|
||||
restoreAnswersToForm();
|
||||
} catch(e) {}
|
||||
}
|
||||
updateNavStatus();
|
||||
}
|
||||
|
||||
function restoreAnswersToForm() {
|
||||
for (const [qid, val] of Object.entries(answers)) {
|
||||
const radio = document.querySelector(`input[name="q-${qid}"][value="${val}"]`);
|
||||
if (radio) { radio.checked = true; continue; }
|
||||
const input = document.querySelector(`[name="q-${qid}"]`);
|
||||
if (input) input.value = val;
|
||||
// 恢复图片预览
|
||||
const imgMatches = val.matchAll(/\[img:(\/static\/uploads\/[^\]]+)\]/g);
|
||||
const preview = document.getElementById('img-preview-' + qid);
|
||||
if (preview) {
|
||||
for (const m of imgMatches) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative group';
|
||||
div.innerHTML = `<img src="${m[1]}" class="w-16 h-16 object-cover rounded border border-slate-200"><button type="button" onclick="this.parentElement.remove()" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
|
||||
preview.appendChild(div);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onAnswerChange(qid) {
|
||||
const radio = document.querySelector(`input[name="q-${qid}"]:checked`);
|
||||
if (radio) {
|
||||
answers[String(qid)] = radio.value;
|
||||
} else {
|
||||
const input = document.querySelector(`[name="q-${qid}"]`);
|
||||
if (input) answers[String(qid)] = input.value;
|
||||
}
|
||||
saveToLocal();
|
||||
updateNavStatus();
|
||||
debounceSaveToServer();
|
||||
}
|
||||
|
||||
function saveToLocal() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(answers));
|
||||
}
|
||||
|
||||
let saveTimer = null;
|
||||
function debounceSaveToServer() {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveToServer, 3000);
|
||||
}
|
||||
|
||||
function saveToServer() {
|
||||
fetch(`/api/exams/${EXAM_ID}/save-draft`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({answers})
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('save-status').textContent = '已保存 ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// 题号导航状态
|
||||
function updateNavStatus() {
|
||||
let answeredCount = 0;
|
||||
for (let i = 0; i < TOTAL_Q; i++) {
|
||||
const qid = getQidByIndex(i);
|
||||
const isAnswered = answers[String(qid)] && answers[String(qid)].trim() !== '';
|
||||
const isCurrent = i === currentIndex;
|
||||
if (isAnswered) answeredCount++;
|
||||
|
||||
['nav-', 'nav-m-'].forEach(prefix => {
|
||||
const btn = document.getElementById(prefix + i);
|
||||
if (!btn) return;
|
||||
btn.className = 'w-' + (prefix === 'nav-' ? '8 h-8' : '7 h-7') + ' rounded' + (prefix === 'nav-' ? '-md' : '') + ' text-xs font-medium border flex items-center justify-center transition-colors ';
|
||||
if (isCurrent) {
|
||||
btn.className += 'border-primary bg-primary text-white';
|
||||
} else if (isAnswered) {
|
||||
btn.className += 'border-green-400 bg-green-500 text-white';
|
||||
} else {
|
||||
btn.className += 'border-slate-200 bg-slate-50 text-slate-600 hover:border-primary hover:text-primary';
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('progress-text').textContent = answeredCount;
|
||||
document.getElementById('progress-bar').style.width = (answeredCount / TOTAL_Q * 100) + '%';
|
||||
}
|
||||
|
||||
function getQidByIndex(idx) {
|
||||
const qids = [{% for q in questions %}{{ q.id }}{% if not loop.last %},{% endif %}{% endfor %}];
|
||||
return qids[idx];
|
||||
}
|
||||
|
||||
// 分页切换
|
||||
function showQuestion(idx) {
|
||||
document.querySelectorAll('.question-card').forEach((card, i) => {
|
||||
card.style.display = i === idx ? '' : 'none';
|
||||
});
|
||||
currentIndex = idx;
|
||||
document.getElementById('current-num').textContent = idx + 1;
|
||||
document.getElementById('prev-btn').disabled = idx === 0;
|
||||
|
||||
// 渲染当前题目的数学公式
|
||||
const card = document.querySelectorAll('.question-card')[idx];
|
||||
if (card && typeof renderMathInElement === 'function') {
|
||||
renderMathInElement(card, {
|
||||
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
|
||||
throwOnError: false
|
||||
});
|
||||
}
|
||||
|
||||
if (idx === TOTAL_Q - 1) {
|
||||
document.getElementById('next-btn').classList.add('hidden');
|
||||
document.getElementById('submit-btn').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('next-btn').classList.remove('hidden');
|
||||
document.getElementById('submit-btn').classList.add('hidden');
|
||||
}
|
||||
updateNavStatus();
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
}
|
||||
|
||||
function goToQuestion(idx) { showQuestion(idx); }
|
||||
function prevQuestion() { if (currentIndex > 0) showQuestion(currentIndex - 1); }
|
||||
function nextQuestion() { if (currentIndex < TOTAL_Q - 1) showQuestion(currentIndex + 1); }
|
||||
|
||||
// 计时器(持久化,支持预定结束时间)
|
||||
function initTimer() {
|
||||
let startTime = localStorage.getItem(TIMER_KEY);
|
||||
if (!startTime) {
|
||||
startTime = Date.now();
|
||||
localStorage.setItem(TIMER_KEY, startTime);
|
||||
} else {
|
||||
startTime = parseInt(startTime);
|
||||
}
|
||||
const durationEnd = startTime + DURATION * 60 * 1000;
|
||||
// 如果有预定结束时间,取两者中较早的
|
||||
const endTime = SCHEDULED_END ? Math.min(durationEnd, SCHEDULED_END) : durationEnd;
|
||||
|
||||
function tick() {
|
||||
const remaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
|
||||
if (remaining <= 0) {
|
||||
document.getElementById('timer').textContent = '00:00:00';
|
||||
autoSubmit();
|
||||
return;
|
||||
}
|
||||
const h = Math.floor(remaining / 3600);
|
||||
const m = Math.floor((remaining % 3600) / 60);
|
||||
const s = remaining % 60;
|
||||
document.getElementById('timer').textContent =
|
||||
`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
|
||||
// 最后5分钟变红闪烁
|
||||
if (remaining <= 300) {
|
||||
document.getElementById('timer').classList.add('animate-pulse');
|
||||
}
|
||||
setTimeout(tick, 1000);
|
||||
}
|
||||
tick();
|
||||
}
|
||||
|
||||
function autoSubmit() {
|
||||
alert('考试时间到,系统将自动提交试卷!');
|
||||
doSubmit();
|
||||
}
|
||||
|
||||
// 防切屏
|
||||
function initTabDetection() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
tabSwitchCount++;
|
||||
document.getElementById('tab-count').textContent = tabSwitchCount;
|
||||
document.getElementById('tab-warning').classList.remove('hidden');
|
||||
if (tabSwitchCount >= 3) {
|
||||
alert('警告:您已切屏' + tabSwitchCount + '次!频繁切屏可能被视为作弊行为。');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提交
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
// 检查未答题数
|
||||
let unanswered = 0;
|
||||
for (let i = 0; i < TOTAL_Q; i++) {
|
||||
const qid = getQidByIndex(i);
|
||||
if (!answers[String(qid)] || answers[String(qid)].trim() === '') unanswered++;
|
||||
}
|
||||
let msg = '确定提交试卷?提交后不可修改。';
|
||||
if (unanswered > 0) msg = `您还有 ${unanswered} 道题未作答,${msg}`;
|
||||
if (!confirm(msg)) return;
|
||||
doSubmit();
|
||||
}
|
||||
|
||||
function doSubmit() {
|
||||
const btn = document.getElementById('submit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '正在提交...';
|
||||
|
||||
fetch(`/api/exams/${EXAM_ID}/submit`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({answers})
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
// 清除本地存储
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(TIMER_KEY);
|
||||
alert('提交成功!');
|
||||
window.location.href = `/exams/${EXAM_ID}/result`;
|
||||
} else {
|
||||
alert(data.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '提交试卷';
|
||||
}
|
||||
}).catch(() => {
|
||||
alert('提交失败,请重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '提交试卷';
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
initAnswers();
|
||||
initTimer();
|
||||
initTabDetection();
|
||||
showQuestion(0);
|
||||
|
||||
// 离开页面提醒
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user