mobile app admin_base admin_contests admin_dashboard chat contest_detail exam_create exam_detail exam_grade exam_list profile themes

This commit is contained in:
2026-03-07 19:07:01 +08:00
parent 2d3163a1a0
commit 86a6c7dd03
13 changed files with 656 additions and 169 deletions

View File

@@ -0,0 +1,61 @@
{% extends "admin_base.html" %}
{% block title %}数据备份 - 管理后台 - 智联青云{% endblock %}
{% block admin_content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-extrabold bg-gradient-to-r from-emerald-400 via-teal-400 to-cyan-400 bg-clip-text text-transparent tracking-tight">数据备份</h1>
<p class="text-sm text-slate-400 mt-1 font-medium">将服务器端所有数据(用户、杯赛、考试、帖子、聊天等)导出备份</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 完整备份 -->
<div class="futuristic-card-dark border-2 border-emerald-500/30 hover:border-emerald-500/50 transition-all overflow-hidden group">
<div class="absolute -right-8 -bottom-8 w-32 h-32 bg-emerald-500/20 rounded-full blur-2xl group-hover:bg-emerald-500/30"></div>
<div class="relative z-10 p-6">
<div class="w-14 h-14 rounded-2xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-2xl mb-4 border border-emerald-500/30">
📦
</div>
<h2 class="text-xl font-bold text-white mb-2">完整备份</h2>
<p class="text-sm text-slate-400 mb-4">包含所有数据库表数据JSON及上传文件头像、图片、附件等</p>
<a href="/admin/backup/download?full=1" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5">
<svg class="w-5 h-5" 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>
下载完整备份 (ZIP)
</a>
</div>
</div>
<!-- 仅数据备份 -->
<div class="futuristic-card-dark border-2 border-cyan-500/30 hover:border-cyan-500/50 transition-all overflow-hidden group">
<div class="absolute -right-8 -bottom-8 w-32 h-32 bg-cyan-500/20 rounded-full blur-2xl group-hover:bg-cyan-500/30"></div>
<div class="relative z-10 p-6">
<div class="w-14 h-14 rounded-2xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center text-2xl mb-4 border border-cyan-500/30">
📄
</div>
<h2 class="text-xl font-bold text-white mb-2">仅数据备份</h2>
<p class="text-sm text-slate-400 mb-4">仅导出所有表数据为 JSON 文件,不含上传文件,体积更小</p>
<a href="/admin/backup/download?full=0" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/30">
<svg class="w-5 h-5" 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>
下载数据备份 (JSON)
</a>
</div>
</div>
</div>
<div class="futuristic-card-dark p-6 border border-amber-500/20 bg-amber-500/5">
<div class="flex gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center flex-shrink-0"></div>
<div>
<h3 class="font-bold text-amber-400 mb-1">备份说明</h3>
<ul class="text-sm text-slate-400 space-y-1 list-disc list-inside">
<li>备份包含:用户、杯赛、考试、提交记录、帖子、回复、通知、聊天记录、好友关系等全部数据</li>
<li>完整备份中的 <code class="text-cyan-400">uploads/</code> 目录包含用户上传的头像、考试图片、聊天文件等</li>
<li>建议定期备份,重要操作前请先备份</li>
<li>备份文件请妥善保管,内含敏感信息(如密码哈希)</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -24,7 +24,8 @@
('/admin/exams', '考试管理', 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'),
('/admin/users', '用户管理', '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'),
('/admin/posts', '帖子管理', 'M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z'),
('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9')
('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9'),
('/admin/backup', '数据备份', 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4')
] %}
{% for path, name, icon in nav_items %}

View File

@@ -30,6 +30,7 @@
{% block scripts %}
<script>
const isAdmin = {{ 'true' if user and user.role == 'admin' else 'false' }};
const statusMap = {
'upcoming': ['即将开始', 'bg-blue-500/20 text-blue-400 border-blue-500/30'],
'registering': ['正在报名', 'bg-green-500/20 text-green-400 border-green-500/30'],
@@ -53,7 +54,10 @@ async function loadContests() {
const [statusText, statusClass] = statusMap[c.status] || ['未知', 'bg-slate-500/20 text-slate-400 border-slate-500/30'];
const abolishBtn = c.status !== 'abolished'
? `<button onclick="abolishContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-red-500/30 text-red-400 hover:bg-red-500/20 hover:border-red-500/50">废止</button>`
: '<span class="text-xs text-red-500">已废止</span>';
: '';
const deleteBtn = isAdmin && c.status === 'abolished'
? `<button onclick="deleteContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-orange-500/30 text-orange-400 hover:bg-orange-500/20 hover:border-orange-500/50">删除</button>`
: '';
html += `<tr>
<td>${c.id}</td>
<td class="font-medium text-slate-200" style="max-width: 200px; white-space: normal; word-wrap: break-word;">${c.name}</td>
@@ -63,6 +67,7 @@ async function loadContests() {
<td class="space-x-2">
<a href="/contests/${c.id}" class="text-xs text-cyan-400 hover:text-cyan-300 hover:underline">查看</a>
${abolishBtn}
${deleteBtn}
</td>
</tr>`;
});
@@ -73,7 +78,7 @@ async function loadContests() {
}
async function abolishContest(id, name) {
if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留但杯赛不可恢复`)) return;
if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留,可再执行删除彻底移除`)) return;
try {
const res = await fetch(`/api/admin/contests/${id}/abolish`, {method: 'POST'});
const data = await res.json();
@@ -88,6 +93,22 @@ async function abolishContest(id, name) {
}
}
async function deleteContest(id, name) {
if (!confirm(`确定要彻底删除杯赛「${name}」吗?\n\n删除后:\n- 杯赛将从系统中完全移除\n- 不再显示在首页杯赛数量中\n- 用户无法再看到该杯赛\n- 此操作不可恢复!`)) return;
try {
const res = await fetch(`/api/admin/contests/${id}/delete`, {method: 'POST'});
const data = await res.json();
if (data.success) {
alert('杯赛已彻底删除');
loadContests();
} else {
alert(data.message || '操作失败');
}
} catch(e) {
alert('网络错误');
}
}
document.addEventListener('DOMContentLoaded', loadContests);
</script>
{% endblock %}

View File

@@ -134,6 +134,10 @@
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-rose-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-rose-500/30">📢</div>
<div class="text-xs sm:text-sm font-bold text-slate-300">通知管理</div>
</a>
<a href="/admin/backup" class="futuristic-card-dark hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-emerald-500/30">📦</div>
<div class="text-xs sm:text-sm font-bold text-slate-300">数据备份</div>
</a>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@
<span id="chatName" class="text-lg font-bold text-white tracking-tight"></span>
<span id="chatMemberCount" class="badge-futuristic text-xs font-bold px-2 py-0.5 rounded-full"></span>
</div>
<div id="peerExamBanner" class="text-xs font-medium text-amber-400 bg-amber-500/20 px-2 py-1 rounded mt-1 hidden">对方正在考试中,请勿打扰</div>
<div id="typingIndicator" class="text-[11px] font-medium text-cyan-400 hidden animate-pulse mt-0.5"></div>
</div>
</div>
@@ -275,6 +276,8 @@
<div id="imagePreview" class="fixed inset-0 bg-black/80 z-[9990] hidden flex items-center justify-center cursor-pointer" onclick="this.classList.add('hidden')">
<img id="previewImg" class="max-w-[90vw] max-h-[90vh] rounded-lg">
</div>
<!-- 轻提示 -->
<div id="chatToast" class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-slate-800 text-amber-400 rounded-lg shadow-lg z-[9995] hidden text-sm font-medium"></div>
{% endblock %}
{% block scripts %}
@@ -297,6 +300,7 @@ let recordingStartTime = null;
let recordingInterval = null;
let searchTimer = null;
let mentionMembers = [];
let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}}
function isMobile() { return window.innerWidth < 640; }
@@ -372,9 +376,16 @@ function switchTab(tab) {
tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600';
tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary relative';
loadNotifications();
// Mark all notifications as read when switching to the notification tab
fetch('/api/notifications/read-all', { method: 'POST' }).then(() => {
checkUnreadNotifs();
});
}
const badge = document.getElementById('chatNotifBadge');
tabNotif.appendChild(badge);
if (badge) {
tabNotif.appendChild(badge);
}
}
async function loadNotifications() {
@@ -527,11 +538,24 @@ async function checkUnreadNotifs() {
const data = await res.json();
if (data.success) {
const badge = document.getElementById('chatNotifBadge');
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
if (badge) {
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
// Also update the global notification badge if it exists
const globalBadge = document.getElementById('notifBadge');
if (globalBadge) {
if (data.count > 0) {
globalBadge.textContent = data.count > 99 ? '99+' : data.count;
globalBadge.classList.remove('hidden');
} else {
globalBadge.classList.add('hidden');
}
}
}
}
@@ -639,7 +663,9 @@ function renderRooms() {
const search = document.getElementById('roomSearch').value.toLowerCase();
const list = document.getElementById('roomList');
const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search));
list.innerHTML = filtered.map(r => `
list.innerHTML = filtered.map(r => {
const peerInExam = r.type === 'private' && r.peer_id && peerExamStatus[r.peer_id]?.in_exam;
return `
<div class="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-slate-50 transition ${currentRoomId === r.id ? 'bg-blue-50 border-r-2 border-primary' : ''}" onclick="selectRoom(${r.id})">
<div class="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 overflow-hidden">
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` :
@@ -648,6 +674,7 @@ function renderRooms() {
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-slate-800 truncate">${escHtml(r.name || '未命名')}</span>
${peerInExam ? '<span class="text-[10px] text-amber-400 bg-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">考试中</span>' : ''}
<span class="text-xs text-slate-400 flex-shrink-0">${r.last_message ? formatTime(r.last_message.created_at) : ''}</span>
</div>
<div class="flex justify-between items-center mt-0.5">
@@ -656,7 +683,7 @@ function renderRooms() {
</div>
</div>
</div>
`).join('');
`}).join('');
}
function getPreview(r) {
@@ -688,11 +715,34 @@ async function selectRoom(roomId) {
if (room) {
document.getElementById('chatName').textContent = room.name || '聊天';
document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : '';
// 私聊:获取对方考试状态
if (room.type === 'private' && room.peer_id) {
try {
const res = await fetch(`/api/user/${room.peer_id}/exam-status`);
const data = await res.json();
if (data.success) {
peerExamStatus[room.peer_id] = { in_exam: data.in_exam, exam_title: data.exam_title || '', end_time: data.end_time };
}
} catch (e) { peerExamStatus[room.peer_id] = { in_exam: false }; }
}
updateChatHeaderExamStatus(room);
}
renderRooms();
await loadMessages(roomId);
markRead(roomId);
}
function updateChatHeaderExamStatus(room) {
const examBanner = document.getElementById('peerExamBanner');
if (!examBanner) return;
if (room && room.type === 'private' && room.peer_id && peerExamStatus[room.peer_id]?.in_exam) {
const info = peerExamStatus[room.peer_id];
examBanner.textContent = `${room.name || '对方'} 正在考试中(${info.exam_title || '考试'}),请勿打扰`;
examBanner.classList.remove('hidden');
} else {
examBanner.classList.add('hidden');
}
}
// ========== 消息加载与渲染 ==========
async function loadMessages(roomId, beforeId) {
let url = `/api/chat/rooms/${roomId}/messages?limit=30`;
@@ -1439,6 +1489,33 @@ socket.on('error', (data) => {
if (data.message) alert(data.message);
});
socket.on('friend_exam_status', (data) => {
const uid = data.user_id;
peerExamStatus[uid] = {
in_exam: data.in_exam,
exam_title: data.exam_title || '',
end_time: data.end_time
};
const room = rooms.find(r => r.id === currentRoomId);
if (room && room.type === 'private' && room.peer_id === uid) {
updateChatHeaderExamStatus(room);
}
renderRooms();
});
socket.on('peer_in_exam', (data) => {
showToast(data.message || '对方正在考试中,消息将在考试结束后送达');
});
function showToast(msg) {
const el = document.getElementById('chatToast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(el._toastTimer);
el._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
}
// ========== 工具函数 ==========
function escHtml(s) {
if (!s) return '';

View File

@@ -51,11 +51,11 @@
<a href="{{ url_for('contest_question_bank', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
题库管理
</a>
{% endif %}
{% if is_owner %}
<a href="{{ url_for('exam_create', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
创建考试
</a>
{% endif %}
{% if is_owner %}
<a href="{{ url_for('admin_teacher_applications') }}" class="btn-outline-futuristic px-6 py-2 font-medium">
审批老师申请
</a>
@@ -239,6 +239,7 @@
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 }};
const isMember = {{ (is_member is defined and is_member) | lower }};
// 报名切换
async function toggleRegistration(contestId) {
@@ -387,8 +388,9 @@ async function loadExams() {
</div>
<div class="text-xs text-slate-400 mt-1">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}${e.duration ? ' · ' + e.duration + '分钟' : ''}</div>
</div>
<div class="flex items-center ml-3 shrink-0">
<div class="flex items-center gap-2 ml-3 shrink-0">
<a href="/exams/${e.id}" class="px-3 py-1 text-xs font-medium text-cyan-400 border border-primary rounded hover:bg-blue-50">进入考试</a>
${isMember ? `<a href="/exams/${e.id}/submissions" class="px-3 py-1 text-xs font-medium text-amber-500 border border-amber-400 rounded hover:bg-amber-50">批改</a>` : ''}
${removeBtn}
</div>
</div>`;

View File

@@ -51,9 +51,9 @@
<label class="block text-sm font-medium text-slate-700">考试可见性</label>
<select id="exam-visibility" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<option value="private">私有(仅自己可见)</option>
<option value="public">公开(所有人可见)</option>
{% if not (is_cup_teacher|default(false)) %}<option value="public">公开(所有人可见)</option>{% endif %}
</select>
<p class="mt-1 text-xs text-slate-400">私有考试只有您自己可以看到和管理</p>
<p class="mt-1 text-xs text-slate-400">{% if is_cup_teacher|default(false) %}杯赛老师仅可创建自己可见的杯赛考试{% else %}私有考试只有您自己可以看到和管理{% endif %}</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
@@ -104,7 +104,7 @@
</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]">
@@ -121,56 +121,42 @@
<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 class="relative flex justify-center mb-6">
<div class="dev-character relative">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-28 h-28 rounded-full bg-cyan-500/10 blur-2xl"></div>
</div>
<svg class="w-36 h-36 mx-auto relative z-10" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 身体/工作服 -->
<ellipse cx="60" cy="85" rx="28" ry="18" fill="#6366f1"/>
<rect x="32" y="55" width="56" height="35" rx="8" fill="#4f46e5"/>
<!-- 头部 -->
<circle cx="60" cy="38" r="26" fill="#fde68a"/>
<circle cx="60" cy="38" r="26" stroke="#f59e0b" stroke-width="2"/>
<!-- 大眼睛 -->
<ellipse cx="52" cy="35" rx="6" ry="8" fill="#1e293b"/>
<ellipse cx="68" cy="35" rx="6" ry="8" fill="#1e293b"/>
<circle cx="54" cy="33" r="2" fill="white"/>
<circle cx="70" cy="33" r="2" fill="white"/>
<!-- 腮红 -->
<ellipse cx="42" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<ellipse cx="78" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<!-- 汗珠(辛苦工作) -->
<ellipse cx="88" cy="28" rx="4" ry="6" fill="#93c5fd" opacity="0.9" class="sweat-drop"/>
<ellipse cx="92" cy="32" rx="3" ry="5" fill="#93c5fd" opacity="0.7" class="sweat-drop"/>
<!-- 锤子/工具(敲代码) -->
<rect x="75" y="45" width="8" height="25" rx="2" fill="#78716c" transform="rotate(-30 79 57)"/>
<rect x="70" y="35" width="18" height="10" rx="2" fill="#a8a29e" transform="rotate(-30 79 40)"/>
<!-- 代码符号 -->
<text x="42" y="72" fill="white" font-size="10" font-family="monospace" font-weight="bold" opacity="0.95">&lt;/&gt;</text>
</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>
<!-- 主文案 -->
<h2 class="text-center text-2xl font-bold bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent mb-2">该功能正在开发中</h2>
<p class="text-center text-purple-200/90 text-lg font-medium mb-1">敬请期待</p>
<p class="text-center text-purple-300/50 text-sm">渔鱼余正在努力敲代码中...</p>
</div>
</div>
</div>
@@ -204,6 +190,20 @@
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.dev-character {
animation: workBounce 2s ease-in-out infinite;
}
@keyframes workBounce {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-4px) scale(1.02); }
}
.dev-character .sweat-drop {
animation: sweatDrop 1.5s ease-in-out infinite;
}
@keyframes sweatDrop {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.4; }
}
.vip-price {
animation: glow 2s ease-in-out infinite alternate;
}
@@ -229,6 +229,8 @@
{% endblock %}
{% block scripts %}
<script>
// 从杯赛页面进入时传入的 contest_id用于创建杯赛考试
const PAGE_CONTEST_ID = {{ contest_id|default(none)|tojson }};
// VIP 弹窗
function showVipModal() {
const modal = document.getElementById('vipModal');
@@ -585,12 +587,16 @@ function submitExam() {
}
const access_password = document.getElementById('exam-password').value.trim();
const visibility = document.getElementById('exam-visibility').value;
const payload = { title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password, visibility };
if (PAGE_CONTEST_ID) payload.contest_id = PAGE_CONTEST_ID;
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, visibility })
body: JSON.stringify(payload)
}).then(r => r.json()).then(data => {
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; }
else alert(data.message);
if (data.success) {
alert('试卷创建成功!');
window.location.href = PAGE_CONTEST_ID ? '/contests/' + PAGE_CONTEST_ID : '/exams';
} else alert(data.message);
}).catch(() => alert('创建失败'));
}

View File

@@ -242,6 +242,7 @@ const SCHEDULED_END = null;
let currentIndex = 0;
let answers = {};
let tabSwitchCount = 0;
let examSubmitted = false; // 提交成功后允许离开
// ===== 图片上传 =====
function examUpload(qid) {
@@ -506,6 +507,9 @@ function doSubmit() {
body: JSON.stringify({answers})
}).then(r => r.json()).then(data => {
if (data.success) {
examSubmitted = true;
// 退出考试状态,通知好友
fetch(`/api/exams/${EXAM_ID}/exit`, { method: 'POST' }).catch(() => {});
// 清除本地存储
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TIMER_KEY);
@@ -523,17 +527,31 @@ function doSubmit() {
});
}
// 进入考试:标记状态并通知好友
fetch(`/api/exams/${EXAM_ID}/enter`, { method: 'POST' }).catch(() => {});
// 禁止中途退出:拦截返回按钮
history.pushState(null, '', location.href);
window.addEventListener('popstate', (e) => {
if (!examSubmitted) {
history.pushState(null, '', location.href);
alert('考试进行中,请勿离开!提交前无法退出。');
}
});
// 离开页面提醒(提交成功后允许离开)
window.addEventListener('beforeunload', (e) => {
if (!examSubmitted) {
e.preventDefault();
e.returnValue = '';
}
});
// 初始化
initAnswers();
initTimer();
initTabDetection();
showQuestion(0);
// 离开页面提醒
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = '';
});
</script>
{% endif %}
{% endblock %}

View File

@@ -71,6 +71,18 @@
{% else %}
<span class="text-red-500 font-medium">0分</span>
{% endif %}
<span class="text-slate-400 ml-2">(可人工修改)</span>
</div>
<div class="flex items-center gap-3 mt-2">
<label class="text-sm text-slate-600">给分:</label>
<input type="number" id="score-{{ q.id }}" min="0" max="{{ q.score }}"
value="{{ question_scores.get(q.id|string, (q.score if answers.get(q.id|string,'') == q.get('answer','') else 0)) }}"
class="grade-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" data-max="{{ q.score }}" data-qid="{{ q.id }}">
<span class="text-sm text-slate-400">/ {{ q.score }}</span>
<div class="flex space-x-1">
<button type="button" onclick="quickScore('{{ q.id }}', 0)" class="px-2 py-0.5 text-xs rounded border border-red-200 text-red-600 hover:bg-red-50">0分</button>
<button type="button" onclick="quickScore('{{ q.id }}', {{ q.score }})" class="px-2 py-0.5 text-xs rounded border border-green-200 text-green-600 hover:bg-green-50">满分</button>
</div>
</div>
</div>
{% else %}
@@ -126,21 +138,7 @@ function quickScore(qid, score) {
function recalcTotal() {
let total = 0;
// 选择题自动得分
{% for q in questions %}
{% if q.type == 'choice' %}
{% if answers.get(q.id|string,'') == q.get('answer','') %}
total += {{ q.score }};
{% endif %}
{% elif q.type == 'fill' %}
{% set student_ans = answers.get(q.id|string,'').strip() %}
{% set correct_answers = q.get('answer','').split('|') %}
{% if student_ans in correct_answers %}
total += {{ q.score }};
{% endif %}
{% endif %}
{% endfor %}
// 主观题手动得分
// 所有题目得分(客观题预填自动判分,教师可人工修改)
document.querySelectorAll('.grade-score').forEach(input => {
total += parseInt(input.value) || 0;
});
@@ -155,23 +153,8 @@ recalcTotal();
function submitGrade() {
const scores = {};
{% for q in questions %}
{% if q.type == 'choice' %}
{% if answers.get(q.id|string,'') == q.get('answer','') %}
scores['{{ q.id }}'] = {{ q.score }};
{% else %}
scores['{{ q.id }}'] = 0;
{% endif %}
{% elif q.type == 'fill' %}
{% set student_ans = answers.get(q.id|string,'').strip() %}
{% set correct_answers = q.get('answer','').split('|') %}
{% if student_ans in correct_answers %}
scores['{{ q.id }}'] = {{ q.score }};
{% else %}
scores['{{ q.id }}'] = 0;
{% endif %}
{% else %}
scores['{{ q.id }}'] = parseInt(document.getElementById('score-{{ q.id }}').value) || 0;
{% endif %}
const inp{{ q.id }} = document.getElementById('score-{{ q.id }}');
scores['{{ q.id }}'] = inp{{ q.id }} ? (parseInt(inp{{ q.id }}.value) || 0) : 0;
{% endfor %}
fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', {
method:'POST', headers:{'Content-Type':'application/json'},

View File

@@ -193,7 +193,7 @@
{% endif %}
</div>
{% if user and (user.role == 'admin' or user.role == 'teacher') %}
{% if user and can_grade_exam(user, exam) %}
<div class="mt-4 pt-3 border-t border-cyan-500/20 flex flex-wrap gap-2 justify-end">
<a href="/exams/{{ exam.id }}/submissions" class="px-3 py-1.5 bg-slate-800/50 text-slate-300 hover:bg-slate-700/50 hover:text-slate-200 rounded-lg text-xs font-medium transition-colors border border-cyan-500/30">提交情况</a>
<a href="/exams/{{ exam.id }}/print" class="px-3 py-1.5 bg-slate-800/50 text-slate-300 hover:bg-slate-700/50 hover:text-slate-200 rounded-lg text-xs font-medium transition-colors border border-cyan-500/30">打印试卷</a>

View File

@@ -148,36 +148,6 @@
<!-- 右侧:主要内容 -->
<div class="space-y-6 lg:col-span-2">
<!-- 快捷入口(科幻未来风格) -->
{% if profile_user.id == user.id %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<a href="/notifications" class="futuristic-card-dark text-center group transition-all duration-300 hover:scale-105">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-2xl flex items-center justify-center text-cyan-400 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3 border border-cyan-500/30">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
</div>
<div class="text-sm font-bold text-slate-300 group-hover:text-cyan-400 transition-colors">通知中心</div>
</a>
<a href="/chat" class="futuristic-card-dark text-center group transition-all duration-300 hover:scale-105">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-2xl flex items-center justify-center text-emerald-400 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3 border border-emerald-500/30">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-300 group-hover:text-emerald-400 transition-colors">我的消息</div>
</a>
<div class="futuristic-card-dark text-center group cursor-pointer transition-all duration-300 hover:scale-105" onclick="document.getElementById('exam-history-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-purple-500/20 to-fuchsia-500/20 rounded-2xl flex items-center justify-center text-purple-400 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3 border border-purple-500/30">
<svg class="w-6 h-6" 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 2m-6 9l2 2 4-4"/></svg>
</div>
<div class="text-sm font-bold text-slate-300 group-hover:text-purple-400 transition-colors">考试记录</div>
</div>
<div class="futuristic-card-dark text-center group cursor-pointer transition-all duration-300 hover:scale-105" onclick="document.getElementById('bookmarks-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-amber-500/20 to-orange-500/20 rounded-2xl flex items-center justify-center text-amber-400 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3 border border-amber-500/30">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-300 group-hover:text-amber-400 transition-colors">我的收藏</div>
</div>
</div>
{% endif %}
<!-- 高级选项卡内容区 -->
<div class="futuristic-card-dark overflow-hidden">
<!-- 科幻未来标签栏 -->