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

View File

@@ -0,0 +1,491 @@
{% 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 %}