Files
zlqy/templates/exam_detail.html
2026-02-27 10:37:11 +08:00

536 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}