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

638 lines
36 KiB
HTML
Raw Permalink 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 %}创建试卷 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-slate-900">创建试卷</h1>
<a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a>
</div>
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">试卷标题</label>
<input id="exam-title" type="text" required class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="如2026年春季联考数学卷">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">科目</label>
<select id="exam-subject" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<option>数学</option><option>物理</option><option>化学</option><option>生物</option><option>语文</option><option>英语</option><option>历史</option><option>地理</option><option>政治</option><option>综合</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">考试时长(分钟)</label>
<input id="exam-duration" type="number" value="120" min="10" max="480" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-slate-700">预定开始时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-scheduled-start" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,考生只能在此时间之后开始考试</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">预定结束时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-scheduled-end" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,到时间自动截止,考生无法再提交</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">成绩公布时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-score-release" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,考生只能在此时间之后查看成绩(必须晚于考试结束时间)</p>
</div>
</div>
</div>
</div>
<!-- 考试密码设置 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-password" type="text" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="留空则不设密码">
<p class="mt-1 text-xs text-slate-400">设置密码后考生需输入密码才能进入考试,试卷内容将加密存储</p>
</div>
</div>
</div>
<!-- 题目统计 -->
<div class="bg-slate-50 rounded-lg p-3 border border-slate-200 flex items-center justify-between">
<div class="flex items-center space-x-4 text-sm text-slate-600">
<span><span id="q-count" class="font-medium text-slate-900">0</span></span>
<span>总分 <span id="q-total-score" class="font-medium text-slate-900">0</span></span>
<span class="text-blue-600">选择题 <span id="q-choice-count">0</span></span>
<span class="text-green-600">填空题 <span id="q-fill-count">0</span></span>
<span class="text-purple-600">解答题 <span id="q-text-count">0</span></span>
<span class="text-orange-600">判断题 <span id="q-judge-count">0</span></span>
</div>
</div>
<div id="questions-container" class="space-y-4"></div>
<div class="flex flex-wrap gap-2">
<button onclick="addQuestion('choice')" class="px-4 py-2 bg-blue-50 text-primary border border-blue-200 rounded-md text-sm font-medium hover:bg-blue-100">+ 选择题</button>
<button onclick="addQuestion('fill')" class="px-4 py-2 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm font-medium hover:bg-green-100">+ 填空题</button>
<button onclick="addQuestion('text')" class="px-4 py-2 bg-purple-50 text-purple-700 border border-purple-200 rounded-md text-sm font-medium hover:bg-purple-100">+ 解答题</button>
<button onclick="addQuestion('judge')" class="px-4 py-2 bg-orange-50 text-orange-700 border border-orange-200 rounded-md text-sm font-medium hover:bg-orange-100">+ 判断题</button>
<span class="border-l border-slate-300 mx-1"></span>
<button onclick="batchAddChoice()" class="px-4 py-2 bg-slate-50 text-slate-700 border border-slate-200 rounded-md text-sm font-medium hover:bg-slate-100">批量添加选择题</button>
<span class="border-l border-slate-300 mx-1"></span>
<button onclick="showVipModal()" class="px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-md text-sm font-medium hover:from-purple-600 hover:to-indigo-600 shadow-sm">智能导入PDF</button>
<input type="file" id="pdfFileInput" accept=".pdf" class="hidden" onchange="parsePDF(this)">
</div>
<div class="flex justify-between items-center">
<div class="text-sm text-slate-400">提示:拖拽题目卡片可调整顺序</div>
<button onclick="submitExam()" class="px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-blue-700">发布试卷</button>
</div>
</div>
<!-- PDF解析加载遮罩 -->
<div id="pdfLoading" class="fixed inset-0 bg-black/50 z-[9990] flex items-center justify-center hidden">
<div class="bg-white rounded-2xl p-8 flex flex-col items-center space-y-4 shadow-2xl">
<div class="w-12 h-12 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
<p class="text-slate-700 font-medium">AI正在识别试卷中...</p>
<p class="text-sm text-slate-400">请耐心等待通常需要10-30秒</p>
</div>
</div>
<!-- VIP 弹窗 -->
<div id="vipModal" class="fixed inset-0 z-[999] hidden" onclick="if(event.target===this)closeVipModal()">
<!-- 背景:深空黑 + 动态星点 -->
<div class="absolute inset-0 bg-gradient-to-br from-[#0a0015] via-[#0d0d2b] to-[#0a0015]">
<div class="stars-bg"></div>
</div>
<!-- 主卡片 -->
<div class="relative flex items-center justify-center min-h-screen p-4">
<div id="vipCard" class="vip-card relative w-full max-w-lg rounded-3xl p-[2px] opacity-0 scale-75">
<!-- 流光边框 -->
<div class="absolute inset-0 rounded-3xl bg-gradient-to-r from-purple-500 via-cyan-400 to-purple-500 animate-border-flow"></div>
<div class="relative bg-[#0e0e2a]/95 backdrop-blur-xl rounded-3xl p-8 overflow-hidden">
<!-- 顶部光晕 -->
<div class="absolute -top-20 left-1/2 -translate-x-1/2 w-60 h-60 bg-purple-500/20 rounded-full blur-3xl"></div>
<div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div>
<!-- 关闭按钮 -->
<button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&times;</button>
<!-- 皇冠图标 -->
<div class="relative flex justify-center mb-4">
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-amber-400 via-yellow-300 to-amber-500 flex items-center justify-center shadow-lg shadow-amber-500/30 vip-crown">
<svg class="w-10 h-10 text-amber-900" fill="currentColor" viewBox="0 0 24 24"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5v-2z"/></svg>
</div>
</div>
<!-- 标题 -->
<h2 class="text-center text-2xl font-bold bg-gradient-to-r from-amber-200 via-yellow-100 to-amber-200 bg-clip-text text-transparent mb-1">SVIP 超级会员</h2>
<p class="text-center text-purple-300/60 text-xs mb-6 tracking-widest">SUPREME VIP MEMBERSHIP</p>
<!-- 功能列表 -->
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">AI 智能识别试卷</p><p class="text-white/30 text-xs">PDF 一键导入,公式图形全自动解析</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center text-cyan-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">量子级 OCR 引擎</p><p class="text-white/30 text-xs">手写体、印刷体、火星文通通拿下</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center text-amber-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">永久有效 · 无限次数</p><p class="text-white/30 text-xs">一次开通,终身尊享(真的吗?)</p></div>
</div>
</div>
<!-- 价格区 -->
<div class="text-center mb-6">
<div class="inline-flex items-baseline space-x-2 mb-1">
<span class="text-white/30 text-sm line-through decoration-red-400/60">原价 ¥998/年</span>
</div>
<div class="flex items-baseline justify-center">
<span class="text-white/50 text-lg mr-1">¥</span>
<span class="text-5xl font-black bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent vip-price">&infin;</span>
</div>
<p class="text-white/20 text-xs mt-2">限时优惠 · 仅剩 <span class="text-amber-400/80">0</span> 个名额</p>
</div>
<!-- 无支付按钮区域 -->
<div class="relative">
<div class="w-full py-3 rounded-xl bg-white/[0.04] border border-dashed border-white/10 text-center">
<p class="text-white/20 text-sm">支付通道维护中,预计恢复时间:</p>
<p class="text-purple-300/40 text-xs mt-1 font-mono tracking-wider">2099年12月31日 23:59:59</p>
</div>
</div>
<!-- 底部小字 -->
<p class="text-center text-white/10 text-[10px] mt-4">本页面仅供观赏,不构成任何消费邀约。如有雷同,纯属巧合。</p>
</div>
</div>
</div>
</div>
<style>
/* VIP 弹窗动画 */
.stars-bg {
position: absolute; inset: 0;
background-image: radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.15), transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.1), transparent),
radial-gradient(1px 1px at 90px 40px, rgba(255,255,255,0.15), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.1), transparent),
radial-gradient(2px 2px at 160px 30px, rgba(255,255,255,0.12), transparent);
background-size: 200px 100px;
animation: twinkle 4s ease-in-out infinite alternate;
}
@keyframes twinkle { 0% { opacity: 0.5; } 100% { opacity: 1; } }
@keyframes border-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.animate-border-flow {
background-size: 200% 200%;
animation: border-flow 3s linear infinite;
}
.vip-crown {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.vip-price {
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
0% { filter: drop-shadow(0 0 8px rgba(168,85,247,0.4)); }
100% { filter: drop-shadow(0 0 20px rgba(34,211,238,0.6)); }
}
.vip-card-enter {
animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes cardIn {
0% { opacity: 0; transform: scale(0.75) translateY(40px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.vip-card-exit {
animation: cardOut 0.3s ease-in forwards;
}
@keyframes cardOut {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.9) translateY(20px); }
}
</style>
{% endblock %}
{% block scripts %}
<script>
// VIP 弹窗
function showVipModal() {
const modal = document.getElementById('vipModal');
const card = document.getElementById('vipCard');
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
card.classList.remove('vip-card-exit');
card.classList.add('vip-card-enter');
}
function closeVipModal() {
const modal = document.getElementById('vipModal');
const card = document.getElementById('vipCard');
card.classList.remove('vip-card-enter');
card.classList.add('vip-card-exit');
setTimeout(function() {
modal.classList.add('hidden');
document.body.style.overflow = '';
}, 300);
}
// 重新渲染页面中的数学公式(带 KaTeX 未加载重试)
function renderMath(el) {
if (typeof renderMathInElement === 'function') {
renderMathInElement(el || document.body, {
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
throwOnError: false
});
} else {
setTimeout(function() { renderMath(el); }, 200);
}
}
// 选择题选项公式预览
function previewOptMath(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const preview = document.getElementById('q-opt-preview-' + qid);
if (!preview) return;
const opts = el.querySelectorAll('.q-opt');
let hasFormula = false;
let html = '';
opts.forEach(function(input, i) {
const val = input.value.trim();
if (val && val.includes('$')) {
hasFormula = true;
html += '<div class="mb-1"><span class="font-medium text-slate-500">' + String.fromCharCode(65 + i) + '.</span> ' + val.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>';
}
});
if (hasFormula) {
preview.innerHTML = html;
preview.classList.remove('hidden');
renderMath(preview);
} else {
preview.classList.add('hidden');
}
}
let questionId = 0;
function addQuestion(type, prefill) {
questionId++;
const qid = questionId;
const container = document.getElementById('questions-container');
const typeLabel = type === 'choice' ? '选择题' : type === 'fill' ? '填空题' : type === 'judge' ? '判断题' : '解答题';
const typeColor = type === 'choice' ? 'blue' : type === 'fill' ? 'green' : type === 'judge' ? 'orange' : 'purple';
const defaultScore = type === 'choice' ? 5 : type === 'fill' ? 5 : type === 'judge' ? 3 : 10;
let html = `<div id="q-${qid}" class="bg-white shadow-sm rounded-lg p-6 border border-slate-200 question-item" data-type="${type}" data-qid="${qid}" draggable="true" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="drop(event)" ondragend="dragEnd(event)">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-3">
<span class="drag-handle cursor-grab text-slate-400 hover:text-slate-600">⠿</span>
<span class="q-number flex-shrink-0 w-8 h-8 bg-${typeColor}-100 rounded-full flex items-center justify-center text-${typeColor}-600 font-medium text-sm">${qid}</span>
<span class="text-xs font-medium px-2 py-1 rounded bg-${typeColor}-50 text-${typeColor}-700">${typeLabel}</span>
<input type="number" class="q-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="分值" value="${prefill?.score || defaultScore}" min="1">
</div>
<div class="flex items-center space-x-2">
<button onclick="moveQuestion(${qid}, -1)" class="text-slate-400 hover:text-slate-600 text-sm" title="上移">↑</button>
<button onclick="moveQuestion(${qid}, 1)" class="text-slate-400 hover:text-slate-600 text-sm" title="下移">↓</button>
<button onclick="duplicateQuestion(${qid})" class="text-blue-400 hover:text-blue-600 text-sm" title="复制">复制</button>
<button onclick="document.getElementById('q-${qid}').remove();updateStats();" class="text-red-400 hover:text-red-600 text-sm">删除</button>
</div>
</div>
<div class="space-y-3">
<textarea class="q-content w-full px-3 py-2 border border-slate-300 rounded-md text-sm" rows="2" placeholder="题目内容" oninput="previewMath(${qid})">${prefill?.content || ''}</textarea>
<div id="q-preview-${qid}" class="q-math-preview text-sm text-slate-700 px-3 py-2 bg-slate-50 rounded-md border border-slate-100 hidden"></div>
<div class="flex items-center space-x-2">
<label class="cursor-pointer inline-flex items-center px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-md text-xs font-medium transition">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
上传图片
<input type="file" accept="image/*" class="hidden q-img-input" onchange="uploadQuestionImage(this, ${qid})">
</label>
<button type="button" onclick="openMathEditor(document.querySelector('#q-${qid} .q-content'), function(){previewMath(${qid})})" class="inline-flex items-center px-3 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-md text-xs font-medium transition">
<span style="font-family:Georgia,serif;font-style:italic;font-weight:700;margin-right:4px">fx</span> 公式编辑器
</button>
<span class="text-xs text-slate-400">支持 PNG/JPG/GIF/WebP最大10MB</span>
</div>
<div class="q-images flex flex-wrap gap-2"></div>`;
// 如果有预填图片,渲染到容器中
if (prefill?.images && prefill.images.length > 0) {
let imgHtml = '';
prefill.images.forEach(function(url) {
imgHtml += '<div class="relative group inline-block"><img src="' + url + '" class="h-24 rounded border border-slate-200 object-cover"><button type="button" onclick="removeQuestionImage(this)" class="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs leading-none hidden group-hover:flex items-center justify-center">&times;</button><input type="hidden" class="q-img-url" value="' + url + '"></div>';
});
html = html.replace('<div class="q-images flex flex-wrap gap-2"></div>', '<div class="q-images flex flex-wrap gap-2">' + imgHtml + '</div>');
}
if (type === 'choice') {
const opts = prefill?.options || ['', '', '', ''];
const ans = prefill?.answer || '';
html += `<div class="space-y-2">
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="A" class="q-answer" ${ans==='A'?'checked':''}><span class="text-sm w-6">A.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项A" value="${opts[0]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="B" class="q-answer" ${ans==='B'?'checked':''}><span class="text-sm w-6">B.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项B" value="${opts[1]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="C" class="q-answer" ${ans==='C'?'checked':''}><span class="text-sm w-6">C.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项C" value="${opts[2]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="D" class="q-answer" ${ans==='D'?'checked':''}><span class="text-sm w-6">D.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项D" value="${opts[3]||''}" oninput="previewOptMath(${qid})"></div>
<div id="q-opt-preview-${qid}" class="text-sm text-slate-700 px-3 py-2 bg-slate-50 rounded-md border border-slate-100 hidden"></div>
<p class="text-xs text-slate-400">请选中正确答案的单选按钮</p>
</div>`;
} else if (type === 'fill') {
html += `<input type="text" class="q-fill-answer w-full px-3 py-2 border border-slate-300 rounded-md text-sm" placeholder="标准答案(多个答案用 | 分隔)" value="${prefill?.answer || ''}">`;
} else if (type === 'judge') {
const ans = prefill?.answer || '';
html += `<div class="flex items-center space-x-4">
<label class="flex items-center space-x-2 cursor-pointer"><input type="radio" name="judge-${qid}" value="A" class="q-judge-answer" ${ans==='A'?'checked':''}><span class="text-sm">✓ 正确</span></label>
<label class="flex items-center space-x-2 cursor-pointer"><input type="radio" name="judge-${qid}" value="B" class="q-judge-answer" ${ans==='B'?'checked':''}><span class="text-sm">✗ 错误</span></label>
</div>`;
} else {
html += `<textarea class="q-ref-answer w-full px-3 py-2 border border-slate-300 rounded-md text-sm" rows="3" placeholder="参考答案(可选,方便批改时参考)">${prefill?.answer || ''}</textarea>`;
}
html += `<textarea class="q-explanation w-full px-3 py-2 border border-dashed border-slate-300 rounded-md text-sm bg-slate-50" rows="2" placeholder="题目解析(可选,考生交卷后可查看)">${prefill?.explanation || ''}</textarea>`;
html += `</div></div>`;
container.insertAdjacentHTML('beforeend', html);
// 如果有预填内容含公式,触发预览
if (prefill?.content) { previewMath(qid); }
// 选择题选项公式预览
if (type === 'choice' && prefill?.options) { setTimeout(function() { previewOptMath(qid); }, 50); }
updateStats();
}
// 数学公式实时预览
function previewMath(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const textarea = el.querySelector('.q-content');
const preview = document.getElementById('q-preview-' + qid);
if (!textarea || !preview) return;
const text = textarea.value.trim();
if (text && (text.includes('$') || text.includes('\\') || text.includes('^') || text.includes('_'))) {
preview.textContent = text;
preview.classList.remove('hidden');
renderMath(preview);
} else {
preview.classList.add('hidden');
}
}
function duplicateQuestion(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const type = el.dataset.type;
const content = el.querySelector('.q-content').value;
const score = parseInt(el.querySelector('.q-score').value) || 5;
const images = Array.from(el.querySelectorAll('.q-img-url')).map(i => i.value);
const explanation = el.querySelector('.q-explanation')?.value || '';
const prefill = { content, score, images, explanation };
if (type === 'choice') {
prefill.options = Array.from(el.querySelectorAll('.q-opt')).map(o => o.value);
const checked = el.querySelector('.q-answer:checked');
prefill.answer = checked ? checked.value : '';
} else if (type === 'fill') {
prefill.answer = el.querySelector('.q-fill-answer')?.value || '';
} else if (type === 'judge') {
const checked = el.querySelector('.q-judge-answer:checked');
prefill.answer = checked ? checked.value : '';
} else {
prefill.answer = el.querySelector('.q-ref-answer')?.value || '';
}
addQuestion(type, prefill);
}
function moveQuestion(qid, direction) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const container = document.getElementById('questions-container');
if (direction === -1 && el.previousElementSibling) {
container.insertBefore(el, el.previousElementSibling);
} else if (direction === 1 && el.nextElementSibling) {
container.insertBefore(el.nextElementSibling, el);
}
renumberQuestions();
}
function renumberQuestions() {
const items = document.querySelectorAll('#questions-container > .question-item');
items.forEach((el, i) => {
const numEl = el.querySelector('.q-number');
if (numEl) numEl.textContent = i + 1;
});
}
// 拖拽排序
let draggedEl = null;
function dragStart(e) {
draggedEl = e.target.closest('.question-item');
if (draggedEl) {
draggedEl.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
}
function dragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function drop(e) {
e.preventDefault();
const target = e.target.closest('.question-item');
if (target && draggedEl && target !== draggedEl) {
const container = document.getElementById('questions-container');
const items = Array.from(container.children);
const dragIdx = items.indexOf(draggedEl);
const dropIdx = items.indexOf(target);
if (dragIdx < dropIdx) {
container.insertBefore(draggedEl, target.nextSibling);
} else {
container.insertBefore(draggedEl, target);
}
renumberQuestions();
}
}
function dragEnd(e) {
if (draggedEl) {
draggedEl.style.opacity = '1';
draggedEl = null;
}
}
function batchAddChoice() {
const count = parseInt(prompt('请输入要批量添加的选择题数量:', '5'));
if (!count || count < 1 || count > 50) return;
for (let i = 0; i < count; i++) {
addQuestion('choice');
}
}
function updateStats() {
const items = document.querySelectorAll('#questions-container > .question-item');
let total = 0, choiceCount = 0, fillCount = 0, textCount = 0, judgeCount = 0, totalScore = 0;
items.forEach(el => {
total++;
const type = el.dataset.type;
if (type === 'choice') choiceCount++;
else if (type === 'fill') fillCount++;
else if (type === 'judge') judgeCount++;
else textCount++;
totalScore += parseInt(el.querySelector('.q-score').value) || 0;
});
document.getElementById('q-count').textContent = total;
document.getElementById('q-total-score').textContent = totalScore;
document.getElementById('q-choice-count').textContent = choiceCount;
document.getElementById('q-fill-count').textContent = fillCount;
document.getElementById('q-text-count').textContent = textCount;
document.getElementById('q-judge-count').textContent = judgeCount;
}
// 监听分值变化
document.getElementById('questions-container').addEventListener('input', (e) => {
if (e.target.classList.contains('q-score')) updateStats();
});
function uploadQuestionImage(input, qid) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const label = input.closest('label');
const origText = label.querySelector('svg').nextSibling.textContent;
label.querySelector('svg').nextSibling.textContent = ' 上传中...';
fetch('/api/upload', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
label.querySelector('svg').nextSibling.textContent = origText;
if (data.success) {
const container = document.querySelector(`#q-${qid} .q-images`);
container.insertAdjacentHTML('beforeend',
`<div class="relative group inline-block"><img src="${data.url}" class="h-24 rounded border border-slate-200 object-cover"><button type="button" onclick="removeQuestionImage(this)" class="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs leading-none hidden group-hover:flex items-center justify-center">&times;</button><input type="hidden" class="q-img-url" value="${data.url}"></div>`);
} else {
alert(data.message || '上传失败');
}
})
.catch(() => { label.querySelector('svg').nextSibling.textContent = origText; alert('上传失败'); });
input.value = '';
}
function removeQuestionImage(btn) {
btn.closest('div.relative').remove();
}
function submitExam() {
const title = document.getElementById('exam-title').value;
const subject = document.getElementById('exam-subject').value;
const duration = parseInt(document.getElementById('exam-duration').value) || 120;
if (!title) { alert('请输入试卷标题'); return; }
const qEls = document.querySelectorAll('#questions-container > .question-item');
if (qEls.length === 0) { alert('请至少添加一道题目'); return; }
const questions = [];
let qIdx = 0;
for (const el of qEls) {
qIdx++;
const type = el.dataset.type;
const content = el.querySelector('.q-content').value;
const score = parseInt(el.querySelector('.q-score').value) || 0;
if (!content) { alert(`${qIdx}题内容不能为空`); return; }
const q = { id: qIdx, type, content, score };
// 收集题目图片
const imgUrls = Array.from(el.querySelectorAll('.q-img-url')).map(i => i.value);
if (imgUrls.length > 0) q.images = imgUrls;
// 收集题目解析
const explanation = el.querySelector('.q-explanation')?.value || '';
if (explanation) q.explanation = explanation;
if (type === 'choice') {
const opts = el.querySelectorAll('.q-opt');
q.options = Array.from(opts).map(o => o.value);
const checked = el.querySelector('.q-answer:checked');
q.answer = checked ? checked.value : '';
if (!q.answer) { alert(`${qIdx}题请选择正确答案`); return; }
} else if (type === 'fill') {
q.answer = el.querySelector('.q-fill-answer')?.value || '';
} else if (type === 'judge') {
q.options = ['正确', '错误'];
const checked = el.querySelector('.q-judge-answer:checked');
q.answer = checked ? checked.value : '';
if (!q.answer) { alert(`${qIdx}题请选择正确答案`); return; }
q.type = 'choice'; // 判断题在后端按选择题处理
} else {
q.answer = el.querySelector('.q-ref-answer')?.value || '';
}
questions.push(q);
}
const scheduled_start = document.getElementById('exam-scheduled-start').value;
const scheduled_end = document.getElementById('exam-scheduled-end').value;
const score_release_time = document.getElementById('exam-score-release').value;
if (scheduled_start && scheduled_end && new Date(scheduled_start) >= new Date(scheduled_end)) {
alert('预定结束时间必须晚于开始时间'); return;
}
if (score_release_time && scheduled_end && new Date(score_release_time) <= new Date(scheduled_end)) {
alert('成绩公布时间必须晚于考试结束时间'); return;
}
if (score_release_time && scheduled_start && !scheduled_end) {
// 没有设置结束时间时,公布时间至少要晚于开始时间+考试时长
const examEnd = new Date(new Date(scheduled_start).getTime() + duration * 60000);
if (new Date(score_release_time) <= examEnd) {
alert('成绩公布时间必须晚于考试结束(开始时间 + 考试时长)'); return;
}
}
const access_password = document.getElementById('exam-password').value.trim();
fetch('/api/exams', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password })
}).then(r => r.json()).then(data => {
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; }
else alert(data.message);
}).catch(() => alert('创建失败'));
}
// 默认添加一道选择题
addQuestion('choice');
// PDF智能识别
function parsePDF(input) {
const file = input.files[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.pdf')) {
alert('请选择PDF格式文件');
input.value = '';
return;
}
const existing = document.querySelectorAll('.question-item');
if (existing.length > 0) {
if (!confirm('当前已有 ' + existing.length + ' 道题目,导入将追加到末尾。是否继续?')) {
input.value = '';
return;
}
}
document.getElementById('pdfLoading').classList.remove('hidden');
const formData = new FormData();
formData.append('file', file);
fetch('/api/parse-pdf', {
method: 'POST',
body: formData
}).then(r => r.json()).then(data => {
document.getElementById('pdfLoading').classList.add('hidden');
if (data.success) {
const startId = questionId + 1;
data.questions.forEach(q => addQuestion(q.type, q));
const endId = questionId;
// 延迟渲染:确保 DOM 稳定 + KaTeX 已加载
setTimeout(function() {
for (let id = startId; id <= endId; id++) {
previewMath(id);
previewOptMath(id);
}
renderMath(document.getElementById('questions-container'));
}, 300);
alert('成功识别 ' + data.questions.length + ' 道题目,请检查后提交');
} else {
alert(data.message || '解析失败');
}
}).catch(err => {
document.getElementById('pdfLoading').classList.add('hidden');
alert('请求失败: ' + err.message);
}).finally(() => {
input.value = '';
});
}
</script>
{% endblock %}