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:
293
app.py
293
app.py
@@ -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
115
backup_utils.py
Normal 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
|
||||||
61
templates/admin_backup.html
Normal file
61
templates/admin_backup.html
Normal 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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
@@ -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">×</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">×</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"></></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">∞</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('创建失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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'},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<!-- 科幻未来标签栏 -->
|
<!-- 科幻未来标签栏 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user