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

100
templates/admin_base.html Normal file
View File

@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col md:flex-row gap-6">
<!-- 左侧侧边栏 -->
<aside class="w-full md:w-64 shrink-0">
<nav class="bg-white/90 backdrop-blur-xl rounded-3xl shadow-sm border border-slate-100 p-4 flex flex-col gap-1 sticky top-[80px]">
<div class="px-3 pb-4 mb-2 border-b border-slate-100/60">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white shadow-md shadow-indigo-200 transform -rotate-6">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<div>
<h2 class="text-lg font-extrabold text-slate-800 tracking-tight">管理后台</h2>
<p class="text-xs font-medium text-slate-400 mt-0.5">智联青云控制中心</p>
</div>
</div>
</div>
{% set nav_items = [
('/admin', '仪表盘', 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'),
('/admin/contests', '杯赛管理', 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'),
('/admin/contest-applications', '杯赛申请', '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'),
('/admin/teacher-applications', '教师申请', 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'),
('/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')
] %}
{% for path, name, icon in nav_items %}
{% set is_active = request.path == path %}
<a href="{{ path }}" class="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 group relative overflow-hidden
{% if is_active %}
text-indigo-600 bg-indigo-50/80 shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]
{% else %}
text-slate-500 hover:text-slate-800 hover:bg-slate-50
{% endif %}
">
{% if is_active %}
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-8 bg-indigo-500 rounded-r-full"></div>
{% endif %}
<svg class="w-5 h-5 flex-shrink-0 {% if is_active %}text-indigo-500{% else %}text-slate-400 group-hover:text-slate-500{% endif %} transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon }}"/></svg>
{{ name }}
{% if is_active %}
<div class="absolute right-4 w-1.5 h-1.5 rounded-full bg-indigo-400 animate-pulse"></div>
{% endif %}
</a>
{% endfor %}
</nav>
</aside>
<!-- 主体内容区 -->
<main class="flex-1 min-w-0">
<div class="bg-white/80 backdrop-blur-xl rounded-3xl shadow-sm border border-slate-100 p-6 sm:p-8 relative">
<div class="absolute top-0 right-0 w-64 h-64 bg-indigo-50/30 rounded-bl-full -z-10"></div>
{% block admin_content %}{% endblock %}
</div>
</main>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// 简单的路由活动状态高亮处理以防jinja的request.path不够精确
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('aside nav a[href]');
// 如果没有被Jinja正确高亮这里做前端补充高亮
let foundActive = false;
navLinks.forEach(link => {
if(link.classList.contains('text-indigo-600')) foundActive = true;
});
if(!foundActive) {
navLinks.forEach(link => {
const href = link.getAttribute('href');
if (currentPath === href || (href !== '/admin' && currentPath.startsWith(href))) {
// 移除默认样式
link.className = link.className.replace('text-slate-500 hover:text-slate-800 hover:bg-slate-50', '');
// 添加活动样式
link.classList.add('text-indigo-600', 'bg-indigo-50/80', 'shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]');
// 插入左侧高亮条和右侧小圆点
if(!link.querySelector('.absolute.left-0')) {
link.insertAdjacentHTML('afterbegin', '<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-8 bg-indigo-500 rounded-r-full"></div>');
}
if(!link.querySelector('.absolute.right-4')) {
link.insertAdjacentHTML('beforeend', '<div class="absolute right-4 w-1.5 h-1.5 rounded-full bg-indigo-400 animate-pulse"></div>');
}
const icon = link.querySelector('svg');
if(icon) {
icon.classList.remove('text-slate-400', 'group-hover:text-slate-500');
icon.classList.add('text-indigo-500');
}
}
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends "admin_base.html" %}
{% block title %}杯赛申请管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">杯赛申请管理</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请人</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">杯赛名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">主办方</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">描述</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">联系方式</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">责任人</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">责任人电话</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">责任人邮箱</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">所属机构</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody id="applications-table-body" class="bg-white divide-y divide-slate-200">
<!-- 动态加载 -->
</tbody>
</table>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden">
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm">加载中...</span>
</div>
<div id="empty-message" class="text-center py-12 text-slate-400 hidden">
<svg class="mx-auto h-12 w-12 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
<p class="mt-2 text-sm">暂无杯赛申请</p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadApplications() {
const tbody = document.getElementById('applications-table-body');
const spinner = document.getElementById('loading-spinner');
const empty = document.getElementById('empty-message');
tbody.innerHTML = '';
spinner.classList.remove('hidden');
empty.classList.add('hidden');
try {
// 假设后端已有获取所有申请的路由: /api/admin/contest-applications
const res = await fetch('/api/admin/contest-applications');
const data = await res.json();
spinner.classList.add('hidden');
if (!data.success || data.applications.length === 0) {
empty.classList.remove('hidden');
return;
}
let html = '';
data.applications.forEach(app => {
const statusClass = {
'pending': 'bg-yellow-100 text-yellow-800',
'approved': 'bg-green-100 text-green-800',
'rejected': 'bg-red-100 text-red-800'
}[app.status] || 'bg-slate-100 text-slate-800';
const statusText = {
'pending': '待审批',
'approved': '已批准',
'rejected': '已拒绝'
}[app.status] || app.status;
html += `
<tr data-id="${app.id}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">${app.id}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">${app.user_name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">${app.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.organizer || '-'}</td>
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">${app.description || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.contact || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.responsible_person || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.responsible_phone || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.responsible_email || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.organization || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">${app.applied_at}</td>
<td class="px-6 py-4 whitespace-nowrap"><span class="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${statusClass}">${statusText}</span></td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
${app.status === 'pending' ? `
<button onclick="handleApprove(${app.id})" class="text-green-600 hover:text-green-900 mr-2">批准</button>
<button onclick="handleReject(${app.id})" class="text-red-600 hover:text-red-900">拒绝</button>
` : '-'}
</td>
</tr>`;
});
tbody.innerHTML = html;
} catch (error) {
spinner.classList.add('hidden');
empty.classList.remove('hidden');
empty.innerHTML = '<p class="mt-2 text-sm">加载失败,请稍后重试</p>';
console.error('加载申请失败:', error);
}
}
async function handleApprove(id) {
if (!confirm('确定批准该申请?杯赛将被创建,申请人将成为杯赛负责人。')) return;
try {
const res = await fetch(`/api/contest-applications/${id}/approve`, { method: 'POST' });
const data = await res.json(); // 假设返回 JSON
if (res.ok) {
alert('已批准');
loadApplications();
} else {
alert(data.message || '操作失败');
}
} catch (e) {
alert('网络错误');
}
}
async function handleReject(id) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/contest-applications/${id}/reject`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
alert('已拒绝');
loadApplications();
} else {
alert(data.message || '操作失败');
}
} catch (e) {
alert('网络错误');
}
}
document.addEventListener('DOMContentLoaded', loadApplications);
</script>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "admin_base.html" %}
{% block title %}杯赛管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">杯赛管理</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">主办方</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">开始日期</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
</tr>
</thead>
<tbody id="contests-tbody" class="bg-white divide-y divide-slate-200">
</tbody>
</table>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无杯赛</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const statusMap = {
'upcoming': ['即将开始', 'bg-blue-100 text-blue-800'],
'registering': ['正在报名', 'bg-green-100 text-green-800'],
'ongoing': ['进行中', 'bg-yellow-100 text-yellow-800'],
'ended': ['已结束', 'bg-slate-100 text-slate-800'],
'abolished': ['已废止', 'bg-red-100 text-red-800']
};
async function loadContests() {
try {
const res = await fetch('/api/admin/contests');
const data = await res.json();
const tbody = document.getElementById('contests-tbody');
const empty = document.getElementById('empty-msg');
if (!data.success || !data.contests.length) {
empty.classList.remove('hidden');
return;
}
let html = '';
data.contests.forEach(c => {
const [statusText, statusClass] = statusMap[c.status] || ['未知', 'bg-slate-100 text-slate-800'];
const abolishBtn = c.status !== 'abolished'
? `<button onclick="abolishContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">废止</button>`
: '<span class="text-xs text-red-500">已废止</span>';
html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${c.id}</td>
<td class="px-6 py-4 text-sm text-slate-900">${c.name}</td>
<td class="px-6 py-4 text-sm text-slate-500">${c.organizer || '-'}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span></td>
<td class="px-6 py-4 text-sm text-slate-500">${c.start_date || '-'}</td>
<td class="px-6 py-4 space-x-2">
<a href="/contests/${c.id}" class="text-xs text-primary hover:underline">查看</a>
${abolishBtn}
</td>
</tr>`;
});
tbody.innerHTML = html;
} catch(e) {
console.error(e);
}
}
async function abolishContest(id, name) {
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();
if (data.success) {
alert('杯赛已废止');
loadContests();
} else {
alert(data.message || '操作失败');
}
} catch(e) {
alert('网络错误');
}
}
document.addEventListener('DOMContentLoaded', loadContests);
</script>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}发布新杯赛 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto py-8">
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-6">
<h1 class="text-2xl font-bold text-slate-900 mb-6">发布新杯赛</h1>
<form method="POST" action="/admin/contests/create" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-slate-700 mb-1">杯赛名称 <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如2026年星火杯">
</div>
<div>
<label for="organizer" class="block text-sm font-medium text-slate-700 mb-1">主办方 <span class="text-red-500">*</span></label>
<input type="text" id="organizer" name="organizer" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如:星火杯组委会">
</div>
<div>
<label for="description" class="block text-sm font-medium text-slate-700 mb-1">描述</label>
<textarea id="description" name="description" rows="4"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="简要介绍杯赛的目的、规则等"></textarea>
</div>
<div>
<label for="status" class="block text-sm font-medium text-slate-700 mb-1">初始状态</label>
<select id="status" name="status"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
<option value="upcoming">即将开始</option>
<option value="registering">报名中</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="start_date" name="start_date"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="end_date" name="end_date"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
</div>
<div>
<label for="total_score" class="block text-sm font-medium text-slate-700 mb-1">杯赛满分</label>
<input type="number" id="total_score" name="total_score" min="1" value="150"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<!-- 报备信息 -->
<div class="border-t border-slate-200 pt-6 mt-2">
<h2 class="text-lg font-semibold text-slate-900 mb-4">报备信息</h2>
<div class="space-y-4">
<div>
<label for="responsible_person" class="block text-sm font-medium text-slate-700 mb-1">责任人姓名</label>
<input type="text" id="responsible_person" name="responsible_person"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="赛事责任人真实姓名">
</div>
<div>
<label for="responsible_phone" class="block text-sm font-medium text-slate-700 mb-1">责任人电话</label>
<input type="tel" id="responsible_phone" name="responsible_phone"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="11位手机号码">
</div>
<div>
<label for="responsible_email" class="block text-sm font-medium text-slate-700 mb-1">责任人邮箱</label>
<input type="email" id="responsible_email" name="responsible_email"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="example@email.com">
</div>
<div>
<label for="organization" class="block text-sm font-medium text-slate-700 mb-1">所属机构/学校</label>
<input type="text" id="organization" name="organization"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如XX大学、XX教育机构">
</div>
</div>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('contest_list') }}" class="px-5 py-2.5 border border-slate-300 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
取消
</a>
<button type="submit"
class="px-5 py-2.5 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
创建并发布
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% extends "admin_base.html" %}
{% block title %}管理后台 - 智联青云{% endblock %}
{% block admin_content %}
<div class="space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-extrabold text-slate-900 tracking-tight">数据概览</h1>
<p class="text-sm text-slate-500 mt-1 font-medium">查看平台运行状态与核心数据</p>
</div>
<button onclick="loadDashboard()" class="w-10 h-10 rounded-xl bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-indigo-600 hover:bg-indigo-50 transition-all flex items-center justify-center group" title="刷新数据">
<svg class="w-5 h-5 group-hover:rotate-180 transition-transform duration-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
<div class="bg-gradient-to-br from-indigo-50 to-white rounded-2xl p-6 shadow-sm border border-indigo-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl group-hover:bg-indigo-500/10 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-indigo-100 text-indigo-600 flex items-center justify-center shadow-inner">
<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="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>
</div>
<div class="text-sm font-bold text-slate-600">总用户数</div>
</div>
<div id="stat-users" class="text-3xl font-black text-slate-900 relative z-10">-</div>
</div>
<div class="bg-gradient-to-br from-purple-50 to-white rounded-2xl p-6 shadow-sm border border-purple-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-purple-500/5 rounded-full blur-2xl group-hover:bg-purple-500/10 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center shadow-inner">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<div class="text-sm font-bold text-slate-600">赛事总数</div>
</div>
<div id="stat-contests" class="text-3xl font-black text-slate-900 relative z-10">-</div>
</div>
<div class="bg-gradient-to-br from-emerald-50 to-white rounded-2xl p-6 shadow-sm border border-emerald-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-emerald-100 text-emerald-600 flex items-center justify-center shadow-inner">
<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="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"/></svg>
</div>
<div class="text-sm font-bold text-slate-600">考试总数</div>
</div>
<div id="stat-exams" class="text-3xl font-black text-slate-900 relative z-10">-</div>
</div>
<div class="bg-gradient-to-br from-amber-50 to-white rounded-2xl p-6 shadow-sm border border-amber-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-amber-500/5 rounded-full blur-2xl group-hover:bg-amber-500/10 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-amber-100 text-amber-600 flex items-center justify-center shadow-inner">
<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="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"/></svg>
</div>
<div class="text-sm font-bold text-slate-600">社区帖子</div>
</div>
<div id="stat-posts" class="text-3xl font-black text-slate-900 relative z-10">-</div>
</div>
</div>
<!-- 待处理 + 最近活动 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 待处理事项 -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 flex flex-col h-full">
<div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-rose-100 text-rose-500 flex items-center justify-center shadow-inner">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<h2 class="text-lg font-bold text-slate-800">待处理事项</h2>
</div>
<div id="pending-items" class="space-y-3 flex-1">
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl">
<svg class="animate-spin h-6 w-6 text-indigo-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span class="text-sm font-medium">加载中...</span>
</div>
</div>
</div>
<!-- 最近活动 -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 flex flex-col h-full">
<div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-blue-100 text-blue-500 flex items-center justify-center shadow-inner">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h2 class="text-lg font-bold text-slate-800">最近活动日志</h2>
</div>
<div id="recent-activities" class="space-y-4 flex-1 relative pl-3">
<div class="absolute left-[19px] top-2 bottom-2 w-0.5 bg-slate-100"></div>
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl ml-4">
<svg class="animate-spin h-6 w-6 text-indigo-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span class="text-sm font-medium">加载中...</span>
</div>
</div>
</div>
</div>
<!-- 快捷导航 -->
<div>
<div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-teal-100 text-teal-600 flex items-center justify-center shadow-inner">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h2 class="text-lg font-bold text-slate-800">快捷操作</h2>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<a href="/admin/contests" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-indigo-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-indigo-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">🏆</div>
<div class="text-sm font-bold text-slate-700">杯赛管理</div>
</a>
<a href="/admin/contest-applications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-orange-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-orange-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📋</div>
<div class="text-sm font-bold text-slate-700">杯赛申请</div>
</a>
<a href="/admin/teacher-applications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-purple-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">👨‍🏫</div>
<div class="text-sm font-bold text-slate-700">教师申请</div>
</a>
<a href="/admin/exams" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-emerald-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📝</div>
<div class="text-sm font-bold text-slate-700">考试管理</div>
</a>
<a href="/admin/users" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-blue-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">👥</div>
<div class="text-sm font-bold text-slate-700">用户管理</div>
</a>
<a href="/admin/posts" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-amber-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">💬</div>
<div class="text-sm font-bold text-slate-700">帖子管理</div>
</a>
<a href="/admin/notifications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-rose-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center">
<div class="w-12 h-12 rounded-xl bg-rose-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📢</div>
<div class="text-sm font-bold text-slate-700">通知管理</div>
</a>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadDashboard() {
// 加载统计数据
try {
const res = await fetch('/api/admin/stats');
const data = await res.json();
if (data.success) {
document.getElementById('stat-users').textContent = data.stats.users;
document.getElementById('stat-contests').textContent = data.stats.contests;
document.getElementById('stat-exams').textContent = data.stats.exams;
document.getElementById('stat-posts').textContent = data.stats.posts;
// 待处理事项
const pending = document.getElementById('pending-items');
const teacherApps = data.stats.pending_teacher_apps || 0;
const contestApps = data.stats.pending_contest_apps || 0;
if (teacherApps === 0 && contestApps === 0) {
pending.innerHTML = `
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl bg-slate-50/50">
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center text-2xl shadow-sm mb-3">☕</div>
<span class="text-sm font-bold">太棒了,所有事项都已处理完毕!</span>
</div>`;
} else {
let html = '';
if (teacherApps > 0) {
html += `<a href="/admin/teacher-applications" class="flex items-center justify-between p-4 bg-gradient-to-r from-orange-50 to-white border border-orange-200/60 rounded-xl hover:shadow-md hover:border-orange-300 transition-all group">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center text-orange-600 group-hover:scale-110 transition-transform">👨‍🏫</div>
<div>
<div class="text-sm font-bold text-slate-800">待审核教师申请</div>
<div class="text-xs text-slate-500 mt-0.5">需要您的审批决定</div>
</div>
</div>
<span class="px-3 py-1 text-xs font-bold bg-orange-500 text-white rounded-full shadow-sm shadow-orange-200 animate-pulse">${teacherApps} 项</span>
</a>`;
}
if (contestApps > 0) {
html += `<a href="/admin/contest-applications" class="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-white border border-indigo-200/60 rounded-xl hover:shadow-md hover:border-indigo-300 transition-all group mt-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 group-hover:scale-110 transition-transform">🏆</div>
<div>
<div class="text-sm font-bold text-slate-800">待审核杯赛申请</div>
<div class="text-xs text-slate-500 mt-0.5">有新的杯赛创建请求</div>
</div>
</div>
<span class="px-3 py-1 text-xs font-bold bg-indigo-500 text-white rounded-full shadow-sm shadow-indigo-200 animate-pulse">${contestApps} 项</span>
</a>`;
}
pending.innerHTML = html;
}
}
} catch(e) { console.error('加载统计失败:', e); }
// 加载最近活动
try {
const res = await fetch('/api/admin/recent-activities');
const data = await res.json();
const container = document.getElementById('recent-activities');
if (data.success && data.activities.length > 0) {
container.innerHTML = data.activities.map(a =>
`<div class="flex items-start gap-4 relative z-10 group">
<div class="w-3 h-3 rounded-full bg-white border-2 border-indigo-400 mt-1.5 flex-shrink-0 group-hover:scale-125 transition-transform group-hover:bg-indigo-400 group-hover:border-indigo-100"></div>
<div class="bg-slate-50 border border-slate-100 rounded-xl p-3 flex-1 group-hover:bg-white group-hover:shadow-sm transition-all group-hover:border-indigo-100">
<div class="flex items-center justify-between gap-4 mb-1">
<span class="text-xs font-bold text-slate-400 flex items-center gap-1.5"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>${a.time}</span>
</div>
<div class="text-sm text-slate-700 leading-relaxed">
<span class="font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-md border border-indigo-100 mr-1">${a.user}</span>
${a.action}
</div>
</div>
</div>`
).join('');
} else {
container.innerHTML = `
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl bg-slate-50/50 relative z-10 ml-4">
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center text-2xl shadow-sm mb-3">📭</div>
<span class="text-sm font-bold">暂无近期活动记录</span>
</div>`;
// Hide the timeline line if no activities
const line = container.previousElementSibling?.classList.contains('absolute') ? container.previousElementSibling : null;
if(line) line.style.display = 'none';
}
} catch(e) { console.error('加载活动失败:', e); }
}
document.addEventListener('DOMContentLoaded', loadDashboard);
</script>
{% endblock %}

113
templates/admin_exams.html Normal file
View File

@@ -0,0 +1,113 @@
{% extends "admin_base.html" %}
{% block title %}考试管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">考试管理</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标题</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">科目</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">出题人</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">创建时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
</tr>
</thead>
<tbody id="exams-tbody" class="bg-white divide-y divide-slate-200"></tbody>
</table>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden">
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm">加载中...</span>
</div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无考试</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const statusMap = {
'available': ['可用', 'bg-green-100 text-green-800'],
'closed': ['已关闭', 'bg-red-100 text-red-800'],
'draft': ['草稿', 'bg-yellow-100 text-yellow-800'],
'scheduled': ['已排期', 'bg-blue-100 text-blue-800']
};
async function loadExams() {
const tbody = document.getElementById('exams-tbody');
const spinner = document.getElementById('loading-spinner');
const empty = document.getElementById('empty-msg');
tbody.innerHTML = '';
spinner.classList.remove('hidden');
empty.classList.add('hidden');
try {
const res = await fetch('/api/admin/exams');
const data = await res.json();
spinner.classList.add('hidden');
if (!data.success || !data.exams.length) {
empty.classList.remove('hidden');
return;
}
let html = '';
data.exams.forEach(e => {
const [statusText, statusClass] = statusMap[e.status] || [e.status, 'bg-slate-100 text-slate-800'];
const isAvailable = e.status === 'available';
html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${e.id}</td>
<td class="px-6 py-4 text-sm text-slate-900 max-w-xs truncate">${e.title}</td>
<td class="px-6 py-4 text-sm text-slate-500">${e.subject || '-'}</td>
<td class="px-6 py-4 text-sm text-slate-500">${e.creator_name || '-'}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span></td>
<td class="px-6 py-4 text-sm text-slate-500">${e.created_at}</td>
<td class="px-6 py-4 space-x-2">
<button onclick="toggleStatus(${e.id}, '${e.status}')" class="px-2 py-1 text-xs ${isAvailable ? 'bg-yellow-100 text-yellow-700 border-yellow-300' : 'bg-green-100 text-green-700 border-green-300'} border rounded hover:opacity-80">${isAvailable ? '停止' : '恢复'}</button>
<button onclick="deleteExam(${e.id}, '${e.title.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button>
</td>
</tr>`;
});
tbody.innerHTML = html;
} catch(e) {
spinner.classList.add('hidden');
empty.textContent = '加载失败,请稍后重试';
empty.classList.remove('hidden');
}
}
async function toggleStatus(id, currentStatus) {
const newStatus = currentStatus === 'available' ? 'closed' : 'available';
const actionText = currentStatus === 'available' ? '停止' : '恢复';
if (!confirm(`确定${actionText}该考试?`)) return;
try {
const res = await fetch(`/api/admin/exams/${id}/status`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: newStatus})
});
const data = await res.json();
if (data.success) { loadExams(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
async function deleteExam(id, title) {
if (!confirm(`确定删除考试「${title}」?此操作不可恢复!`)) return;
try {
const res = await fetch(`/api/admin/exams/${id}`, {method: 'DELETE'});
const data = await res.json();
if (data.success) { loadExams(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
document.addEventListener('DOMContentLoaded', loadExams);
</script>
{% endblock %}

View File

@@ -0,0 +1,239 @@
{% extends "admin_base.html" %}
{% block title %}通知管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">通知管理</h1>
<div class="flex gap-2">
<button onclick="showCreateModal()" class="px-4 py-2 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700">发布通知</button>
<button onclick="showPrivateModal()" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700">私发通知</button>
</div>
</div>
<div id="ann-list" class="space-y-3">
<div class="text-center py-12 text-slate-400">加载中...</div>
</div>
</div>
<!-- 创建/编辑弹窗 -->
<div id="modal" class="hidden fixed inset-0 bg-black/50 z-[9990] flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 id="modal-title" class="text-lg font-bold text-slate-900 mb-4">发布通知</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">标题</label>
<input type="text" id="ann-title" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-primary focus:border-primary" placeholder="通知标题">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">内容</label>
<textarea id="ann-content" rows="6" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-primary focus:border-primary" placeholder="通知内容"></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="ann-pinned" class="rounded border-slate-300">
<label for="ann-pinned" class="text-sm text-slate-700">置顶</label>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button onclick="closeModal()" class="px-4 py-2 border border-slate-300 rounded-md text-sm text-slate-700 hover:bg-slate-50">取消</button>
<button onclick="saveAnn()" id="save-btn" class="px-4 py-2 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700">发布</button>
</div>
</div>
</div>
<!-- 私发通知弹窗 -->
<div id="privateModal" class="hidden fixed inset-0 bg-black/50 z-[9990] flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 class="text-lg font-bold text-slate-900 mb-4">私发通知</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">选择用户</label>
<input type="text" id="private-user-search" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-primary focus:border-primary" placeholder="输入用户名搜索..." oninput="searchUsers()">
<div id="user-search-results" class="mt-1 max-h-32 overflow-y-auto border border-slate-200 rounded-md hidden"></div>
<div id="selected-users" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">通知内容</label>
<textarea id="private-content" rows="4" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-primary focus:border-primary" placeholder="输入通知内容"></textarea>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button onclick="closePrivateModal()" class="px-4 py-2 border border-slate-300 rounded-md text-sm text-slate-700 hover:bg-slate-50">取消</button>
<button onclick="sendPrivateNotif()" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700">发送</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let editingId = null;
function esc(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function showCreateModal() {
editingId = null;
document.getElementById('modal-title').textContent = '发布通知';
document.getElementById('save-btn').textContent = '发布';
document.getElementById('ann-title').value = '';
document.getElementById('ann-content').value = '';
document.getElementById('ann-pinned').checked = false;
document.getElementById('modal').classList.remove('hidden');
}
function showEditModal(id, title, content, pinned) {
editingId = id;
document.getElementById('modal-title').textContent = '编辑通知';
document.getElementById('save-btn').textContent = '保存';
document.getElementById('ann-title').value = title;
document.getElementById('ann-content').value = content;
document.getElementById('ann-pinned').checked = pinned;
document.getElementById('modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal').classList.add('hidden');
}
async function saveAnn() {
const title = document.getElementById('ann-title').value.trim();
const content = document.getElementById('ann-content').value.trim();
const pinned = document.getElementById('ann-pinned').checked;
if (!title || !content) { alert('标题和内容不能为空'); return; }
const url = editingId ? `/api/system-notifications/${editingId}` : '/api/system-notifications';
const method = editingId ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title, content, pinned})
});
const d = await res.json();
if (d.success) {
closeModal();
loadAnnouncements();
} else {
alert(d.message || '操作失败');
}
} catch(e) { alert('网络错误'); }
}
async function deleteAnn(id) {
if (!confirm('确定删除该通知?')) return;
try {
const res = await fetch(`/api/system-notifications/${id}`, {method:'DELETE'});
const d = await res.json();
if (d.success) loadAnnouncements();
else alert(d.message);
} catch(e) { alert('网络错误'); }
}
async function loadAnnouncements() {
const res = await fetch('/api/system-notifications?per_page=100');
const d = await res.json();
const list = document.getElementById('ann-list');
if (!d.success || d.notifications.length === 0) {
list.innerHTML = '<div class="text-center py-12 text-slate-400">暂无通知,点击右上角发布</div>';
return;
}
list.innerHTML = d.notifications.map(a => `
<div class="bg-white border ${a.pinned ? 'border-amber-300' : 'border-slate-200'} rounded-lg p-4 shadow-sm">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
${a.pinned ? '<span class="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">置顶</span>' : ''}
<h3 class="font-semibold text-slate-900">${esc(a.title)}</h3>
</div>
<p class="text-sm text-slate-600 mt-1 whitespace-pre-wrap">${esc(a.content)}</p>
<div class="text-xs text-slate-400 mt-2">发布者:${esc(a.author_name)} · 创建:${a.created_at}${a.updated_at && a.updated_at !== a.created_at ? ' · 更新:' + a.updated_at : ''}</div>
</div>
<div class="flex gap-2 ml-4 flex-shrink-0">
<button onclick='showEditModal(${a.id}, ${JSON.stringify(a.title)}, ${JSON.stringify(a.content)}, ${a.pinned})' class="px-3 py-1 text-xs border border-slate-300 rounded hover:bg-slate-50 text-slate-600">编辑</button>
<button onclick="deleteAnn(${a.id})" class="px-3 py-1 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600">删除</button>
</div>
</div>
</div>
`).join('');
}
loadAnnouncements();
// ========== 私发通知 ==========
let selectedUserIds = [];
function showPrivateModal() {
selectedUserIds = [];
document.getElementById('private-user-search').value = '';
document.getElementById('private-content').value = '';
document.getElementById('selected-users').innerHTML = '';
document.getElementById('user-search-results').classList.add('hidden');
document.getElementById('privateModal').classList.remove('hidden');
}
function closePrivateModal() {
document.getElementById('privateModal').classList.add('hidden');
}
let searchTimer = null;
function searchUsers() {
clearTimeout(searchTimer);
const q = document.getElementById('private-user-search').value.trim();
const results = document.getElementById('user-search-results');
if (q.length < 1) { results.classList.add('hidden'); return; }
searchTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/admin/search-users?q=${encodeURIComponent(q)}`);
const d = await res.json();
if (!d.success || d.users.length === 0) {
results.innerHTML = '<div class="px-3 py-2 text-sm text-slate-400">未找到用户</div>';
results.classList.remove('hidden');
return;
}
results.innerHTML = d.users.filter(u => !selectedUserIds.includes(u.id)).map(u =>
`<div class="px-3 py-2 text-sm hover:bg-slate-50 cursor-pointer flex items-center justify-between" onclick="selectUser(${u.id}, '${esc(u.name)}')">
<span>${esc(u.name)}</span>
<span class="text-xs text-slate-400">${esc(u.role)}</span>
</div>`
).join('');
results.classList.remove('hidden');
} catch(e) { results.classList.add('hidden'); }
}, 300);
}
function selectUser(id, name) {
if (selectedUserIds.includes(id)) return;
selectedUserIds.push(id);
const container = document.getElementById('selected-users');
container.innerHTML += `<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs" data-uid="${id}">
${esc(name)}
<button onclick="removeUser(${id}, this.parentElement)" class="hover:text-red-500">&times;</button>
</span>`;
document.getElementById('user-search-results').classList.add('hidden');
document.getElementById('private-user-search').value = '';
}
function removeUser(id, el) {
selectedUserIds = selectedUserIds.filter(uid => uid !== id);
el.remove();
}
async function sendPrivateNotif() {
if (selectedUserIds.length === 0) { alert('请选择至少一个用户'); return; }
const content = document.getElementById('private-content').value.trim();
if (!content) { alert('请输入通知内容'); return; }
try {
const res = await fetch('/api/admin/send-private-notification', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ user_ids: selectedUserIds, content })
});
const d = await res.json();
if (d.success) {
alert(`已成功发送给 ${selectedUserIds.length} 位用户`);
closePrivateModal();
} else {
alert(d.message || '发送失败');
}
} catch(e) { alert('网络错误'); }
}
</script>
{% endblock %}

126
templates/admin_posts.html Normal file
View File

@@ -0,0 +1,126 @@
{% extends "admin_base.html" %}
{% block title %}帖子管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">帖子管理</h1>
<div class="flex gap-4">
<input id="search-input" type="text" placeholder="搜索标题/内容..." class="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<select id="tag-filter" class="px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">全部标签</option>
<option value="讨论">讨论</option>
<option value="求助">求助</option>
<option value="分享">分享</option>
<option value="公告">公告</option>
</select>
<button onclick="loadPosts()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">搜索</button>
</div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标题</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">作者</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标签</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">置顶</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">发布时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
</tr>
</thead>
<tbody id="posts-tbody" class="bg-white divide-y divide-slate-200"></tbody>
</table>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden">
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm">加载中...</span>
</div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无帖子</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const tagClassMap = {
'讨论': 'bg-blue-100 text-blue-800',
'求助': 'bg-yellow-100 text-yellow-800',
'分享': 'bg-green-100 text-green-800',
'公告': 'bg-red-100 text-red-800'
};
async function loadPosts() {
const q = document.getElementById('search-input').value;
const tag = document.getElementById('tag-filter').value;
const tbody = document.getElementById('posts-tbody');
const spinner = document.getElementById('loading-spinner');
const empty = document.getElementById('empty-msg');
tbody.innerHTML = '';
spinner.classList.remove('hidden');
empty.classList.add('hidden');
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (tag) params.set('tag', tag);
const res = await fetch('/api/admin/posts?' + params);
const data = await res.json();
spinner.classList.add('hidden');
if (!data.success || !data.posts.length) {
empty.classList.remove('hidden');
return;
}
let html = '';
data.posts.forEach(p => {
const tagClass = tagClassMap[p.tag] || 'bg-slate-100 text-slate-800';
html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${p.id}</td>
<td class="px-6 py-4 text-sm text-slate-900 max-w-xs truncate">${p.title}</td>
<td class="px-6 py-4 text-sm text-slate-500">${p.author}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${tagClass}">${p.tag || '-'}</span></td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs rounded-full ${p.pinned ? 'bg-orange-100 text-orange-800' : 'bg-slate-100 text-slate-500'}">${p.pinned ? '已置顶' : '未置顶'}</span>
</td>
<td class="px-6 py-4 text-sm text-slate-500">${p.created_at}</td>
<td class="px-6 py-4 space-x-2">
<button onclick="togglePin(${p.id}, ${p.pinned})" class="px-2 py-1 text-xs ${p.pinned ? 'bg-slate-100 text-slate-700 border-slate-300' : 'bg-orange-100 text-orange-700 border-orange-300'} border rounded hover:opacity-80">${p.pinned ? '取消置顶' : '置顶'}</button>
<button onclick="deletePost(${p.id}, '${p.title.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button>
</td>
</tr>`;
});
tbody.innerHTML = html;
} catch(e) {
spinner.classList.add('hidden');
empty.textContent = '加载失败,请稍后重试';
empty.classList.remove('hidden');
}
}
async function togglePin(id, currentlyPinned) {
if (!confirm(currentlyPinned ? '确定取消置顶?' : '确定置顶该帖子?')) return;
try {
const res = await fetch(`/api/admin/posts/${id}/pin`, {method: 'PUT'});
const data = await res.json();
if (data.success) { loadPosts(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
async function deletePost(id, title) {
if (!confirm(`确定删除帖子「${title}」?此操作不可恢复!`)) return;
try {
const res = await fetch(`/api/admin/posts/${id}`, {method: 'DELETE'});
const data = await res.json();
if (data.success) { loadPosts(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') loadPosts(); });
document.addEventListener('DOMContentLoaded', loadPosts);
</script>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "admin_base.html" %}
{% block title %}教师申请审核 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">教师申请审核</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请人</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">杯赛</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">姓名</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">邮箱</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请理由</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody id="applications-table-body" class="bg-white divide-y divide-slate-200">
{% for app in apps %}
<tr data-id="{{ app.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.user.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.contest.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.email }}</td>
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">{{ app.reason }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.applied_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<form action="{{ url_for('approve_teacher_application', app_id=app.id) }}" method="post" style="display:inline;">
<button type="submit" class="text-green-600 hover:text-green-900 mr-2">批准</button>
</form>
<form action="{{ url_for('reject_teacher_application', app_id=app.id) }}" method="post" style="display:inline;">
<button type="submit" class="text-red-600 hover:text-red-900">拒绝</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="px-6 py-12 text-center text-slate-500">暂无待审核的教师申请</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

127
templates/admin_users.html Normal file
View File

@@ -0,0 +1,127 @@
{% extends "admin_base.html" %}
{% block title %}用户管理 - 智联青云管理后台{% endblock %}
{% block admin_content %}
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">用户管理</h1>
<div class="flex gap-4">
<input id="search-input" type="text" placeholder="搜索用户名/邮箱..." class="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<select id="role-filter" class="px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="teacher">教师</option>
<option value="student">学生</option>
</select>
<button onclick="loadUsers()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">搜索</button>
</div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户名</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">邮箱</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">角色</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">注册时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
</tr>
</thead>
<tbody id="users-tbody" class="bg-white divide-y divide-slate-200"></tbody>
</table>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden">
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-sm">加载中...</span>
</div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无用户</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const roleMap = {
'admin': ['管理员', 'bg-red-100 text-red-800'],
'teacher': ['教师', 'bg-blue-100 text-blue-800'],
'student': ['学生', 'bg-green-100 text-green-800']
};
async function loadUsers() {
const q = document.getElementById('search-input').value;
const role = document.getElementById('role-filter').value;
const tbody = document.getElementById('users-tbody');
const spinner = document.getElementById('loading-spinner');
const empty = document.getElementById('empty-msg');
tbody.innerHTML = '';
spinner.classList.remove('hidden');
empty.classList.add('hidden');
try {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (role) params.set('role', role);
const res = await fetch('/api/admin/users?' + params);
const data = await res.json();
spinner.classList.add('hidden');
if (!data.success || !data.users.length) {
empty.classList.remove('hidden');
return;
}
let html = '';
data.users.forEach(u => {
const [roleText, roleClass] = roleMap[u.role] || ['未知', 'bg-slate-100 text-slate-800'];
const banned = u.is_banned;
html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${u.id}</td>
<td class="px-6 py-4 text-sm text-slate-900">${u.name}</td>
<td class="px-6 py-4 text-sm text-slate-500">${u.email || '-'}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs rounded-full ${roleClass}">${roleText}</span>
</td>
<td class="px-6 py-4">
<span class="px-2 py-1 text-xs rounded-full ${banned ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}">${banned ? '已封禁' : '正常'}</span>
</td>
<td class="px-6 py-4 text-sm text-slate-500">${u.created_at}</td>
<td class="px-6 py-4 space-x-2">
<button onclick="toggleBan(${u.id}, ${banned})" class="px-2 py-1 text-xs ${banned ? 'bg-green-100 text-green-700 border-green-300' : 'bg-yellow-100 text-yellow-700 border-yellow-300'} border rounded hover:opacity-80">${banned ? '解封' : '封禁'}</button>
<button onclick="deleteUser(${u.id}, '${u.name.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button>
</td>
</tr>`;
});
tbody.innerHTML = html;
} catch(e) {
spinner.classList.add('hidden');
empty.textContent = '加载失败,请稍后重试';
empty.classList.remove('hidden');
}
}
async function toggleBan(id, currentlyBanned) {
if (!confirm(currentlyBanned ? '确定解封该用户?' : '确定封禁该用户?')) return;
try {
const res = await fetch(`/api/admin/users/${id}/ban`, {method: 'PUT'});
const data = await res.json();
if (data.success) { loadUsers(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
async function deleteUser(id, name) {
if (!confirm(`确定删除用户「${name}」?此操作不可恢复!`)) return;
try {
const res = await fetch(`/api/admin/users/${id}`, {method: 'DELETE'});
const data = await res.json();
if (data.success) { loadUsers(); } else { alert(data.message || '操作失败'); }
} catch(e) { alert('网络错误'); }
}
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') loadUsers(); });
document.addEventListener('DOMContentLoaded', loadUsers);
</script>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}申请举办杯赛 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto py-8">
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-6">
<h1 class="text-2xl font-bold text-slate-900 mb-6">申请举办杯赛</h1>
<form method="POST" action="{{ url_for('apply_contest') }}" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-slate-700 mb-1">杯赛名称 <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如2026年星火杯">
</div>
<div>
<label for="organizer" class="block text-sm font-medium text-slate-700 mb-1">主办方 <span class="text-red-500">*</span></label>
<input type="text" id="organizer" name="organizer" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如:星火杯组委会">
</div>
<div>
<label for="description" class="block text-sm font-medium text-slate-700 mb-1">描述 <span class="text-red-500">*</span></label>
<textarea id="description" name="description" rows="4" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="简要介绍杯赛的目的、规则等"></textarea>
</div>
<div>
<label for="contact" class="block text-sm font-medium text-slate-700 mb-1">联系方式 <span class="text-red-500">*</span></label>
<input type="text" id="contact" name="contact" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="邮箱或手机号">
<p class="mt-1 text-xs text-slate-500">用于管理员与您联系,不会公开显示</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-slate-700 mb-1">开始日期 <span class="text-red-500">*</span></label>
<input type="date" id="start_date" name="start_date" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-slate-700 mb-1">结束日期 <span class="text-red-500">*</span></label>
<input type="date" id="end_date" name="end_date" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
</div>
<div>
<label for="total_score" class="block text-sm font-medium text-slate-700 mb-1">杯赛满分 <span class="text-red-500">*</span></label>
<input type="number" id="total_score" name="total_score" required min="1" value="150"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如150">
<p class="mt-1 text-xs text-slate-500">杯赛考试的默认满分分数</p>
</div>
<!-- 报备信息 -->
<div class="border-t border-slate-200 pt-6 mt-2">
<h2 class="text-lg font-semibold text-slate-900 mb-4">报备信息</h2>
<div class="space-y-4">
<div>
<label for="responsible_person" class="block text-sm font-medium text-slate-700 mb-1">责任人姓名 <span class="text-red-500">*</span></label>
<input type="text" id="responsible_person" name="responsible_person" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="赛事责任人真实姓名">
</div>
<div>
<label for="responsible_phone" class="block text-sm font-medium text-slate-700 mb-1">责任人电话 <span class="text-red-500">*</span></label>
<input type="tel" id="responsible_phone" name="responsible_phone" required
pattern="1[3-9]\d{9}"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="11位手机号码">
<p class="mt-1 text-xs text-slate-500">请填写有效的手机号码,审核通过后将公开展示</p>
</div>
<div>
<label for="responsible_email" class="block text-sm font-medium text-slate-700 mb-1">责任人邮箱 <span class="text-red-500">*</span></label>
<input type="email" id="responsible_email" name="responsible_email" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="example@email.com">
</div>
<div>
<label for="organization" class="block text-sm font-medium text-slate-700 mb-1">所属机构/学校 <span class="text-red-500">*</span></label>
<input type="text" id="organization" name="organization" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="例如XX大学、XX教育机构">
</div>
</div>
<p class="mt-3 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md p-3">以上报备信息将在杯赛详情页公开展示,请确保信息真实有效。</p>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('contest_list') }}" class="px-5 py-2.5 border border-slate-300 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
取消
</a>
<button type="submit"
class="px-5 py-2.5 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
提交申请
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}申请成为杯赛老师 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto py-8">
<!-- 邀请码激活区域 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-blue-800 mb-2">🎫 已有邀请码?在此激活</h2>
<p class="text-sm text-blue-600 mb-4">审核通过后,您会在私聊消息中收到邀请码。输入邀请码即可正式成为杯赛老师。</p>
<div class="flex gap-3">
<input type="text" id="invite_code" placeholder="请输入邀请码,如 TC-A3K9M2X7"
class="flex-1 px-3 py-2 border border-blue-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm uppercase"
maxlength="11">
<button type="button" id="activate_btn" onclick="activateInviteCode()"
class="px-5 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
激活
</button>
</div>
<p id="invite_result" class="text-sm mt-2 hidden"></p>
</div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-6">
<h1 class="text-2xl font-bold text-slate-900 mb-6">申请成为杯赛老师</h1>
<p class="text-sm text-slate-500 mb-6">请选择您希望担任老师的杯赛,并填写申请理由。</p>
<form method="POST" action="{{ url_for('apply_teacher') }}" class="space-y-6">
<div>
<label for="contest_id" class="block text-sm font-medium text-slate-700 mb-1">选择杯赛 <span class="text-red-500">*</span></label>
<select id="contest_id" name="contest_id" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
<option value="">请选择杯赛</option>
{% for contest in contests %}
<option value="{{ contest.id }}" {% if selected_contest and selected_contest.id == contest.id %}selected{% endif %}>{{ contest.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="name" class="block text-sm font-medium text-slate-700 mb-1">姓名 <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="您的真实姓名">
</div>
<div>
<label for="email" class="block text-sm font-medium text-slate-700 mb-1">邮箱 <span class="text-red-500">*</span></label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="用于接收审核结果通知">
</div>
<div>
<label for="reason" class="block text-sm font-medium text-slate-700 mb-1">申请理由 <span class="text-red-500">*</span></label>
<textarea id="reason" name="reason" rows="5" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
placeholder="请简要说明您的教学经历或申请原因"></textarea>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('home') }}" class="px-5 py-2.5 border border-slate-300 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
取消
</a>
<button type="submit"
class="px-5 py-2.5 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
提交申请
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function activateInviteCode() {
const code = document.getElementById('invite_code').value.trim();
const resultEl = document.getElementById('invite_result');
const btn = document.getElementById('activate_btn');
if (!code) {
resultEl.textContent = '请输入邀请码';
resultEl.className = 'text-sm mt-2 text-red-600';
return;
}
btn.disabled = true;
btn.textContent = '激活中...';
fetch('/api/activate-invite-code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code})
})
.then(r => r.json())
.then(data => {
resultEl.classList.remove('hidden');
if (data.success) {
resultEl.textContent = data.message;
resultEl.className = 'text-sm mt-2 text-green-600 font-medium';
setTimeout(() => location.reload(), 1500);
} else {
resultEl.textContent = data.message;
resultEl.className = 'text-sm mt-2 text-red-600';
}
})
.catch(() => {
resultEl.classList.remove('hidden');
resultEl.textContent = '网络错误,请稍后重试';
resultEl.className = 'text-sm mt-2 text-red-600';
})
.finally(() => {
btn.disabled = false;
btn.textContent = '激活';
});
}
</script>
{% endblock %}

439
templates/base.html Normal file
View File

@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}智联青云{% endblock %}</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#10b981',
accent: '#8b5cf6',
surface: '#ffffff',
background: '#f8fafc',
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05)',
'glass': '0 8px 32px 0 rgba(31, 38, 135, 0.07)',
}
}
}
}
</script>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/rich-editor.css">
<!-- KaTeX 数学公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="document.addEventListener('DOMContentLoaded',function(){renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});})"></script>
<script src="/static/js/rich-editor.js"></script>
</head>
<body class="min-h-screen bg-slate-50 font-sans text-slate-800 antialiased selection:bg-primary/30 selection:text-slate-900">
{% block navbar %}
<nav class="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200/80 shadow-sm transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<a href="/" class="flex-shrink-0 flex items-center">
<span class="text-xl font-bold text-primary">智联青云</span>
</a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/contests" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/></svg>
杯赛专栏
</a>
<a href="/exams" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" 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>
考试系统
</a>
<a href="/forum" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" 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>
社区论坛
</a>
<a href="/chat" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
消息
</a>
<a href="/profile" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
个人
</a>
{% if user and user.role == 'student' %}
<a href="/apply-teacher" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
申请老师
</a>
{% endif %}
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:items-center">
{% if user %}
<div class="flex items-center space-x-4">
<!-- 通知铃铛 -->
<div class="relative" id="notif-wrapper">
<button onclick="toggleNotifPanel()" class="text-slate-500 hover:text-slate-700 relative" title="通知">
<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="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>
<span id="notifBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center hidden" style="font-size:10px">0</span>
</button>
<div id="notifPanel" class="hidden absolute right-0 top-10 w-96 max-h-[500px] bg-white rounded-lg shadow-xl border border-slate-200 z-50 overflow-hidden">
<div class="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<span class="font-medium text-sm text-slate-900">通知</span>
<a href="/chat?tab=notif" class="text-xs text-primary hover:underline">查看全部</a>
</div>
<div id="notifList" class="overflow-y-auto max-h-[440px] divide-y divide-slate-100"></div>
</div>
</div>
{% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="text-slate-500 hover:text-slate-700" title="管理后台">
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</a>
{% endif %}
<span class="text-sm text-slate-700">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ get_user_display_name() }} ({{ '管理员' if user.role == 'admin' else ('老师' if user.role == 'teacher' else '学生') }})
</span>
<a href="/logout" class="text-slate-500 hover:text-slate-700">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
</a>
</div>
{% else %}
<div class="space-x-4">
<a href="/login" class="text-slate-500 hover:text-slate-700 text-sm font-medium">登录</a>
<a href="/register" class="bg-primary text-white px-4 py-2 rounded-lg shadow-sm text-sm font-medium hover:bg-blue-600 hover:shadow transition-all duration-200 transform hover:-translate-y-0.5">注册</a>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endblock %}
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
{% if user %}
<!-- 通知面板脚本 -->
<script>
(function(){
function toggleNotifPanel() {
const panel = document.getElementById('notifPanel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) loadNotifications();
}
window.toggleNotifPanel = toggleNotifPanel;
// 点击外部关闭
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('notif-wrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('notifPanel').classList.add('hidden');
}
});
function updateNotifBadge() {
fetch('/api/notifications/unread-count').then(r=>r.json()).then(d=>{
const badge = document.getElementById('notifBadge');
if (d.count > 0) {
badge.textContent = d.count > 99 ? '99+' : d.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}).catch(()=>{});
}
updateNotifBadge();
setInterval(updateNotifBadge, 30000);
function loadNotifications() {
fetch('/api/notifications').then(r=>r.json()).then(d=>{
if (!d.success) return;
const list = document.getElementById('notifList');
if (d.notifications.length === 0) {
list.innerHTML = '<div class="px-4 py-8 text-center text-slate-400 text-sm">暂无通知</div>';
return;
}
list.innerHTML = d.notifications.map(n => {
let actions = '';
const typeIcons = {'teacher_application':'👨‍🏫','teacher_result':'🎓','contest_application':'🏆','contest_result':'🏅','contest_new_exam':'📝','exam_graded':'✅'};
const icon = typeIcons[n.type] || '🔔';
// 教师申请:显示同意/拒绝按钮
if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id) {
actions = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveTeacher(${n.application_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button>
<button onclick="event.stopPropagation();rejectTeacher(${n.application_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'teacher_application' && n.application_status === 'approved') {
actions = '<div class="text-xs text-green-600 mt-1">✅ 已同意</div>';
} else if (n.type === 'teacher_application' && n.application_status === 'rejected') {
actions = '<div class="text-xs text-red-600 mt-1">❌ 已拒绝</div>';
}
// 杯赛申请:显示同意/拒绝按钮(仅管理员)
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id) {
actions = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveContest(${n.post_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button>
<button onclick="event.stopPropagation();rejectContest(${n.post_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'contest_application' && n.application_status === 'approved') {
actions = '<div class="text-xs text-green-600 mt-1">✅ 已同意</div>';
} else if (n.type === 'contest_application' && n.application_status === 'rejected') {
actions = '<div class="text-xs text-red-600 mt-1">❌ 已拒绝</div>';
}
const unreadDot = n.read ? '' : '<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>';
return `<div class="px-4 py-3 hover:bg-slate-50 cursor-pointer flex gap-2 items-start ${n.read?'':'bg-blue-50/50'}" onclick="markRead(${n.id},this)">
<span class="text-lg flex-shrink-0">${icon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-700">${escNotif(n.content)}</div>
<div class="text-xs text-slate-400 mt-1">${n.created_at}</div>
${actions}
</div>
${unreadDot}
</div>`;
}).join('');
});
}
function markRead(nid, el) {
fetch(`/api/notifications/${nid}/read`, {method:'POST'});
el.classList.remove('bg-blue-50/50');
const dot = el.querySelector('.bg-blue-500');
if (dot) dot.remove();
updateNotifBadge();
}
window.markRead = markRead;
window.approveTeacher = async function(appId, nid) {
try {
const res = await fetch(`/api/teacher-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.rejectTeacher = async function(appId, nid) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/teacher-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.approveContest = async function(appId, nid) {
try {
const res = await fetch(`/api/contest-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.rejectContest = async function(appId, nid) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/contest-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
function escNotif(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
})();
</script>
<!-- 浮动聊天气泡 -->
<div id="chatBubble" class="fixed bottom-6 right-6 z-40">
<button onclick="toggleMiniChat()" class="w-14 h-14 bg-gradient-to-tr from-primary to-blue-400 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-blue-600 flex items-center justify-center relative transition-all duration-300 hover:scale-110">
<svg class="w-7 h-7" 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>
<span id="bubbleBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center hidden">0</span>
</button>
</div>
<!-- 迷你聊天窗口 -->
<div id="miniChat" class="fixed bottom-24 right-6 w-[380px] h-[500px] bg-white rounded-xl shadow-2xl border border-slate-200 z-40 hidden flex flex-col overflow-hidden">
<div class="px-4 py-3 bg-primary text-white flex justify-between items-center rounded-t-xl">
<span class="font-medium text-sm" id="miniTitle">消息</span>
<div class="flex items-center gap-3">
<a href="/chat" class="text-white/80 hover:text-white text-xs">打开完整版</a>
<button onclick="toggleMiniChat()" class="text-white/80 hover:text-white">&times;</button>
</div>
</div>
<div id="miniRoomList" class="flex-1 overflow-y-auto"></div>
<div id="miniChatView" class="flex-1 flex-col hidden">
<div class="px-3 py-2 border-b border-slate-200 flex items-center gap-2">
<button onclick="miniBack()" class="text-slate-400 hover:text-slate-600"><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="M15 19l-7-7 7-7"/></svg></button>
<span id="miniChatName" class="text-sm font-medium truncate"></span>
</div>
<div id="miniMessages" class="flex-1 overflow-y-auto px-3 py-2 space-y-2" style="max-height:340px"></div>
<div class="px-3 py-2 border-t border-slate-200 flex gap-2">
<input id="miniInput" type="text" placeholder="输入消息..." class="flex-1 px-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary/50" onkeydown="if(event.key==='Enter'){event.preventDefault();miniSend();}">
<button onclick="miniSend()" class="px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-600">发送</button>
</div>
</div>
</div>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
(function(){
if (document.getElementById('chatApp')) return; // chat.html 已有自己的socket
const bubbleSocket = io();
let miniRoomId = null;
let miniRooms = [];
function updateBadge() {
fetch('/api/chat/unread-total').then(r=>r.json()).then(d=>{
const badge = document.getElementById('bubbleBadge');
if (d.total > 0) {
badge.textContent = d.total > 99 ? '99+' : d.total;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}).catch(()=>{});
}
updateBadge();
setInterval(updateBadge, 30000);
bubbleSocket.on('new_message', (msg) => {
updateBadge();
// 气泡抖动
const btn = document.querySelector('#chatBubble button');
btn.classList.add('animate-bounce');
setTimeout(() => btn.classList.remove('animate-bounce'), 1000);
// 迷你聊天窗口更新
if (miniRoomId === msg.room_id) {
appendMiniMsg(msg);
}
loadMiniRooms();
});
bubbleSocket.on('message_recalled', () => {
if (miniRoomId) loadMiniMessages(miniRoomId);
});
window.toggleMiniChat = function() {
const mc = document.getElementById('miniChat');
mc.classList.toggle('hidden');
if (!mc.classList.contains('hidden')) {
loadMiniRooms();
}
};
function loadMiniRooms() {
fetch('/api/chat/rooms').then(r=>r.json()).then(d=>{
if (!d.success) return;
miniRooms = d.rooms;
const list = document.getElementById('miniRoomList');
list.innerHTML = d.rooms.map(r => `
<div class="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-slate-50 border-b border-slate-50" onclick="miniOpenRoom(${r.id})">
<div class="w-9 h-9 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">` : `<span class="text-xs text-slate-500">${(r.name||'?')[0]}</span>`}
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between"><span class="text-sm font-medium truncate">${esc(r.name||'')}</span>
${r.unread>0?`<span class="bg-red-500 text-white text-xs rounded-full px-1.5 min-w-[16px] text-center">${r.unread}</span>`:''}</div>
<p class="text-xs text-slate-400 truncate">${r.last_message?esc(r.last_message.content||''):'暂无消息'}</p>
</div>
</div>
`).join('');
});
}
window.miniOpenRoom = function(roomId) {
miniRoomId = roomId;
const room = miniRooms.find(r=>r.id===roomId);
document.getElementById('miniTitle').textContent = room?.name || '聊天';
document.getElementById('miniChatName').textContent = room?.name || '聊天';
document.getElementById('miniRoomList').classList.add('hidden');
const cv = document.getElementById('miniChatView');
cv.classList.remove('hidden');
cv.classList.add('flex');
loadMiniMessages(roomId);
fetch(`/api/chat/rooms/${roomId}/read`, {method:'POST'});
updateBadge();
};
window.miniBack = function() {
miniRoomId = null;
document.getElementById('miniRoomList').classList.remove('hidden');
const cv = document.getElementById('miniChatView');
cv.classList.add('hidden');
cv.classList.remove('flex');
document.getElementById('miniTitle').textContent = '消息';
loadMiniRooms();
};
function loadMiniMessages(roomId) {
fetch(`/api/chat/rooms/${roomId}/messages?limit=20`).then(r=>r.json()).then(d=>{
if (!d.success) return;
const area = document.getElementById('miniMessages');
const currUserStr = '{{ user | tojson | safe }}';
const currUser = currUserStr ? JSON.parse(currUserStr) : null;
area.innerHTML = d.messages.map(m => {
if (m.type==='system') return `<div class="text-center"><span class="text-xs text-slate-400">${esc(m.content)}</span></div>`;
if (m.recalled) return `<div class="text-center"><span class="text-xs text-slate-400">${esc(m.sender_name)} 撤回了一条消息</span></div>`;
const isMe = m.sender_id === currUser.id;
const bubble = isMe ? 'bg-primary text-white ml-auto' : 'bg-slate-100 text-slate-800';
let content = m.type==='image'?'[图片]':m.type==='file'?'[文件]':esc(m.content);
return `<div class="${isMe?'text-right':'text-left'}">
${!isMe?`<div class="text-xs text-slate-400 mb-0.5">${esc(m.sender_name)}</div>`:''}
<div class="inline-block px-3 py-1.5 rounded-lg text-sm max-w-[80%] ${bubble}">${content}</div>
</div>`;
}).join('');
area.scrollTop = area.scrollHeight;
});
}
function appendMiniMsg(msg) {
const area = document.getElementById('miniMessages');
const currUserStr = '{{ user | tojson | safe }}';
const currUser = currUserStr ? JSON.parse(currUserStr) : null;
const isMe = currUser && msg.sender_id === currUser.id;
if (msg.type==='system') {
area.innerHTML += `<div class="text-center"><span class="text-xs text-slate-400">${esc(msg.content)}</span></div>`;
} else {
const bubble = isMe ? 'bg-primary text-white ml-auto' : 'bg-slate-100 text-slate-800';
let content = msg.type==='image'?'[图片]':msg.type==='file'?'[文件]':(typeof renderRichContent==='function'?renderRichContent(msg.content):esc(msg.content));
area.innerHTML += `<div class="${isMe?'text-right':'text-left'}">
${!isMe?`<div class="text-xs text-slate-400 mb-0.5">${esc(msg.sender_name)}</div>`:''}
<div class="inline-block px-3 py-1.5 rounded-lg text-sm max-w-[80%] ${bubble}">${content}</div>
</div>`;
}
area.scrollTop = area.scrollHeight;
}
window.miniSend = function() {
const input = document.getElementById('miniInput');
const content = input.value.trim();
if (!content || !miniRoomId) return;
bubbleSocket.emit('send_message', { room_id: miniRoomId, type: 'text', content: content });
input.value = '';
};
function esc(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
})();
</script>
{% endif %}
</body>
</html>

1307
templates/chat.html Normal file

File diff suppressed because it is too large Load Diff

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 %}

191
templates/contest_edit.html Normal file
View File

@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}编辑杯赛 - {{ contest.name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">编辑杯赛主页</h1>
<a href="{{ url_for('contest_detail', contest_id=contest.id) }}" class="px-4 py-2 bg-slate-100 text-slate-700 border border-slate-300 rounded-md hover:bg-slate-200 font-medium">返回杯赛</a>
</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">基本信息</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">主办方</label>
<input type="text" id="organizer" value="{{ contest.organizer or '' }}" class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">杯赛简介</label>
<textarea id="description" rows="6" class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">{{ contest.description or '' }}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
<input type="date" id="start_date" value="{{ contest.start_date or '' }}" class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
<input type="date" id="end_date" value="{{ contest.end_date or '' }}" class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">杯赛状态</label>
<select id="status" class="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
<option value="upcoming" {{ 'selected' if contest.status == 'upcoming' }}>即将开始</option>
<option value="registering" {{ 'selected' if contest.status == 'registering' }}>正在报名</option>
<option value="ongoing" {{ 'selected' if contest.status == 'ongoing' }}>进行中</option>
<option value="ended" {{ 'selected' if contest.status == 'ended' }}>已结束</option>
</select>
</div>
<button onclick="saveInfo()" id="save-btn" class="px-6 py-2 bg-primary text-white rounded-md hover:bg-blue-700 font-medium">保存信息</button>
<span id="save-msg" class="ml-3 text-sm text-green-600 hidden">已保存</span>
</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">往届真题管理</h2>
<div id="papers-list" class="space-y-2 mb-6">
{% for paper in contest.get_past_papers() %}
<div class="flex items-center justify-between bg-slate-50 rounded-md px-4 py-3 border border-slate-200">
<div class="flex items-center space-x-3">
<span class="text-sm font-medium text-primary">{{ paper.year }}</span>
<span class="text-sm text-slate-700">{{ paper.title }}</span>
<a href="{{ paper.file }}" target="_blank" class="text-xs text-blue-500 hover:underline">下载</a>
</div>
<button onclick="deletePaper({{ loop.index0 }})" class="text-sm text-red-500 hover:text-red-700">删除</button>
</div>
{% endfor %}
</div>
<h3 class="text-sm font-semibold text-slate-700 mb-3">添加真题</h3>
<form id="upload-form" class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs text-slate-500 mb-1">年份</label>
<input type="text" id="paper-year" placeholder="如 2025" class="px-3 py-2 border border-slate-300 rounded-md text-sm w-24 focus:outline-none focus:ring-2 focus:ring-primary" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">标题</label>
<input type="text" id="paper-title" placeholder="如 初赛试题" class="px-3 py-2 border border-slate-300 rounded-md text-sm w-40 focus:outline-none focus:ring-2 focus:ring-primary" />
</div>
<div>
<label class="block text-xs text-slate-500 mb-1">文件 (PDF/图片)</label>
<input type="file" id="paper-file" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp" class="text-sm" />
</div>
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm font-medium">上传</button>
</form>
<span id="upload-msg" class="text-sm text-red-500 hidden mt-2 block"></span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CONTEST_ID = {{ contest.id }};
async function saveInfo() {
const btn = document.getElementById('save-btn');
const msg = document.getElementById('save-msg');
btn.disabled = true;
btn.textContent = '保存中...';
msg.classList.add('hidden');
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: document.getElementById('description').value,
organizer: document.getElementById('organizer').value,
start_date: document.getElementById('start_date').value,
end_date: document.getElementById('end_date').value,
status: document.getElementById('status').value
})
});
const data = await res.json();
if (data.success) {
msg.textContent = '已保存';
msg.classList.remove('hidden', 'text-red-500');
msg.classList.add('text-green-600');
} else {
msg.textContent = data.message || '保存失败';
msg.classList.remove('hidden', 'text-green-600');
msg.classList.add('text-red-500');
}
} catch (e) {
msg.textContent = '网络错误';
msg.classList.remove('hidden', 'text-green-600');
msg.classList.add('text-red-500');
}
btn.disabled = false;
btn.textContent = '保存信息';
}
function renderPapers(papers) {
const list = document.getElementById('papers-list');
if (!papers.length) {
list.innerHTML = '<p class="text-sm text-slate-400">暂无真题</p>';
return;
}
list.innerHTML = papers.map((p, i) => `
<div class="flex items-center justify-between bg-slate-50 rounded-md px-4 py-3 border border-slate-200">
<div class="flex items-center space-x-3">
<span class="text-sm font-medium text-primary">${p.year}</span>
<span class="text-sm text-slate-700">${p.title}</span>
<a href="${p.file}" target="_blank" class="text-xs text-blue-500 hover:underline">下载</a>
</div>
<button onclick="deletePaper(${i})" class="text-sm text-red-500 hover:text-red-700">删除</button>
</div>
`).join('');
}
document.getElementById('upload-form').addEventListener('submit', async function(e) {
e.preventDefault();
const year = document.getElementById('paper-year').value.trim();
const title = document.getElementById('paper-title').value.trim();
const fileInput = document.getElementById('paper-file');
const msg = document.getElementById('upload-msg');
msg.classList.add('hidden');
if (!year || !title || !fileInput.files.length) {
msg.textContent = '请填写年份、标题并选择文件';
msg.classList.remove('hidden');
return;
}
const fd = new FormData();
fd.append('file', fileInput.files[0]);
fd.append('year', year);
fd.append('title', title);
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/past-papers`, { method: 'POST', body: fd });
const data = await res.json();
if (data.success) {
renderPapers(data.papers);
document.getElementById('paper-year').value = '';
document.getElementById('paper-title').value = '';
fileInput.value = '';
} else {
msg.textContent = data.message;
msg.classList.remove('hidden');
}
} catch (e) {
msg.textContent = '上传失败';
msg.classList.remove('hidden');
}
});
async function deletePaper(index) {
if (!confirm('确定删除这份真题?')) return;
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/past-papers/${index}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
renderPapers(data.papers);
} else {
alert(data.message || '删除失败');
}
} catch (e) {
alert('删除失败');
}
}
</script>
{% endblock %}

141
templates/contest_list.html Normal file
View File

@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}杯赛专栏 - 智联青云{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- 头部区域 -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div>
<h1 class="text-2xl font-bold text-slate-900 flex items-center">
<span class="w-10 h-10 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center mr-3 shadow-sm border border-blue-100">🏆</span>
杯赛专栏
</h1>
<p class="text-slate-500 text-sm mt-1 ml-13">参与官方联考,检验学习成果,赢取荣誉与奖励。</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="{{ url_for('apply_contest') }}" class="inline-flex items-center px-4 py-2.5 bg-green-50 text-green-600 rounded-xl hover:bg-green-100 shadow-sm border border-green-200 text-sm font-medium transition-all transform hover:-translate-y-0.5">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
申请举办杯赛
</a>
{% if user and (user.role == 'admin' or user.role == 'teacher') %}
<a href="/admin/contests/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white rounded-xl hover:bg-blue-600 shadow-sm text-sm font-medium transition-all transform hover:-translate-y-0.5">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
发布新杯赛
</a>
{% endif %}
</div>
</div>
<!-- 筛选和搜索 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10 glass-panel">
<div class="flex flex-wrap gap-3 items-center w-full sm:w-auto">
<div class="relative w-full sm:w-auto">
<select id="statusFilter" class="w-full sm:w-40 appearance-none px-4 py-2.5 pl-10 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer" onchange="searchContests()">
<option value="">所有状态</option>
<option value="registering">🟢 报名中</option>
<option value="upcoming">🔵 进行中</option>
<option value="ended">⚪ 已结束</option>
</select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg>
</div>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
</div>
<div class="flex gap-2 w-full sm:w-auto relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<input type="text" id="search-contest" placeholder="搜索杯赛名称..." class="flex-1 sm:w-72 pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all" onkeydown="if(event.key==='Enter') searchContests()">
<button onclick="searchContests()" class="px-5 py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm">
搜索
</button>
</div>
</div>
<!-- 列表 -->
<div id="contest-list" class="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400">
<svg class="animate-spin h-8 w-8 text-primary mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<p>正在加载精彩杯赛...</p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allContests = [];
function renderContests(contests) {
const container = document.getElementById('contest-list');
if (contests.length === 0) {
container.innerHTML = '<div class="col-span-full text-center py-12 text-slate-500">暂无杯赛</div>';
return;
}
let html = '';
contests.forEach(c => {
let statusHtml = '';
if (c.status === 'registering') {
statusHtml = '<span class="absolute top-3 right-3 bg-blue-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-blue-400/50"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>报名中</span>';
} else if (c.status === 'upcoming') {
statusHtml = '<span class="absolute top-3 right-3 bg-green-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-green-400/50"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>即将开始</span>';
} else if (c.status === 'abolished') {
statusHtml = '<span class="absolute top-3 right-3 bg-red-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-red-400/50">已废止</span>';
} else {
statusHtml = '<span class="absolute top-3 right-3 bg-slate-600/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-slate-500/50">已结束</span>';
}
html += `
<div class="group bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-soft hover:border-blue-200 transition-all duration-300 transform hover:-translate-y-1 cursor-pointer flex flex-col" onclick="location.href='/contests/${c.id}'">
<div class="h-40 bg-slate-100 relative overflow-hidden">
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-indigo-100 to-purple-100 text-indigo-400 group-hover:scale-105 transition-transform duration-500">
<svg class="w-16 h-16 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
${statusHtml}
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div>
<div class="p-5 flex-1 flex flex-col relative">
<h3 class="text-lg font-bold text-slate-900 mb-2 line-clamp-2 group-hover:text-primary transition-colors">${c.name}</h3>
<p class="text-xs text-indigo-600 font-medium mb-2 bg-indigo-50 inline-block px-2 py-1 rounded-md w-max border border-indigo-100">主办方:${c.organizer || '未知'}</p>
<div class="text-sm text-slate-500 mb-5 flex-1 line-clamp-2 leading-relaxed">${c.description || '暂无简介'}</div>
<div class="mt-auto pt-4 border-t border-slate-100 grid grid-cols-2 gap-4">
<div class="flex flex-col">
<span class="text-[10px] font-medium text-slate-400 uppercase tracking-wider mb-1">开始时间</span>
<span class="text-xs font-medium text-slate-700 flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" 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>
${c.start_date ? c.start_date.split(' ')[0] : '待定'}
</span>
</div>
<div class="flex flex-col border-l border-slate-100 pl-4">
<span class="text-[10px] font-medium text-slate-400 uppercase tracking-wider mb-1">参与人数</span>
<span class="text-xs font-bold text-primary flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-primary/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
${c.participants}
</span>
</div>
</div>
</div>
</div>`;
});
container.innerHTML = html;
}
async function searchContests() {
const keyword = document.getElementById('search-contest').value;
const url = keyword ? `/api/contests/search?q=${encodeURIComponent(keyword)}` : '/api/contests/search';
const res = await fetch(url);
const data = await res.json();
if (data.success) {
renderContests(data.data);
} else {
alert('搜索失败');
}
}
// 初始化加载所有杯赛
searchContests();
</script>
{% endblock %}

View File

@@ -0,0 +1,259 @@
{% extends "base.html" %}
{% block title %}题库管理 - {{ contest.name }} - 智联青云{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-slate-900">📚 题库管理</h1>
<p class="text-sm text-slate-500 mt-1">{{ contest.name }}</p>
</div>
<div class="flex gap-3">
<a href="/contests/{{ contest.id }}" class="px-4 py-2 border border-slate-300 rounded-md text-sm text-slate-700 hover:bg-slate-50">返回杯赛</a>
{% if is_owner %}
<button onclick="showCreateExamModal()" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700">选题组卷</button>
{% endif %}
</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">添加题目</h2>
<form id="add-question-form" onsubmit="addQuestion(event)">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">题目类型</label>
<select id="q-type" onchange="toggleOptions()" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
<option value="choice">选择题</option>
<option value="fill">填空题</option>
<option value="essay">主观题</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">建议分值</label>
<input type="number" id="q-score" value="10" min="1" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">题目内容</label>
<textarea id="q-content" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" required></textarea>
</div>
<div id="options-section" class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">选项(每行一个)</label>
<textarea id="q-options" rows="4" placeholder="A. 选项一&#10;B. 选项二&#10;C. 选项三&#10;D. 选项四" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm"></textarea>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">答案</label>
<input type="text" id="q-answer" placeholder="填写正确答案" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-blue-700">添加到题库</button>
</form>
</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">题库列表 (<span id="q-count">0</span>题)</h2>
<div id="question-list" class="space-y-4">
<div class="text-center py-8 text-slate-500">加载中...</div>
</div>
</div>
</div>
<!-- 选题组卷弹窗 -->
{% if is_owner %}
<div id="create-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-2xl max-h-[80vh] overflow-y-auto p-6">
<h3 class="text-lg font-semibold mb-4">选题组卷</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">考试标题</label>
<input type="text" id="exam-title" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm" required>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">考试时长(分钟)</label>
<input type="number" id="exam-duration" value="120" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">开始时间</label>
<input type="datetime-local" id="exam-start" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">结束时间</label>
<input type="datetime-local" id="exam-end" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">成绩公布时间</label>
<input type="datetime-local" id="exam-release" class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">选择题目</label>
<div id="exam-question-list" class="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-md p-3">
</div>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button onclick="hideCreateExamModal()" class="px-4 py-2 border border-slate-300 rounded-md text-sm">取消</button>
<button onclick="createExamFromBank()" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700">创建考试</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const CONTEST_ID = {{ contest.id }};
const IS_OWNER = {{ 'true' if is_owner else 'false' }};
const CURRENT_USER_ID = {{ user.id if user else 0 }};
let allQuestions = [];
function toggleOptions() {
const type = document.getElementById('q-type').value;
document.getElementById('options-section').style.display = type === 'choice' ? 'block' : 'none';
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadQuestions() {
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/question-bank`);
const data = await res.json();
if (!data.success) { alert(data.message); return; }
allQuestions = data.questions;
document.getElementById('q-count').textContent = allQuestions.length;
const container = document.getElementById('question-list');
if (allQuestions.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-slate-500">题库暂无题目</div>';
return;
}
const typeMap = {'choice':'选择题','fill':'填空题','essay':'主观题'};
let html = '';
allQuestions.forEach((q, i) => {
const canDel = IS_OWNER || q.contributor_id === CURRENT_USER_ID;
html += `<div class="border border-slate-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800">${typeMap[q.type]||q.type}</span>
<span class="text-xs text-slate-500">${q.score}分</span>
<span class="text-xs text-slate-400">贡献者: ${escapeHtml(q.contributor_name)}</span>
<span class="text-xs text-slate-400">${q.created_at}</span>
</div>
<p class="text-sm text-slate-800 mb-2">${escapeHtml(q.content)}</p>`;
if (q.type === 'choice' && q.options && q.options.length) {
html += '<div class="text-sm text-slate-600 space-y-1 ml-4">';
q.options.forEach(opt => { html += `<div>${escapeHtml(opt)}</div>`; });
html += '</div>';
}
if (q.answer) {
html += `<div class="text-sm text-green-700 mt-1">答案: ${escapeHtml(q.answer)}</div>`;
}
html += `</div>`;
if (canDel) {
html += `<button onclick="deleteQuestion(${q.id})" class="text-red-500 hover:text-red-700 text-sm shrink-0">删除</button>`;
}
html += `</div></div>`;
});
container.innerHTML = html;
} catch(e) {
document.getElementById('question-list').innerHTML = '<div class="text-center py-8 text-red-500">加载失败</div>';
}
}
async function addQuestion(e) {
e.preventDefault();
const type = document.getElementById('q-type').value;
const content = document.getElementById('q-content').value.trim();
const score = parseInt(document.getElementById('q-score').value) || 10;
const answer = document.getElementById('q-answer').value.trim();
if (!content) { alert('请填写题目内容'); return; }
let options = [];
if (type === 'choice') {
const raw = document.getElementById('q-options').value.trim();
if (raw) options = raw.split('\n').filter(l => l.trim());
}
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/question-bank`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, content, options, answer, score})
});
const data = await res.json();
if (data.success) {
document.getElementById('q-content').value = '';
document.getElementById('q-options').value = '';
document.getElementById('q-answer').value = '';
loadQuestions();
} else { alert(data.message); }
} catch(e) { alert('添加失败'); }
}
async function deleteQuestion(qid) {
if (!confirm('确定删除该题目?')) return;
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/question-bank/${qid}`, {method:'DELETE'});
const data = await res.json();
if (data.success) { loadQuestions(); } else { alert(data.message); }
} catch(e) { alert('删除失败'); }
}
function showCreateExamModal() {
const list = document.getElementById('exam-question-list');
const typeMap = {'choice':'选择题','fill':'填空题','essay':'主观题'};
let html = '';
allQuestions.forEach(q => {
html += `<label class="flex items-start gap-2 p-2 hover:bg-slate-50 rounded cursor-pointer">
<input type="checkbox" value="${q.id}" class="mt-1 exam-q-check">
<div class="flex-1">
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">${typeMap[q.type]||q.type}</span>
<span class="text-xs text-slate-500">${q.score}分</span>
<p class="text-sm text-slate-700 mt-1">${escapeHtml(q.content)}</p>
</div>
</label>`;
});
if (!html) html = '<div class="text-center text-slate-500 text-sm py-4">题库暂无题目</div>';
list.innerHTML = html;
document.getElementById('create-exam-modal').classList.remove('hidden');
}
function hideCreateExamModal() {
document.getElementById('create-exam-modal').classList.add('hidden');
}
async function createExamFromBank() {
const title = document.getElementById('exam-title').value.trim();
const duration = parseInt(document.getElementById('exam-duration').value) || 120;
const start = document.getElementById('exam-start').value;
const end = document.getElementById('exam-end').value;
const release = document.getElementById('exam-release').value;
const checks = document.querySelectorAll('.exam-q-check:checked');
const qids = Array.from(checks).map(c => parseInt(c.value));
if (!title) { alert('请填写考试标题'); return; }
if (qids.length === 0) { alert('请至少选择一道题目'); return; }
try {
const res = await fetch(`/api/contests/${CONTEST_ID}/create-exam-from-bank`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
title, duration, question_ids: qids,
scheduled_start: start, scheduled_end: end, score_release_time: release
})
});
const data = await res.json();
if (data.success) {
alert('考试创建成功!');
hideCreateExamModal();
window.location.href = `/exams/${data.exam_id}`;
} else { alert(data.message); }
} catch(e) { alert('创建失败'); }
}
loadQuestions();
</script>
{% endblock %}

638
templates/exam_create.html Normal file
View File

@@ -0,0 +1,638 @@
{% extends "base.html" %}
{% block title %}创建试卷 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-slate-900">创建试卷</h1>
<a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a>
</div>
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">试卷标题</label>
<input id="exam-title" type="text" required class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="如2026年春季联考数学卷">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">科目</label>
<select id="exam-subject" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<option>数学</option><option>物理</option><option>化学</option><option>生物</option><option>语文</option><option>英语</option><option>历史</option><option>地理</option><option>政治</option><option>综合</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">考试时长(分钟)</label>
<input id="exam-duration" type="number" value="120" min="10" max="480" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-slate-700">预定开始时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-scheduled-start" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,考生只能在此时间之后开始考试</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">预定结束时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-scheduled-end" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,到时间自动截止,考生无法再提交</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">成绩公布时间 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-score-release" type="datetime-local" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<p class="mt-1 text-xs text-slate-400">设置后,考生只能在此时间之后查看成绩(必须晚于考试结束时间)</p>
</div>
</div>
</div>
</div>
<!-- 考试密码设置 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-password" type="text" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="留空则不设密码">
<p class="mt-1 text-xs text-slate-400">设置密码后考生需输入密码才能进入考试,试卷内容将加密存储</p>
</div>
</div>
</div>
<!-- 题目统计 -->
<div class="bg-slate-50 rounded-lg p-3 border border-slate-200 flex items-center justify-between">
<div class="flex items-center space-x-4 text-sm text-slate-600">
<span><span id="q-count" class="font-medium text-slate-900">0</span></span>
<span>总分 <span id="q-total-score" class="font-medium text-slate-900">0</span></span>
<span class="text-blue-600">选择题 <span id="q-choice-count">0</span></span>
<span class="text-green-600">填空题 <span id="q-fill-count">0</span></span>
<span class="text-purple-600">解答题 <span id="q-text-count">0</span></span>
<span class="text-orange-600">判断题 <span id="q-judge-count">0</span></span>
</div>
</div>
<div id="questions-container" class="space-y-4"></div>
<div class="flex flex-wrap gap-2">
<button onclick="addQuestion('choice')" class="px-4 py-2 bg-blue-50 text-primary border border-blue-200 rounded-md text-sm font-medium hover:bg-blue-100">+ 选择题</button>
<button onclick="addQuestion('fill')" class="px-4 py-2 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm font-medium hover:bg-green-100">+ 填空题</button>
<button onclick="addQuestion('text')" class="px-4 py-2 bg-purple-50 text-purple-700 border border-purple-200 rounded-md text-sm font-medium hover:bg-purple-100">+ 解答题</button>
<button onclick="addQuestion('judge')" class="px-4 py-2 bg-orange-50 text-orange-700 border border-orange-200 rounded-md text-sm font-medium hover:bg-orange-100">+ 判断题</button>
<span class="border-l border-slate-300 mx-1"></span>
<button onclick="batchAddChoice()" class="px-4 py-2 bg-slate-50 text-slate-700 border border-slate-200 rounded-md text-sm font-medium hover:bg-slate-100">批量添加选择题</button>
<span class="border-l border-slate-300 mx-1"></span>
<button onclick="showVipModal()" class="px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-md text-sm font-medium hover:from-purple-600 hover:to-indigo-600 shadow-sm">智能导入PDF</button>
<input type="file" id="pdfFileInput" accept=".pdf" class="hidden" onchange="parsePDF(this)">
</div>
<div class="flex justify-between items-center">
<div class="text-sm text-slate-400">提示:拖拽题目卡片可调整顺序</div>
<button onclick="submitExam()" class="px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-blue-700">发布试卷</button>
</div>
</div>
<!-- PDF解析加载遮罩 -->
<div id="pdfLoading" class="fixed inset-0 bg-black/50 z-[9990] flex items-center justify-center hidden">
<div class="bg-white rounded-2xl p-8 flex flex-col items-center space-y-4 shadow-2xl">
<div class="w-12 h-12 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
<p class="text-slate-700 font-medium">AI正在识别试卷中...</p>
<p class="text-sm text-slate-400">请耐心等待通常需要10-30秒</p>
</div>
</div>
<!-- VIP 弹窗 -->
<div id="vipModal" class="fixed inset-0 z-[999] hidden" onclick="if(event.target===this)closeVipModal()">
<!-- 背景:深空黑 + 动态星点 -->
<div class="absolute inset-0 bg-gradient-to-br from-[#0a0015] via-[#0d0d2b] to-[#0a0015]">
<div class="stars-bg"></div>
</div>
<!-- 主卡片 -->
<div class="relative flex items-center justify-center min-h-screen p-4">
<div id="vipCard" class="vip-card relative w-full max-w-lg rounded-3xl p-[2px] opacity-0 scale-75">
<!-- 流光边框 -->
<div class="absolute inset-0 rounded-3xl bg-gradient-to-r from-purple-500 via-cyan-400 to-purple-500 animate-border-flow"></div>
<div class="relative bg-[#0e0e2a]/95 backdrop-blur-xl rounded-3xl p-8 overflow-hidden">
<!-- 顶部光晕 -->
<div class="absolute -top-20 left-1/2 -translate-x-1/2 w-60 h-60 bg-purple-500/20 rounded-full blur-3xl"></div>
<div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div>
<!-- 关闭按钮 -->
<button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&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>
</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>
</div>
</div>
</div>
</div>
<style>
/* VIP 弹窗动画 */
.stars-bg {
position: absolute; inset: 0;
background-image: radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,0.15), transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.1), transparent),
radial-gradient(1px 1px at 90px 40px, rgba(255,255,255,0.15), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.1), transparent),
radial-gradient(2px 2px at 160px 30px, rgba(255,255,255,0.12), transparent);
background-size: 200px 100px;
animation: twinkle 4s ease-in-out infinite alternate;
}
@keyframes twinkle { 0% { opacity: 0.5; } 100% { opacity: 1; } }
@keyframes border-flow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.animate-border-flow {
background-size: 200% 200%;
animation: border-flow 3s linear infinite;
}
.vip-crown {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.vip-price {
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
0% { filter: drop-shadow(0 0 8px rgba(168,85,247,0.4)); }
100% { filter: drop-shadow(0 0 20px rgba(34,211,238,0.6)); }
}
.vip-card-enter {
animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes cardIn {
0% { opacity: 0; transform: scale(0.75) translateY(40px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.vip-card-exit {
animation: cardOut 0.3s ease-in forwards;
}
@keyframes cardOut {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.9) translateY(20px); }
}
</style>
{% endblock %}
{% block scripts %}
<script>
// VIP 弹窗
function showVipModal() {
const modal = document.getElementById('vipModal');
const card = document.getElementById('vipCard');
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
card.classList.remove('vip-card-exit');
card.classList.add('vip-card-enter');
}
function closeVipModal() {
const modal = document.getElementById('vipModal');
const card = document.getElementById('vipCard');
card.classList.remove('vip-card-enter');
card.classList.add('vip-card-exit');
setTimeout(function() {
modal.classList.add('hidden');
document.body.style.overflow = '';
}, 300);
}
// 重新渲染页面中的数学公式(带 KaTeX 未加载重试)
function renderMath(el) {
if (typeof renderMathInElement === 'function') {
renderMathInElement(el || document.body, {
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
throwOnError: false
});
} else {
setTimeout(function() { renderMath(el); }, 200);
}
}
// 选择题选项公式预览
function previewOptMath(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const preview = document.getElementById('q-opt-preview-' + qid);
if (!preview) return;
const opts = el.querySelectorAll('.q-opt');
let hasFormula = false;
let html = '';
opts.forEach(function(input, i) {
const val = input.value.trim();
if (val && val.includes('$')) {
hasFormula = true;
html += '<div class="mb-1"><span class="font-medium text-slate-500">' + String.fromCharCode(65 + i) + '.</span> ' + val.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>';
}
});
if (hasFormula) {
preview.innerHTML = html;
preview.classList.remove('hidden');
renderMath(preview);
} else {
preview.classList.add('hidden');
}
}
let questionId = 0;
function addQuestion(type, prefill) {
questionId++;
const qid = questionId;
const container = document.getElementById('questions-container');
const typeLabel = type === 'choice' ? '选择题' : type === 'fill' ? '填空题' : type === 'judge' ? '判断题' : '解答题';
const typeColor = type === 'choice' ? 'blue' : type === 'fill' ? 'green' : type === 'judge' ? 'orange' : 'purple';
const defaultScore = type === 'choice' ? 5 : type === 'fill' ? 5 : type === 'judge' ? 3 : 10;
let html = `<div id="q-${qid}" class="bg-white shadow-sm rounded-lg p-6 border border-slate-200 question-item" data-type="${type}" data-qid="${qid}" draggable="true" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="drop(event)" ondragend="dragEnd(event)">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-3">
<span class="drag-handle cursor-grab text-slate-400 hover:text-slate-600">⠿</span>
<span class="q-number flex-shrink-0 w-8 h-8 bg-${typeColor}-100 rounded-full flex items-center justify-center text-${typeColor}-600 font-medium text-sm">${qid}</span>
<span class="text-xs font-medium px-2 py-1 rounded bg-${typeColor}-50 text-${typeColor}-700">${typeLabel}</span>
<input type="number" class="q-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="分值" value="${prefill?.score || defaultScore}" min="1">
</div>
<div class="flex items-center space-x-2">
<button onclick="moveQuestion(${qid}, -1)" class="text-slate-400 hover:text-slate-600 text-sm" title="上移">↑</button>
<button onclick="moveQuestion(${qid}, 1)" class="text-slate-400 hover:text-slate-600 text-sm" title="下移">↓</button>
<button onclick="duplicateQuestion(${qid})" class="text-blue-400 hover:text-blue-600 text-sm" title="复制">复制</button>
<button onclick="document.getElementById('q-${qid}').remove();updateStats();" class="text-red-400 hover:text-red-600 text-sm">删除</button>
</div>
</div>
<div class="space-y-3">
<textarea class="q-content w-full px-3 py-2 border border-slate-300 rounded-md text-sm" rows="2" placeholder="题目内容" oninput="previewMath(${qid})">${prefill?.content || ''}</textarea>
<div id="q-preview-${qid}" class="q-math-preview text-sm text-slate-700 px-3 py-2 bg-slate-50 rounded-md border border-slate-100 hidden"></div>
<div class="flex items-center space-x-2">
<label class="cursor-pointer inline-flex items-center px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-md text-xs font-medium transition">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
上传图片
<input type="file" accept="image/*" class="hidden q-img-input" onchange="uploadQuestionImage(this, ${qid})">
</label>
<button type="button" onclick="openMathEditor(document.querySelector('#q-${qid} .q-content'), function(){previewMath(${qid})})" class="inline-flex items-center px-3 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-md text-xs font-medium transition">
<span style="font-family:Georgia,serif;font-style:italic;font-weight:700;margin-right:4px">fx</span> 公式编辑器
</button>
<span class="text-xs text-slate-400">支持 PNG/JPG/GIF/WebP最大10MB</span>
</div>
<div class="q-images flex flex-wrap gap-2"></div>`;
// 如果有预填图片,渲染到容器中
if (prefill?.images && prefill.images.length > 0) {
let imgHtml = '';
prefill.images.forEach(function(url) {
imgHtml += '<div class="relative group inline-block"><img src="' + url + '" class="h-24 rounded border border-slate-200 object-cover"><button type="button" onclick="removeQuestionImage(this)" class="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs leading-none hidden group-hover:flex items-center justify-center">&times;</button><input type="hidden" class="q-img-url" value="' + url + '"></div>';
});
html = html.replace('<div class="q-images flex flex-wrap gap-2"></div>', '<div class="q-images flex flex-wrap gap-2">' + imgHtml + '</div>');
}
if (type === 'choice') {
const opts = prefill?.options || ['', '', '', ''];
const ans = prefill?.answer || '';
html += `<div class="space-y-2">
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="A" class="q-answer" ${ans==='A'?'checked':''}><span class="text-sm w-6">A.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项A" value="${opts[0]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="B" class="q-answer" ${ans==='B'?'checked':''}><span class="text-sm w-6">B.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项B" value="${opts[1]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="C" class="q-answer" ${ans==='C'?'checked':''}><span class="text-sm w-6">C.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项C" value="${opts[2]||''}" oninput="previewOptMath(${qid})"></div>
<div class="flex items-center space-x-2"><input type="radio" name="ans-${qid}" value="D" class="q-answer" ${ans==='D'?'checked':''}><span class="text-sm w-6">D.</span><input type="text" class="q-opt flex-1 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="选项D" value="${opts[3]||''}" oninput="previewOptMath(${qid})"></div>
<div id="q-opt-preview-${qid}" class="text-sm text-slate-700 px-3 py-2 bg-slate-50 rounded-md border border-slate-100 hidden"></div>
<p class="text-xs text-slate-400">请选中正确答案的单选按钮</p>
</div>`;
} else if (type === 'fill') {
html += `<input type="text" class="q-fill-answer w-full px-3 py-2 border border-slate-300 rounded-md text-sm" placeholder="标准答案(多个答案用 | 分隔)" value="${prefill?.answer || ''}">`;
} else if (type === 'judge') {
const ans = prefill?.answer || '';
html += `<div class="flex items-center space-x-4">
<label class="flex items-center space-x-2 cursor-pointer"><input type="radio" name="judge-${qid}" value="A" class="q-judge-answer" ${ans==='A'?'checked':''}><span class="text-sm">✓ 正确</span></label>
<label class="flex items-center space-x-2 cursor-pointer"><input type="radio" name="judge-${qid}" value="B" class="q-judge-answer" ${ans==='B'?'checked':''}><span class="text-sm">✗ 错误</span></label>
</div>`;
} else {
html += `<textarea class="q-ref-answer w-full px-3 py-2 border border-slate-300 rounded-md text-sm" rows="3" placeholder="参考答案(可选,方便批改时参考)">${prefill?.answer || ''}</textarea>`;
}
html += `<textarea class="q-explanation w-full px-3 py-2 border border-dashed border-slate-300 rounded-md text-sm bg-slate-50" rows="2" placeholder="题目解析(可选,考生交卷后可查看)">${prefill?.explanation || ''}</textarea>`;
html += `</div></div>`;
container.insertAdjacentHTML('beforeend', html);
// 如果有预填内容含公式,触发预览
if (prefill?.content) { previewMath(qid); }
// 选择题选项公式预览
if (type === 'choice' && prefill?.options) { setTimeout(function() { previewOptMath(qid); }, 50); }
updateStats();
}
// 数学公式实时预览
function previewMath(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const textarea = el.querySelector('.q-content');
const preview = document.getElementById('q-preview-' + qid);
if (!textarea || !preview) return;
const text = textarea.value.trim();
if (text && (text.includes('$') || text.includes('\\') || text.includes('^') || text.includes('_'))) {
preview.textContent = text;
preview.classList.remove('hidden');
renderMath(preview);
} else {
preview.classList.add('hidden');
}
}
function duplicateQuestion(qid) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const type = el.dataset.type;
const content = el.querySelector('.q-content').value;
const score = parseInt(el.querySelector('.q-score').value) || 5;
const images = Array.from(el.querySelectorAll('.q-img-url')).map(i => i.value);
const explanation = el.querySelector('.q-explanation')?.value || '';
const prefill = { content, score, images, explanation };
if (type === 'choice') {
prefill.options = Array.from(el.querySelectorAll('.q-opt')).map(o => o.value);
const checked = el.querySelector('.q-answer:checked');
prefill.answer = checked ? checked.value : '';
} else if (type === 'fill') {
prefill.answer = el.querySelector('.q-fill-answer')?.value || '';
} else if (type === 'judge') {
const checked = el.querySelector('.q-judge-answer:checked');
prefill.answer = checked ? checked.value : '';
} else {
prefill.answer = el.querySelector('.q-ref-answer')?.value || '';
}
addQuestion(type, prefill);
}
function moveQuestion(qid, direction) {
const el = document.getElementById('q-' + qid);
if (!el) return;
const container = document.getElementById('questions-container');
if (direction === -1 && el.previousElementSibling) {
container.insertBefore(el, el.previousElementSibling);
} else if (direction === 1 && el.nextElementSibling) {
container.insertBefore(el.nextElementSibling, el);
}
renumberQuestions();
}
function renumberQuestions() {
const items = document.querySelectorAll('#questions-container > .question-item');
items.forEach((el, i) => {
const numEl = el.querySelector('.q-number');
if (numEl) numEl.textContent = i + 1;
});
}
// 拖拽排序
let draggedEl = null;
function dragStart(e) {
draggedEl = e.target.closest('.question-item');
if (draggedEl) {
draggedEl.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
}
function dragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function drop(e) {
e.preventDefault();
const target = e.target.closest('.question-item');
if (target && draggedEl && target !== draggedEl) {
const container = document.getElementById('questions-container');
const items = Array.from(container.children);
const dragIdx = items.indexOf(draggedEl);
const dropIdx = items.indexOf(target);
if (dragIdx < dropIdx) {
container.insertBefore(draggedEl, target.nextSibling);
} else {
container.insertBefore(draggedEl, target);
}
renumberQuestions();
}
}
function dragEnd(e) {
if (draggedEl) {
draggedEl.style.opacity = '1';
draggedEl = null;
}
}
function batchAddChoice() {
const count = parseInt(prompt('请输入要批量添加的选择题数量:', '5'));
if (!count || count < 1 || count > 50) return;
for (let i = 0; i < count; i++) {
addQuestion('choice');
}
}
function updateStats() {
const items = document.querySelectorAll('#questions-container > .question-item');
let total = 0, choiceCount = 0, fillCount = 0, textCount = 0, judgeCount = 0, totalScore = 0;
items.forEach(el => {
total++;
const type = el.dataset.type;
if (type === 'choice') choiceCount++;
else if (type === 'fill') fillCount++;
else if (type === 'judge') judgeCount++;
else textCount++;
totalScore += parseInt(el.querySelector('.q-score').value) || 0;
});
document.getElementById('q-count').textContent = total;
document.getElementById('q-total-score').textContent = totalScore;
document.getElementById('q-choice-count').textContent = choiceCount;
document.getElementById('q-fill-count').textContent = fillCount;
document.getElementById('q-text-count').textContent = textCount;
document.getElementById('q-judge-count').textContent = judgeCount;
}
// 监听分值变化
document.getElementById('questions-container').addEventListener('input', (e) => {
if (e.target.classList.contains('q-score')) updateStats();
});
function uploadQuestionImage(input, qid) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const label = input.closest('label');
const origText = label.querySelector('svg').nextSibling.textContent;
label.querySelector('svg').nextSibling.textContent = ' 上传中...';
fetch('/api/upload', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
label.querySelector('svg').nextSibling.textContent = origText;
if (data.success) {
const container = document.querySelector(`#q-${qid} .q-images`);
container.insertAdjacentHTML('beforeend',
`<div class="relative group inline-block"><img src="${data.url}" class="h-24 rounded border border-slate-200 object-cover"><button type="button" onclick="removeQuestionImage(this)" class="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs leading-none hidden group-hover:flex items-center justify-center">&times;</button><input type="hidden" class="q-img-url" value="${data.url}"></div>`);
} else {
alert(data.message || '上传失败');
}
})
.catch(() => { label.querySelector('svg').nextSibling.textContent = origText; alert('上传失败'); });
input.value = '';
}
function removeQuestionImage(btn) {
btn.closest('div.relative').remove();
}
function submitExam() {
const title = document.getElementById('exam-title').value;
const subject = document.getElementById('exam-subject').value;
const duration = parseInt(document.getElementById('exam-duration').value) || 120;
if (!title) { alert('请输入试卷标题'); return; }
const qEls = document.querySelectorAll('#questions-container > .question-item');
if (qEls.length === 0) { alert('请至少添加一道题目'); return; }
const questions = [];
let qIdx = 0;
for (const el of qEls) {
qIdx++;
const type = el.dataset.type;
const content = el.querySelector('.q-content').value;
const score = parseInt(el.querySelector('.q-score').value) || 0;
if (!content) { alert(`${qIdx}题内容不能为空`); return; }
const q = { id: qIdx, type, content, score };
// 收集题目图片
const imgUrls = Array.from(el.querySelectorAll('.q-img-url')).map(i => i.value);
if (imgUrls.length > 0) q.images = imgUrls;
// 收集题目解析
const explanation = el.querySelector('.q-explanation')?.value || '';
if (explanation) q.explanation = explanation;
if (type === 'choice') {
const opts = el.querySelectorAll('.q-opt');
q.options = Array.from(opts).map(o => o.value);
const checked = el.querySelector('.q-answer:checked');
q.answer = checked ? checked.value : '';
if (!q.answer) { alert(`${qIdx}题请选择正确答案`); return; }
} else if (type === 'fill') {
q.answer = el.querySelector('.q-fill-answer')?.value || '';
} else if (type === 'judge') {
q.options = ['正确', '错误'];
const checked = el.querySelector('.q-judge-answer:checked');
q.answer = checked ? checked.value : '';
if (!q.answer) { alert(`${qIdx}题请选择正确答案`); return; }
q.type = 'choice'; // 判断题在后端按选择题处理
} else {
q.answer = el.querySelector('.q-ref-answer')?.value || '';
}
questions.push(q);
}
const scheduled_start = document.getElementById('exam-scheduled-start').value;
const scheduled_end = document.getElementById('exam-scheduled-end').value;
const score_release_time = document.getElementById('exam-score-release').value;
if (scheduled_start && scheduled_end && new Date(scheduled_start) >= new Date(scheduled_end)) {
alert('预定结束时间必须晚于开始时间'); return;
}
if (score_release_time && scheduled_end && new Date(score_release_time) <= new Date(scheduled_end)) {
alert('成绩公布时间必须晚于考试结束时间'); return;
}
if (score_release_time && scheduled_start && !scheduled_end) {
// 没有设置结束时间时,公布时间至少要晚于开始时间+考试时长
const examEnd = new Date(new Date(scheduled_start).getTime() + duration * 60000);
if (new Date(score_release_time) <= examEnd) {
alert('成绩公布时间必须晚于考试结束(开始时间 + 考试时长)'); return;
}
}
const access_password = document.getElementById('exam-password').value.trim();
fetch('/api/exams', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password })
}).then(r => r.json()).then(data => {
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; }
else alert(data.message);
}).catch(() => alert('创建失败'));
}
// 默认添加一道选择题
addQuestion('choice');
// PDF智能识别
function parsePDF(input) {
const file = input.files[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.pdf')) {
alert('请选择PDF格式文件');
input.value = '';
return;
}
const existing = document.querySelectorAll('.question-item');
if (existing.length > 0) {
if (!confirm('当前已有 ' + existing.length + ' 道题目,导入将追加到末尾。是否继续?')) {
input.value = '';
return;
}
}
document.getElementById('pdfLoading').classList.remove('hidden');
const formData = new FormData();
formData.append('file', file);
fetch('/api/parse-pdf', {
method: 'POST',
body: formData
}).then(r => r.json()).then(data => {
document.getElementById('pdfLoading').classList.add('hidden');
if (data.success) {
const startId = questionId + 1;
data.questions.forEach(q => addQuestion(q.type, q));
const endId = questionId;
// 延迟渲染:确保 DOM 稳定 + KaTeX 已加载
setTimeout(function() {
for (let id = startId; id <= endId; id++) {
previewMath(id);
previewOptMath(id);
}
renderMath(document.getElementById('questions-container'));
}, 300);
alert('成功识别 ' + data.questions.length + ' 道题目,请检查后提交');
} else {
alert(data.message || '解析失败');
}
}).catch(err => {
document.getElementById('pdfLoading').classList.add('hidden');
alert('请求失败: ' + err.message);
}).finally(() => {
input.value = '';
});
}
</script>
{% endblock %}

536
templates/exam_detail.html Normal file
View File

@@ -0,0 +1,536 @@
{% extends "base.html" %}
{% block title %}{{ exam.title }} - 智联青云{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto">
{% if need_password %}
<div class="bg-white shadow-sm rounded-lg p-8 border border-slate-200 max-w-md mx-auto mt-12">
<div class="text-center mb-6">
<svg class="w-16 h-16 mx-auto text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
<h2 class="text-xl font-bold text-slate-900">{{ exam.title }}</h2>
<p class="text-sm text-slate-500 mt-1">该考试需要输入密码才能进入</p>
</div>
<div id="password-form">
<input type="password" id="exam-pwd" placeholder="请输入考试密码"
class="w-full px-4 py-3 border border-slate-300 rounded-md text-center text-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
onkeydown="if(event.key==='Enter')verifyPassword()">
<p id="pwd-error" class="text-red-500 text-sm text-center mt-2 hidden">密码错误,请重试</p>
<button onclick="verifyPassword()" class="w-full mt-4 px-4 py-3 bg-primary text-white rounded-md font-medium hover:bg-blue-700">
验证并进入考试
</button>
<a href="/exams" class="block text-center mt-3 text-sm text-slate-500 hover:text-slate-700">返回列表</a>
</div>
</div>
<script>
async function verifyPassword() {
const pwd = document.getElementById('exam-pwd').value;
if (!pwd) { document.getElementById('pwd-error').textContent = '请输入密码'; document.getElementById('pwd-error').classList.remove('hidden'); return; }
try {
const res = await fetch('/api/exams/{{ exam.id }}/verify-password', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({password: pwd})
});
const data = await res.json();
if (data.success) { location.reload(); }
else { document.getElementById('pwd-error').textContent = data.message || '密码错误'; document.getElementById('pwd-error').classList.remove('hidden'); }
} catch(e) { document.getElementById('pwd-error').textContent = '验证失败,请重试'; document.getElementById('pwd-error').classList.remove('hidden'); }
}
</script>
{% elif existing_submission %}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p class="text-yellow-800 font-medium">您已提交过该试卷</p>
<a href="/exams/{{ exam.id }}/result" class="mt-3 inline-block px-4 py-2 bg-primary text-white rounded-md text-sm">查看结果</a>
</div>
{% elif exam.status == 'closed' %}
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p class="text-red-800 font-medium">该考试已关闭</p>
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
</div>
{% elif schedule_status == 'not_started' %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 text-center">
<p class="text-blue-800 font-medium text-xl mb-2">⏰ 考试尚未开始</p>
<p class="text-blue-600">预定开始时间:{{ exam.scheduled_start.strftime('%Y-%m-%d %H:%M') }}</p>
{% if exam.scheduled_end %}
<p class="text-blue-600">预定结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
{% endif %}
<div class="mt-4">
<p class="text-sm text-blue-500 mb-2">距离开考还有:</p>
<div id="countdown" class="text-3xl font-bold text-blue-700"></div>
</div>
<a href="/exams" class="mt-4 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
</div>
<script>
(function() {
const startTime = new Date('{{ exam.scheduled_start.strftime("%Y-%m-%dT%H:%M:%S") }}').getTime();
function updateCountdown() {
const now = Date.now();
const diff = Math.max(0, Math.floor((startTime - now) / 1000));
if (diff <= 0) { location.reload(); return; }
const d = Math.floor(diff / 86400);
const h = Math.floor((diff % 86400) / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
let text = '';
if (d > 0) text += d + '天 ';
text += String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
document.getElementById('countdown').textContent = text;
setTimeout(updateCountdown, 1000);
}
updateCountdown();
})();
</script>
{% elif schedule_status == 'ended' %}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center">
<p class="text-gray-800 font-medium">该考试已结束</p>
<p class="text-gray-600 text-sm mt-1">结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a>
</div>
{% else %}
<!-- 顶部信息栏 -->
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-0 z-20">
<div class="flex justify-between items-center">
<div>
<h1 class="text-lg font-bold text-slate-900">{{ exam.title }}</h1>
<div class="mt-1 text-sm text-slate-500">
{{ exam.subject }} · {{ exam.duration }}分钟 · 满分{{ exam.total_score }}分
{% if exam.scheduled_end %}
· 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }}
{% endif %}
</div>
</div>
<div class="flex items-center space-x-4">
<div class="text-sm text-slate-500">
<span id="progress-text">0</span>/{{ questions|length }} 已答
</div>
<div class="flex items-center text-red-600 font-medium">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span id="timer">--:--:--</span>
</div>
<div id="tab-warning" class="hidden text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded">
切屏 <span id="tab-count">0</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="mt-3 w-full bg-slate-100 rounded-full h-1.5">
<div id="progress-bar" class="bg-primary h-1.5 rounded-full transition-all duration-300" style="width:0%"></div>
</div>
</div>
<div class="mt-4 flex gap-4">
<!-- 题号导航面板 -->
<div class="hidden lg:block w-48 flex-shrink-0">
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-24">
<div class="text-sm font-medium text-slate-700 mb-3">题目导航</div>
<div class="grid grid-cols-5 gap-2" id="nav-panel">
{% for q in questions %}
<button onclick="goToQuestion({{ loop.index0 }})" id="nav-{{ loop.index0 }}"
class="w-8 h-8 rounded-md text-xs font-medium border border-slate-200 bg-slate-50 text-slate-600 hover:border-primary hover:text-primary transition-colors flex items-center justify-center">
{{ loop.index }}
</button>
{% endfor %}
</div>
<div class="mt-4 space-y-1 text-xs text-slate-500">
<div class="flex items-center"><span class="w-3 h-3 rounded bg-primary mr-2"></span>当前题</div>
<div class="flex items-center"><span class="w-3 h-3 rounded bg-green-500 mr-2"></span>已答</div>
<div class="flex items-center"><span class="w-3 h-3 rounded bg-slate-200 mr-2"></span>未答</div>
</div>
<div class="mt-4 text-xs text-slate-400" id="save-status">自动保存中...</div>
</div>
</div>
<!-- 主答题区 -->
<div class="flex-1 min-w-0">
<!-- 移动端题号导航 -->
<div class="lg:hidden mb-4 bg-white shadow-sm rounded-lg p-3 border border-slate-200">
<div class="flex flex-wrap gap-1.5" id="nav-panel-mobile">
{% for q in questions %}
<button onclick="goToQuestion({{ loop.index0 }})" id="nav-m-{{ loop.index0 }}"
class="w-7 h-7 rounded text-xs font-medium border border-slate-200 bg-slate-50 text-slate-600 flex items-center justify-center">
{{ loop.index }}
</button>
{% endfor %}
</div>
</div>
<form id="exam-form" onsubmit="handleSubmit(event)">
{% for q in questions %}
<div class="question-card bg-white shadow-sm rounded-lg p-6 border border-slate-200 mb-4" data-index="{{ loop.index0 }}" style="{% if loop.index0 != 0 %}display:none{% endif %}">
<div class="flex items-start space-x-4">
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span>
<div class="flex-1 space-y-4">
<div class="flex justify-between items-start">
<div>
<span class="text-xs font-medium px-2 py-0.5 rounded
{% if q.type == 'choice' %}bg-blue-50 text-blue-700
{% elif q.type == 'fill' %}bg-green-50 text-green-700
{% else %}bg-purple-50 text-purple-700{% endif %}">
{% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %}
</span>
<p class="mt-2 text-lg text-slate-900">{{ q.content }}</p>
{% if q.get('images') %}
<div class="mt-2 flex flex-wrap gap-2">
{% for img in q.images %}
<img src="{{ img }}" class="max-h-48 rounded border border-slate-200 cursor-pointer" onclick="window.open(this.src)" alt="题目图片">
{% endfor %}
</div>
{% endif %}
</div>
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">{{ q.score }}分)</span>
</div>
{% if q.type == 'choice' %}
<div class="space-y-3">
{% for opt in q.options %}
<label class="flex items-center space-x-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer transition-colors option-label" data-qid="{{ q.id }}">
<input type="radio" name="q-{{ q.id }}" value="{{ ['A','B','C','D'][loop.index0] }}" class="h-4 w-4 text-primary border-slate-300 focus:ring-primary answer-input" data-qid="{{ q.id }}" onchange="onAnswerChange({{ q.id }})">
<span class="text-slate-700">{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}</span>
</label>
{% endfor %}
</div>
{% elif q.type == 'fill' %}
<input type="text" name="q-{{ q.id }}" class="w-full px-3 py-2 border border-slate-300 rounded-md answer-input" placeholder="请输入答案" data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})">
{% else %}
<textarea name="q-{{ q.id }}" rows="6" class="w-full rounded-lg border-slate-300 shadow-sm focus:border-primary focus:ring-primary border px-3 py-2 answer-input" placeholder="请输入您的答案..." data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})"></textarea>
<div class="flex items-center gap-2 mt-2">
<button type="button" onclick="examUpload({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📷 上传图片</button>
<button type="button" onclick="examCamera({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📸 拍照上传</button>
</div>
<div id="img-preview-{{ q.id }}" class="flex flex-wrap gap-2 mt-2"></div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
<!-- 分页控制 -->
<div class="flex justify-between items-center mt-4 mb-8">
<button type="button" id="prev-btn" onclick="prevQuestion()" class="px-5 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed" disabled>
← 上一题
</button>
<span class="text-sm text-slate-500">
<span id="current-num">1</span> / {{ questions|length }} 题
</span>
<button type="button" id="next-btn" onclick="nextQuestion()" class="px-5 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-blue-700">
下一题 →
</button>
<button type="submit" id="submit-btn" class="px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 hidden">
提交试卷
</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% if not existing_submission and exam.status != 'closed' and schedule_status == 'available' %}
<script>
const EXAM_ID = {{ exam.id }};
const TOTAL_Q = {{ questions|length }};
const DURATION = {{ exam.duration }};
const STORAGE_KEY = `exam_${EXAM_ID}_answers`;
const TIMER_KEY = `exam_${EXAM_ID}_start`;
{% if exam.scheduled_end %}
const SCHEDULED_END = new Date('{{ exam.scheduled_end.strftime("%Y-%m-%dT%H:%M:%S") }}').getTime();
{% else %}
const SCHEDULED_END = null;
{% endif %}
let currentIndex = 0;
let answers = {};
let tabSwitchCount = 0;
// ===== 图片上传 =====
function examUpload(qid) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
inp.onchange = () => { if (inp.files.length) examUploadFiles(inp.files, qid); };
inp.click();
}
function examCamera(qid) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'environment';
inp.onchange = () => { if (inp.files.length) examUploadFiles(inp.files, qid); };
inp.click();
}
async function examUploadFiles(files, qid) {
for (const file of files) {
if (file.size > 10*1024*1024) { alert('文件不能超过10MB'); continue; }
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/upload', {method:'POST', body: fd});
const d = await res.json();
if (d.success) {
const ta = document.querySelector(`[name="q-${qid}"]`);
if (ta) {
const tag = `\n[img:${d.url}]\n`;
ta.value += tag;
onAnswerChange(qid);
}
const preview = document.getElementById('img-preview-' + qid);
if (preview) {
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `<img src="${d.url}" class="w-16 h-16 object-cover rounded border border-slate-200"><button type="button" onclick="this.parentElement.remove()" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
preview.appendChild(div);
}
} else { alert(d.message); }
} catch(e) { alert('上传失败'); }
}
}
// 初始化从服务器草稿或localStorage恢复答案
function initAnswers() {
// 优先从服务器草稿恢复
{% if draft %}
const serverDraft = {{ draft.answers | tojson }};
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
answers = serverDraft;
restoreAnswersToForm();
saveToLocal();
updateNavStatus();
return;
}
{% endif %}
// 其次从localStorage恢复
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
answers = JSON.parse(saved);
restoreAnswersToForm();
} catch(e) {}
}
updateNavStatus();
}
function restoreAnswersToForm() {
for (const [qid, val] of Object.entries(answers)) {
const radio = document.querySelector(`input[name="q-${qid}"][value="${val}"]`);
if (radio) { radio.checked = true; continue; }
const input = document.querySelector(`[name="q-${qid}"]`);
if (input) input.value = val;
// 恢复图片预览
const imgMatches = val.matchAll(/\[img:(\/static\/uploads\/[^\]]+)\]/g);
const preview = document.getElementById('img-preview-' + qid);
if (preview) {
for (const m of imgMatches) {
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `<img src="${m[1]}" class="w-16 h-16 object-cover rounded border border-slate-200"><button type="button" onclick="this.parentElement.remove()" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
preview.appendChild(div);
}
}
}
}
function onAnswerChange(qid) {
const radio = document.querySelector(`input[name="q-${qid}"]:checked`);
if (radio) {
answers[String(qid)] = radio.value;
} else {
const input = document.querySelector(`[name="q-${qid}"]`);
if (input) answers[String(qid)] = input.value;
}
saveToLocal();
updateNavStatus();
debounceSaveToServer();
}
function saveToLocal() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(answers));
}
let saveTimer = null;
function debounceSaveToServer() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveToServer, 3000);
}
function saveToServer() {
fetch(`/api/exams/${EXAM_ID}/save-draft`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({answers})
}).then(r => r.json()).then(data => {
if (data.success) {
document.getElementById('save-status').textContent = '已保存 ' + new Date().toLocaleTimeString();
}
}).catch(() => {});
}
// 题号导航状态
function updateNavStatus() {
let answeredCount = 0;
for (let i = 0; i < TOTAL_Q; i++) {
const qid = getQidByIndex(i);
const isAnswered = answers[String(qid)] && answers[String(qid)].trim() !== '';
const isCurrent = i === currentIndex;
if (isAnswered) answeredCount++;
['nav-', 'nav-m-'].forEach(prefix => {
const btn = document.getElementById(prefix + i);
if (!btn) return;
btn.className = 'w-' + (prefix === 'nav-' ? '8 h-8' : '7 h-7') + ' rounded' + (prefix === 'nav-' ? '-md' : '') + ' text-xs font-medium border flex items-center justify-center transition-colors ';
if (isCurrent) {
btn.className += 'border-primary bg-primary text-white';
} else if (isAnswered) {
btn.className += 'border-green-400 bg-green-500 text-white';
} else {
btn.className += 'border-slate-200 bg-slate-50 text-slate-600 hover:border-primary hover:text-primary';
}
});
}
document.getElementById('progress-text').textContent = answeredCount;
document.getElementById('progress-bar').style.width = (answeredCount / TOTAL_Q * 100) + '%';
}
function getQidByIndex(idx) {
const qids = [{% for q in questions %}{{ q.id }}{% if not loop.last %},{% endif %}{% endfor %}];
return qids[idx];
}
// 分页切换
function showQuestion(idx) {
document.querySelectorAll('.question-card').forEach((card, i) => {
card.style.display = i === idx ? '' : 'none';
});
currentIndex = idx;
document.getElementById('current-num').textContent = idx + 1;
document.getElementById('prev-btn').disabled = idx === 0;
// 渲染当前题目的数学公式
const card = document.querySelectorAll('.question-card')[idx];
if (card && typeof renderMathInElement === 'function') {
renderMathInElement(card, {
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
throwOnError: false
});
}
if (idx === TOTAL_Q - 1) {
document.getElementById('next-btn').classList.add('hidden');
document.getElementById('submit-btn').classList.remove('hidden');
} else {
document.getElementById('next-btn').classList.remove('hidden');
document.getElementById('submit-btn').classList.add('hidden');
}
updateNavStatus();
window.scrollTo({top: 0, behavior: 'smooth'});
}
function goToQuestion(idx) { showQuestion(idx); }
function prevQuestion() { if (currentIndex > 0) showQuestion(currentIndex - 1); }
function nextQuestion() { if (currentIndex < TOTAL_Q - 1) showQuestion(currentIndex + 1); }
// 计时器(持久化,支持预定结束时间)
function initTimer() {
let startTime = localStorage.getItem(TIMER_KEY);
if (!startTime) {
startTime = Date.now();
localStorage.setItem(TIMER_KEY, startTime);
} else {
startTime = parseInt(startTime);
}
const durationEnd = startTime + DURATION * 60 * 1000;
// 如果有预定结束时间,取两者中较早的
const endTime = SCHEDULED_END ? Math.min(durationEnd, SCHEDULED_END) : durationEnd;
function tick() {
const remaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
if (remaining <= 0) {
document.getElementById('timer').textContent = '00:00:00';
autoSubmit();
return;
}
const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
document.getElementById('timer').textContent =
`${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
// 最后5分钟变红闪烁
if (remaining <= 300) {
document.getElementById('timer').classList.add('animate-pulse');
}
setTimeout(tick, 1000);
}
tick();
}
function autoSubmit() {
alert('考试时间到,系统将自动提交试卷!');
doSubmit();
}
// 防切屏
function initTabDetection() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
tabSwitchCount++;
document.getElementById('tab-count').textContent = tabSwitchCount;
document.getElementById('tab-warning').classList.remove('hidden');
if (tabSwitchCount >= 3) {
alert('警告:您已切屏' + tabSwitchCount + '次!频繁切屏可能被视为作弊行为。');
}
}
});
}
// 提交
function handleSubmit(e) {
e.preventDefault();
// 检查未答题数
let unanswered = 0;
for (let i = 0; i < TOTAL_Q; i++) {
const qid = getQidByIndex(i);
if (!answers[String(qid)] || answers[String(qid)].trim() === '') unanswered++;
}
let msg = '确定提交试卷?提交后不可修改。';
if (unanswered > 0) msg = `您还有 ${unanswered} 道题未作答,${msg}`;
if (!confirm(msg)) return;
doSubmit();
}
function doSubmit() {
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.textContent = '正在提交...';
fetch(`/api/exams/${EXAM_ID}/submit`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({answers})
}).then(r => r.json()).then(data => {
if (data.success) {
// 清除本地存储
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TIMER_KEY);
alert('提交成功!');
window.location.href = `/exams/${EXAM_ID}/result`;
} else {
alert(data.message);
btn.disabled = false;
btn.textContent = '提交试卷';
}
}).catch(() => {
alert('提交失败,请重试');
btn.disabled = false;
btn.textContent = '提交试卷';
});
}
// 初始化
initAnswers();
initTimer();
initTabDetection();
showQuestion(0);
// 离开页面提醒
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = '';
});
</script>
{% endif %}
{% endblock %}

192
templates/exam_grade.html Normal file
View File

@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}批改试卷 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-slate-900">批改试卷</h1>
<p class="text-sm text-slate-500 mt-1">{{ exam.title }} · 考生:{{ submission.user.name if submission.user else '未知' }} · 提交时间:{{ submission.submitted_at }}</p>
</div>
<div class="flex items-center space-x-3">
{% if next_ungraded %}
<a href="/exams/{{ exam.id }}/grade/{{ next_ungraded }}" class="inline-flex items-center px-3 py-1.5 bg-yellow-50 border border-yellow-300 text-yellow-700 text-sm font-medium rounded-md hover:bg-yellow-100">
下一个未批改 →
</a>
{% endif %}
<a href="/exams/{{ exam.id }}/submissions" class="text-sm text-slate-500 hover:text-slate-700">← 返回提交列表</a>
</div>
</div>
<!-- 批改状态提示 -->
{% if submission.graded %}
<div class="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center justify-between">
<span class="text-sm text-green-700">该试卷已批改完成,得分:{{ submission.score }}/{{ exam.total_score }},批改人:{{ submission.graded_by }}</span>
<span class="text-xs text-green-500">可重新批改覆盖</span>
</div>
{% endif %}
{% for q in questions %}
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="flex items-start space-x-4">
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span>
<div class="flex-1 space-y-3">
<div class="flex justify-between items-start">
<div>
<span class="text-xs font-medium px-2 py-0.5 rounded
{% if q.type == 'choice' %}bg-blue-50 text-blue-700
{% elif q.type == 'fill' %}bg-green-50 text-green-700
{% else %}bg-purple-50 text-purple-700{% endif %}">
{% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %}
</span>
<p class="mt-2 text-lg text-slate-900">{{ q.content }}</p>
{% if q.get('images') %}
<div class="mt-2 flex flex-wrap gap-2">
{% for img in q.images %}
<img src="{{ img }}" class="max-h-48 rounded border border-slate-200 cursor-pointer" onclick="window.open(this.src)" alt="题目图片">
{% endfor %}
</div>
{% endif %}
</div>
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">{{ q.score }}分)</span>
</div>
{% if q.type == 'choice' %}
<div class="space-y-2">
{% for opt in q.options %}
{% set letter = ['A','B','C','D'][loop.index0] %}
{% set is_answer = letter == q.get('answer','') %}
{% set is_selected = letter == answers.get(q.id|string,'') %}
<div class="flex items-center space-x-3 p-2 rounded border
{% if is_answer %}border-green-300 bg-green-50
{% elif is_selected and not is_answer %}border-red-300 bg-red-50
{% else %}border-slate-100{% endif %}">
<span class="text-sm text-slate-700">{{ letter }}. {{ opt }}</span>
{% if is_selected %}<span class="text-xs {% if is_answer %}text-green-600{% else %}text-red-500{% endif %} font-medium">← 考生选择</span>{% endif %}
{% if is_answer %}<span class="text-xs text-green-600 font-medium">✓ 正确</span>{% endif %}
</div>
{% endfor %}
<div class="text-sm text-slate-500 mt-1">
自动判分:{% if answers.get(q.id|string,'') == q.get('answer','') %}
<span class="text-green-600 font-medium">+{{ q.score }}分</span>
{% else %}
<span class="text-red-500 font-medium">0分</span>
{% endif %}
</div>
</div>
{% else %}
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div class="text-sm text-slate-500 mb-1">考生答案:</div>
<div class="text-slate-800 whitespace-pre-wrap">{{ answers.get(q.id|string, '(未作答)') | render_images }}</div>
</div>
{% if q.get('answer') %}
<div class="p-3 bg-green-50 rounded-lg border border-green-200">
<div class="text-sm text-green-600 mb-1">参考答案:</div>
<div class="text-green-800 whitespace-pre-wrap">{{ q.answer }}</div>
</div>
{% endif %}
<div class="flex items-center space-x-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, 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 / 2)|int }})" class="px-2 py-0.5 text-xs rounded border border-yellow-200 text-yellow-600 hover:bg-yellow-50">{{ (q.score / 2)|int }}分</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>
{% endif %}
{% if q.get('explanation') %}
<div class="p-3 bg-indigo-50 rounded-lg border border-indigo-200 mt-2">
<div class="text-sm text-indigo-600 mb-1 font-medium">题目解析:</div>
<div class="text-indigo-900 text-sm whitespace-pre-wrap">{{ q.explanation }}</div>
</div>
{% endif %}
</div>
<div class="flex justify-between items-center bg-white shadow-sm rounded-lg p-6 border border-slate-200 sticky bottom-4">
<div class="text-lg font-medium text-slate-900">总分:<span id="total-score" class="text-primary">0</span> / {{ exam.total_score }}</div>
<div class="flex items-center space-x-3">
{% if next_ungraded %}
<span class="text-sm text-slate-400">批改后自动跳转下一个</span>
{% endif %}
<button onclick="submitGrade()" class="px-8 py-3 bg-primary text-white rounded-lg font-medium hover:bg-blue-700">提交批改</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function quickScore(qid, score) {
document.getElementById('score-' + qid).value = score;
recalcTotal();
}
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;
});
document.getElementById('total-score').textContent = total;
}
document.querySelectorAll('.grade-score').forEach(input => {
input.addEventListener('input', recalcTotal);
});
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 %}
{% endfor %}
fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({scores})
}).then(r=>r.json()).then(data=>{
if(data.success) {
alert('批改完成!总分:'+data.total_score);
{% if next_ungraded %}
window.location.href='/exams/{{ exam.id }}/grade/{{ next_ungraded }}';
{% else %}
window.location.href='/exams/{{ exam.id }}/submissions';
{% endif %}
}
else alert(data.message);
}).catch(()=>alert('批改失败'));
}
</script>
{% endblock %}

249
templates/exam_list.html Normal file
View File

@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title %}考试系统 - 智联青云{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- 头部区域 -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<div>
<h1 class="text-2xl font-bold text-slate-900 flex items-center">
<span class="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mr-3 shadow-sm border border-indigo-100">📝</span>
考试中心
</h1>
<p class="text-slate-500 text-sm mt-1 ml-13">海量真题与模拟卷,随时随地进行练习与自测。</p>
</div>
<div class="flex flex-wrap items-center gap-3">
{% if user and (user.role == 'admin' or user.role == 'teacher') %}
<a href="/exams/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white rounded-xl hover:bg-blue-600 shadow-sm text-sm font-medium transition-all transform hover:-translate-y-0.5">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
创建新试卷
</a>
{% endif %}
</div>
</div>
<!-- 搜索区域 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10 glass-panel">
<form method="GET" action="/exams" class="flex flex-wrap items-center gap-3 w-full">
<div class="relative w-full sm:w-auto flex-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="搜索试卷名称..." class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all">
</div>
<div class="relative w-full sm:w-auto">
<select name="subject" class="w-full sm:w-32 appearance-none px-4 py-2.5 pl-10 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer">
<option value="">所有科目</option>
{% for subject in all_subjects %}
<option value="{{ subject }}" {% if subject_filter == subject %}selected{% endif %}>{{ subject }}</option>
{% endfor %}
</select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" 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>
</div>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<button type="submit" class="w-full sm:w-auto px-5 py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm">
搜索
</button>
{% if search_query or subject_filter %}
<a href="/exams" class="w-full sm:w-auto px-5 py-2.5 bg-slate-100 text-slate-600 rounded-xl hover:bg-slate-200 text-sm font-medium transition-colors text-center border border-slate-200">
重置
</a>
{% endif %}
</form>
{% if search_query or subject_filter %}
<div class="mt-3 w-full text-xs text-slate-500 bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 inline-block">
<span class="font-medium text-slate-700">筛选结果:</span>
{% if search_query %}包含 "<span class="text-primary">{{ search_query }}</span>"{% endif %}
{% if subject_filter %}{% if search_query %}{% endif %}科目为 "<span class="text-primary">{{ subject_filter }}</span>"{% endif %}
<span class="ml-2 text-slate-400">共找到 {{ exams|length }} 份试卷</span>
</div>
{% endif %}
</div>
<!-- 试卷列表 -->
{% if exams|length == 0 %}
<div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400 bg-white rounded-2xl border border-slate-100 border-dashed">
<svg class="w-16 h-16 mb-4 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 13h6m-3-3v6m5 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"/></svg>
<p>暂无符合条件的试卷</p>
{% if user and (user.role == 'admin' or user.role == 'teacher') %}
<p class="text-sm mt-2">点击上方"创建新试卷"按钮开始命题吧</p>
{% endif %}
</div>
{% else %}
<div class="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{% for exam in exams %}
{% set subjectColor = 'blue' %}
{% set subjectGradient = 'from-blue-500 to-cyan-400' %}
{% set subjectIcon = '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' %}
{% if exam.subject == '数学' %}
{% set subjectColor = 'indigo' %}
{% set subjectGradient = 'from-indigo-500 to-purple-400' %}
{% set subjectIcon = 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z' %}
{% elif exam.subject == '英语' %}
{% set subjectColor = 'rose' %}
{% set subjectGradient = 'from-rose-500 to-pink-400' %}
{% set subjectIcon = 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129' %}
{% elif exam.subject in ['物理', '化学', '生物'] %}
{% set subjectColor = 'emerald' %}
{% set subjectGradient = 'from-emerald-500 to-teal-400' %}
{% set subjectIcon = 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z' %}
{% elif exam.subject in ['历史', '地理', '政治'] %}
{% set subjectColor = 'amber' %}
{% set subjectGradient = 'from-amber-500 to-orange-400' %}
{% set subjectIcon = 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z' %}
{% endif %}
<div class="group bg-white rounded-3xl shadow-sm border border-slate-100 overflow-hidden hover-card-up flex flex-col relative">
<!-- 卡片封面海报 -->
<div class="relative h-28 bg-gradient-to-r {{ subjectGradient }} overflow-hidden">
<div class="absolute inset-0 bg-grid-pattern opacity-10"></div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-white/20 blur-2xl rounded-full"></div>
{% if exam.status == 'closed' %}
<span class="absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-white text-xs font-medium px-3 py-1 rounded-full shadow-sm border border-white/10">已关闭</span>
{% else %}
<span class="absolute top-4 right-4 bg-white/90 backdrop-blur-md text-{{subjectColor}}-600 text-xs font-bold px-3 py-1 rounded-full shadow-sm flex items-center">
<span class="w-1.5 h-1.5 rounded-full bg-{{subjectColor}}-500 mr-1.5 animate-pulse"></span>进行中
</span>
{% endif %}
</div>
<!-- 悬浮图标 -->
<div class="absolute top-[4.5rem] left-6">
<div class="w-14 h-14 bg-white rounded-2xl flex items-center justify-center text-{{subjectColor}}-500 shadow-md border-4 border-white group-hover:scale-110 transition-transform duration-300">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{subjectIcon}}"/></svg>
</div>
</div>
<div class="p-6 pt-12 flex-1 flex flex-col">
<div class="flex items-start mb-3">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg bg-slate-100 text-slate-600 text-xs font-medium border border-slate-200">
{{ exam.subject }}
</span>
</div>
<h3 class="text-lg font-bold text-slate-900 mb-3 line-clamp-2 group-hover:text-{{subjectColor}}-600 transition-colors flex-1">{{ exam.title }}</h3>
<div class="grid grid-cols-2 gap-3 mb-5 bg-slate-50 p-3 rounded-xl border border-slate-100">
<div>
<span class="text-[10px] text-slate-400 block mb-0.5">满分 / 题目</span>
<span class="text-xs font-semibold text-slate-700">{{ exam.total_score }}分 / {{ exam.questions|fromjson|length }}题</span>
</div>
<div>
<span class="text-[10px] text-slate-400 block mb-0.5">考试时长</span>
<span class="text-xs font-semibold text-slate-700">{{ exam.duration }} 分钟</span>
</div>
</div>
{% if exam.scheduled_start or exam.scheduled_end %}
<div class="mb-4 text-xs text-slate-500 flex flex-col gap-1 border-l-2 border-{{subjectColor}}-200 pl-2">
{% if exam.scheduled_start %}
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
开始:{{ exam.scheduled_start.strftime('%m-%d %H:%M') }}
</div>
{% endif %}
{% if exam.scheduled_end %}
<div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }}
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-auto pt-4 border-t border-slate-100 flex items-center justify-between gap-2 flex-wrap">
{% set sub = user_submissions.get(exam.id) %}
{% if sub %}
<div class="flex items-center bg-slate-50 px-2.5 py-1.5 rounded-lg border border-slate-200">
{% if sub.graded %}
<span class="text-xs font-medium text-slate-600 mr-2 border-r border-slate-200 pr-2">已批改</span>
<span class="text-sm font-bold text-{{subjectColor}}-600">{{ sub.score }} <span class="text-[10px] text-slate-400 font-normal">/ {{ exam.total_score }}</span></span>
{% else %}
<span class="text-xs font-medium text-amber-600 flex items-center">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
待批改
</span>
{% endif %}
</div>
<a href="/exams/{{ exam.id }}/result" class="ml-auto inline-flex items-center px-4 py-2 border border-slate-200 text-xs font-medium rounded-xl text-slate-700 bg-white hover:bg-slate-50 hover:border-slate-300 transition-colors shadow-sm">
查看试卷
</a>
{% else %}
<div class="text-xs text-slate-400 flex items-center">
<span class="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center mr-1.5 text-[10px]">{{ exam.creator.name[0] if exam.creator else '?' }}</span>
{{ exam.creator.name if exam.creator else '未知出题人' }}
</div>
{% if exam.status != 'closed' %}
<a href="/exams/{{ exam.id }}" class="ml-auto inline-flex items-center px-5 py-2 border border-transparent text-sm font-medium rounded-xl shadow-sm text-white bg-{{subjectColor}}-600 hover:bg-{{subjectColor}}-700 transition-colors transform hover:-translate-y-0.5">
开始考试
</a>
{% else %}
<span class="ml-auto text-sm text-slate-400 font-medium px-4 py-2 bg-slate-50 rounded-xl">已关闭</span>
{% endif %}
{% endif %}
</div>
{% if user and (user.role == 'admin' or user.role == 'teacher') %}
<div class="mt-4 pt-3 border-t border-slate-100/50 flex flex-wrap gap-2 justify-end">
<a href="/exams/{{ exam.id }}/submissions" class="px-3 py-1.5 bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 rounded-lg text-xs font-medium transition-colors border border-slate-200">提交情况</a>
<a href="/exams/{{ exam.id }}/print" class="px-3 py-1.5 bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 rounded-lg text-xs font-medium transition-colors border border-slate-200">打印试卷</a>
{% if user.role == 'admin' or exam.creator_id == user.id %}
{% if exam.status == 'available' %}
<button onclick="toggleExamStatus({{ exam.id }}, 'closed')" class="px-3 py-1.5 bg-orange-50 text-orange-600 hover:bg-orange-100 rounded-lg text-xs font-medium transition-colors border border-orange-200">关闭考试</button>
{% else %}
<button onclick="toggleExamStatus({{ exam.id }}, 'available')" class="px-3 py-1.5 bg-green-50 text-green-600 hover:bg-green-100 rounded-lg text-xs font-medium transition-colors border border-green-200">开放考试</button>
{% endif %}
<button onclick="deleteExam({{ exam.id }})" class="px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-lg text-xs font-medium transition-colors border border-red-200">删除</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleExamStatus(examId, status) {
const label = status === 'closed' ? '关闭' : '开放';
if (!confirm(`确定${label}该考试?`)) return;
fetch(`/api/exams/${examId}/status`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status: status })
}).then(r => r.json()).then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message);
}
}).catch(() => alert('操作失败'));
}
function deleteExam(examId) {
if (!confirm('确定删除该试卷?此操作不可恢复,所有提交记录也将被删除。')) return;
fetch(`/api/exams/${examId}`, {
method: 'DELETE'
}).then(r => r.json()).then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message);
}
}).catch(() => alert('删除失败'));
}
</script>
{% endblock %}

105
templates/exam_print.html Normal file
View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ exam.title }} - 打印版</title>
<style>
@media print { .no-print { display: none !important; } body { margin: 0; } }
body { font-family: 'SimSun', 'STSong', serif; color: #000; background: #fff; max-width: 210mm; margin: 0 auto; padding: 20mm 15mm; font-size: 14px; line-height: 1.8; }
.header { text-align: center; border-bottom: 3px double #000; padding-bottom: 15px; margin-bottom: 20px; }
.header h1 { font-size: 22px; margin: 0 0 8px 0; letter-spacing: 2px; }
.header .info { font-size: 12px; color: #333; }
.header .info span { margin: 0 15px; }
.student-info { border: 1px solid #000; padding: 10px 15px; margin-bottom: 20px; font-size: 13px; }
.student-info span { margin-right: 40px; }
.student-info .blank { display: inline-block; width: 120px; border-bottom: 1px solid #000; }
.section-title { font-size: 16px; font-weight: bold; margin: 25px 0 15px 0; padding-left: 5px; border-left: 4px solid #000; }
.question { margin-bottom: 18px; page-break-inside: avoid; }
.question .q-num { font-weight: bold; }
.question .q-score { float: right; font-size: 12px; color: #555; }
.options { margin: 8px 0 0 25px; }
.options .opt { margin-bottom: 4px; }
.answer-area { margin: 8px 0 0 25px; }
.answer-line { border-bottom: 1px solid #ccc; height: 30px; margin-bottom: 2px; }
.footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }
.print-btn { position: fixed; top: 20px; right: 20px; padding: 10px 24px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; z-index: 100; }
.print-btn:hover { background: #2563eb; }
</style>
</head>
<body>
<button class="print-btn no-print" onclick="window.print()">打印 / 导出PDF</button>
<div class="header">
<h1>{{ exam.title }}</h1>
<div class="info">
<span>科目:{{ exam.subject }}</span>
<span>考试时长:{{ exam.duration }}分钟</span>
<span>满分:{{ exam.total_score }}分</span>
<span>出题人:{{ exam.creator.name if exam.creator else '' }}</span>
</div>
</div>
<div class="student-info">
<span>姓名:<span class="blank"></span></span>
<span>考号:<span class="blank"></span></span>
<span>得分:<span class="blank"></span></span>
</div>
{% set ns = namespace(choice_idx=0, fill_idx=0, text_idx=0) %}
{% set choices = [] %}
{% set fills = [] %}
{% set texts = [] %}
{% for q in questions %}
{% if q.type == 'choice' %}{% if choices.append(q) %}{% endif %}
{% elif q.type == 'fill' %}{% if fills.append(q) %}{% endif %}
{% else %}{% if texts.append(q) %}{% endif %}
{% endif %}
{% endfor %}
{% if choices|length > 0 %}
<div class="section-title">一、选择题(共{{ choices|length }}题)</div>
{% for q in choices %}
<div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span>
{% if q.options %}
<div class="options">
{% for opt in q.options %}
<div class="opt">{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% if fills|length > 0 %}
<div class="section-title">二、填空题(共{{ fills|length }}题)</div>
{% for q in fills %}
<div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span>
<div class="answer-area">
<div class="answer-line"></div>
</div>
</div>
{% endfor %}
{% endif %}
{% if texts|length > 0 %}
<div class="section-title">三、解答题(共{{ texts|length }}题)</div>
{% for q in texts %}
<div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span>
<div class="answer-area">
{% for i in range(8) %}
<div class="answer-line"></div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
<div class="footer">--- 试卷结束 ---</div>
</body>
</html>

View File

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}考试结果 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-slate-900">考试结果</h1>
<a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a>
</div>
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h2 class="text-xl font-bold text-slate-900">{{ exam.title }}</h2>
<div class="mt-2 flex items-center text-sm text-slate-500 space-x-4">
<span>{{ exam.subject }}</span>
<span>满分{{ exam.total_score }}分</span>
<span>提交时间:{{ submission.submitted_at }}</span>
</div>
<div class="mt-4 p-4 rounded-lg {% if score_hidden %}bg-blue-50 border border-blue-200{% elif submission.graded %}bg-green-50 border border-green-200{% else %}bg-yellow-50 border border-yellow-200{% endif %}">
{% if score_hidden %}
<div class="text-lg font-medium text-blue-700">成绩尚未公布</div>
<div class="text-sm text-blue-600 mt-1">成绩将于 {{ exam.score_release_time.strftime('%Y年%m月%d日 %H:%M') }} 公布</div>
{% elif submission.graded %}
<div class="text-3xl font-bold text-green-700">{{ submission.score }} <span class="text-lg font-normal text-green-600">/ {{ exam.total_score }}</span></div>
<div class="text-sm text-green-600 mt-1">批改人:{{ submission.graded_by }}</div>
{% else %}
<div class="text-lg font-medium text-yellow-700">待批改</div>
<div class="text-sm text-yellow-600">选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改</div>
{% endif %}
</div>
</div>
{% for q in questions %}
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="flex items-start space-x-4">
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span>
<div class="flex-1 space-y-3">
<div class="flex justify-between">
<p class="text-lg text-slate-900">{{ q.content }}</p>
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">{{ q.score }}分)</span>
</div>
{% if q.get('images') %}
<div class="flex flex-wrap gap-2">
{% for img in q.images %}
<img src="{{ img }}" class="max-h-48 rounded border border-slate-200 cursor-pointer" onclick="window.open(this.src)" alt="题目图片">
{% endfor %}
</div>
{% endif %}
{% if q.type == 'choice' %}
<div class="space-y-2">
{% for opt in q.options %}
{% set letter = ['A','B','C','D'][loop.index0] %}
{% set is_answer = letter == q.get('answer','') %}
{% set is_selected = letter == answers.get(q.id|string,'') %}
<div class="flex items-center space-x-3 p-3 rounded-lg border
{% if is_answer %}border-green-300 bg-green-50
{% elif is_selected and not is_answer %}border-red-300 bg-red-50
{% else %}border-slate-200{% endif %}">
{% if is_selected %}
<svg class="w-5 h-5 {% if is_answer %}text-green-600{% else %}text-red-500{% endif %}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
{% else %}
<div class="w-5 h-5"></div>
{% endif %}
<span class="text-slate-700">{{ letter }}. {{ opt }}</span>
{% if is_answer %}<span class="text-xs text-green-600 font-medium ml-2">✓ 正确答案</span>{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
<div class="text-sm text-slate-500 mb-1">您的答案:</div>
<div class="text-slate-800">{{ answers.get(q.id|string, '(未作答)') | render_images }}</div>
</div>
{% if q.get('answer') %}
<div class="p-3 bg-green-50 rounded-lg border border-green-200">
<div class="text-sm text-green-600 mb-1">参考答案:</div>
<div class="text-green-800">{{ q.answer }}</div>
</div>
{% endif %}
{% endif %}
{% if q.get('explanation') %}
<div class="p-3 bg-indigo-50 rounded-lg border border-indigo-200">
<div class="text-sm text-indigo-600 mb-1 font-medium">解析:</div>
<div class="text-indigo-900 text-sm">{{ q.explanation }}</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}提交列表 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-slate-900">提交列表</h1>
<p class="text-sm text-slate-500 mt-1">{{ exam.title }} · 共{{ submissions|length }}份提交</p>
</div>
<div class="flex items-center space-x-3">
{% if next_ungraded %}
<a href="/exams/{{ exam.id }}/grade/{{ next_ungraded }}" class="inline-flex items-center px-3 py-1.5 bg-yellow-50 border border-yellow-300 text-yellow-700 text-sm font-medium rounded-md hover:bg-yellow-100">
开始批改 →
</a>
{% endif %}
<a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a>
</div>
</div>
<!-- 统计摘要 -->
{% if stats %}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-4 text-center">
<div class="text-2xl font-bold text-slate-900">{{ stats.total }}</div>
<div class="text-xs text-slate-500 mt-1">总提交</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-green-200 p-4 text-center">
<div class="text-2xl font-bold text-green-600">{{ stats.graded }}</div>
<div class="text-xs text-slate-500 mt-1">已批改</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-yellow-200 p-4 text-center">
<div class="text-2xl font-bold text-yellow-600">{{ stats.ungraded }}</div>
<div class="text-xs text-slate-500 mt-1">待批改</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-blue-200 p-4 text-center">
<div class="text-2xl font-bold text-blue-600">{{ stats.avg_score }}</div>
<div class="text-xs text-slate-500 mt-1">平均分</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-purple-200 p-4 text-center">
<div class="text-2xl font-bold text-purple-600">{{ stats.max_score }}</div>
<div class="text-xs text-slate-500 mt-1">最高分</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-4 text-center">
<div class="text-2xl font-bold text-slate-600">{{ stats.min_score }}</div>
<div class="text-xs text-slate-500 mt-1">最低分</div>
</div>
</div>
{% endif %}
{% if submissions|length == 0 %}
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-12 text-center text-slate-500">暂无提交</div>
{% else %}
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-slate-200">
{% for sub in submissions %}
<li>
<div class="px-4 py-4 sm:px-6 hover:bg-slate-50 transition-colors
{% if sub.graded %}border-l-4 border-l-green-400{% else %}border-l-4 border-l-yellow-400{% endif %}">
<div class="flex items-center justify-between">
<div class="flex flex-col">
<p class="text-sm font-medium text-slate-900">{{ sub.user_name }}</p>
<div class="mt-1 text-sm text-slate-500">提交时间:{{ sub.submitted_at }}</div>
</div>
<div class="flex items-center space-x-3">
{% if sub.graded %}
<div class="text-right">
<span class="text-sm font-medium text-green-600">{{ sub.score }}/{{ exam.totalScore }}分</span>
<div class="text-xs text-slate-400">批改人:{{ sub.graded_by }}</div>
</div>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">待批改</span>
{% endif %}
<a href="/exams/{{ exam.id }}/grade/{{ sub.id }}" class="inline-flex items-center px-3 py-1.5 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50">
{% if sub.graded %}查看{% else %}批改{% endif %}
</a>
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endblock %}

936
templates/forum.html Normal file
View File

@@ -0,0 +1,936 @@
{% extends "base.html" %}
{% block title %}社区论坛 - 智联青云{% endblock %}
{% block content %}
<style>
.forum-grid{display:grid;grid-template-columns:1fr 280px;gap:20px}
@media(max-width:1024px){.forum-grid{grid-template-columns:1fr}}
.toast{position:fixed;top:20px;right:20px;z-index:9999;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;animation:slideIn .3s ease;box-shadow:0 4px 12px rgba(0,0,0,.15)}
.toast-success{background:#10b981}.toast-error{background:#ef4444}.toast-info{background:#3b82f6}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.reaction-btn{transition:all .2s;cursor:pointer;padding:2px 8px;border-radius:20px;border:1px solid #e2e8f0;font-size:13px;display:inline-flex;align-items:center;gap:3px}
.reaction-btn:hover{transform:scale(1.15);border-color:#93c5fd;background:#eff6ff}
.reaction-btn.active{background:#dbeafe;border-color:#60a5fa}
.poll-bar{height:28px;border-radius:6px;background:#f1f5f9;overflow:hidden;position:relative;margin:4px 0}
.poll-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,#3b82f6,#60a5fa);transition:width .6s ease;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#fff;font-weight:500;min-width:fit-content}
.poll-bar.voted .poll-fill{background:linear-gradient(90deg,#10b981,#34d399)}
.stat-card{border-radius:12px;padding:14px;color:#fff}
.stat-card.blue{background:linear-gradient(135deg,#3b82f6,#2563eb)}
.stat-card.green{background:linear-gradient(135deg,#10b981,#059669)}
.stat-card.orange{background:linear-gradient(135deg,#f59e0b,#d97706)}
.stat-card.purple{background:linear-gradient(135deg,#8b5cf6,#7c3aed)}
.level-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600}
.level-1,.level-2{background:#e2e8f0;color:#64748b}
.level-3,.level-4{background:#dbeafe;color:#2563eb}
.level-5,.level-6{background:#d1fae5;color:#059669}
.level-7,.level-8{background:#fef3c7;color:#d97706}
.level-9,.level-10{background:#fce7f3;color:#db2777}
.tab-pill{padding:6px 16px;border-radius:20px;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s;border:1px solid transparent}
.tab-pill.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
.tab-pill:not(.active){background:#f1f5f9;color:#64748b;border-color:#e2e8f0}
.tab-pill:not(.active):hover{background:#e2e8f0;color:#334155}
.post-card{transition:all .2s;border:1px solid #e2e8f0;border-radius:12px;background:#fff}
.post-card:hover{border-color:#93c5fd;box-shadow:0 4px 12px rgba(59,130,246,.08)}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9990;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto;backdrop-filter:blur(2px)}
.modal-content{background:#fff;border-radius:16px;width:100%;max-width:720px;box-shadow:0 20px 60px rgba(0,0,0,.2);animation:modalIn .25s ease}
@keyframes modalIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
.hot-item{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid #f1f5f9;cursor:pointer}
.hot-item:last-child{border-bottom:none}
.hot-rank{width:20px;height:20px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;margin-top:2px}
.hot-rank.r1{background:#ef4444;color:#fff}.hot-rank.r2{background:#f97316;color:#fff}.hot-rank.r3{background:#eab308;color:#fff}
.hot-rank:not(.r1):not(.r2):not(.r3){background:#f1f5f9;color:#94a3b8}
</style>
<div class="forum-grid">
<div class="space-y-6">
<!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-posts">0</div>
<div class="text-sm text-blue-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
总帖子数
</div>
</div>
</div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-replies">0</div>
<div class="text-sm text-emerald-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" 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>
总回复数
</div>
</div>
</div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-today">0</div>
<div class="text-sm text-orange-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
今日新帖
</div>
</div>
</div>
<div class="bg-gradient-to-br from-violet-500 to-violet-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10">
<div class="flex items-center mb-1">
<span class="relative flex h-3 w-3 mr-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-white"></span>
</span>
<div class="text-3xl font-bold" id="s-online">0</div>
</div>
<div class="text-sm text-violet-100 flex items-center">
<svg class="w-4 h-4 mr-1.5" 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>
当前在线人数
</div>
</div>
</div>
</div>
<!-- 头部操作区: 高级毛玻璃渐变 -->
<div class="relative flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-gradient-to-r from-indigo-600 to-purple-600 p-8 rounded-3xl shadow-xl border border-indigo-500 overflow-hidden text-white">
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
<div class="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-500/20 blur-2xl rounded-full"></div>
<div class="relative z-10">
<h1 class="text-3xl font-extrabold flex items-center drop-shadow-md">
<span class="w-12 h-12 bg-white/20 backdrop-blur-md text-white rounded-2xl flex items-center justify-center mr-4 shadow-inner border border-white/20">💬</span>
社区论坛
</h1>
<p class="text-indigo-100 text-base mt-2 ml-16 opacity-90">分享经验、交流问题、结识志同道合的学习伙伴。</p>
</div>
<div class="relative z-10 flex flex-wrap items-center gap-3">
{% if user %}
<button onclick="showLeaderboard()" class="p-3 bg-white/10 hover:bg-white/20 text-white backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-white/40 shadow-lg transform hover:-translate-y-1" title="排行榜">
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</button>
<button onclick="showBookmarks()" class="p-3 bg-white/10 hover:bg-white/20 text-yellow-300 backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-yellow-300/50 shadow-lg transform hover:-translate-y-1" title="我的收藏">
<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="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</button>
<button onclick="openNewPost()" class="flex items-center gap-2 px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
发布新帖
</button>
{% else %}
<a href="/login" class="px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105">登录后发帖</a>
{% endif %}
</div>
</div>
<!-- 搜索和分类 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 sticky top-20 z-10 glass-panel">
<div class="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center">
<!-- 分类标签 -->
<div class="flex flex-wrap gap-2" id="tab-nav">
<button onclick="filterTab('全部')" class="tab-pill active" data-tab="全部">📋 全部</button>
<button onclick="filterTab('官方公告')" class="tab-pill" data-tab="官方公告">📢 公告</button>
<button onclick="filterTab('题目讨论')" class="tab-pill" data-tab="题目讨论">📐 讨论</button>
<button onclick="filterTab('经验分享')" class="tab-pill" data-tab="经验分享">💡 分享</button>
<button onclick="filterTab('求助答疑')" class="tab-pill" data-tab="求助答疑">🙋 求助</button>
<button onclick="filterTab('闲聊灌水')" class="tab-pill" data-tab="闲聊灌水">☕ 闲聊</button>
</div>
<!-- 搜索框 -->
<div class="flex items-center gap-3 w-full lg:w-auto">
<div class="relative w-full sm:w-64">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<input type="text" id="search-input" placeholder="搜索帖子内容..." class="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all" onkeydown="if(event.key==='Enter')loadPosts()">
</div>
<div class="relative">
<select id="sort-select" onchange="loadPosts()" class="appearance-none pl-9 pr-8 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer">
<option value="newest">最新发布</option>
<option value="hottest">热度最高</option>
<option value="most_replies">最多回复</option>
</select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"/></svg>
</div>
<div class="absolute inset-y-0 right-0 pr-2 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
<button onclick="loadPosts()" class="px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm hidden sm:block">
搜索
</button>
</div>
</div>
</div>
<!-- 帖子列表 -->
<div id="post-list" class="space-y-4">
<!-- 骨架屏加载状态 -->
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-pulse">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-slate-200 rounded-full"></div>
<div class="flex-1 space-y-3">
<div class="h-4 bg-slate-200 rounded w-1/4"></div>
<div class="h-5 bg-slate-200 rounded w-3/4"></div>
<div class="h-4 bg-slate-200 rounded w-full"></div>
<div class="h-4 bg-slate-200 rounded w-5/6"></div>
<div class="flex gap-4 pt-2">
<div class="h-3 bg-slate-200 rounded w-12"></div>
<div class="h-3 bg-slate-200 rounded w-12"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧边栏 -->
<div class="space-y-6 hidden lg:block">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="bg-gradient-to-r from-red-50 to-orange-50 px-5 py-4 border-b border-red-100">
<h3 class="text-sm font-bold text-red-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/></svg>
热门讨论
</h3>
</div>
<div class="p-2" id="hot-posts">
<div class="p-4 text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-5 py-4 border-b border-blue-100">
<h3 class="text-sm font-bold text-blue-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" 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>
活跃用户
</h3>
</div>
<div class="p-2" id="active-users">
<div class="p-4 text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden sticky top-20">
<div class="bg-gradient-to-r from-slate-50 to-gray-50 px-5 py-4 border-b border-slate-100">
<h3 class="text-sm font-bold text-slate-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
版块统计
</h3>
</div>
<div class="p-4" id="tag-stats">
<div class="text-center text-sm text-slate-400 animate-pulse">加载中...</div>
</div>
</div>
</div>
</div>
<!-- 发帖弹窗 -->
<div id="new-post-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closeNewPost()"><div class="modal-content p-6">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-bold">✏️ 发布新帖</h2>
<button onclick="closeNewPost()" class="text-slate-400 hover:text-slate-600 text-xl"></button>
</div>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-slate-700 mb-1">标题</label>
<input id="new-title" type="text" maxlength="100" class="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm" placeholder="请输入标题"></div>
<div class="flex gap-3">
<div class="flex-1"><label class="block text-sm font-medium text-slate-700 mb-1">分类</label>
<select id="new-tag" class="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm">
<option>题目讨论</option><option>经验分享</option><option>求助答疑</option><option>闲聊灌水</option>
{% if user and user.role == 'teacher' %}<option>官方公告</option>{% endif %}
</select></div>
{% if user and user.role == 'teacher' %}
<div class="flex items-end pb-1"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="new-official" class="rounded"> 官方公告</label></div>
{% endif %}
</div>
<div><label class="block text-sm font-medium text-slate-700 mb-1">内容</label>
<div class="border border-slate-200 rounded-lg overflow-hidden">
<textarea id="new-content" rows="8" class="w-full px-3 py-2.5 text-sm border-0 focus:outline-none resize-y" placeholder="畅所欲言..."></textarea>
<div id="new-content-preview" class="flex flex-wrap gap-2 px-3 py-2 border-t border-slate-100 hidden"></div>
</div></div>
<div><label class="flex items-center gap-2 text-sm font-medium text-slate-700 cursor-pointer mb-2">
<input type="checkbox" id="enable-poll" onchange="togglePoll()" class="rounded"> 📊 添加投票</label>
<div id="poll-section" class="hidden space-y-3 p-4 bg-blue-50 rounded-lg border border-blue-100">
<input id="poll-question" type="text" class="w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="投票问题">
<div id="poll-options" class="space-y-2">
<input type="text" class="poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="选项 1">
<input type="text" class="poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white" placeholder="选项 2">
</div>
<div class="flex items-center gap-3">
<button onclick="addPollOpt()" class="text-sm text-primary hover:text-blue-700 font-medium">+ 添加选项</button>
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" id="poll-multi" class="rounded"> 允许多选</label>
</div>
</div></div>
<div class="flex justify-end gap-3 pt-2">
<button onclick="closeNewPost()" class="px-5 py-2.5 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">取消</button>
<button onclick="submitPost()" class="px-6 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-blue-700">发布帖子</button>
</div>
</div>
</div></div></div>
<!-- 帖子详情弹窗 -->
<div id="post-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closePostModal()">
<div class="modal-content" id="post-modal-content"></div>
</div></div>
<!-- 举报弹窗 -->
<div id="report-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)closeReport()"><div class="modal-content p-6 max-w-md">
<h3 class="text-lg font-bold mb-4">🚩 举报</h3>
<input type="hidden" id="report-type"><input type="hidden" id="report-target-id">
<div class="space-y-3">
<select id="report-reason" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm">
<option>垃圾广告</option><option>不当言论</option><option>抄袭内容</option><option>人身攻击</option><option>其他</option></select>
<textarea id="report-detail" rows="3" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm" placeholder="补充说明(可选)"></textarea>
<div class="flex justify-end gap-3">
<button onclick="closeReport()" class="px-4 py-2 text-sm border border-slate-200 rounded-lg hover:bg-slate-50">取消</button>
<button onclick="submitReport()" class="px-5 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600">提交举报</button>
</div>
</div>
</div></div></div>
<!-- 排行榜弹窗 -->
<div id="leaderboard-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)document.getElementById('leaderboard-modal').classList.add('hidden')"><div class="modal-content p-6 max-w-lg">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-bold">🏆 积分排行榜</h2>
<button onclick="document.getElementById('leaderboard-modal').classList.add('hidden')" class="text-slate-400 hover:text-slate-600 text-xl"></button>
</div>
<div id="lb-content"></div>
</div></div></div>
<!-- 用户资料弹窗 -->
<div id="profile-modal" class="hidden"><div class="modal-overlay" onclick="if(event.target===this)document.getElementById('profile-modal').classList.add('hidden')"><div class="modal-content p-6 max-w-lg">
<div id="profile-content"></div>
</div></div></div>
{% endblock %}
{% block scripts %}
<script>
const CU = {{ user | tojson if user else 'null' }};
let curTag = '全部';
const REACTIONS = {like:'👍',love:'❤️',haha:'😂',wow:'😮',sad:'😢',angry:'😡'};
const TAG_ICONS = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
const TAG_COLORS = {'官方公告':'red','题目讨论':'blue','经验分享':'green','求助答疑':'yellow','闲聊灌水':'purple'};
function toast(msg, type='success') {
const d = document.createElement('div');
d.className = `toast toast-${type}`;
d.textContent = msg;
document.body.appendChild(d);
setTimeout(() => { d.style.animation = 'fadeOut .3s ease forwards'; setTimeout(() => d.remove(), 300); }, 2500);
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ===== 发帖 =====
function openNewPost() { document.getElementById('new-post-modal').classList.remove('hidden'); document.getElementById('new-title').focus(); }
function closeNewPost() { document.getElementById('new-post-modal').classList.add('hidden'); }
function togglePoll() { document.getElementById('poll-section').classList.toggle('hidden', !document.getElementById('enable-poll').checked); }
function addPollOpt() {
const c = document.getElementById('poll-options');
const n = c.children.length + 1;
const inp = document.createElement('input');
inp.type = 'text'; inp.className = 'poll-opt w-full px-3 py-2 border border-blue-200 rounded-lg text-sm bg-white';
inp.placeholder = `选项 ${n}`; c.appendChild(inp);
}
function insertFmt(pre, suf) {
// 保留兼容性RichEditor 已接管
const ta = document.getElementById('new-content');
const s = ta.selectionStart, e = ta.selectionEnd;
const txt = ta.value;
ta.value = txt.substring(0, s) + pre + txt.substring(s, e) + suf + txt.substring(e);
ta.focus(); ta.selectionStart = ta.selectionEnd = s + pre.length;
}
function insEmoji(em) {
const ta = document.getElementById('new-content');
const s = ta.selectionStart;
ta.value = ta.value.substring(0, s) + em + ta.value.substring(s);
ta.focus(); ta.selectionStart = ta.selectionEnd = s + em.length;
}
// 初始化富文本编辑器
document.addEventListener('DOMContentLoaded', function() {
new RichEditor('new-content');
});
// ===== 图片上传 =====
function triggerUpload(targetId) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.multiple = true;
inp.onchange = () => { if (inp.files.length) uploadFiles(inp.files, targetId); };
inp.click();
}
function triggerCamera(targetId) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'environment';
inp.onchange = () => { if (inp.files.length) uploadFiles(inp.files, targetId); };
inp.click();
}
async function uploadFiles(files, targetId) {
for (const file of files) {
if (file.size > 10*1024*1024) { toast('文件不能超过10MB','error'); continue; }
const fd = new FormData();
fd.append('file', file);
toast('上传中...','info');
try {
const res = await fetch('/api/upload', {method:'POST', body: fd});
const d = await res.json();
if (d.success) {
const ta = document.getElementById(targetId);
const tag = `\n[img:${d.url}]\n`;
const s = ta.selectionStart;
ta.value = ta.value.substring(0, s) + tag + ta.value.substring(s);
ta.focus();
showUploadPreview(targetId, d.url);
toast('图片上传成功');
} else { toast(d.message,'error'); }
} catch(e) { toast('上传失败','error'); }
}
}
function showUploadPreview(targetId, url) {
const previewId = targetId === 'reply-input' ? 'reply-preview' : targetId + '-preview';
const container = document.getElementById(previewId);
if (!container) return;
container.classList.remove('hidden');
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `<img src="${url}" class="w-16 h-16 object-cover rounded border border-slate-200"><button onclick="this.parentElement.remove();checkPreviewEmpty('${previewId}')" class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs leading-none flex items-center justify-center opacity-0 group-hover:opacity-100">×</button>`;
container.appendChild(div);
}
function checkPreviewEmpty(previewId) {
const c = document.getElementById(previewId);
if (c && !c.children.length) c.classList.add('hidden');
}
function renderContent(text) {
return renderRichContent(text);
}
async function submitPost() {
const title = document.getElementById('new-title').value.trim();
const content = document.getElementById('new-content').value.trim();
const tag = document.getElementById('new-tag').value;
const isOfficial = document.getElementById('new-official')?.checked || false;
if (!title || !content) { toast('标题和内容不能为空', 'error'); return; }
const body = { title, content, tag, is_official: isOfficial };
let url = '/api/posts';
if (document.getElementById('enable-poll').checked) {
url = '/api/posts/with-poll';
const q = document.getElementById('poll-question').value.trim();
const opts = [...document.querySelectorAll('.poll-opt')].map(i => i.value.trim()).filter(Boolean);
if (q && opts.length >= 2) {
body.poll = { question: q, options: opts, multi: document.getElementById('poll-multi').checked };
}
}
const res = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json();
if (data.success) { closeNewPost(); document.getElementById('new-title').value=''; document.getElementById('new-content').value=''; toast('发帖成功!'); loadPosts(); loadSidebar(); }
else toast(data.message, 'error');
}
// ===== 帖子列表 =====
function renderPosts(posts) {
const c = document.getElementById('post-list');
if (!posts.length) {
c.innerHTML = `
<div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400 bg-white rounded-2xl border border-slate-100 border-dashed">
<svg class="w-16 h-16 mb-4 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p>暂无符合条件的帖子</p>
${CU ? '<button onclick="openNewPost()" class="mt-4 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors">来发第一帖吧</button>' : ''}
</div>`;
return;
}
posts.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
let h = '';
posts.forEach(p => {
const tc = TAG_COLORS[p.tag] || 'slate';
const ti = TAG_ICONS[p.tag] || '📋';
const reactions = p.reactions || {};
let reactHtml = '';
for (const [k, v] of Object.entries(reactions)) {
if (v > 0) reactHtml += `<span class="inline-flex items-center gap-1 text-xs bg-slate-50 border border-slate-100 px-2 py-0.5 rounded-md text-slate-600">${REACTIONS[k]||k} ${v}</span>`;
}
// 生成纯文本内容用于预览
let cleanContent = p.content.replace(/\[img:[^\]]+\]/g, '[图片]');
// 生成等级
const randomLv = (p.id % 5) + 1;
h += `<div class="bg-white rounded-3xl p-6 border ${p.pinned ? 'border-amber-200 shadow-md bg-gradient-to-br from-amber-50/40 to-white' : 'border-slate-100 shadow-sm'} hover-card-up transition-all duration-300 cursor-pointer group flex flex-col sm:flex-row gap-5" onclick="openPost(${p.id})">
<div class="hidden sm:flex flex-col items-center gap-3 flex-shrink-0" onclick="event.stopPropagation()">
<!-- 游戏化头像与等级 -->
<div class="relative w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-bold border-2 border-white shadow-md transform group-hover:rotate-6 transition-transform z-10">
<span class="text-xl">${esc(p.author.charAt(0))}</span>
${p.is_official ?
'<div class="absolute -bottom-2 -right-2 bg-gradient-to-r from-red-500 to-rose-600 text-white text-[9px] font-black px-1.5 py-0.5 rounded-md shadow-sm border border-white">官方</div>' :
'<div class="absolute -bottom-1.5 -right-1.5 bg-slate-800 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full shadow-sm border border-white">Lv.' + randomLv + '</div>'}
</div>
<button onclick="toggleLike(${p.id},this)" class="group/btn w-12 h-12 flex flex-col items-center justify-center rounded-2xl border ${p.liked?'border-rose-200 bg-rose-50 text-rose-500':'border-slate-100 bg-slate-50 text-slate-400 hover:bg-rose-50 hover:text-rose-500 hover:border-rose-200'} transition-all transform hover:-translate-y-1">
<svg class="w-5 h-5 mb-0.5 group-hover/btn:scale-110 transition-transform" fill="${p.liked?'currentColor':'none'}" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="lc text-[10px] font-bold leading-none">${p.likes}</span>
</button>
</div>
<div class="flex-1 min-w-0 flex flex-col pt-1">
<div class="flex items-center flex-wrap gap-2 mb-3">
${p.pinned ? '<span class="inline-flex items-center text-[10px] bg-gradient-to-r from-amber-400 to-orange-500 text-white px-2.5 py-1 rounded-md font-bold shadow-sm animate-pulse">📌 置顶推荐</span>' : ''}
${p.is_official ? '<span class="inline-flex items-center text-[10px] bg-gradient-to-r from-red-500 to-rose-500 text-white px-2.5 py-1 rounded-md font-bold shadow-sm">官方公告</span>' : ''}
<span class="inline-flex items-center text-xs bg-slate-100 text-slate-600 px-2.5 py-1 rounded-lg font-medium border border-slate-200">
${ti} ${p.tag}
</span>
${p.has_poll ? '<span class="inline-flex items-center text-xs bg-indigo-50 text-indigo-600 px-2.5 py-1 rounded-lg font-medium border border-indigo-200"><svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>参与投票</span>' : ''}
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2 line-clamp-1 group-hover:text-indigo-600 transition-colors">${esc(p.title)}</h3>
<p class="text-sm text-slate-500 line-clamp-2 mb-4 leading-relaxed group-hover:line-clamp-none transition-all duration-500">${esc(cleanContent)}</p>
<div class="mt-auto flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-4 border-t border-slate-100/60">
<div class="flex items-center gap-3">
<span class="text-sm font-bold text-slate-800 hover:text-indigo-600 transition-colors cursor-pointer" onclick="event.stopPropagation();showProfile('${p.author_id}')">${esc(p.author)}</span>
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${randomLv}</span>` : ''}
<span class="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded-md">${p.created_at}</span>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100" title="浏览量">
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
${p.views || 0}
</div>
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-colors" title="回复数">
<svg class="w-4 h-4" 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>
${p.replies} 评论
</div>
<div class="flex sm:hidden items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 text-slate-500 text-xs font-medium border border-slate-100" onclick="event.stopPropagation();toggleLike(${p.id},this)">
<svg class="w-4 h-4 ${p.liked?'text-rose-500 fill-current':'text-slate-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
<span class="lc">${p.likes}</span>
</div>
<div onclick="event.stopPropagation();toggleBookmark(${p.id},this)" class="flex items-center justify-center p-2 rounded-xl bg-slate-50 hover:bg-yellow-50 border border-slate-100 hover:border-yellow-200 transition-colors ${p.bookmarked?'text-yellow-500':'text-slate-400'}" title="收藏">
<svg class="w-4 h-4" fill="${p.bookmarked?'currentColor':'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>
</div>
${reactHtml ? `<div class="mt-4 pt-3 border-t border-slate-100/60 flex flex-wrap gap-2">${reactHtml}</div>` : ''}
</div>
</div>`;
});
c.innerHTML = h;
}
async function loadPosts() {
const q = document.getElementById('search-input').value;
const sort = document.getElementById('sort-select').value;
const tag = curTag === '全部' ? '' : curTag;
const res = await fetch(`/api/posts/search?q=${encodeURIComponent(q)}&tag=${encodeURIComponent(tag)}&sort=${sort}`);
const data = await res.json();
if (data.success) renderPosts(data.data);
}
function filterTab(tag) {
curTag = tag;
document.querySelectorAll('.tab-pill').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tag);
});
loadPosts();
}
async function toggleLike(pid, btn) {
if (!CU) { toast('请先登录', 'error'); return; }
const res = await fetch(`/api/posts/${pid}/like`, {method:'POST'});
const d = await res.json();
if (d.success) {
btn.querySelector('svg').setAttribute('fill', d.liked ? 'currentColor' : 'none');
btn.className = btn.className.replace(/text-\w+-\d+/g, '') + (d.liked ? ' text-red-500' : ' text-slate-400');
btn.querySelector('.lc').textContent = d.likes;
}
}
async function toggleBookmark(pid, btn) {
if (!CU) { toast('请先登录', 'error'); return; }
const res = await fetch(`/api/posts/${pid}/bookmark`, {method:'POST'});
const d = await res.json();
if (d.success) {
btn.querySelector('svg').setAttribute('fill', d.bookmarked ? 'currentColor' : 'none');
btn.className = btn.className.replace(/text-\w+-\d+/g, '') + (d.bookmarked ? ' text-yellow-500' : ' text-slate-400');
}
}
// ===== 帖子详情 =====
async function openPost(pid) {
const res = await fetch(`/api/posts/${pid}`);
const data = await res.json();
if (!data.success) { toast(data.message, 'error'); return; }
const p = data.data;
const replies = data.replies || [];
const isAuthor = CU && CU.id === p.author_id;
const isTeacher = CU && CU.role === 'teacher';
const reactions = p.reactions || {};
let reactBtns = '';
for (const [k, emoji] of Object.entries(REACTIONS)) {
const cnt = reactions[k] || 0;
reactBtns += `<button onclick="reactPost(${p.id},'${k}',this)" class="reaction-btn${cnt>0?' active':''}">${emoji} <span>${cnt}</span></button>`;
}
let pollHtml = '';
if (p.has_poll) {
try {
const pr = await fetch(`/api/posts/${pid}/poll`);
const pd = await pr.json();
if (pd.success) {
const poll = pd.poll;
const voted = pd.voted;
const myC = pd.my_choices || [];
const totalV = poll.options.reduce((s, o) => s + o.votes, 0) || 1;
pollHtml = `<div class="bg-blue-50 rounded-lg p-4 mb-4 border border-blue-100">
<div class="font-medium text-sm text-slate-800 mb-3">📊 ${esc(poll.question)}</div>`;
poll.options.forEach((opt, i) => {
const pct = Math.round(opt.votes / totalV * 100);
const isMy = myC.includes(i);
if (voted) {
pollHtml += `<div class="mb-2"><div class="flex justify-between text-xs text-slate-600 mb-1"><span>${isMy?'✅ ':''}${esc(opt.text)}</span><span>${opt.votes}票 (${pct}%)</span></div><div class="poll-bar${isMy?' voted':''}"><div class="poll-fill" style="width:${Math.max(pct,2)}%">${pct}%</div></div></div>`;
} else {
pollHtml += `<label class="flex items-center gap-2 p-2 rounded-lg hover:bg-blue-100 cursor-pointer mb-1"><input type="${poll.multi?'checkbox':'radio'}" name="poll-vote" value="${i}" class="poll-choice rounded"> <span class="text-sm">${esc(opt.text)}</span></label>`;
}
});
if (!voted && CU) {
pollHtml += `<button onclick="votePoll(${pid})" class="mt-2 px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">投票</button>`;
}
pollHtml += `<div class="text-xs text-slate-400 mt-2">${poll.total_votes} 人已投票${poll.multi?' · 可多选':''}</div></div>`;
}
} catch(e) {}
}
let h = `<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2 flex-wrap">
${p.pinned?'<span class="text-xs bg-red-50 text-red-600 px-2 py-0.5 rounded-full">📌 置顶</span>':''}
${p.is_official?'<span class="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded-full">官方</span>':''}
<span class="text-xs bg-slate-100 text-slate-700 px-2 py-0.5 rounded-full">${TAG_ICONS[p.tag]||''} ${p.tag}</span>
${p.edited?'<span class="text-xs text-slate-400">✏️ 已编辑</span>':''}
</div>
<h2 class="text-xl font-bold text-slate-900">${esc(p.title)}</h2>
<div class="mt-2 flex items-center text-sm text-slate-400 gap-3">
<span class="cursor-pointer hover:text-primary" onclick="showProfile('${p.author_id}')">${esc(p.author)}</span>
<span>${p.created_at}</span><span>👁 ${p.views||0}</span><span>❤️ ${p.likes}</span><span>💬 ${replies.length}</span>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${isTeacher?`<button onclick="pinPost(${p.id})" class="text-xs px-2 py-1 rounded border ${p.pinned?'border-red-300 text-red-600 bg-red-50':'border-slate-200 text-slate-600'} hover:bg-slate-50">${p.pinned?'取消置顶':'置顶'}</button>`:''}
${isAuthor?`<button onclick="editPost(${p.id})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-600 hover:bg-slate-50">编辑</button>`:''}
${isAuthor||isTeacher?`<button onclick="deletePost(${p.id})" class="text-xs px-2 py-1 rounded border border-red-200 text-red-600 hover:bg-red-50">删除</button>`:''}
<button onclick="openReport('post',${p.id})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-500 hover:bg-slate-50">举报</button>
<button onclick="closePostModal()" class="text-slate-400 hover:text-slate-600 text-xl ml-1">✕</button>
</div>
</div>
<div class="prose prose-sm max-w-none text-slate-800 whitespace-pre-wrap border-b border-slate-100 pb-5 mb-4">${renderContent(p.content)}</div>
${pollHtml}
<div class="flex items-center gap-2 flex-wrap mb-4">${reactBtns}</div>
<div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<button onclick="toggleLikeModal(${p.id})" id="ml-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.liked?'border-red-300 text-red-500 bg-red-50':'border-slate-200 text-slate-500'} hover:bg-red-50 text-sm">❤️ <span id="ml-cnt">${p.likes}</span></button>
<button onclick="toggleBmModal(${p.id})" id="mb-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.bookmarked?'border-yellow-300 text-yellow-500 bg-yellow-50':'border-slate-200 text-slate-500'} hover:bg-yellow-50 text-sm">${p.bookmarked?'⭐ 已收藏':'☆ 收藏'}</button>
<button onclick="sharePost(${p.id})" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-slate-200 text-slate-500 hover:bg-slate-50 text-sm">🔗 分享</button>
</div>
<h3 class="text-sm font-bold text-slate-700 mb-4">💬 回复 (${replies.length})</h3>`;
if (CU) {
h += `<div class="flex gap-3 mb-6"><div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">${esc(CU.name.charAt(0))}</div>
<div class="flex-1"><textarea id="reply-input" rows="2" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="写下你的回复..."></textarea>
<div id="reply-preview" class="flex flex-wrap gap-2 mt-1 hidden"></div>
<div class="flex justify-between items-center mt-2"><div class="flex gap-1"><button onclick="triggerUpload('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="上传图片">📷</button><button onclick="triggerCamera('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="拍照上传">📸</button></div><button onclick="submitReply(${p.id})" class="px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">回复</button></div></div></div>`;
}
if (!replies.length) {
h += '<div class="text-center py-8 text-slate-400 text-sm">暂无回复,来抢沙发吧 🛋️</div>';
} else {
replies.forEach((r, i) => {
const canDel = CU && (CU.id === r.author_id || CU.role === 'teacher');
const canEdit = CU && CU.id === r.author_id;
h += `<div class="flex gap-3 py-3 ${i>0?'border-t border-slate-100':''}">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold flex-shrink-0">${esc(r.author.charAt(0))}</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-slate-900 cursor-pointer hover:text-primary" onclick="showProfile('${r.author_id}')">${esc(r.author)}</span>
${r.reply_to?`<span class="text-xs text-slate-400">回复 ${esc(r.reply_to)}</span>`:''}
<span class="text-xs text-slate-400">${r.created_at}</span>
${r.edited?'<span class="text-xs text-slate-400">✏️已编辑</span>':''}
</div>
<div class="flex items-center gap-2">
<button onclick="likeReply(${r.id},this)" class="flex items-center gap-1 text-xs ${r.liked?'text-red-500':'text-slate-400'} hover:text-red-500">❤️ <span>${r.likes}</span></button>
<button onclick="replyTo('${esc(r.author)}',${p.id})" class="text-xs text-slate-400 hover:text-primary">回复</button>
${canEdit?`<button onclick="editReply(${r.id},'${esc(r.content).replace(/'/g,"\\\\'")}',${p.id})" class="text-xs text-slate-400 hover:text-blue-500">编辑</button>`:''}
${canDel?`<button onclick="deleteReply(${r.id},${p.id})" class="text-xs text-slate-400 hover:text-red-500">删除</button>`:''}
<button onclick="openReport('reply',${r.id})" class="text-xs text-slate-400 hover:text-orange-500">举报</button>
</div>
</div>
<p class="mt-1 text-sm text-slate-700 whitespace-pre-wrap">${renderContent(r.content)}</p>
</div></div>`;
});
}
h += '</div>';
document.getElementById('post-modal-content').innerHTML = h;
document.getElementById('post-modal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 初始化回复输入框的富文本编辑器
if (document.getElementById('reply-input')) {
new RichEditor('reply-input', { compact: true });
}
}
function closePostModal() {
document.getElementById('post-modal').classList.add('hidden');
document.body.style.overflow = '';
loadPosts();
}
async function submitReply(pid) {
const inp = document.getElementById('reply-input');
const content = inp.value.trim();
if (!content) return;
const replyTo = inp.dataset.replyTo || '';
const res = await fetch(`/api/posts/${pid}/replies`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content, reply_to: replyTo})});
const d = await res.json();
if (d.success) { toast('回复成功'); openPost(pid); } else toast(d.message,'error');
}
function replyTo(name, pid) {
const inp = document.getElementById('reply-input');
if (inp) { inp.dataset.replyTo = name; inp.placeholder = `回复 ${name}...`; inp.focus(); }
}
async function toggleLikeModal(pid) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/like`,{method:'POST'});
const d = await res.json();
if (d.success) { document.getElementById('ml-cnt').textContent = d.likes; openPost(pid); }
}
async function toggleBmModal(pid) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/bookmark`,{method:'POST'});
const d = await res.json();
if (d.success) { openPost(pid); }
}
async function reactPost(pid, reaction, btn) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/posts/${pid}/react`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reaction})});
const d = await res.json();
if (d.success) openPost(pid);
}
async function votePoll(pid) {
const checks = document.querySelectorAll('.poll-choice:checked');
const choices = [...checks].map(c => parseInt(c.value));
if (!choices.length) { toast('请选择选项','error'); return; }
const res = await fetch(`/api/posts/${pid}/vote`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({choices})});
const d = await res.json();
if (d.success) { toast('投票成功'); openPost(pid); } else toast(d.message,'error');
}
async function sharePost(pid) {
const url = window.location.origin + '/forum#post-' + pid;
try { await navigator.clipboard.writeText(url); toast('链接已复制到剪贴板'); } catch(e) { toast('分享链接: ' + url, 'info'); }
fetch(`/api/posts/${pid}/share`,{method:'POST'});
}
async function deletePost(pid) {
if (!confirm('确定删除该帖子?')) return;
const res = await fetch(`/api/posts/${pid}`,{method:'DELETE'});
const d = await res.json();
if (d.success) { closePostModal(); toast('帖子已删除'); } else toast(d.message,'error');
}
async function pinPost(pid) {
const res = await fetch(`/api/posts/${pid}/pin`,{method:'POST'});
const d = await res.json();
if (d.success) openPost(pid);
}
async function editPost(pid) {
const res = await fetch(`/api/posts/${pid}`);
const d = await res.json();
if (!d.success) return;
const p = d.data;
const newTitle = prompt('编辑标题:', p.title);
if (newTitle === null) return;
const newContent = prompt('编辑内容:', p.content);
if (newContent === null) return;
const res2 = await fetch(`/api/posts/${pid}/edit`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:newTitle,content:newContent,tag:p.tag})});
const d2 = await res2.json();
if (d2.success) { toast('编辑成功'); openPost(pid); } else toast(d2.message,'error');
}
async function editReply(rid, oldContent, pid) {
const newContent = prompt('编辑回复:', oldContent);
if (newContent === null || !newContent.trim()) return;
const res = await fetch(`/api/replies/${rid}/edit`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:newContent})});
const d = await res.json();
if (d.success) { toast('编辑成功'); openPost(pid); } else toast(d.message,'error');
}
async function likeReply(rid, btn) {
if (!CU) { toast('请先登录','error'); return; }
const res = await fetch(`/api/replies/${rid}/like`,{method:'POST'});
const d = await res.json();
if (d.success) { btn.querySelector('span').textContent = d.likes; btn.className = btn.className.replace(/text-\w+-\d+/g,'') + (d.liked?' text-red-500':' text-slate-400'); }
}
async function deleteReply(rid, pid) {
if (!confirm('确定删除该回复?')) return;
const res = await fetch(`/api/replies/${rid}`,{method:'DELETE'});
const d = await res.json();
if (d.success) { toast('回复已删除'); openPost(pid); } else toast(d.message,'error');
}
// ===== 举报 =====
function openReport(type, targetId) {
document.getElementById('report-type').value = type;
document.getElementById('report-target-id').value = targetId;
document.getElementById('report-modal').classList.remove('hidden');
}
function closeReport() { document.getElementById('report-modal').classList.add('hidden'); }
async function submitReport() {
const res = await fetch('/api/report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
type: document.getElementById('report-type').value,
target_id: parseInt(document.getElementById('report-target-id').value),
reason: document.getElementById('report-reason').value,
detail: document.getElementById('report-detail').value
})});
const d = await res.json();
if (d.success) { closeReport(); toast(d.message); } else toast(d.message,'error');
}
// ===== 收藏 =====
async function showBookmarks() {
const res = await fetch('/api/user/bookmarks');
const d = await res.json();
if (d.success) { renderPosts(d.data); document.querySelectorAll('.tab-pill').forEach(b => b.classList.remove('active')); toast('显示收藏帖子','info'); }
}
// ===== 排行榜 =====
async function showLeaderboard() {
document.getElementById('leaderboard-modal').classList.remove('hidden');
const res = await fetch('/api/forum/leaderboard');
const d = await res.json();
if (!d.success) return;
let h = '';
d.data.forEach((u, i) => {
const rc = i===0?'gold':i===1?'silver':i===2?'bronze':'';
const medal = i===0?'🥇':i===1?'🥈':i===2?'🥉':`${i+1}`;
h += `<div class="flex items-center gap-3 py-3 ${i>0?'border-t border-slate-100':''}">
<div class="w-6 text-center font-bold ${rc?'text-lg':''}">${medal}</div>
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-primary text-xs font-bold">${esc(u.name.charAt(0))}</div>
<div class="flex-1"><div class="flex items-center gap-2"><span class="text-sm font-medium cursor-pointer hover:text-primary" onclick="showProfile('${u.user_id}')">${esc(u.name)}</span><span class="level-badge level-${u.level}">Lv.${u.level} ${u.level_title}</span></div>
<div class="text-xs text-slate-400 mt-0.5">${u.points}积分 · ${u.posts_count}帖子 · ${u.likes_received}赞</div></div></div>`;
});
document.getElementById('lb-content').innerHTML = h || '<div class="text-center py-8 text-slate-400">暂无数据</div>';
}
// ===== 用户资料 =====
async function showProfile(uid) {
document.getElementById('profile-modal').classList.remove('hidden');
const res = await fetch(`/api/user/profile/${uid}`);
const d = await res.json();
if (!d.success) return;
const p = d.profile;
let badges = p.badges.map(b => `<span class="inline-flex items-center gap-1 px-2 py-1 bg-slate-50 rounded-lg text-xs" title="${b.desc}">${b.icon} ${b.name}</span>`).join('');
let posts = p.recent_posts.map(pp => `<div class="text-sm py-1.5 border-b border-slate-50 cursor-pointer hover:text-primary" onclick="document.getElementById('profile-modal').classList.add('hidden');openPost(${pp.id})">${esc(pp.title)}</div>`).join('');
document.getElementById('profile-content').innerHTML = `
<div class="flex items-center gap-4 mb-5">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center text-primary text-2xl font-bold">${esc(p.name.charAt(0))}</div>
<div><div class="flex items-center gap-2"><span class="text-xl font-bold">${esc(p.name)}</span><span class="level-badge level-${p.level}">Lv.${p.level} ${p.level_title}</span></div>
<div class="text-sm text-slate-500 mt-1">${p.points} 积分</div></div>
<button onclick="document.getElementById('profile-modal').classList.add('hidden')" class="ml-auto text-slate-400 hover:text-slate-600 text-xl">✕</button>
</div>
<div class="grid grid-cols-3 gap-3 mb-5">
<div class="text-center p-3 bg-blue-50 rounded-lg"><div class="text-xl font-bold text-blue-600">${p.posts_count}</div><div class="text-xs text-slate-500">帖子</div></div>
<div class="text-center p-3 bg-green-50 rounded-lg"><div class="text-xl font-bold text-green-600">${p.replies_count}</div><div class="text-xs text-slate-500">回复</div></div>
<div class="text-center p-3 bg-red-50 rounded-lg"><div class="text-xl font-bold text-red-500">${p.likes_received}</div><div class="text-xs text-slate-500">获赞</div></div>
</div>
${badges?`<div class="mb-5"><div class="text-sm font-bold text-slate-700 mb-2">🏅 成就徽章</div><div class="flex flex-wrap gap-2">${badges}</div></div>`:''}
${posts?`<div><div class="text-sm font-bold text-slate-700 mb-2">📝 最近帖子</div>${posts}</div>`:''}`;
}
// ===== 侧边栏 =====
async function loadSidebar() {
// 热门帖子
const hr = await fetch('/api/forum/hot');
const hd = await hr.json();
if (hd.success) {
let h = '';
hd.data.slice(0, 6).forEach((p, i) => {
let colorClass = 'bg-slate-100 text-slate-500';
if (i === 0) colorClass = 'bg-red-500 text-white shadow-sm shadow-red-200';
else if (i === 1) colorClass = 'bg-orange-500 text-white shadow-sm shadow-orange-200';
else if (i === 2) colorClass = 'bg-amber-500 text-white shadow-sm shadow-amber-200';
h += `
<div class="flex items-start gap-3 p-3 rounded-xl hover:bg-slate-50 cursor-pointer transition-colors group mb-1 border border-transparent hover:border-slate-100" onclick="openPost(${p.id})">
<div class="w-6 h-6 rounded-lg ${colorClass} flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">
${i + 1}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700 group-hover:text-primary line-clamp-2 mb-1.5 transition-colors leading-snug">
${esc(p.title)}
</div>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span class="flex items-center"><svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>${p.views || 0}</span>
<span class="flex items-center"><svg class="w-3 h-3 mr-1" 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>${p.replies}</span>
</div>
</div>
</div>`;
});
document.getElementById('hot-posts').innerHTML = h || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
}
// 论坛统计
const sr = await fetch('/api/forum/stats');
const sd = await sr.json();
if (sd.success) {
const s = sd.stats;
document.getElementById('s-posts').textContent = s.total_posts;
document.getElementById('s-replies').textContent = s.total_replies;
document.getElementById('s-today').textContent = s.today_posts;
document.getElementById('s-online').textContent = s.online_count;
// 活跃用户
let au = '';
s.active_users.forEach(u => {
au += `<div class="flex items-center gap-3 p-2.5 rounded-xl hover:bg-slate-50 cursor-pointer transition-colors border border-transparent hover:border-slate-100 mb-1" onclick="showProfile('${u.user_id}')">
<div class="relative">
<div class="w-9 h-9 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center text-primary font-bold shadow-sm border border-white">
${esc(u.name.charAt(0))}
</div>
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white flex items-center justify-center" title="在线"></div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-800 truncate">${esc(u.name)}</div>
<div class="text-xs text-slate-400 mt-0.5 truncate">Lv.${u.level} ${u.level_title || ''}</div>
</div>
</div>`;
});
document.getElementById('active-users').innerHTML = au || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
// 标签统计
let ts = '';
const tagIcons = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
for (const [tag, cnt] of Object.entries(s.tag_counts)) {
ts += `<div class="flex items-center justify-between text-xs py-1"><span>${tagIcons[tag]||'📋'} ${tag}</span><span class="text-slate-400">${cnt} 帖</span></div>`;
}
document.getElementById('tag-stats').innerHTML = ts || '<div class="text-xs text-slate-400 text-center py-4">暂无数据</div>';
}
}
// ===== 初始化 =====
loadPosts();
loadSidebar();
// 定时刷新在线人数
setInterval(async () => {
try {
const r = await fetch('/api/forum/stats');
const d = await r.json();
if (d.success) document.getElementById('s-online').textContent = d.stats.online_count;
} catch(e) {}
}, 30000);
</script>
{% endblock %}

346
templates/home.html Normal file
View File

@@ -0,0 +1,346 @@
{% extends "base.html" %}
{% block title %}智联青云 - 首页{% endblock %}
{% block content %}
<div class="space-y-20 pb-16">
<!-- 高级 Hero Section -->
<div class="relative overflow-hidden rounded-[2.5rem] bg-slate-950 text-white shadow-2xl animated-border p-1">
<div class="absolute inset-0 bg-grid-pattern-dark opacity-40"></div>
<div class="absolute -top-40 -right-40 w-96 h-96 bg-blue-500/30 rounded-full blur-[100px]"></div>
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-500/30 rounded-full blur-[100px]"></div>
<div class="relative glass-panel-dark rounded-[2.4rem] px-6 py-24 sm:py-32 lg:px-16 text-center flex flex-col items-center">
<canvas id="hero-particles" class="absolute inset-0 w-full h-full pointer-events-auto" style="z-index:0;border-radius:inherit"></canvas>
<!-- 内容层z-index 高于 canvas -->
<div class="relative flex flex-col items-center w-full" style="z-index:1">
<!-- Badge -->
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-md mb-8 hover-card-up">
<span class="flex h-2 w-2 relative">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
<span class="text-sm font-medium text-blue-100">汇九智,步青云</span>
</div>
<h1 class="text-5xl font-extrabold tracking-tight sm:text-6xl lg:text-7xl mb-6">
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-indigo-400 to-purple-400 text-gradient-glow">
智联青云
</span>
<span class="block text-2xl sm:text-3xl text-slate-300 font-light mt-3">汇九智,步青云</span>
</h1>
<p class="mt-4 max-w-2xl mx-auto text-xl text-slate-300 leading-relaxed font-light">
打破空间限制,重塑考试体验。集成智能防作弊、自动阅卷与沉浸式备考社区的现代化教育解决方案。
</p>
<div class="mt-12 flex flex-col sm:flex-row items-center justify-center gap-5">
<a href="/contests" class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 border border-transparent text-lg font-medium rounded-2xl text-slate-900 bg-white hover:bg-slate-50 transition-all-300 hover:scale-105 hover-glow shadow-[0_0_20px_rgba(255,255,255,0.2)]">
浏览全部杯赛
<svg class="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</a>
{% if not user %}
<a href="/register" class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 border border-white/20 text-lg font-medium rounded-2xl text-white bg-white/5 hover:bg-white/10 backdrop-blur-md transition-all-300 hover:scale-105 hover:border-white/40">
免费注册账号
</a>
{% elif user.role == 'student' %}
<a href="/apply-teacher" class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 border border-white/20 text-lg font-medium rounded-2xl text-white bg-white/5 hover:bg-white/10 backdrop-blur-md transition-all-300 hover:scale-105 hover:border-white/40">
👨‍🏫 申请成为教师
</a>
{% elif user.role == 'teacher' %}
<a href="/contests" class="w-full sm:w-auto inline-flex justify-center items-center px-8 py-4 border border-white/20 text-lg font-medium rounded-2xl text-white bg-white/5 hover:bg-white/10 backdrop-blur-md transition-all-300 hover:scale-105 hover:border-white/40">
👨‍🏫 进入教师后台
</a>
{% endif %}
</div>
<!-- 底部装饰数据 -->
<div class="mt-16 pt-8 border-t border-white/10 grid grid-cols-2 gap-8 max-w-2xl w-full">
<div class="text-center">
<div class="relative inline-block">
<div class="absolute inset-0 bg-blue-400/20 rounded-full blur-xl animate-pulse"></div>
<div id="stat-online" class="relative text-3xl font-bold text-white mb-1" data-target="{{ online_count }}">0</div>
</div>
<div class="text-sm text-slate-400">实时在线</div>
</div>
<div class="text-center">
<div class="relative inline-block">
<div class="absolute inset-0 bg-purple-400/20 rounded-full blur-xl animate-pulse"></div>
<div id="stat-contests" class="relative text-3xl font-bold text-white mb-1" data-target="{{ contest_count }}">0</div>
</div>
<div class="text-sm text-slate-400">杯赛总数</div>
</div>
</div>
</div><!-- /内容层 -->
</div>
</div>
<!-- Bento Box 功能区 -->
<div class="py-12">
<div class="text-center mb-16">
<h2 class="text-sm font-bold tracking-wider text-primary uppercase mb-3">核心优势</h2>
<h3 class="text-3xl md:text-4xl font-extrabold text-slate-900">重新定义考试标准</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 auto-rows-[320px]">
<!-- Bento Item 1: 大格 -->
<div class="md:col-span-2 group relative overflow-hidden bg-white rounded-3xl p-8 hover-card-up border border-slate-100/50 shadow-lg">
<div class="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-100 to-transparent rounded-bl-full opacity-50 group-hover:scale-110 transition-transform duration-700"></div>
<div class="relative z-10 h-full flex flex-col justify-between">
<div>
<div class="w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-400 text-white rounded-2xl flex items-center justify-center mb-6 shadow-md shadow-blue-500/30">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div>
<h3 class="text-2xl font-bold text-slate-900 mb-4">全矩阵杯赛专栏</h3>
<p class="text-slate-500 leading-relaxed text-lg max-w-md">汇聚各类大型模拟考与学科竞赛。提供一键报名、成绩预测、历年真题库及专家解析,为您打造最硬核的升学备考阵地。</p>
</div>
<a href="/contests" class="inline-flex items-center text-primary font-semibold hover:text-blue-700 mt-4">
探索赛事 <svg class="ml-2 w-5 h-5 group-hover:translate-x-2 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
</a>
</div>
</div>
<!-- Bento Item 2: 竖格 -->
<div class="group relative overflow-hidden bg-slate-900 rounded-3xl p-8 hover-card-up shadow-xl border border-slate-800">
<div class="absolute inset-0 bg-grid-pattern-dark opacity-20"></div>
<div class="absolute bottom-0 right-0 w-32 h-32 bg-purple-500/20 blur-2xl rounded-full"></div>
<div class="relative z-10 h-full flex flex-col justify-between">
<div>
<div class="w-14 h-14 bg-white/10 backdrop-blur-md text-white rounded-2xl flex items-center justify-center mb-6 border border-white/10">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
</div>
<h3 class="text-xl font-bold text-white mb-3">极致防作弊监控</h3>
<p class="text-slate-400 leading-relaxed">切屏检测、人脸核验与实时录屏,打造无懈可击的在线考场。</p>
</div>
</div>
</div>
<!-- Bento Item 3: 小格 -->
<div class="group relative overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 rounded-3xl p-8 hover-card-up border border-green-100 shadow-sm">
<div class="relative z-10">
<div class="w-12 h-12 bg-white text-green-500 rounded-xl flex items-center justify-center mb-5 shadow-sm">
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">秒级智能判卷</h3>
<p class="text-slate-600">客观题自动批改主观题支持教师流水线协同阅卷效率提升300%。</p>
</div>
</div>
<!-- Bento Item 4: 小格 -->
<div class="group relative overflow-hidden bg-white rounded-3xl p-8 hover-card-up border border-slate-100/50 shadow-sm md:col-span-2">
<div class="absolute right-0 top-1/2 -translate-y-1/2 opacity-10 group-hover:opacity-20 transition-opacity">
<svg class="w-48 h-48 text-primary" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM11 19.93C7.05 19.43 4 16.05 4 12C4 7.95 7.05 4.57 11 4.07V19.93ZM13 4.07C16.95 4.57 20 7.95 20 12C20 16.05 16.95 19.43 13 19.93V4.07Z"/></svg>
</div>
<div class="relative z-10 flex flex-col justify-center h-full">
<div class="w-12 h-12 bg-purple-100 text-purple-600 rounded-xl flex items-center justify-center mb-5">
<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="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"/></svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">高活跃沉浸式社区</h3>
<p class="text-slate-600 max-w-md">与数万名同龄人探讨难题,获取名师独家冲刺资料。支持 Markdown 与公式渲染,交流毫无障碍。</p>
</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="bg-white rounded-3xl p-10 shadow-soft border border-slate-100 bg-[url('data:image/svg+xml,%3Csvg width=\'20\' height=\'20\' viewBox=\'0 0 20 20\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23f1f5f9\' fill-opacity=\'1\' fill-rule=\'evenodd\'%3E%3Ccircle cx=\'3\' cy=\'3\' r=\'3\'/%3E%3Cg%3E%3C/g%3E%3C/svg%3E')]">
<h2 class="text-2xl font-bold text-center text-slate-800 mb-8">平台数据一览</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 text-center divide-y sm:divide-y-0 sm:divide-x divide-slate-100 max-w-2xl mx-auto">
<div class="pt-6 sm:pt-0">
<div class="relative inline-block">
<div class="absolute inset-0 bg-blue-400/10 rounded-full blur-xl animate-pulse"></div>
<div id="stat-online2" class="relative text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-cyan-500 mb-3 drop-shadow-sm" data-target="{{ online_count }}">0</div>
</div>
<div class="text-slate-500 font-medium tracking-wide">实时在线</div>
</div>
<div class="pt-6 sm:pt-0">
<div class="relative inline-block">
<div class="absolute inset-0 bg-green-400/10 rounded-full blur-xl animate-pulse"></div>
<div id="stat-contests2" class="relative text-5xl font-black text-transparent bg-clip-text bg-gradient-to-r from-green-500 to-emerald-500 mb-3 drop-shadow-sm" data-target="{{ contest_count }}">0</div>
</div>
<div class="text-slate-500 font-medium tracking-wide">杯赛总数</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ===== Hero 粒子系统 =====
(function() {
const canvas = document.getElementById('hero-particles');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const container = canvas.parentElement;
// 颜色池(蓝/紫/青)
const COLORS = [
[96, 165, 250], // blue-400
[129, 140, 248], // indigo-400
[192, 132, 252], // purple-400
[34, 211, 238], // cyan-400
[99, 102, 241], // indigo-500
];
const PARTICLE_COUNT = 120;
const CONNECT_DIST = 120;
const MOUSE_DIST = 150;
let W, H, particles = [], explosions = [];
let mouse = { x: -9999, y: -9999, active: false };
function resize() {
W = canvas.width = container.offsetWidth;
H = canvas.height = container.offsetHeight;
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
function randColor() { return COLORS[Math.floor(Math.random() * COLORS.length)]; }
// 初始化粒子
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push({
x: Math.random() * W,
y: Math.random() * H,
vx: (Math.random() - 0.5) * 0.6,
vy: (Math.random() - 0.5) * 0.6,
r: Math.random() * 2 + 1,
color: randColor(),
alpha: Math.random() * 0.5 + 0.3,
});
}
// 鼠标事件
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
mouse.active = true;
});
canvas.addEventListener('mouseleave', () => { mouse.active = false; });
// 点击爆炸
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const count = 30 + Math.floor(Math.random() * 10);
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 4 + 2;
const c = randColor();
explosions.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: Math.random() * 2.5 + 1,
color: [Math.min(c[0] + 80, 255), Math.min(c[1] + 80, 255), Math.min(c[2] + 80, 255)],
alpha: 1,
decay: Math.random() * 0.015 + 0.015,
});
}
});
function draw() {
ctx.clearRect(0, 0, W, H);
// 更新 & 绘制常驻粒子
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > W) p.vx *= -1;
if (p.y < 0 || p.y > H) p.vy *= -1;
// 鼠标吸引
if (mouse.active) {
const dx = mouse.x - p.x, dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_DIST && dist > 0) {
const force = (MOUSE_DIST - dist) / MOUSE_DIST * 0.015;
p.vx += dx / dist * force;
p.vy += dy / dist * force;
}
// 鼠标连线
if (dist < MOUSE_DIST) {
const a = (1 - dist / MOUSE_DIST) * 0.4;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.strokeStyle = 'rgba(' + p.color[0] + ',' + p.color[1] + ',' + p.color[2] + ',' + a + ')';
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// 限速
const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (spd > 1.5) { p.vx *= 1.5 / spd; p.vy *= 1.5 / spd; }
// 粒子间连线
for (let j = i + 1; j < particles.length; j++) {
const q = particles[j];
const dx = p.x - q.x, dy = p.y - q.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECT_DIST) {
const a = (1 - dist / CONNECT_DIST) * 0.15;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.strokeStyle = 'rgba(' + p.color[0] + ',' + p.color[1] + ',' + p.color[2] + ',' + a + ')';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
// 绘制粒子(发光)
ctx.save();
ctx.shadowBlur = 8;
ctx.shadowColor = 'rgba(' + p.color[0] + ',' + p.color[1] + ',' + p.color[2] + ',0.6)';
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(' + p.color[0] + ',' + p.color[1] + ',' + p.color[2] + ',' + p.alpha + ')';
ctx.fill();
ctx.restore();
}
// 更新 & 绘制爆炸粒子
for (let i = explosions.length - 1; i >= 0; i--) {
const e = explosions[i];
e.x += e.vx; e.y += e.vy;
e.vx *= 0.97; e.vy *= 0.97;
e.alpha -= e.decay;
if (e.alpha <= 0) { explosions.splice(i, 1); continue; }
ctx.save();
ctx.shadowBlur = 12;
ctx.shadowColor = 'rgba(' + e.color[0] + ',' + e.color[1] + ',' + e.color[2] + ',0.8)';
ctx.beginPath();
ctx.arc(e.x, e.y, e.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(' + e.color[0] + ',' + e.color[1] + ',' + e.color[2] + ',' + e.alpha + ')';
ctx.fill();
ctx.restore();
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
})();
// countUp 动画
function countUp(el, target, duration) {
if (target === 0) { el.textContent = '0'; return; }
let start = 0;
const step = Math.max(1, Math.floor(target / (duration / 16)));
const timer = setInterval(() => {
start += step;
if (start >= target) { start = target; clearInterval(timer); }
el.textContent = start.toLocaleString();
}, 16);
}
// 启动所有 countUp 元素
document.querySelectorAll('[data-target]').forEach(el => {
const target = parseInt(el.dataset.target) || 0;
countUp(el, target, 1500);
});
</script>
{% endblock %}

138
templates/login.html Normal file
View File

@@ -0,0 +1,138 @@
{% extends "base.html" %}
{% block title %}登录 - 智联青云{% endblock %}
{% block navbar %}{% endblock %}
{% block content %}
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-slate-900">登录您的账户</h2>
<p class="mt-2 text-center text-sm text-slate-600">
或者 <a href="/register" class="font-medium text-primary hover:text-blue-500">注册新账户</a>
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div class="flex border-b border-slate-200 mb-6">
<button id="tab-phone" onclick="switchTab('phone')" class="flex-1 pb-4 text-sm font-medium text-center text-primary border-b-2 border-primary">手机验证码登录</button>
<button id="tab-email" onclick="switchTab('email')" class="flex-1 pb-4 text-sm font-medium text-center text-slate-500 hover:text-slate-700">邮箱密码登录</button>
</div>
<!-- 手机登录表单 -->
<form id="form-phone" class="space-y-6" onsubmit="handlePhoneLogin(event)">
<div>
<label class="block text-sm font-medium text-slate-700">手机号码</label>
<div class="mt-1 relative rounded-md shadow-sm">
<input id="phone" type="tel" required class="focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入11位手机号">
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-phone" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码">
<img id="captcha-img-phone" class="cursor-pointer h-10" onclick="fetchCaptcha('phone')" alt="验证码">
<button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">短信验证码</label>
<div class="mt-1 flex rounded-md shadow-sm">
<input id="sms-code" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入短信验证码">
<button type="button" id="send-sms-btn" onclick="handleSendSms()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button>
</div>
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">登录</button>
</form>
<!-- 邮箱登录表单 -->
<form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailLogin(event)">
<div>
<label class="block text-sm font-medium text-slate-700">邮箱地址</label>
<input id="login-email" type="email" required class="mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入邮箱">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">密码</label>
<input id="login-password" type="password" required class="mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入密码">
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">登录</button>
</form>
<div class="mt-6 relative">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-300"></div></div>
<div class="relative flex justify-center text-sm">
<span id="login-hint" class="px-2 bg-white text-slate-500">提示:手机号登录默认为学生权限</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let captchaId = '';
let countdown = 0;
let countdownTimer = null;
function switchTab(tab) {
document.getElementById('form-phone').classList.toggle('hidden', tab !== 'phone');
document.getElementById('form-email').classList.toggle('hidden', tab !== 'email');
document.getElementById('tab-phone').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'phone' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
document.getElementById('tab-email').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'email' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
document.getElementById('login-hint').textContent = tab === 'phone' ? '提示:手机号登录默认为学生权限' : '提示:管理员请使用管理员账号登录';
}
async function fetchCaptcha(suffix) {
try {
const res = await fetch('/api/captcha');
const data = await res.json();
captchaId = data.captchaId;
document.getElementById('captcha-img-' + suffix).src = 'data:image/png;base64,' + data.img;
const input = document.getElementById('captcha-text-' + suffix);
if (input) input.value = '';
} catch(e) { console.error('获取验证码失败', e); }
}
async function handleSendSms() {
const phone = document.getElementById('phone').value;
const captchaText = document.getElementById('captcha-text-phone').value;
if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请输入有效的中国手机号码'); return; }
if (!captchaText) { alert('请输入图形验证码'); return; }
const btn = document.getElementById('send-sms-btn');
btn.disabled = true;
try {
const res = await fetch('/api/send-sms', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({phone, captchaId, captchaText}) });
const data = await res.json();
if (data.success) {
countdown = 60;
countdownTimer = setInterval(() => { countdown--; btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码'; if(countdown<=0){clearInterval(countdownTimer);btn.disabled=false;} }, 1000);
if (data.mockCode) alert('验证码已发送: ' + data.mockCode);
else alert('验证码已发送');
} else { alert(data.message); btn.disabled = false; if(data.refreshCaptcha) fetchCaptcha('phone'); }
} catch(e) { alert('发送失败,请确保后端已启动'); btn.disabled = false; }
fetchCaptcha('phone');
}
async function handlePhoneLogin(e) {
e.preventDefault();
const phone = document.getElementById('phone').value;
const code = document.getElementById('sms-code').value;
try {
const res = await fetch('/api/verify-code', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone, code}) });
const data = await res.json();
if (data.success) { window.location.href = '/'; }
else alert(data.message);
} catch(e) { alert('验证失败'); }
}
async function handleEmailLogin(e) {
e.preventDefault();
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password}) });
const data = await res.json();
if (data.success) { window.location.href = '/'; }
else alert(data.message);
} catch(e) { alert('登录失败'); }
}
fetchCaptcha('phone');
</script>
{% endblock %}

View File

@@ -0,0 +1,298 @@
{% extends "base.html" %}
{% block title %}通知中心 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto py-8 px-4 sm:px-6">
<!-- 头部区域 -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative overflow-hidden group">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50/50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-indigo-200 transform group-hover:rotate-12 transition-transform duration-300">
<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>
<h1 class="text-2xl font-extrabold text-slate-900 tracking-tight">通知中心</h1>
<p class="text-sm text-slate-500 font-medium mt-0.5">查看系统通知、审核结果及最新公告</p>
</div>
</div>
<button onclick="markAllRead()" class="px-5 py-2.5 text-sm font-bold text-slate-600 bg-slate-50 border border-slate-200 rounded-xl hover:bg-white hover:text-indigo-600 hover:border-indigo-200 hover:shadow-sm transition-all flex items-center gap-2 group/btn">
<svg class="w-4 h-4 text-slate-400 group-hover/btn:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
全部标为已读
</button>
</div>
<!-- 标签切换 (玻璃拟物化) -->
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-1.5 shadow-sm border border-slate-100 mb-6 flex overflow-x-auto hide-scrollbar sticky top-4 z-10">
<button onclick="switchTab('all')" id="tab-all" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 shadow-sm rounded-xl transition-all duration-300">全部</button>
<button onclick="switchTab('system')" id="tab-system" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">📢 系统公告</button>
<button onclick="switchTab('teacher')" id="tab-teacher" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">👨‍🏫 教师相关</button>
<button onclick="switchTab('contest')" id="tab-contest" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">🏆 杯赛相关</button>
<button onclick="switchTab('exam')" id="tab-exam" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">📝 考试相关</button>
</div>
<!-- 列表内容区 -->
<div class="space-y-6">
<!-- 系统公告区域 -->
<div id="announcements-section" class="hidden">
<div class="flex items-center gap-2 mb-4 px-2">
<span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center font-bold">📢</span>
<h2 class="text-lg font-bold text-slate-800">系统公告</h2>
</div>
<div id="announcements-list" class="space-y-4"></div>
</div>
<!-- 个人通知列表 -->
<div id="notif-container" class="space-y-3">
<div class="flex flex-col items-center justify-center py-16 text-slate-400">
<svg class="animate-spin -ml-1 mr-3 h-8 w-8 text-indigo-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span class="font-medium">正在加载通知...</span>
</div>
</div>
</div>
</div>
<style>
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
{% endblock %}
{% block scripts %}
<script>
const currentUser = {{ user | tojson }};
let currentTab = 'all';
let allNotifs = [];
let announcements = [];
function esc(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('[id^="tab-"]').forEach(el => {
el.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300';
});
const activeBtn = document.getElementById('tab-' + tab);
activeBtn.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 shadow-sm rounded-xl transition-all duration-300';
renderNotifications();
}
function getTypeFilter(tab) {
if (tab === 'all') return null;
if (tab === 'system') return ['system_announcement'];
if (tab === 'teacher') return ['teacher_application', 'teacher_result'];
if (tab === 'contest') return ['contest_application', 'contest_result', 'contest_new_exam'];
if (tab === 'exam') return ['exam_graded', 'contest_new_exam'];
return null;
}
function getTypeIcon(type) {
const icons = {
'teacher_application': '👨‍🏫', 'teacher_result': '🎓',
'contest_application': '🏆', 'contest_result': '🏅',
'contest_new_exam': '📝', 'exam_graded': '✅',
'system_announcement': '📢'
};
return icons[type] || '🔔';
}
function getTypeLabel(type) {
const labels = {
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
'system_announcement': '系统公告'
};
return labels[type] || '通知';
}
function renderNotifications() {
const container = document.getElementById('notif-container');
const annSection = document.getElementById('announcements-section');
const annList = document.getElementById('announcements-list');
// Show announcements only on 'all' or 'system' tab
if ((currentTab === 'all' || currentTab === 'system') && announcements.length > 0) {
annSection.style.display = '';
annList.innerHTML = announcements.map(a => `
<div class="bg-white rounded-2xl p-5 shadow-sm border ${a.pinned ? 'border-amber-200 shadow-amber-100/50' : 'border-indigo-100 shadow-indigo-100/50'} relative overflow-hidden group hover:shadow-md transition-all duration-300">
<div class="absolute top-0 right-0 w-24 h-24 ${a.pinned ? 'bg-amber-50' : 'bg-indigo-50'} rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<div class="flex items-start gap-4">
<div class="flex-1">
<div class="flex items-center gap-2">
${a.pinned ? '<span class="text-[10px] bg-gradient-to-r from-amber-400 to-orange-500 text-white px-2 py-0.5 rounded-full font-bold shadow-sm flex items-center gap-1"><svg class="w-3 h-3" 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> 置顶</span>' : ''}
<h3 class="text-lg font-bold text-slate-900 group-hover:text-indigo-600 transition-colors">${esc(a.title)}</h3>
</div>
<p class="text-sm text-slate-600 mt-3 whitespace-pre-wrap leading-relaxed">${esc(a.content)}</p>
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-100/60">
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-500 bg-slate-50 px-2.5 py-1 rounded-lg">
<span class="w-4 h-4 rounded-full bg-slate-200 flex items-center justify-center text-[8px]">${esc(a.author_name)[0]}</span>
${esc(a.author_name)}
</span>
<span class="text-xs font-medium text-slate-400 flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
${a.created_at}
</span>
</div>
</div>
</div>
</div>`).join('');
} else {
annSection.style.display = 'none';
if(annList) annList.innerHTML = '';
}
// Filter personal notifications
const filter = getTypeFilter(currentTab);
let filtered = allNotifs;
if (filter) {
if (currentTab === 'system') {
filtered = []; // system tab only shows announcements above
} else {
filtered = allNotifs.filter(n => filter.includes(n.type));
}
}
if (currentTab === 'system') {
container.innerHTML = announcements.length === 0 ? `
<div class="flex flex-col items-center justify-center py-16 text-slate-400 bg-white rounded-3xl border border-slate-100 border-dashed">
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div>
<div class="font-medium">暂无系统公告</div>
</div>` : '';
return;
}
if (filtered.length === 0) {
container.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-slate-400 bg-white rounded-3xl border border-slate-100 border-dashed">
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div>
<div class="font-medium">暂无通知</div>
</div>`;
return;
}
container.innerHTML = filtered.map(n => {
let actions = buildActions(n);
const iconBgColor = {
'teacher_application': 'bg-purple-100 text-purple-600', 'teacher_result': 'bg-fuchsia-100 text-fuchsia-600',
'contest_application': 'bg-orange-100 text-orange-600', 'contest_result': 'bg-amber-100 text-amber-600',
'contest_new_exam': 'bg-indigo-100 text-indigo-600', 'exam_graded': 'bg-emerald-100 text-emerald-600',
'system_announcement': 'bg-blue-100 text-blue-600'
}[n.type] || 'bg-slate-100 text-slate-600';
return `<div class="bg-white border ${n.read ? 'border-slate-100' : 'border-indigo-200 shadow-md shadow-indigo-100/50 relative'} rounded-2xl p-4 sm:p-5 hover:border-indigo-300 hover:shadow-lg transition-all duration-300 cursor-pointer group" onclick="markSingleRead(${n.id}, this)">
${!n.read ? '<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white animate-pulse"></div>' : ''}
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-2xl ${iconBgColor} flex items-center justify-center text-2xl flex-shrink-0 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform">
${getTypeIcon(n.type)}
</div>
<div class="flex-1 min-w-0 pt-0.5">
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<span class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 border border-slate-200/60">${getTypeLabel(n.type)}</span>
${n.from_user ? `<span class="text-xs font-medium text-slate-500 flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>${esc(n.from_user)}</span>` : ''}
<span class="text-xs font-medium text-slate-400 flex items-center gap-1 ml-auto">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
${n.created_at}
</span>
</div>
<div class="text-sm font-medium text-slate-800 leading-relaxed ${!n.read ? 'font-bold' : ''}">${esc(n.content)}</div>
${actions}
</div>
</div>
</div>`;
}).join('');
}
function buildActions(n) {
if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') {
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-100">
<button onclick="event.stopPropagation();approveTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button>
<button onclick="event.stopPropagation();rejectTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button>
</div>`;
}
if (n.type === 'teacher_application' && n.application_status === 'approved') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</div>';
if (n.type === 'teacher_application' && n.application_status === 'rejected') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</div>';
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') {
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-100">
<button onclick="event.stopPropagation();approveContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button>
<button onclick="event.stopPropagation();rejectContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button>
</div>`;
}
if (n.type === 'contest_application' && n.application_status === 'approved') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</div>';
if (n.type === 'contest_application' && n.application_status === 'rejected') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</div>';
// 如果有考试关联的通知,添加快捷入口
if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
if(n.post_id) {
return `<div class="mt-4 pt-3 border-t border-slate-100"><a href="/exams/${n.post_id}" class="inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-bold bg-indigo-50 text-indigo-600 border border-indigo-200 rounded-lg hover:bg-indigo-600 hover:text-white hover:border-indigo-600 transition-colors shadow-sm"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg> 查看考试</a></div>`;
}
}
return '';
}
async function approveTeacherN(appId) {
try {
const res = await fetch(`/api/teacher-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadAll();
} catch(e) { alert('操作失败'); }
}
async function rejectTeacherN(appId) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/teacher-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadAll();
} catch(e) { alert('操作失败'); }
}
async function approveContestN(appId) {
try {
const res = await fetch(`/api/contest-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadAll();
} catch(e) { alert('操作失败'); }
}
async function rejectContestN(appId) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/contest-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadAll();
} catch(e) { alert('操作失败'); }
}
function markSingleRead(nid, el) {
fetch(`/api/notifications/${nid}/read`, {method:'POST'});
// 移除未读的特定样式
el.classList.remove('border-indigo-200', 'shadow-md', 'shadow-indigo-100/50');
el.classList.add('border-slate-100');
const content = el.querySelector('.font-bold.leading-relaxed');
if(content) content.classList.remove('font-bold');
const dot = el.querySelector('.bg-red-500.animate-pulse');
if (dot) dot.remove();
}
function markAllRead() {
fetch('/api/notifications/read-all', {method:'POST'}).then(() => {
allNotifs.forEach(n => n.read = true);
renderNotifications();
});
}
async function loadAll() {
const [notifRes, annRes] = await Promise.all([
fetch('/api/notifications').then(r => r.json()),
fetch('/api/system-notifications').then(r => r.json())
]);
if (notifRes.success) allNotifs = notifRes.notifications;
if (annRes.success) announcements = annRes.notifications;
renderNotifications();
}
loadAll();
</script>
{% endblock %}

548
templates/profile.html Normal file
View File

@@ -0,0 +1,548 @@
{% extends "base.html" %}
{% block title %}个人中心 - 智联青云{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto space-y-6">
<!-- 顶部横幅与头像:高级游戏化设计 -->
<div class="bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden relative group">
<div class="h-48 bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 relative overflow-hidden">
<!-- 高级光影动画背景 -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M20 20.5V18H0v-2h20v-2H0v-2h20v-2H0V8h20V6H0V4h20V2H0V0h22v20h2V0h2v20h2V0h2v20h2V0h2v20h2V0h2v20h2v2H20v-1.5zM0 20h2v20H0V20zm4 0h2v20H4V20zm4 0h2v20H8V20zm4 0h2v20h-2V20zm4 0h2v20h-2V20zm4 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2z\' fill=\'%23ffffff\' fill-opacity=\'0.05\' fill-rule=\'evenodd\'/%3E%3C/svg%3E')] opacity-50"></div>
<div class="absolute top-0 right-0 w-96 h-96 bg-white/20 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2 transform group-hover:scale-110 transition-transform duration-1000"></div>
<div class="absolute bottom-0 left-0 w-64 h-64 bg-indigo-500/30 blur-3xl rounded-full -translate-x-1/2 translate-y-1/2"></div>
</div>
<div class="px-6 pb-8 sm:px-12 relative">
<div class="flex flex-col sm:flex-row items-center sm:items-end -mt-20 sm:-mt-24 sm:space-x-8">
<!-- 头像区域(呼吸发光效果) -->
<div class="relative group/avatar">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full blur-md opacity-40 group-hover/avatar:opacity-60 animate-pulse transition-opacity"></div>
<div id="avatar-display" class="relative w-36 h-36 rounded-full overflow-hidden border-4 border-white shadow-xl flex items-center justify-center bg-gradient-to-br from-indigo-50 to-blue-50 text-indigo-600 text-5xl font-black cursor-pointer transform group-hover/avatar:scale-105 group-hover/avatar:rotate-3 transition-all duration-300 z-10" onclick="uploadAvatar()">
{% if profile_user.avatar %}
<img src="{{ profile_user.avatar }}" class="w-full h-full object-cover" id="avatar-img">
{% else %}
<span id="avatar-letter">{{ profile_user.name[0]|upper }}</span>
{% endif %}
</div>
{% if profile_user.id == user.id %}
<div class="absolute bottom-1 right-1 bg-white p-2.5 rounded-full shadow-lg border-2 border-slate-100 cursor-pointer text-slate-500 hover:text-indigo-600 hover:border-indigo-200 transition-all z-20 transform hover:scale-110" onclick="uploadAvatar()">
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
{% endif %}
<!-- 浮动等级徽章 -->
<div class="absolute -top-2 -right-2 bg-slate-900 text-white text-xs font-black px-3 py-1.5 rounded-full shadow-lg border-2 border-white z-20 transform -rotate-12 group-hover/avatar:rotate-0 transition-transform">
Lv.{{ level }}
</div>
</div>
<!-- 个人信息 -->
<div class="mt-5 sm:mt-0 text-center sm:text-left flex-1 pb-2">
<h1 class="text-4xl font-extrabold text-slate-900 flex items-center justify-center sm:justify-start gap-4 tracking-tight drop-shadow-sm">
{{ get_display_name(profile_user.id, profile_user.name) if profile_user.id == user.id else profile_user.name }}
{% if profile_user.role == 'admin' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-200 transform hover:scale-105 transition-transform">管理员</span>
{% elif profile_user.role == 'teacher' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-sm shadow-blue-200 transform hover:scale-105 transition-transform flex items-center gap-1">👨‍🏫 认证教师</span>
{% else %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-200 transform hover:scale-105 transition-transform flex items-center gap-1">🎓 学生</span>
{% endif %}
</h1>
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-5 mt-4 text-sm font-medium text-slate-500">
{% if profile_user.email %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>{{ profile_user.email }}</span>
{% endif %}
{% if profile_user.phone %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>{{ profile_user.phone }}</span>
{% endif %}
<span class="flex items-center gap-1.5 bg-indigo-50 text-indigo-600 px-3 py-1.5 rounded-lg border border-indigo-100"><svg class="w-4 h-4" 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>加入于 {{ profile_user.created_at.strftime('%Y-%m-%d') }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧:统计与信息 -->
<div class="space-y-6 lg:col-span-1">
<!-- 游戏化数据统计Bento 风格) -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 relative overflow-hidden group">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<h3 class="text-xl font-extrabold text-slate-900 mb-5 flex items-center">
<span class="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-xl flex items-center justify-center mr-3 shadow-sm">📊</span>
活跃数据
</h3>
<!-- 积分进度条 -->
<div class="mb-6 bg-slate-50 rounded-2xl p-4 border border-slate-100">
<div class="flex justify-between text-xs font-bold text-slate-600 mb-2">
<span>当前等级进度</span>
<span class="text-indigo-600">{{ points }} / {{ (level * 100) }} XP</span>
</div>
<div class="h-3 w-full bg-slate-200 rounded-full overflow-hidden shadow-inner relative">
{% set progress = (points / (level * 100) * 100) | int %}
{% set p_width = progress if progress <= 100 else 100 %}
<div class="absolute top-0 left-0 h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full transition-all duration-1000 ease-out overflow-hidden" style="width: {{ p_width }}%;">
<div class="absolute inset-0 bg-white/30 w-full h-full transform -skew-x-12 translate-x-full" style="animation: shimmer 2s infinite;"></div>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gradient-to-br from-indigo-50 to-blue-50 rounded-2xl p-4 text-center border border-indigo-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-indigo-600 drop-shadow-sm">{{ points }}</div>
<div class="text-[11px] font-bold text-indigo-400 uppercase tracking-widest mt-1">总积分</div>
</div>
<div class="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-2xl p-4 text-center border border-emerald-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-emerald-600 drop-shadow-sm">{{ post_count }}</div>
<div class="text-[11px] font-bold text-emerald-500 uppercase tracking-widest mt-1">发帖数</div>
</div>
<div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl p-4 text-center border border-amber-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-amber-600 drop-shadow-sm">{{ reply_count }}</div>
<div class="text-[11px] font-bold text-amber-500 uppercase tracking-widest mt-1">回复数</div>
</div>
<div class="bg-gradient-to-br from-rose-50 to-pink-50 rounded-2xl p-4 text-center border border-rose-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-rose-500 drop-shadow-sm">{{ likes_received }}</div>
<div class="text-[11px] font-bold text-rose-400 uppercase tracking-widest mt-1">获赞数</div>
</div>
</div>
</div>
<!-- 账号设置 -->
{% if profile_user.id == user.id %}
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
<h3 class="text-lg font-bold 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
账号设置
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-xl">
<div>
<div class="text-sm font-medium text-slate-900">用户名</div>
<div class="text-xs text-slate-500 mt-0.5" id="display-username">{{ profile_user.name }}</div>
</div>
<button onclick="changeName()" class="px-3 py-1.5 text-xs font-medium text-primary bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">修改</button>
</div>
{% if user.role == 'student' %}
<a href="/apply-teacher" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group">
<div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">申请成为老师</div>
<div class="text-xs text-slate-500 mt-0.5">获取发布赛事和考试的权限</div>
</div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>
{% endif %}
{% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group">
<div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">进入管理后台</div>
<div class="text-xs text-slate-500 mt-0.5">管理系统各项数据</div>
</div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- 右侧:主要内容 -->
<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="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-blue-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center text-blue-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<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-700 group-hover:text-blue-600 transition-colors">通知中心</div>
</a>
<a href="/chat" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-emerald-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-emerald-100 to-teal-100 rounded-2xl flex items-center justify-center text-emerald-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<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-700 group-hover:text-emerald-600 transition-colors">我的消息</div>
</a>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-purple-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('exam-history-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-purple-100 to-fuchsia-100 rounded-2xl flex items-center justify-center text-purple-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<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-700 group-hover:text-purple-600 transition-colors">考试记录</div>
</div>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-amber-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('bookmarks-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl flex items-center justify-center text-amber-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<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-700 group-hover:text-amber-600 transition-colors">我的收藏</div>
</div>
</div>
{% endif %}
<!-- 高级选项卡内容区 -->
<div class="bg-white shadow-sm rounded-3xl border border-slate-100 overflow-hidden">
<!-- 游戏化标签栏 -->
<div class="p-2 bg-slate-50/80 border-b border-slate-100">
<div class="flex gap-2 overflow-x-auto hide-scrollbar">
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-bold text-indigo-600 bg-white rounded-xl shadow-sm transition-all duration-300 whitespace-nowrap" id="tab-btn-history" onclick="switchTab('history')">📜 考试经历</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-posts" onclick="switchTab('posts')">📝 我的帖子</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-bookmarks" onclick="switchTab('bookmarks')">⭐ 收藏试卷</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-friends" onclick="switchTab('friends')">👥 好友列表</button>
</div>
</div>
<div class="p-6 min-h-[400px]">
<!-- 考试经历 -->
<div id="tab-content-history" class="space-y-4" id="exam-history-tab">
<div id="exam-history">
<div class="flex justify-center items-center py-12 text-slate-400">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
加载中...
</div>
</div>
</div>
<!-- 我的帖子 -->
<div id="tab-content-posts" class="space-y-4 hidden">
<div id="my-posts"></div>
</div>
<!-- 收藏试卷 -->
<div id="tab-content-bookmarks" class="space-y-4 hidden" id="bookmarks-tab">
<div id="bookmarked-exams"></div>
</div>
<!-- 好友列表 -->
<div id="tab-content-friends" class="space-y-4 hidden">
<!-- 搜索用户 -->
{% if profile_user.id == user.id %}
<div class="bg-slate-50 rounded-xl p-4 space-y-3">
<h4 class="text-sm font-bold text-slate-700">搜索用户</h4>
<div class="flex gap-2">
<input id="friend-search-input" type="text" placeholder="输入用户名搜索..." class="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button onclick="searchUsers()" class="px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-blue-600 transition-colors">搜索</button>
</div>
<div id="search-results"></div>
</div>
<!-- 好友请求 -->
<div class="bg-amber-50 rounded-xl p-4 space-y-3">
<h4 class="text-sm font-bold text-amber-700">好友请求</h4>
<div id="friend-requests"></div>
</div>
{% endif %}
<!-- 好友列表 -->
<div id="friends-list" class="grid grid-cols-1 sm:grid-cols-2 gap-4"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function uploadAvatar() {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = () => { if (inp.files.length) doUploadAvatar(inp.files[0]); };
inp.click();
}
function cameraAvatar() {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*'; inp.capture = 'user';
inp.onchange = () => { if (inp.files.length) doUploadAvatar(inp.files[0]); };
inp.click();
}
async function doUploadAvatar(file) {
if (file.size > 10*1024*1024) { alert('文件不能超过10MB'); return; }
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/user/avatar', {method:'POST', body: fd});
const d = await res.json();
if (d.success) {
const display = document.getElementById('avatar-display');
display.innerHTML = `<img src="${d.url}" class="w-full h-full object-cover" id="avatar-img">`;
alert('头像更新成功');
} else { alert(d.message); }
} catch(e) { alert('上传失败'); }
}
async function loadFriends() {
const container = document.getElementById('friends-list');
try {
const res = await fetch('/api/user/friends');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.friends.length === 0) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-slate-400">暂无好友</div>';
return;
}
let html = '';
data.friends.forEach(f => {
html += `
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
</div>
<a href="/chat?dm=${f.id}" class="px-3 py-1.5 text-xs bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors font-medium">私聊</a>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>';
}
loadFriendRequests();
}
async function searchUsers() {
const q = document.getElementById('friend-search-input').value.trim();
const container = document.getElementById('search-results');
if (!q) { container.innerHTML = ''; return; }
try {
const res = await fetch('/api/users/search?q=' + encodeURIComponent(q));
const data = await res.json();
if (!data.success || data.users.length === 0) {
container.innerHTML = '<div class="text-sm text-slate-400 py-2">未找到用户</div>';
return;
}
let html = '';
data.users.forEach(u => {
let actionBtn = '';
if (u.friend_status === 'accepted') {
actionBtn = '<span class="text-xs text-green-600 font-medium">已是好友</span>';
} else if (u.friend_status === 'pending') {
actionBtn = '<span class="text-xs text-amber-600 font-medium">已申请</span>';
} else {
actionBtn = `<button onclick="addFriend(${u.id}, this)" class="px-3 py-1 text-xs bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">加好友</button>`;
}
html += `
<div class="flex items-center space-x-3 p-2 rounded-lg hover:bg-white transition-colors">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold overflow-hidden">
${u.avatar ? `<img src="${u.avatar}" class="w-full h-full object-cover">` : u.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${u.name}</div></div>
${actionBtn}
</div>`;
});
container.innerHTML = html;
} catch(e) { container.innerHTML = '<div class="text-sm text-red-500 py-2">搜索失败</div>'; }
}
async function addFriend(userId, btn) {
try {
btn.disabled = true; btn.textContent = '发送中...';
const res = await fetch('/api/friend/add', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({friend_id: userId})});
const data = await res.json();
if (data.success) { btn.outerHTML = '<span class="text-xs text-amber-600 font-medium">已申请</span>'; }
else { alert(data.message); btn.disabled = false; btn.textContent = '加好友'; }
} catch(e) { alert('发送失败'); btn.disabled = false; btn.textContent = '加好友'; }
}
async function loadFriendRequests() {
const container = document.getElementById('friend-requests');
if (!container) return;
try {
const res = await fetch('/api/friend/requests');
const data = await res.json();
if (!data.success || data.requests.length === 0) {
container.innerHTML = '<div class="text-sm text-amber-600/60 py-1">暂无待处理请求</div>';
return;
}
let html = '';
data.requests.forEach(r => {
html += `
<div class="flex items-center space-x-3 p-2 rounded-lg bg-white" id="freq-${r.id}">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold overflow-hidden">
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` : r.name.charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${r.name}</div></div>
<button onclick="acceptFriend(${r.id})" class="px-2.5 py-1 text-xs bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">同意</button>
<button onclick="rejectFriend(${r.id})" class="px-2.5 py-1 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">拒绝</button>
</div>`;
});
container.innerHTML = html;
} catch(e) { container.innerHTML = '<div class="text-sm text-red-500 py-1">加载失败</div>'; }
}
async function acceptFriend(id) {
try {
const res = await fetch('/api/friend/accept/' + id, {method:'POST'});
const data = await res.json();
if (data.success) { document.getElementById('freq-' + id).remove(); loadFriends(); }
else alert(data.message);
} catch(e) { alert('操作失败'); }
}
async function rejectFriend(id) {
try {
const res = await fetch('/api/friend/reject/' + id, {method:'POST'});
const data = await res.json();
if (data.success) { document.getElementById('freq-' + id).remove(); }
else alert(data.message);
} catch(e) { alert('操作失败'); }
}
async function loadPosts() {
const container = document.getElementById('my-posts');
try {
const res = await fetch('/api/user/posts');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.posts.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无帖子</div>';
return;
}
let html = '';
data.posts.slice(0, 5).forEach(p => {
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer" onclick="location.href='/forum#post-${p.id}'">
<div class="text-sm font-medium text-slate-900 truncate">${p.title}</div>
<div class="text-xs text-slate-500 mt-1">${p.created_at} · ${p.replies} 回复 · ${p.likes} 赞</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
async function changeName() {
const newName = prompt('请输入新用户名(每月仅可修改一次):');
if (!newName || !newName.trim()) return;
try {
const res = await fetch('/api/user/change-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
});
const d = await res.json();
if (d.success) {
document.getElementById('display-username').textContent = d.name;
alert('用户名修改成功');
location.reload();
} else {
alert(d.message);
}
} catch(e) { alert('修改失败'); }
}
async function loadExamHistory() {
const container = document.getElementById('exam-history');
try {
const res = await fetch('/api/user/exam-history');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.history.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无考试经历</div>';
return;
}
let html = '';
data.history.forEach(h => {
let scoreText = '';
if (!h.graded) {
scoreText = '<span class="text-amber-500">待批改</span>';
} else if (h.score === null) {
scoreText = '<span class="text-slate-400">成绩未公布</span>';
} else {
scoreText = `<span class="text-green-600 font-medium">${h.score}/${h.total_score}</span>`;
}
const contestTag = h.contest_name ? `<span class="text-xs bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded">${h.contest_name}</span>` : '';
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer flex items-center justify-between" onclick="location.href='/exams/${h.exam_id}/result'">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900 truncate">${h.title} ${contestTag}</div>
<div class="text-xs text-slate-500 mt-1">${h.submitted_at}</div>
</div>
<div class="ml-3 text-sm">${scoreText}</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
async function loadBookmarks() {
const container = document.getElementById('bookmarked-exams');
try {
const res = await fetch('/api/user/exam-bookmarks');
const data = await res.json();
if (!data.success) throw new Error(data.message);
if (data.bookmarks.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-slate-400">暂无收藏试卷</div>';
return;
}
let html = '';
data.bookmarks.slice(0, 5).forEach(e => {
html += `
<div class="p-3 border border-slate-100 rounded-lg hover:bg-slate-50 cursor-pointer" onclick="location.href='/exams/${e.id}'">
<div class="text-sm font-medium text-slate-900 truncate">${e.title}</div>
<div class="text-xs text-slate-500 mt-1">${e.subject} · 收藏于 ${e.bookmarked_at}</div>
</div>`;
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="text-center py-4 text-red-500">加载失败</div>';
}
}
function switchTab(tabName) {
// 隐藏所有内容并重置动画
['history', 'posts', 'bookmarks', 'friends'].forEach(t => {
const content = document.getElementById(`tab-content-${t}`);
if(content) {
content.classList.add('hidden');
content.style.animation = 'none';
}
const btn = document.getElementById(`tab-btn-${t}`);
if(btn) {
btn.classList.remove('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold');
btn.classList.add('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium');
}
});
// 显示选中内容并添加动画
const activeContent = document.getElementById(`tab-content-${tabName}`);
if(activeContent) {
activeContent.classList.remove('hidden');
void activeContent.offsetWidth; // 触发重绘
activeContent.style.animation = 'fadeIn 0.3s ease-out';
}
// 激活按钮样式
const activeBtn = document.getElementById(`tab-btn-${tabName}`);
if(activeBtn) {
activeBtn.classList.remove('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium');
activeBtn.classList.add('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold');
}
}
loadExamHistory();
loadFriends();
loadPosts();
loadBookmarks();
</script>
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
100% { transform: translateX(100%); }
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{% endblock %}

264
templates/register.html Normal file
View File

@@ -0,0 +1,264 @@
{% extends "base.html" %}
{% block title %}注册 - 智联青云{% endblock %}
{% block navbar %}{% endblock %}
{% block content %}
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-slate-900">注册新账户</h2>
<p class="mt-2 text-center text-sm text-slate-600">
已有账户? <a href="/login" class="font-medium text-primary hover:text-blue-500">立即登录</a>
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<!-- 选项卡 -->
<div class="flex border-b border-slate-200 mb-6">
<button id="tab-phone" onclick="switchTab('phone')" class="flex-1 pb-4 text-sm font-medium text-center text-primary border-b-2 border-primary">手机号注册</button>
<button id="tab-email" onclick="switchTab('email')" class="flex-1 pb-4 text-sm font-medium text-center text-slate-500 hover:text-slate-700">邮箱注册</button>
</div>
<!-- 手机注册表单 -->
<form id="form-phone" class="space-y-6" onsubmit="handlePhoneRegister(event)">
<div>
<label class="block text-sm font-medium text-slate-700">姓名</label>
<input id="phone-name" type="text" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">手机号码</label>
<input id="phone-number" type="tel" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-phone" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码">
<img id="captcha-img-phone" class="cursor-pointer h-10" onclick="fetchCaptcha('phone')" alt="验证码">
<button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">短信验证码</label>
<div class="mt-1 flex rounded-md shadow-sm">
<input id="sms-code-phone" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入短信验证码">
<button type="button" id="send-sms-btn-phone" onclick="handleSendSmsPhone()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">密码</label>
<input id="phone-password" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">确认密码</label>
<input id="phone-confirm" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">注册</button>
</form>
<!-- 邮箱注册表单(已添加手机号绑定) -->
<form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailRegister(event)">
<div>
<label class="block text-sm font-medium text-slate-700">姓名</label>
<input id="reg-name" type="text" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">邮箱地址</label>
<input id="reg-email" type="email" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">手机号码(用于绑定)</label>
<input id="reg-phone" type="tel" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-reg" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码">
<img id="captcha-img-reg" class="cursor-pointer h-10" onclick="fetchCaptcha('reg')" alt="验证码">
<button type="button" onclick="fetchCaptcha('reg')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">邮箱验证码</label>
<div class="mt-1 flex rounded-md shadow-sm">
<input id="reg-email-code" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入邮箱验证码">
<button type="button" id="send-email-btn" onclick="handleSendEmailCode()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">密码</label>
<input id="reg-password" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">确认密码</label>
<input id="reg-confirm" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">注册</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let captchaId = '';
let countdown = 0;
// 切换选项卡
function switchTab(tab) {
document.getElementById('form-phone').classList.toggle('hidden', tab !== 'phone');
document.getElementById('form-email').classList.toggle('hidden', tab !== 'email');
document.getElementById('tab-phone').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'phone' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
document.getElementById('tab-email').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'email' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
}
// 获取图形验证码
async function fetchCaptcha(suffix) {
try {
const res = await fetch('/api/captcha');
const data = await res.json();
captchaId = data.captchaId;
document.getElementById('captcha-img-' + suffix).src = 'data:image/png;base64,' + data.img;
const input = document.getElementById('captcha-text-' + suffix);
if (input) input.value = '';
} catch(e) { console.error('获取验证码失败', e); }
}
// 手机注册发送短信验证码
async function handleSendSmsPhone() {
const phone = document.getElementById('phone-number').value;
const captchaText = document.getElementById('captcha-text-phone').value;
if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请输入有效的中国手机号码'); return; }
if (!captchaText) { alert('请输入图形验证码'); return; }
const btn = document.getElementById('send-sms-btn-phone');
btn.disabled = true;
try {
const res = await fetch('/api/send-sms', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({phone, captchaId, captchaText})
});
const data = await res.json();
if (data.success) {
countdown = 60;
const timer = setInterval(() => {
countdown--;
btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码';
if(countdown<=0){clearInterval(timer);btn.disabled=false;}
}, 1000);
if (data.mockCode) alert('验证码已发送: ' + data.mockCode);
else alert('验证码已发送');
} else {
alert(data.message);
btn.disabled = false;
if(data.refreshCaptcha) fetchCaptcha('phone');
}
} catch(e) {
alert('发送失败,请确保后端已启动');
btn.disabled = false;
}
fetchCaptcha('phone');
}
// 手机注册提交
async function handlePhoneRegister(e) {
e.preventDefault();
const password = document.getElementById('phone-password').value;
const confirm = document.getElementById('phone-confirm').value;
if (password !== confirm) { alert('两次输入的密码不一致'); return; }
const body = {
name: document.getElementById('phone-name').value,
phone: document.getElementById('phone-number').value,
password: password,
smsCode: document.getElementById('sms-code-phone').value
};
try {
const res = await fetch('/api/register-mobile', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) {
alert('注册成功!');
window.location.href = '/';
} else {
alert(data.message);
}
} catch(e) {
alert('注册失败');
}
}
// 邮箱发送验证码(保持不变)
async function handleSendEmailCode() {
const email = document.getElementById('reg-email').value;
const captchaText = document.getElementById('captcha-text-reg').value;
if (!email) { alert('请先输入邮箱地址'); return; }
if (!captchaText) { alert('请输入图形验证码'); return; }
const btn = document.getElementById('send-email-btn');
btn.disabled = true;
try {
const res = await fetch('/api/send-email-code', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({email, captchaId, captchaText})
});
const data = await res.json();
if (data.success) {
countdown = 60;
const timer = setInterval(() => {
countdown--;
btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码';
if(countdown<=0){clearInterval(timer);btn.disabled=false;}
}, 1000);
alert('验证码已发送到邮箱,请查收');
} else {
alert(data.message);
btn.disabled = false;
if(data.refreshCaptcha) fetchCaptcha('reg');
}
} catch(e) {
alert('发送失败');
btn.disabled = false;
}
fetchCaptcha('reg');
}
// 邮箱注册提交(修改,增加手机号)
async function handleEmailRegister(e) {
e.preventDefault();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
if (password !== confirm) { alert('两次输入的密码不一致'); return; }
const body = {
name: document.getElementById('reg-name').value,
email: document.getElementById('reg-email').value,
phone: document.getElementById('reg-phone').value,
password: password,
emailCode: document.getElementById('reg-email-code').value
};
try {
const res = await fetch('/api/register', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) {
alert('注册成功!');
window.location.href = '/';
} else {
alert(data.message);
}
} catch(e) {
alert('注册失败');
}
}
// 初始化图形验证码(默认手机选项卡)
fetchCaptcha('phone');
</script>
{% endblock %}