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

491 lines
25 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 %}{{ contest.name }} - 智联青云{% endblock %}
{% block content %}
<div class="space-y-8">
{% if contest.status == 'abolished' %}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 text-red-700 font-medium">
⚠️ 该杯赛已被废止,所有考试已关闭,无法报名或参加考试。
</div>
{% endif %}
{% if not contest.visible and is_owner %}
<div class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 flex justify-between items-center">
<span class="text-yellow-800 font-medium">该杯赛尚未发布,仅负责人和管理员可见。完善资料后请点击发布。</span>
<button onclick="publishContest()" id="publish-btn" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700">发布杯赛</button>
</div>
{% endif %}
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold text-slate-900 mb-2">
{{ contest.name }}
{% if contest.status == 'abolished' %}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-800">已废止</span>
{% endif %}
</h1>
<div class="flex items-center space-x-4 text-sm text-slate-500">
<span class="flex items-center">
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ contest.start_date }}
</span>
<span class="flex items-center" id="participants-count">
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
<span id="participants-value">{{ contest.participants }}</span>人已报名
</span>
</div>
</div>
<div class="flex space-x-3">
{% if contest.status != 'abolished' %}
{% if user %}
<button id="register-btn"
onclick="toggleRegistration({{ contest.id }})"
class="px-6 py-2 {% if registered %}bg-slate-100 text-slate-700 border border-slate-300 hover:bg-slate-200{% else %}bg-primary text-white hover:bg-blue-700{% endif %} rounded-md font-medium">
{% if registered %}已报名{% else %}立即报名{% endif %}
</button>
{% if not is_member %}
<a href="{{ url_for('apply_teacher', contest_id=contest.id) }}" class="px-6 py-2 bg-green-100 text-green-700 border border-green-300 rounded-md font-medium hover:bg-green-200">
申请成为本杯赛老师
</a>
{% endif %}
{% if is_member %}
<a href="{{ url_for('contest_question_bank', contest_id=contest.id) }}" class="px-6 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-md font-medium hover:bg-purple-200">
题库管理
</a>
{% endif %}
{% if is_owner %}
<a href="{{ url_for('exam_create', contest_id=contest.id) }}" class="px-6 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-md font-medium hover:bg-blue-200">
创建考试
</a>
<a href="{{ url_for('admin_teacher_applications') }}" class="px-6 py-2 bg-orange-100 text-orange-700 border border-orange-300 rounded-md font-medium hover:bg-orange-200">
审批老师申请
</a>
<a href="{{ url_for('contest_edit', contest_id=contest.id) }}" class="px-6 py-2 bg-yellow-100 text-yellow-700 border border-yellow-300 rounded-md font-medium hover:bg-yellow-200">
编辑主页
</a>
{% endif %}
{% else %}
<a href="/login?next={{ url_for('contest_detail', contest_id=contest.id) }}"
class="px-6 py-2 bg-primary text-white rounded-md hover:bg-blue-700 font-medium">
登录后报名
</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
<!-- 主体区域:左侧两列 + 右侧一列 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- 左侧两列(比赛详情、历年真题) -->
<div class="lg:col-span-2 space-y-8">
<!-- 比赛详情 -->
<!-- 历年真题 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
历年真题
</h2>
{% set papers = contest.get_past_papers() %}
{% if papers %}
<div class="space-y-3">
{% for paper in papers %}
<div class="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
<div class="flex items-center">
<span class="text-sm font-medium text-slate-700 w-16">{{ paper.year }}</span>
<span class="text-sm text-slate-600">{{ paper.title }}</span>
</div>
<a href="{{ paper.file }}" target="_blank" class="inline-flex items-center px-3 py-1 text-xs font-medium text-primary border border-primary rounded hover:bg-blue-50">
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
下载
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-slate-500">暂无历年真题,敬请期待!</div>
{% endif %}
</div>
<!-- 考试列表 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-slate-900 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
考试列表
</h2>
{% if is_owner %}
<button onclick="showImportModal()" class="px-3 py-1.5 bg-green-600 text-white rounded-md text-sm hover:bg-green-700">导入考试</button>
{% endif %}
</div>
<div id="exam-list" class="space-y-3">
<div class="text-center py-4 text-slate-400 text-sm">加载中...</div>
</div>
</div>
</div>
<!-- 右侧一列(主办方信息) -->
<div class="space-y-6">
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h3 class="text-lg font-semibold text-slate-900 mb-4">主办方信息</h3>
<div class="space-y-4">
<div>
<div class="font-medium text-slate-900">{{ contest.organizer }}</div>
<p class="text-sm text-slate-500 mt-1">{{ contest.description[:100] + '...' if contest.description|length > 100 else contest.description }}</p>
</div>
{% if contest.responsible_person %}
<div class="pt-4 border-t border-slate-100">
<div class="text-sm text-slate-500 mb-1">报备信息</div>
<div class="space-y-2">
<div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">责任人</span>
<span class="font-medium text-slate-900">{{ contest.responsible_person }}</span>
</div>
<div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">电话</span>
<span class="font-medium text-slate-900">{{ contest.responsible_phone }}</span>
</div>
<div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">邮箱</span>
<span class="font-medium text-primary">{{ contest.responsible_email }}</span>
</div>
<div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">机构</span>
<span class="font-medium text-slate-900">{{ contest.organization }}</span>
</div>
</div>
</div>
{% endif %}
{% if contest.contact %}
<div class="pt-4 border-t border-slate-100">
<div class="text-sm text-slate-500">联系方式</div>
<div class="text-sm font-medium text-primary">{{ contest.contact }}</div>
</div>
{% endif %}
</div>
</div>
<!-- 排行榜 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h3 class="text-lg font-semibold text-slate-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg>
成绩排行榜
</h3>
<div id="leaderboard" class="space-y-2">
<div class="text-center py-4 text-slate-400 text-sm">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 讨论区(动态区域) -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
讨论区
</h2>
<!-- 发帖表单(仅对有权限用户显示) -->
{% if user and can_post %}
<div class="mb-6 border-b border-slate-200 pb-4">
<form id="post-form" onsubmit="submitPost(event)">
<input type="text" id="post-title" placeholder="帖子标题" class="w-full px-3 py-2 border border-slate-300 rounded-md mb-2 text-sm" required>
<textarea id="post-content" rows="3" placeholder="写下你的讨论内容..." class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" required></textarea>
<div class="flex justify-end mt-2">
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-blue-700">发布帖子</button>
</div>
</form>
</div>
{% elif user and not can_post %}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4 text-sm text-yellow-700">
⚠️ 您需要报名该杯赛并至少参与一次考试,才能参与讨论。
</div>
{% endif %}
<!-- 帖子列表容器 -->
<div id="post-list" class="space-y-4">
<div class="text-center py-8 text-slate-500">加载中...</div>
</div>
</div>
</div>
<!-- 导入考试弹窗 -->
{% if is_owner %}
<div id="import-exam-modal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">导入已有考试</h3>
<button onclick="hideImportModal()" class="text-slate-400 hover:text-slate-600 text-xl">&times;</button>
</div>
<p class="text-sm text-slate-500 mb-4">选择您创建的未关联杯赛的考试,导入到当前杯赛。</p>
<div id="available-exams-list" class="space-y-2">
<div class="text-center py-4 text-slate-400 text-sm">加载中...</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const CONTEST_ID = {{ contest.id }};
let canPost = {{ (can_post is defined and can_post) | lower }};
const isOwner = {{ (is_owner is defined and is_owner) | lower }};
// 报名切换
async function toggleRegistration(contestId) {
const btn = document.getElementById('register-btn');
const isRegistered = btn.textContent.trim() === '已报名';
const url = isRegistered ? `/api/contests/${contestId}/unregister` : `/api/contests/${contestId}/register`;
try {
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (data.success) {
if (isRegistered) {
btn.textContent = '立即报名';
btn.className = 'px-6 py-2 bg-primary text-white rounded-md hover:bg-blue-700 font-medium';
} else {
btn.textContent = '已报名';
btn.className = 'px-6 py-2 bg-slate-100 text-slate-700 border border-slate-300 rounded-md hover:bg-slate-200 font-medium';
}
document.getElementById('participants-value').textContent = data.participants;
// 报名状态变化可能影响发帖权限,可以重新加载页面或更新 canPost
location.reload(); // 简单处理,刷新页面
} else {
alert(data.message);
}
} catch (err) {
alert('操作失败,请重试');
}
}
// 加载帖子列表
async function loadPosts() {
const container = document.getElementById('post-list');
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/posts`);
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.data.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-slate-500">暂无讨论,来抢沙发吧!</div>';
return;
}
let html = '';
data.data.forEach(p => {
html += `
<div class="border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
<h3 class="text-base font-semibold text-slate-900 mb-1">${escapeHtml(p.title)}</h3>
<p class="text-sm text-slate-600 mb-2">${escapeHtml(p.content)}</p>
<div class="flex items-center text-xs text-slate-400 space-x-3">
<span>${escapeHtml(p.author)}</span>
<span>${p.created_at}</span>
<span>❤️ ${p.likes}</span>
<span>💬 ${p.replies}</span>
</div>
</div>`;
});
container.innerHTML = html;
} catch (err) {
container.innerHTML = '<div class="text-center py-8 text-red-500">加载失败,请刷新重试</div>';
}
}
// 提交新帖子
async function submitPost(e) {
e.preventDefault();
if (!canPost) {
alert('您没有权限发帖');
return;
}
const title = document.getElementById('post-title').value.trim();
const content = document.getElementById('post-content').value.trim();
if (!title || !content) {
alert('标题和内容不能为空');
return;
}
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
});
const data = await res.json();
if (data.success) {
document.getElementById('post-title').value = '';
document.getElementById('post-content').value = '';
loadPosts(); // 重新加载列表
} else {
alert(data.message);
}
} catch (err) {
alert('发布失败');
}
}
// 简单的转义函数防止XSS
function escapeHtml(unsafe) {
return unsafe.replace(/[&<>"]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
if (m === '"') return '&quot;';
return m;
});
}
// 初始化
loadPosts();
loadExams();
loadLeaderboard();
// 发布/取消发布杯赛
async function publishContest() {
const btn = document.getElementById('publish-btn');
if (!confirm('确定发布该杯赛?发布后所有用户可见。')) return;
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/publish`, {method: 'PUT'});
const data = await res.json();
if (data.success) {
location.reload();
} else {
alert(data.message || '操作失败');
}
} catch(e) { alert('网络错误'); }
}
// 加载考试列表
async function loadExams() {
const container = document.getElementById('exam-list');
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/exams`);
const data = await res.json();
if (!data.success || !data.exams.length) {
container.innerHTML = '<div class="text-center py-6 text-slate-400 text-sm">暂无考试,负责人可点击"导入考试"关联已有试卷</div>';
return;
}
let html = '';
data.exams.forEach(e => {
const statusClass = e.status === 'available' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
const statusText = e.status === 'available' ? '进行中' : '已关闭';
const subCount = e.submission_count !== null ? `<span class="text-xs text-slate-400 ml-2">${e.submission_count}人提交</span>` : '';
const removeBtn = isOwner ? `<button onclick="removeExam(${e.id}, '${escapeHtml(e.title)}')" class="ml-2 px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">移除</button>` : '';
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<a href="/exams/${e.id}" class="text-sm font-medium text-slate-900 hover:text-primary truncate">${escapeHtml(e.title)}</a>
<span class="px-2 py-0.5 text-xs rounded-full ${statusClass}">${statusText}</span>
${subCount}
</div>
<div class="text-xs text-slate-500 mt-1">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}${e.duration ? ' · ' + e.duration + '分钟' : ''}</div>
</div>
<div class="flex items-center ml-3 shrink-0">
<a href="/exams/${e.id}" class="px-3 py-1 text-xs font-medium text-primary border border-primary rounded hover:bg-blue-50">进入考试</a>
${removeBtn}
</div>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="text-center py-4 text-red-500 text-sm">加载失败</div>';
}
}
// 移除考试(不删除,只取消关联)
async function removeExam(examId, title) {
if (!confirm('确定将考试「' + title + '」从杯赛中移除?考试本身不会被删除。')) return;
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/remove-exam`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({exam_id: examId})
});
const data = await res.json();
if (data.success) { loadExams(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
// 导入考试弹窗
function showImportModal() {
document.getElementById('import-exam-modal').classList.remove('hidden');
loadAvailableExams();
}
function hideImportModal() {
document.getElementById('import-exam-modal').classList.add('hidden');
}
async function loadAvailableExams() {
const container = document.getElementById('available-exams-list');
container.innerHTML = '<div class="text-center py-4 text-slate-400 text-sm">加载中...</div>';
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/available-exams`);
const data = await res.json();
if (!data.success || !data.exams.length) {
container.innerHTML = '<div class="text-center py-6 text-slate-400 text-sm">没有可导入的考试。请先在考试系统中创建试卷。</div>';
return;
}
let html = '';
data.exams.forEach(e => {
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900">${escapeHtml(e.title)}</div>
<div class="text-xs text-slate-500">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}分 · ${e.created_at}</div>
</div>
<button onclick="importExam(${e.id})" class="ml-3 px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700 shrink-0">导入</button>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="text-center py-4 text-red-500 text-sm">加载失败</div>';
}
}
async function importExam(examId) {
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/import-exam`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({exam_id: examId})
});
const data = await res.json();
if (data.success) {
loadExams();
loadAvailableExams();
} else { alert(data.message || '导入失败'); }
} catch(e) { alert('网络错误'); }
}
// 加载排行榜
async function loadLeaderboard() {
const container = document.getElementById('leaderboard');
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/leaderboard`);
const data = await res.json();
if (!data.success || !data.leaderboard.length) {
container.innerHTML = '<div class="text-center py-4 text-slate-400 text-sm">暂无成绩数据</div>';
return;
}
let html = '';
data.leaderboard.forEach(item => {
const rankClass = item.rank <= 3 ? 'font-bold text-yellow-600' : 'text-slate-500';
const medal = item.rank === 1 ? '🥇' : (item.rank === 2 ? '🥈' : (item.rank === 3 ? '🥉' : item.rank));
html += `<div class="flex items-center justify-between py-2 ${item.rank <= 3 ? '' : 'border-t border-slate-100'}">
<div class="flex items-center gap-2">
<span class="w-8 text-center ${rankClass}">${medal}</span>
<span class="text-sm text-slate-900">${escapeHtml(item.user_name)}</span>
</div>
<div class="text-sm font-medium text-slate-700">${item.total_score}分 <span class="text-xs text-slate-400">(${item.exam_count}科)</span></div>
</div>`;
});
container.innerHTML = html;
} catch(e) {
container.innerHTML = '<div class="text-center py-4 text-red-500 text-sm">加载失败</div>';
}
}
</script>
{% endblock %}