first commit
This commit is contained in:
100
templates/admin_base.html
Normal file
100
templates/admin_base.html
Normal 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 %}
|
||||
151
templates/admin_contest_applications.html
Normal file
151
templates/admin_contest_applications.html
Normal 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 %}
|
||||
91
templates/admin_contests.html
Normal file
91
templates/admin_contests.html
Normal 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 %}
|
||||
95
templates/admin_create_contest.html
Normal file
95
templates/admin_create_contest.html
Normal 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 %}
|
||||
230
templates/admin_dashboard.html
Normal file
230
templates/admin_dashboard.html
Normal 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
113
templates/admin_exams.html
Normal 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 %}
|
||||
239
templates/admin_notifications.html
Normal file
239
templates/admin_notifications.html
Normal 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">×</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
126
templates/admin_posts.html
Normal 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 %}
|
||||
51
templates/admin_teacher_applications.html
Normal file
51
templates/admin_teacher_applications.html
Normal 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
127
templates/admin_users.html
Normal 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 %}
|
||||
99
templates/apply_contest.html
Normal file
99
templates/apply_contest.html
Normal 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 %}
|
||||
110
templates/apply_teacher.html
Normal file
110
templates/apply_teacher.html
Normal 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
439
templates/base.html
Normal 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">×</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
1307
templates/chat.html
Normal file
File diff suppressed because it is too large
Load Diff
491
templates/contest_detail.html
Normal file
491
templates/contest_detail.html
Normal 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">×</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 '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
if (m === '"') return '"';
|
||||
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
191
templates/contest_edit.html
Normal 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
141
templates/contest_list.html
Normal 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 %}
|
||||
259
templates/contest_question_bank.html
Normal file
259
templates/contest_question_bank.html
Normal 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. 选项一 B. 选项二 C. 选项三 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
638
templates/exam_create.html
Normal 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">×</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">∞</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, '<').replace(/>/g, '>') + '</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">×</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">×</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
536
templates/exam_detail.html
Normal 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
192
templates/exam_grade.html
Normal 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
249
templates/exam_list.html
Normal 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
105
templates/exam_print.html
Normal 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>
|
||||
88
templates/exam_result.html
Normal file
88
templates/exam_result.html
Normal 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 %}
|
||||
85
templates/exam_submissions.html
Normal file
85
templates/exam_submissions.html
Normal 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
936
templates/forum.html
Normal 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
346
templates/home.html
Normal 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
138
templates/login.html
Normal 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 %}
|
||||
298
templates/notifications.html
Normal file
298
templates/notifications.html
Normal 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
548
templates/profile.html
Normal 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
264
templates/register.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user