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 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/<int:exam_id>')
@@ -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/<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'])
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/<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'):
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/<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'])
@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)