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:
61
templates/admin_backup.html
Normal file
61
templates/admin_backup.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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">×</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"></></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">∞</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('创建失败'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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'},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<!-- 科幻未来标签栏 -->
|
||||
|
||||
Reference in New Issue
Block a user