diff --git a/app.py b/app.py index 0936bb0..56b4a6d 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ import os import time import json +from io import BytesIO import random import string 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 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 backup_utils import create_backup_zip, export_all_data load_dotenv() @@ -46,6 +48,20 @@ email_codes = {} # 在线用户追踪(记录用户最后活跃时间) 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_TRACK_MODIFICATIONS'] = False @@ -379,13 +395,14 @@ def inject_display_name(): if not user: return '' 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('/') def home(): 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) @app.route('/login', methods=['GET']) @@ -556,14 +573,19 @@ def exam_list(): query = Exam.query - # 只显示公开考试或用户自己创建的私有考试 + # 只显示公开考试、用户自己创建的私有考试、或用户作为杯赛成员可批改的杯赛考试 from sqlalchemy import or_ - query = query.filter( - or_( - Exam.visibility == 'public', - Exam.creator_id == user.get('id') - ) + base_filter = or_( + Exam.visibility == 'public', + 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: query = query.filter_by(subject=subject_filter) @@ -597,20 +619,22 @@ def exam_list(): @login_required def exam_create(): user = session.get('user') + contest_id = request.args.get('contest_id', type=int) + is_cup_teacher = False # 允许 teacher/admin 创建任意考试 if user.get('role') in ('teacher', 'admin'): - return render_template('exam_create.html') - # 允许杯赛负责人创建其杯赛的考试 - contest_id = request.args.get('contest_id', type=int) + return render_template('exam_create.html', contest_id=contest_id or None, is_cup_teacher=False) + # 允许杯赛负责人或杯赛老师创建其杯赛的考试 if contest_id: 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: - 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() 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')) @app.route('/exams/') @@ -1587,13 +1611,13 @@ def apply_teacher(): # 如果存在申请,检查拒绝次数和冷却期 if existing: - # 检查是否被拒绝5次且在冷却期内 - if existing.rejection_count >= 5 and existing.last_rejected_at: + # 检查是否被拒绝3次且在冷却期内 + if existing.rejection_count >= 3 and existing.last_rejected_at: 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: 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)) # 如果存在申请,更新它;否则创建新申请 @@ -2544,9 +2568,16 @@ def api_create_exam(): # 检查是否是负责人、老师或管理员 if not membership and user.get('role') != 'admin': return jsonify({'success': False, 'message': '只有杯赛成员才能创建杯赛考试'}), 403 - # 杯赛考试默认为公开(杯赛内可见) - if visibility == 'private': - visibility = 'public' + # 权限:仅管理员和杯赛负责人可创建公开杯赛,杯赛老师仅可创建私有(自己可见) + is_owner = membership and membership.role == 'owner' + 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: # 普通考试:所有登录用户都可以创建私有考试 # 只有 teacher 或 admin 可以创建公开考试 @@ -2626,6 +2657,73 @@ def api_verify_exam_password(exam_id): return jsonify({'success': True, 'message': '验证通过'}) 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//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//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//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//save-draft', methods=['POST']) def api_save_draft(exam_id): user = session.get('user') @@ -2668,37 +2766,69 @@ def api_submit_exam(exam_id): auto_graded = True question_scores = {} questions = get_exam_questions(exam) + + # AI批改客观题 for q in questions: qid = str(q['id']) if q['type'] == 'choice': + # 选择题:精确匹配 if answers.get(qid) == q.get('answer'): score += q.get('score', 0) question_scores[qid] = q.get('score', 0) else: question_scores[qid] = 0 elif q['type'] == 'fill': + # 填空题:使用AI智能判断 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: score += 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: question_scores[qid] = 0 else: + # 主观题需要人工批改 auto_graded = False question_scores[qid] = 0 + sub = Submission( exam_id=exam_id, user_id=user.get('id'), score=score, graded=auto_graded, - graded_by='系统自动' if auto_graded else '' + graded_by='AI自动批改' if auto_graded else '' ) sub.set_answers(answers) sub.set_question_scores(question_scores) db.session.add(sub) Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete() 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}) @app.route('/api/exams//grade/', methods=['POST']) @@ -2762,18 +2892,21 @@ def api_update_exam_visibility(exam_id): if new_visibility not in ('private', 'public'): return jsonify({'success': False, 'message': '无效的可见性设置'}), 400 - # 检查权限:教师、管理员、杯赛负责人和杯赛老师可以设置为公开 + # 检查权限:仅管理员和杯赛负责人可设置为公开,杯赛老师仅可设为私有 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: membership = ContestMembership.query.filter_by( 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 + # 系统教师可设置普通考试为公开(非杯赛考试) + if not has_permission and not exam.contest_id and user.get('role') == 'teacher': + has_permission = True if not has_permission: - return jsonify({'success': False, 'message': '只有教师、管理员或杯赛负责人/老师可以创建公开考试'}), 403 + return jsonify({'success': False, 'message': '只有管理员和杯赛负责人才可以将杯赛考试设为公开'}), 403 exam.visibility = new_visibility db.session.commit() @@ -2801,7 +2934,7 @@ def api_delete_exam(exam_id): @cache.cached(timeout=60, query_string=True) def api_search_contests(): keyword = request.args.get('q', '').strip().lower() - query = Contest.query + query = Contest.query.filter(Contest.status != 'abolished') if keyword: query = query.filter( (Contest.name.contains(keyword)) | @@ -3911,12 +4044,14 @@ def api_chat_rooms(): # 私聊显示对方名字和头像 room_name = room.name room_avatar = room.avatar + peer_id = None if room.type == 'private': other_member = ChatRoomMember.query.filter( ChatRoomMember.room_id == room.id, ChatRoomMember.user_id != user_id ).first() if other_member: + peer_id = other_member.user_id other_user = User.query.get(other_member.user_id) if other_user: room_name = other_user.name @@ -3924,6 +4059,7 @@ def api_chat_rooms(): rooms_data.append({ 'id': room.id, 'type': room.type, + 'peer_id': peer_id, 'name': room_name or '未命名群聊', 'avatar': room_avatar or '', 'muted': m.muted, @@ -4204,10 +4340,24 @@ def handle_connect(): user = session.get('user') if not user: 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: 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') def handle_send_message(data): user = session.get('user') @@ -4240,7 +4390,7 @@ def handle_send_message(data): rs = User.query.get(reply_msg.sender_id) reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs 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, 'sender_name': sender.name if sender else user['name'], 'sender_avatar': (sender.avatar or '') if sender else '', @@ -4249,7 +4399,24 @@ def handle_send_message(data): 'reply_to': reply_to_data, 'mentions': mentions_raw, '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') def handle_typing(data): @@ -4595,6 +4762,33 @@ def admin_contest_detail(contest_id): db.session.commit() return jsonify({'success': True}) +@app.route('/api/admin/contests//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//abolish', methods=['POST']) @admin_required def admin_abolish_contest(contest_id): @@ -5018,5 +5212,40 @@ def admin_export_posts(): 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__': socketio.run(app, debug=True,host='0.0.0.0', port=5080) \ No newline at end of file diff --git a/backup_utils.py b/backup_utils.py new file mode 100644 index 0000000..bb003a7 --- /dev/null +++ b/backup_utils.py @@ -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 diff --git a/templates/admin_backup.html b/templates/admin_backup.html new file mode 100644 index 0000000..63a0824 --- /dev/null +++ b/templates/admin_backup.html @@ -0,0 +1,61 @@ +{% extends "admin_base.html" %} + +{% block title %}数据备份 - 管理后台 - 智联青云{% endblock %} + +{% block admin_content %} +
+
+

数据备份

+

将服务器端所有数据(用户、杯赛、考试、帖子、聊天等)导出备份

+
+ +
+ +
+
+
+
+ 📦 +
+

完整备份

+

包含所有数据库表数据(JSON)及上传文件(头像、图片、附件等)

+ + + 下载完整备份 (ZIP) + +
+
+ + +
+
+
+
+ 📄 +
+

仅数据备份

+

仅导出所有表数据为 JSON 文件,不含上传文件,体积更小

+ + + 下载数据备份 (JSON) + +
+
+
+ +
+
+
ℹ️
+
+

备份说明

+
    +
  • 备份包含:用户、杯赛、考试、提交记录、帖子、回复、通知、聊天记录、好友关系等全部数据
  • +
  • 完整备份中的 uploads/ 目录包含用户上传的头像、考试图片、聊天文件等
  • +
  • 建议定期备份,重要操作前请先备份
  • +
  • 备份文件请妥善保管,内含敏感信息(如密码哈希)
  • +
+
+
+
+
+{% endblock %} diff --git a/templates/admin_base.html b/templates/admin_base.html index 705e332..c9e5195 100644 --- a/templates/admin_base.html +++ b/templates/admin_base.html @@ -24,7 +24,8 @@ ('/admin/exams', '考试管理', 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'), ('/admin/users', '用户管理', 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'), ('/admin/posts', '帖子管理', 'M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z'), - ('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9') + ('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9'), + ('/admin/backup', '数据备份', 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4') ] %} {% for path, name, icon in nav_items %} diff --git a/templates/admin_contests.html b/templates/admin_contests.html index 5664901..7ff6c36 100644 --- a/templates/admin_contests.html +++ b/templates/admin_contests.html @@ -30,6 +30,7 @@ {% block scripts %} {% endblock %} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index b225d7a..e2a10e3 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -134,6 +134,10 @@
📢
通知管理
+ +
📦
+
数据备份
+
diff --git a/templates/chat.html b/templates/chat.html index 93d407c..2fc994f 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -95,6 +95,7 @@ + @@ -275,6 +276,8 @@ + + {% endblock %} {% block scripts %} @@ -297,6 +300,7 @@ let recordingStartTime = null; let recordingInterval = null; let searchTimer = null; let mentionMembers = []; +let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}} function isMobile() { return window.innerWidth < 640; } @@ -372,9 +376,16 @@ function switchTab(tab) { tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600'; tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary relative'; loadNotifications(); + + // Mark all notifications as read when switching to the notification tab + fetch('/api/notifications/read-all', { method: 'POST' }).then(() => { + checkUnreadNotifs(); + }); } const badge = document.getElementById('chatNotifBadge'); - tabNotif.appendChild(badge); + if (badge) { + tabNotif.appendChild(badge); + } } async function loadNotifications() { @@ -527,11 +538,24 @@ async function checkUnreadNotifs() { const data = await res.json(); if (data.success) { const badge = document.getElementById('chatNotifBadge'); - if (data.count > 0) { - badge.textContent = data.count > 99 ? '99+' : data.count; - badge.classList.remove('hidden'); - } else { - badge.classList.add('hidden'); + if (badge) { + if (data.count > 0) { + badge.textContent = data.count > 99 ? '99+' : data.count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } + + // Also update the global notification badge if it exists + const globalBadge = document.getElementById('notifBadge'); + if (globalBadge) { + if (data.count > 0) { + globalBadge.textContent = data.count > 99 ? '99+' : data.count; + globalBadge.classList.remove('hidden'); + } else { + globalBadge.classList.add('hidden'); + } } } } @@ -639,7 +663,9 @@ function renderRooms() { const search = document.getElementById('roomSearch').value.toLowerCase(); const list = document.getElementById('roomList'); const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search)); - list.innerHTML = filtered.map(r => ` + list.innerHTML = filtered.map(r => { + const peerInExam = r.type === 'private' && r.peer_id && peerExamStatus[r.peer_id]?.in_exam; + return `
${r.avatar ? `` : @@ -648,6 +674,7 @@ function renderRooms() {
${escHtml(r.name || '未命名')} + ${peerInExam ? '考试中' : ''} ${r.last_message ? formatTime(r.last_message.created_at) : ''}
@@ -656,7 +683,7 @@ function renderRooms() {
- `).join(''); + `}).join(''); } function getPreview(r) { @@ -688,11 +715,34 @@ async function selectRoom(roomId) { if (room) { document.getElementById('chatName').textContent = room.name || '聊天'; document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : ''; + // 私聊:获取对方考试状态 + if (room.type === 'private' && room.peer_id) { + try { + const res = await fetch(`/api/user/${room.peer_id}/exam-status`); + const data = await res.json(); + if (data.success) { + peerExamStatus[room.peer_id] = { in_exam: data.in_exam, exam_title: data.exam_title || '', end_time: data.end_time }; + } + } catch (e) { peerExamStatus[room.peer_id] = { in_exam: false }; } + } + updateChatHeaderExamStatus(room); } renderRooms(); await loadMessages(roomId); markRead(roomId); } + +function updateChatHeaderExamStatus(room) { + const examBanner = document.getElementById('peerExamBanner'); + if (!examBanner) return; + if (room && room.type === 'private' && room.peer_id && peerExamStatus[room.peer_id]?.in_exam) { + const info = peerExamStatus[room.peer_id]; + examBanner.textContent = `${room.name || '对方'} 正在考试中(${info.exam_title || '考试'}),请勿打扰`; + examBanner.classList.remove('hidden'); + } else { + examBanner.classList.add('hidden'); + } +} // ========== 消息加载与渲染 ========== async function loadMessages(roomId, beforeId) { let url = `/api/chat/rooms/${roomId}/messages?limit=30`; @@ -1439,6 +1489,33 @@ socket.on('error', (data) => { if (data.message) alert(data.message); }); +socket.on('friend_exam_status', (data) => { + const uid = data.user_id; + peerExamStatus[uid] = { + in_exam: data.in_exam, + exam_title: data.exam_title || '', + end_time: data.end_time + }; + const room = rooms.find(r => r.id === currentRoomId); + if (room && room.type === 'private' && room.peer_id === uid) { + updateChatHeaderExamStatus(room); + } + renderRooms(); +}); + +socket.on('peer_in_exam', (data) => { + showToast(data.message || '对方正在考试中,消息将在考试结束后送达'); +}); + +function showToast(msg) { + const el = document.getElementById('chatToast'); + if (!el) return; + el.textContent = msg; + el.classList.remove('hidden'); + clearTimeout(el._toastTimer); + el._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000); +} + // ========== 工具函数 ========== function escHtml(s) { if (!s) return ''; diff --git a/templates/contest_detail.html b/templates/contest_detail.html index a335ce6..682f73e 100644 --- a/templates/contest_detail.html +++ b/templates/contest_detail.html @@ -51,11 +51,11 @@ 题库管理 - {% endif %} - {% if is_owner %} 创建考试 + {% endif %} + {% if is_owner %} 审批老师申请 @@ -239,6 +239,7 @@ const CONTEST_ID = {{ contest.id }}; let canPost = {{ (can_post is defined and can_post) | lower }}; const isOwner = {{ (is_owner is defined and is_owner) | lower }}; +const isMember = {{ (is_member is defined and is_member) | lower }}; // 报名切换 async function toggleRegistration(contestId) { @@ -387,8 +388,9 @@ async function loadExams() {
${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}分${e.duration ? ' · ' + e.duration + '分钟' : ''}
-
+
进入考试 + ${isMember ? `批改` : ''} ${removeBtn}
`; diff --git a/templates/exam_create.html b/templates/exam_create.html index 3c70149..d35c163 100644 --- a/templates/exam_create.html +++ b/templates/exam_create.html @@ -51,9 +51,9 @@ -

私有考试只有您自己可以看到和管理

+

{% if is_cup_teacher|default(false) %}杯赛老师仅可创建自己可见的杯赛考试{% else %}私有考试只有您自己可以看到和管理{% endif %}

@@ -104,7 +104,7 @@
- + +
+ + + / {{ q.score }} +
+ + +
{% else %} @@ -126,21 +138,7 @@ function quickScore(qid, score) { function recalcTotal() { let total = 0; - // 选择题自动得分 - {% for q in questions %} - {% if q.type == 'choice' %} - {% if answers.get(q.id|string,'') == q.get('answer','') %} - total += {{ q.score }}; - {% endif %} - {% elif q.type == 'fill' %} - {% set student_ans = answers.get(q.id|string,'').strip() %} - {% set correct_answers = q.get('answer','').split('|') %} - {% if student_ans in correct_answers %} - total += {{ q.score }}; - {% endif %} - {% endif %} - {% endfor %} - // 主观题手动得分 + // 所有题目得分(客观题预填自动判分,教师可人工修改) document.querySelectorAll('.grade-score').forEach(input => { total += parseInt(input.value) || 0; }); @@ -155,23 +153,8 @@ recalcTotal(); function submitGrade() { const scores = {}; {% for q in questions %} - {% if q.type == 'choice' %} - {% if answers.get(q.id|string,'') == q.get('answer','') %} - scores['{{ q.id }}'] = {{ q.score }}; - {% else %} - scores['{{ q.id }}'] = 0; - {% endif %} - {% elif q.type == 'fill' %} - {% set student_ans = answers.get(q.id|string,'').strip() %} - {% set correct_answers = q.get('answer','').split('|') %} - {% if student_ans in correct_answers %} - scores['{{ q.id }}'] = {{ q.score }}; - {% else %} - scores['{{ q.id }}'] = 0; - {% endif %} - {% else %} - scores['{{ q.id }}'] = parseInt(document.getElementById('score-{{ q.id }}').value) || 0; - {% endif %} + const inp{{ q.id }} = document.getElementById('score-{{ q.id }}'); + scores['{{ q.id }}'] = inp{{ q.id }} ? (parseInt(inp{{ q.id }}.value) || 0) : 0; {% endfor %} fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', { method:'POST', headers:{'Content-Type':'application/json'}, diff --git a/templates/exam_list.html b/templates/exam_list.html index dccec9a..8afa9b3 100644 --- a/templates/exam_list.html +++ b/templates/exam_list.html @@ -193,7 +193,7 @@ {% endif %} - {% if user and (user.role == 'admin' or user.role == 'teacher') %} + {% if user and can_grade_exam(user, exam) %}
提交情况 打印试卷 diff --git a/templates/profile.html b/templates/profile.html index 643b4da..ca48119 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -148,36 +148,6 @@
- - {% if profile_user.id == user.id %} -
- -
- -
-
通知中心
-
- -
- -
-
我的消息
-
-
-
- -
-
考试记录
-
-
-
- -
-
我的收藏
-
-
- {% endif %} -