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

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

293
app.py
View File

@@ -2,6 +2,7 @@
import os import os
import time import time
import json import json
from io import BytesIO
import random import random
import string import string
import smtplib import smtplib
@@ -28,6 +29,7 @@ from openai import OpenAI as DashScopeClient
from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction, QuestionBankItem, InviteCode, SystemNotification from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction, QuestionBankItem, InviteCode, SystemNotification
from flask_socketio import SocketIO, emit, join_room as sio_join, leave_room as sio_leave from flask_socketio import SocketIO, emit, join_room as sio_join, leave_room as sio_leave
from excel_export import export_users_to_excel, export_exams_to_excel, export_submissions_to_excel, export_contests_to_excel, export_posts_to_excel from excel_export import export_users_to_excel, export_exams_to_excel, export_submissions_to_excel, export_contests_to_excel, export_posts_to_excel
from backup_utils import create_backup_zip, export_all_data
load_dotenv() load_dotenv()
@@ -46,6 +48,20 @@ email_codes = {}
# 在线用户追踪(记录用户最后活跃时间) # 在线用户追踪(记录用户最后活跃时间)
online_users = {} # {user_id: last_active_timestamp} online_users = {} # {user_id: last_active_timestamp}
# 正在考试中的用户 {user_id: {exam_id, exam_title, end_time}}
users_in_exam = {}
# SocketIO 用户与连接映射 {user_id: set(sids)},用于向好友推送考试状态
user_sids = {}
def _prune_expired_exam_users():
"""移除已过期的考试用户"""
now = int(time.time())
expired = [uid for uid, info in users_in_exam.items() if info.get('end_time', 0) < now]
for uid in expired:
del users_in_exam[uid]
_notify_friends_exam_status(uid, False)
# 数据库配置 # 数据库配置
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
@@ -379,13 +395,14 @@ def inject_display_name():
if not user: if not user:
return '' return ''
return get_display_name(user['id'], user['name']) return get_display_name(user['id'], user['name'])
return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name) return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name, can_grade_exam=can_grade_exam)
# ========== 页面路由 ========== # ========== 页面路由 ==========
@app.route('/') @app.route('/')
def home(): def home():
online_count = get_online_count() online_count = get_online_count()
contest_count = Contest.query.count() # 首页杯赛数量:排除已废止和已删除的杯赛
contest_count = Contest.query.filter(Contest.status != 'abolished').count()
return render_template('home.html', online_count=online_count, contest_count=contest_count) return render_template('home.html', online_count=online_count, contest_count=contest_count)
@app.route('/login', methods=['GET']) @app.route('/login', methods=['GET'])
@@ -556,14 +573,19 @@ def exam_list():
query = Exam.query query = Exam.query
# 只显示公开考试用户自己创建的私有考试 # 只显示公开考试用户自己创建的私有考试、或用户作为杯赛成员可批改的杯赛考试
from sqlalchemy import or_ from sqlalchemy import or_
query = query.filter( base_filter = or_(
or_( Exam.visibility == 'public',
Exam.visibility == 'public', Exam.creator_id == user.get('id')
Exam.creator_id == user.get('id')
)
) )
# 杯赛负责人和杯赛老师可见其杯赛下的所有考试(用于批改)
cup_member_contest_ids = [m.contest_id for m in ContestMembership.query.filter_by(
user_id=user.get('id')).filter(
ContestMembership.role.in_(['owner', 'teacher'])).all()]
if cup_member_contest_ids:
base_filter = or_(base_filter, Exam.contest_id.in_(cup_member_contest_ids))
query = query.filter(base_filter)
if subject_filter: if subject_filter:
query = query.filter_by(subject=subject_filter) query = query.filter_by(subject=subject_filter)
@@ -597,20 +619,22 @@ def exam_list():
@login_required @login_required
def exam_create(): def exam_create():
user = session.get('user') user = session.get('user')
contest_id = request.args.get('contest_id', type=int)
is_cup_teacher = False
# 允许 teacher/admin 创建任意考试 # 允许 teacher/admin 创建任意考试
if user.get('role') in ('teacher', 'admin'): if user.get('role') in ('teacher', 'admin'):
return render_template('exam_create.html') return render_template('exam_create.html', contest_id=contest_id or None, is_cup_teacher=False)
# 允许杯赛负责人创建其杯赛的考试 # 允许杯赛负责人或杯赛老师创建其杯赛的考试
contest_id = request.args.get('contest_id', type=int)
if contest_id: if contest_id:
membership = ContestMembership.query.filter_by( membership = ContestMembership.query.filter_by(
user_id=user['id'], contest_id=contest_id, role='owner').first() user_id=user['id'], contest_id=contest_id).first()
if membership: if membership:
return render_template('exam_create.html') is_cup_teacher = (membership.role == 'teacher')
return render_template('exam_create.html', contest_id=contest_id, is_cup_teacher=is_cup_teacher)
# 检查用户是否是任何杯赛的负责人 # 检查用户是否是任何杯赛的负责人
any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first() any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first()
if any_ownership: if any_ownership:
return render_template('exam_create.html') return render_template('exam_create.html', contest_id=contest_id or None, is_cup_teacher=False)
return redirect(url_for('exam_list')) return redirect(url_for('exam_list'))
@app.route('/exams/<int:exam_id>') @app.route('/exams/<int:exam_id>')
@@ -1587,13 +1611,13 @@ def apply_teacher():
# 如果存在申请,检查拒绝次数和冷却期 # 如果存在申请,检查拒绝次数和冷却期
if existing: if existing:
# 检查是否被拒绝5次且在冷却期内 # 检查是否被拒绝3次且在冷却期内
if existing.rejection_count >= 5 and existing.last_rejected_at: if existing.rejection_count >= 3 and existing.last_rejected_at:
from datetime import timedelta from datetime import timedelta
cooldown_end = existing.last_rejected_at + timedelta(days=30) cooldown_end = existing.last_rejected_at + timedelta(days=10)
if datetime.utcnow() < cooldown_end: if datetime.utcnow() < cooldown_end:
remaining_days = (cooldown_end - datetime.utcnow()).days + 1 remaining_days = (cooldown_end - datetime.utcnow()).days + 1
flash(f'您对该杯赛的申请已被拒绝5次,需等待 {remaining_days} 天后才能再次申请', 'error') flash(f'您对该杯赛的申请已被拒绝3次,需等待 {remaining_days} 天后才能再次申请', 'error')
return redirect(url_for('contest_detail', contest_id=contest_id)) return redirect(url_for('contest_detail', contest_id=contest_id))
# 如果存在申请,更新它;否则创建新申请 # 如果存在申请,更新它;否则创建新申请
@@ -2544,9 +2568,16 @@ def api_create_exam():
# 检查是否是负责人、老师或管理员 # 检查是否是负责人、老师或管理员
if not membership and user.get('role') != 'admin': if not membership and user.get('role') != 'admin':
return jsonify({'success': False, 'message': '只有杯赛成员才能创建杯赛考试'}), 403 return jsonify({'success': False, 'message': '只有杯赛成员才能创建杯赛考试'}), 403
# 杯赛考试默认为公开(杯赛内可见) # 权限:仅管理员和杯赛负责人可创建公开杯赛,杯赛老师仅可创建私有(自己可见)
if visibility == 'private': is_owner = membership and membership.role == 'owner'
visibility = 'public' is_admin = user.get('role') == 'admin'
is_cup_teacher = membership and membership.role == 'teacher'
if visibility == 'public':
if not is_owner and not is_admin:
return jsonify({'success': False, 'message': '只有管理员和杯赛负责人才可以创建公开杯赛,杯赛老师仅可创建自己可见的杯赛'}), 403
# 杯赛老师创建的强制为私有
if is_cup_teacher:
visibility = 'private'
else: else:
# 普通考试:所有登录用户都可以创建私有考试 # 普通考试:所有登录用户都可以创建私有考试
# 只有 teacher 或 admin 可以创建公开考试 # 只有 teacher 或 admin 可以创建公开考试
@@ -2626,6 +2657,73 @@ def api_verify_exam_password(exam_id):
return jsonify({'success': True, 'message': '验证通过'}) return jsonify({'success': True, 'message': '验证通过'})
return jsonify({'success': False, 'message': '密码错误'}), 403 return jsonify({'success': False, 'message': '密码错误'}), 403
def _notify_friends_exam_status(user_id, in_exam, exam_id=None, exam_title=None, end_time=None):
"""通知好友用户的考试状态变化"""
friendships = Friend.query.filter(
((Friend.user_id == user_id) | (Friend.friend_id == user_id)) &
(Friend.status == 'accepted')
).all()
friend_ids = []
for f in friendships:
fid = f.friend_id if f.user_id == user_id else f.user_id
friend_ids.append(fid)
user = User.query.get(user_id)
user_name = user.name if user else ''
payload = {
'user_id': user_id, 'user_name': user_name,
'in_exam': in_exam, 'exam_id': exam_id, 'exam_title': exam_title, 'end_time': end_time
}
for fid in friend_ids:
sids = user_sids.get(fid, set())
for sid in sids:
socketio.emit('friend_exam_status', payload, room=sid)
@app.route('/api/exams/<int:exam_id>/enter', methods=['POST'])
@login_required
def api_exam_enter(exam_id):
"""用户进入考试,标记状态并通知好友"""
user = session.get('user')
if not user:
return jsonify({'success': False, 'message': '请先登录'}), 401
exam = Exam.query.get(exam_id)
if not exam:
return jsonify({'success': False, 'message': '试卷不存在'}), 404
if Submission.query.filter_by(exam_id=exam_id, user_id=user['id']).first():
return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
duration_min = exam.duration or 120
end_ts = int(time.time()) + duration_min * 60
users_in_exam[user['id']] = {
'exam_id': exam_id, 'exam_title': exam.title or '', 'end_time': end_ts
}
_notify_friends_exam_status(user['id'], True, exam_id, exam.title, end_ts)
return jsonify({'success': True})
@app.route('/api/exams/<int:exam_id>/exit', methods=['POST'])
@login_required
def api_exam_exit(exam_id):
"""用户退出考试(提交后或离开页面时调用)"""
user = session.get('user')
if not user:
return jsonify({'success': False}), 401
if user['id'] in users_in_exam and users_in_exam[user['id']].get('exam_id') == exam_id:
del users_in_exam[user['id']]
_notify_friends_exam_status(user['id'], False)
return jsonify({'success': True})
@app.route('/api/user/<int:user_id>/exam-status')
@login_required
def api_user_exam_status(user_id):
"""获取用户是否正在考试(供好友在聊天界面查询)"""
_prune_expired_exam_users()
info = users_in_exam.get(user_id)
if not info:
return jsonify({'success': True, 'in_exam': False})
return jsonify({
'success': True, 'in_exam': True,
'exam_id': info.get('exam_id'), 'exam_title': info.get('exam_title', ''),
'end_time': info.get('end_time')
})
@app.route('/api/exams/<int:exam_id>/save-draft', methods=['POST']) @app.route('/api/exams/<int:exam_id>/save-draft', methods=['POST'])
def api_save_draft(exam_id): def api_save_draft(exam_id):
user = session.get('user') user = session.get('user')
@@ -2668,37 +2766,69 @@ def api_submit_exam(exam_id):
auto_graded = True auto_graded = True
question_scores = {} question_scores = {}
questions = get_exam_questions(exam) questions = get_exam_questions(exam)
# AI批改客观题
for q in questions: for q in questions:
qid = str(q['id']) qid = str(q['id'])
if q['type'] == 'choice': if q['type'] == 'choice':
# 选择题:精确匹配
if answers.get(qid) == q.get('answer'): if answers.get(qid) == q.get('answer'):
score += q.get('score', 0) score += q.get('score', 0)
question_scores[qid] = q.get('score', 0) question_scores[qid] = q.get('score', 0)
else: else:
question_scores[qid] = 0 question_scores[qid] = 0
elif q['type'] == 'fill': elif q['type'] == 'fill':
# 填空题使用AI智能判断
student_answer = answers.get(qid, '').strip() student_answer = answers.get(qid, '').strip()
correct_answers = [a.strip() for a in q.get('answer', '').split('|')] correct_answer = q.get('answer', '').strip()
# 先尝试精确匹配(支持多个正确答案,用|分隔)
correct_answers = [a.strip() for a in correct_answer.split('|')]
if student_answer in correct_answers: if student_answer in correct_answers:
score += q.get('score', 0) score += q.get('score', 0)
question_scores[qid] = q.get('score', 0) question_scores[qid] = q.get('score', 0)
else:
# 使用AI判断答案是否正确
ai_score = ai_grade_fill_question(q.get('content', ''), correct_answer, student_answer, q.get('score', 0))
score += ai_score
question_scores[qid] = ai_score
elif q['type'] == 'judge':
# 判断题:精确匹配
if answers.get(qid) == q.get('answer'):
score += q.get('score', 0)
question_scores[qid] = q.get('score', 0)
else: else:
question_scores[qid] = 0 question_scores[qid] = 0
else: else:
# 主观题需要人工批改
auto_graded = False auto_graded = False
question_scores[qid] = 0 question_scores[qid] = 0
sub = Submission( sub = Submission(
exam_id=exam_id, exam_id=exam_id,
user_id=user.get('id'), user_id=user.get('id'),
score=score, score=score,
graded=auto_graded, graded=auto_graded,
graded_by='系统自动' if auto_graded else '' graded_by='AI自动批改' if auto_graded else ''
) )
sub.set_answers(answers) sub.set_answers(answers)
sub.set_question_scores(question_scores) sub.set_question_scores(question_scores)
db.session.add(sub) db.session.add(sub)
Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete() Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete()
db.session.commit() db.session.commit()
# 如果有主观题,通知老师批改
if not auto_graded and exam.contest_id:
contest = Contest.query.get(exam.contest_id)
if contest:
# 通知杯赛负责人和老师
memberships = ContestMembership.query.filter_by(contest_id=exam.contest_id).all()
for m in memberships:
if m.role in ('owner', 'teacher'):
add_notification(m.user_id, 'exam_submission',
f'考生 {user.get("name")} 提交了考试「{exam.title}」,请批改主观题。',
from_user=user.get('name', ''), post_id=sub.id)
return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id}) return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})
@app.route('/api/exams/<int:exam_id>/grade/<int:sub_id>', methods=['POST']) @app.route('/api/exams/<int:exam_id>/grade/<int:sub_id>', methods=['POST'])
@@ -2762,18 +2892,21 @@ def api_update_exam_visibility(exam_id):
if new_visibility not in ('private', 'public'): if new_visibility not in ('private', 'public'):
return jsonify({'success': False, 'message': '无效的可见性设置'}), 400 return jsonify({'success': False, 'message': '无效的可见性设置'}), 400
# 检查权限:教师、管理员杯赛负责人和杯赛老师可以设置为公开 # 检查权限:管理员杯赛负责人可设置为公开,杯赛老师仅可设为私有
if new_visibility == 'public': if new_visibility == 'public':
has_permission = user.get('role') in ('teacher', 'admin') has_permission = user.get('role') == 'admin'
# 如果是杯赛考试,检查是否为杯赛负责人或老师 # 如果是杯赛考试,杯赛负责人也可以设置为公开
if not has_permission and exam.contest_id: if not has_permission and exam.contest_id:
membership = ContestMembership.query.filter_by( membership = ContestMembership.query.filter_by(
user_id=user['id'], contest_id=exam.contest_id).first() user_id=user['id'], contest_id=exam.contest_id).first()
if membership and membership.role in ('owner', 'teacher'): if membership and membership.role == 'owner':
has_permission = True has_permission = True
# 系统教师可设置普通考试为公开(非杯赛考试)
if not has_permission and not exam.contest_id and user.get('role') == 'teacher':
has_permission = True
if not has_permission: if not has_permission:
return jsonify({'success': False, 'message': '只有教师、管理员杯赛负责人/老师可以创建公开考试'}), 403 return jsonify({'success': False, 'message': '只有管理员杯赛负责人才可以将杯赛考试设为公开'}), 403
exam.visibility = new_visibility exam.visibility = new_visibility
db.session.commit() db.session.commit()
@@ -2801,7 +2934,7 @@ def api_delete_exam(exam_id):
@cache.cached(timeout=60, query_string=True) @cache.cached(timeout=60, query_string=True)
def api_search_contests(): def api_search_contests():
keyword = request.args.get('q', '').strip().lower() keyword = request.args.get('q', '').strip().lower()
query = Contest.query query = Contest.query.filter(Contest.status != 'abolished')
if keyword: if keyword:
query = query.filter( query = query.filter(
(Contest.name.contains(keyword)) | (Contest.name.contains(keyword)) |
@@ -3911,12 +4044,14 @@ def api_chat_rooms():
# 私聊显示对方名字和头像 # 私聊显示对方名字和头像
room_name = room.name room_name = room.name
room_avatar = room.avatar room_avatar = room.avatar
peer_id = None
if room.type == 'private': if room.type == 'private':
other_member = ChatRoomMember.query.filter( other_member = ChatRoomMember.query.filter(
ChatRoomMember.room_id == room.id, ChatRoomMember.room_id == room.id,
ChatRoomMember.user_id != user_id ChatRoomMember.user_id != user_id
).first() ).first()
if other_member: if other_member:
peer_id = other_member.user_id
other_user = User.query.get(other_member.user_id) other_user = User.query.get(other_member.user_id)
if other_user: if other_user:
room_name = other_user.name room_name = other_user.name
@@ -3924,6 +4059,7 @@ def api_chat_rooms():
rooms_data.append({ rooms_data.append({
'id': room.id, 'id': room.id,
'type': room.type, 'type': room.type,
'peer_id': peer_id,
'name': room_name or '未命名群聊', 'name': room_name or '未命名群聊',
'avatar': room_avatar or '', 'avatar': room_avatar or '',
'muted': m.muted, 'muted': m.muted,
@@ -4204,10 +4340,24 @@ def handle_connect():
user = session.get('user') user = session.get('user')
if not user: if not user:
return False return False
memberships = ChatRoomMember.query.filter_by(user_id=user['id']).all() user_id = user['id']
if user_id not in user_sids:
user_sids[user_id] = set()
user_sids[user_id].add(request.sid)
memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
for m in memberships: for m in memberships:
sio_join(f'room_{m.room_id}') sio_join(f'room_{m.room_id}')
@socketio.on('disconnect')
def handle_disconnect():
user = session.get('user')
if user:
uid = user['id']
if uid in user_sids:
user_sids[uid].discard(request.sid)
if not user_sids[uid]:
del user_sids[uid]
@socketio.on('send_message') @socketio.on('send_message')
def handle_send_message(data): def handle_send_message(data):
user = session.get('user') user = session.get('user')
@@ -4240,7 +4390,7 @@ def handle_send_message(data):
rs = User.query.get(reply_msg.sender_id) rs = User.query.get(reply_msg.sender_id)
reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知', reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知',
'content': reply_msg.content[:50] if reply_msg.content else '[文件]'} 'content': reply_msg.content[:50] if reply_msg.content else '[文件]'}
emit('new_message', { payload = {
'id': msg.id, 'room_id': room_id, 'sender_id': user_id, 'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
'sender_name': sender.name if sender else user['name'], 'sender_name': sender.name if sender else user['name'],
'sender_avatar': (sender.avatar or '') if sender else '', 'sender_avatar': (sender.avatar or '') if sender else '',
@@ -4249,7 +4399,24 @@ def handle_send_message(data):
'reply_to': reply_to_data, 'reply_to': reply_to_data,
'mentions': mentions_raw, 'mentions': mentions_raw,
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
}, room=f'room_{room_id}') }
# 考试期间不向考生推送聊天消息(排除正在考试的用户)
_prune_expired_exam_users()
members = ChatRoomMember.query.filter_by(room_id=room_id).all()
has_exam_user = any(m.user_id in users_in_exam for m in members)
if has_exam_user:
for m in members:
if m.user_id not in users_in_exam:
for sid in user_sids.get(m.user_id, set()):
socketio.emit('new_message', payload, room=sid)
# 私聊时通知发送者:对方已收到,但考试期间不推送
room = ChatRoom.query.get(room_id)
if room and room.type == 'private':
exam_member = next((m for m in members if m.user_id in users_in_exam), None)
if exam_member and exam_member.user_id != user_id:
emit('peer_in_exam', {'message': '对方正在考试中,消息将在考试结束后送达'}, room=request.sid)
else:
emit('new_message', payload, room=f'room_{room_id}')
@socketio.on('typing') @socketio.on('typing')
def handle_typing(data): def handle_typing(data):
@@ -4595,6 +4762,33 @@ def admin_contest_detail(contest_id):
db.session.commit() db.session.commit()
return jsonify({'success': True}) return jsonify({'success': True})
@app.route('/api/admin/contests/<int:contest_id>/delete', methods=['POST'])
@admin_required
def admin_delete_contest(contest_id):
"""管理员彻底删除杯赛(需先废止)。删除后杯赛将不再显示,不计入首页数量"""
contest = Contest.query.get(contest_id)
if not contest:
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
if contest.status != 'abolished':
return jsonify({'success': False, 'message': '请先废止杯赛再删除'}), 400
# 清理关联数据(无 cascade 的需手动处理)
ContestRegistration.query.filter_by(contest_id=contest_id).delete()
ContestApplication.query.filter_by(contest_id=contest_id).delete()
QuestionBankItem.query.filter_by(contest_id=contest_id).delete()
# 删除该杯赛教师申请的邀请码
ta_ids = [ta.id for ta in TeacherApplication.query.filter_by(contest_id=contest_id).all()]
if ta_ids:
InviteCode.query.filter(InviteCode.application_id.in_(ta_ids)).delete(synchronize_session=False)
# 解除考试与杯赛的关联(保留考试数据)
Exam.query.filter_by(contest_id=contest_id).update({Exam.contest_id: None})
# 解除聊天室与杯赛的关联
chat_room = ChatRoom.query.filter_by(contest_id=contest_id).first()
if chat_room:
chat_room.contest_id = None
db.session.delete(contest)
db.session.commit()
return jsonify({'success': True, 'message': '杯赛已彻底删除'})
@app.route('/api/admin/contests/<int:contest_id>/abolish', methods=['POST']) @app.route('/api/admin/contests/<int:contest_id>/abolish', methods=['POST'])
@admin_required @admin_required
def admin_abolish_contest(contest_id): def admin_abolish_contest(contest_id):
@@ -5018,5 +5212,40 @@ def admin_export_posts():
download_name=filename download_name=filename
) )
# ========== 数据备份(管理员) ==========
UPLOAD_FOLDER_BACKUP = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
@app.route('/admin/backup')
@admin_required
def admin_backup_page():
"""数据备份页面"""
return render_template('admin_backup.html')
@app.route('/admin/backup/download')
@admin_required
def admin_backup_download():
"""下载完整数据备份ZIP数据 JSON + 上传文件)"""
full = request.args.get('full', '1') == '1'
if full:
zip_buf = create_backup_zip(include_uploads=True, upload_folder=UPLOAD_FOLDER_BACKUP)
filename = f"智联青云_完整备份_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
return send_file(
zip_buf,
mimetype='application/zip',
as_attachment=True,
download_name=filename
)
else:
data = export_all_data()
buf = BytesIO(json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8'))
buf.seek(0)
filename = f"智联青云_数据备份_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
return send_file(
buf,
mimetype='application/json; charset=utf-8',
as_attachment=True,
download_name=filename
)
if __name__ == '__main__': if __name__ == '__main__':
socketio.run(app, debug=True,host='0.0.0.0', port=5080) socketio.run(app, debug=True,host='0.0.0.0', port=5080)

115
backup_utils.py Normal file
View File

@@ -0,0 +1,115 @@
# backup_utils.py - 数据备份工具
"""管理员数据备份:导出所有表数据为 JSON支持打包上传文件"""
import json
import os
import zipfile
from datetime import datetime
from io import BytesIO
from models import (
db, User, Exam, Submission, Draft, Contest, ContestMembership,
ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction,
Notification, EditHistory, ContestRegistration, TeacherApplication,
Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction,
QuestionBankItem, InviteCode, SystemNotification
)
def _serialize_value(val):
"""序列化单个值为 JSON 可存储格式"""
if val is None:
return None
if isinstance(val, datetime):
return val.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(val, bool):
return val
if isinstance(val, (int, float, str)):
return val
if isinstance(val, (list, dict)):
return val
return str(val)
def _model_to_dict(obj):
"""将 SQLAlchemy 模型实例转为可序列化字典"""
if obj is None:
return None
d = {}
for col in obj.__table__.columns:
val = getattr(obj, col.name)
d[col.name] = _serialize_value(val)
return d
# 按依赖顺序排列的表(父表在前)
BACKUP_MODELS = [
('user', User),
('contest', Contest),
('contest_membership', ContestMembership),
('contest_application', ContestApplication),
('contest_registration', ContestRegistration),
('question_bank_item', QuestionBankItem),
('exam', Exam),
('submission', Submission),
('draft', Draft),
('post', Post),
('reply', Reply),
('poll', Poll),
('reaction', Reaction),
('bookmark', Bookmark),
('report', Report),
('notification', Notification),
('edit_history', EditHistory),
('teacher_application', TeacherApplication),
('friend', Friend),
('exam_bookmark', ExamBookmark),
('chat_room', ChatRoom),
('chat_room_member', ChatRoomMember),
('message', Message),
('message_reaction', MessageReaction),
('invite_code', InviteCode),
('system_notification', SystemNotification),
]
def export_all_data():
"""导出所有表数据为 JSON 字典"""
data = {
'meta': {
'exported_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0',
},
'tables': {}
}
for table_name, model in BACKUP_MODELS:
try:
rows = model.query.order_by(model.id).all()
data['tables'][table_name] = [_model_to_dict(r) for r in rows]
except Exception as e:
data['tables'][table_name] = {'_error': str(e)}
return data
def create_backup_zip(include_uploads=True, upload_folder=None):
"""
创建完整备份 ZIP 包
:param include_uploads: 是否包含上传文件
:param upload_folder: 上传目录路径,默认 static/uploads
:return: BytesIO 中的 ZIP 文件
"""
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# 1. 导出数据 JSON
data = export_all_data()
zf.writestr('data.json', json.dumps(data, ensure_ascii=False, indent=2))
# 2. 可选:上传文件
if include_uploads and upload_folder and os.path.isdir(upload_folder):
for root, dirs, files in os.walk(upload_folder):
for f in files:
fp = os.path.join(root, f)
arcname = 'uploads/' + os.path.relpath(fp, upload_folder).replace('\\', '/')
zf.write(fp, arcname, compress_type=zipfile.ZIP_DEFLATED)
buf.seek(0)
return buf

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,9 +51,9 @@
<label class="block text-sm font-medium text-slate-700">考试可见性</label> <label class="block text-sm font-medium text-slate-700">考试可见性</label>
<select id="exam-visibility" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm"> <select id="exam-visibility" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<option value="private">私有(仅自己可见)</option> <option value="private">私有(仅自己可见)</option>
<option value="public">公开(所有人可见)</option> {% if not (is_cup_teacher|default(false)) %}<option value="public">公开(所有人可见)</option>{% endif %}
</select> </select>
<p class="mt-1 text-xs text-slate-400">私有考试只有您自己可以看到和管理</p> <p class="mt-1 text-xs text-slate-400">{% if is_cup_teacher|default(false) %}杯赛老师仅可创建自己可见的杯赛考试{% else %}私有考试只有您自己可以看到和管理{% endif %}</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label> <label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
@@ -104,7 +104,7 @@
</div> </div>
</div> </div>
<!-- VIP 弹窗 --> <!-- 智能识别功能开发中弹窗 -->
<div id="vipModal" class="fixed inset-0 z-[999] hidden" onclick="if(event.target===this)closeVipModal()"> <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="absolute inset-0 bg-gradient-to-br from-[#0a0015] via-[#0d0d2b] to-[#0a0015]">
@@ -121,56 +121,42 @@
<div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div> <div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&times;</button> <button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&times;</button>
<!-- 皇冠图标 --> <!-- 动漫小人辛苦工作插图 -->
<div class="relative flex justify-center mb-4"> <div class="relative flex justify-center mb-6">
<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"> <div class="dev-character relative">
<svg class="w-10 h-10 text-amber-900" fill="currentColor" viewBox="0 0 24 24"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5v-2z"/></svg> <div class="absolute inset-0 flex items-center justify-center">
<div class="w-28 h-28 rounded-full bg-cyan-500/10 blur-2xl"></div>
</div>
<svg class="w-36 h-36 mx-auto relative z-10" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 身体/工作服 -->
<ellipse cx="60" cy="85" rx="28" ry="18" fill="#6366f1"/>
<rect x="32" y="55" width="56" height="35" rx="8" fill="#4f46e5"/>
<!-- 头部 -->
<circle cx="60" cy="38" r="26" fill="#fde68a"/>
<circle cx="60" cy="38" r="26" stroke="#f59e0b" stroke-width="2"/>
<!-- 大眼睛 -->
<ellipse cx="52" cy="35" rx="6" ry="8" fill="#1e293b"/>
<ellipse cx="68" cy="35" rx="6" ry="8" fill="#1e293b"/>
<circle cx="54" cy="33" r="2" fill="white"/>
<circle cx="70" cy="33" r="2" fill="white"/>
<!-- 腮红 -->
<ellipse cx="42" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<ellipse cx="78" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<!-- 汗珠(辛苦工作) -->
<ellipse cx="88" cy="28" rx="4" ry="6" fill="#93c5fd" opacity="0.9" class="sweat-drop"/>
<ellipse cx="92" cy="32" rx="3" ry="5" fill="#93c5fd" opacity="0.7" class="sweat-drop"/>
<!-- 锤子/工具(敲代码) -->
<rect x="75" y="45" width="8" height="25" rx="2" fill="#78716c" transform="rotate(-30 79 57)"/>
<rect x="70" y="35" width="18" height="10" rx="2" fill="#a8a29e" transform="rotate(-30 79 40)"/>
<!-- 代码符号 -->
<text x="42" y="72" fill="white" font-size="10" font-family="monospace" font-weight="bold" opacity="0.95">&lt;/&gt;</text>
</svg>
</div> </div>
</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> <h2 class="text-center text-2xl font-bold bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent mb-2">该功能正在开发中</h2>
<p class="text-center text-purple-300/60 text-xs mb-6 tracking-widest">SUPREME VIP MEMBERSHIP</p> <p class="text-center text-purple-200/90 text-lg font-medium mb-1">敬请期待</p>
<!-- 功能列表 --> <p class="text-center text-purple-300/50 text-sm">渔鱼余正在努力敲代码中...</p>
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">AI 智能识别试卷</p><p class="text-white/30 text-xs">PDF 一键导入,公式图形全自动解析</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center text-cyan-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">量子级 OCR 引擎</p><p class="text-white/30 text-xs">手写体、印刷体、火星文通通拿下</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center text-amber-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">永久有效 · 无限次数</p><p class="text-white/30 text-xs">一次开通,终身尊享(真的吗?)</p></div>
</div>
</div>
<!-- 价格区 -->
<div class="text-center mb-6">
<div class="inline-flex items-baseline space-x-2 mb-1">
<span class="text-white/30 text-sm line-through decoration-red-400/60">原价 ¥998/年</span>
</div>
<div class="flex items-baseline justify-center">
<span class="text-white/50 text-lg mr-1">¥</span>
<span class="text-5xl font-black bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent vip-price">&infin;</span>
</div>
<p class="text-white/20 text-xs mt-2">限时优惠 · 仅剩 <span class="text-amber-400/80">0</span> 个名额</p>
</div>
<!-- 无支付按钮区域 -->
<div class="relative">
<div class="w-full py-3 rounded-xl bg-white/[0.04] border border-dashed border-white/10 text-center">
<p class="text-white/20 text-sm">支付通道维护中,预计恢复时间:</p>
<p class="text-purple-300/40 text-xs mt-1 font-mono tracking-wider">2099年12月31日 23:59:59</p>
</div>
</div>
<!-- 底部小字 -->
<p class="text-center text-white/10 text-[10px] mt-4">本页面仅供观赏,不构成任何消费邀约。如有雷同,纯属巧合。</p>
</div> </div>
</div> </div>
</div> </div>
@@ -204,6 +190,20 @@
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); } 50% { transform: translateY(-6px); }
} }
.dev-character {
animation: workBounce 2s ease-in-out infinite;
}
@keyframes workBounce {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-4px) scale(1.02); }
}
.dev-character .sweat-drop {
animation: sweatDrop 1.5s ease-in-out infinite;
}
@keyframes sweatDrop {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.4; }
}
.vip-price { .vip-price {
animation: glow 2s ease-in-out infinite alternate; animation: glow 2s ease-in-out infinite alternate;
} }
@@ -229,6 +229,8 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// 从杯赛页面进入时传入的 contest_id用于创建杯赛考试
const PAGE_CONTEST_ID = {{ contest_id|default(none)|tojson }};
// VIP 弹窗 // VIP 弹窗
function showVipModal() { function showVipModal() {
const modal = document.getElementById('vipModal'); const modal = document.getElementById('vipModal');
@@ -585,12 +587,16 @@ function submitExam() {
} }
const access_password = document.getElementById('exam-password').value.trim(); const access_password = document.getElementById('exam-password').value.trim();
const visibility = document.getElementById('exam-visibility').value; const visibility = document.getElementById('exam-visibility').value;
const payload = { title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password, visibility };
if (PAGE_CONTEST_ID) payload.contest_id = PAGE_CONTEST_ID;
fetch('/api/exams', { fetch('/api/exams', {
method: 'POST', headers: {'Content-Type':'application/json'}, method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password, visibility }) body: JSON.stringify(payload)
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; } if (data.success) {
else alert(data.message); alert('试卷创建成功!');
window.location.href = PAGE_CONTEST_ID ? '/contests/' + PAGE_CONTEST_ID : '/exams';
} else alert(data.message);
}).catch(() => alert('创建失败')); }).catch(() => alert('创建失败'));
} }

View File

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

View File

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

View File

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

View File

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