first commit

This commit is contained in:
2026-02-27 10:37:11 +08:00
commit 74f19aad0b
86 changed files with 18642 additions and 0 deletions

536
templates/exam_detail.html Normal file
View 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 %}