638 lines
36 KiB
HTML
638 lines
36 KiB
HTML
{% 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">×</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">∞</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, '<').replace(/>/g, '>') + '</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">×</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">×</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 %} |