mobile app chat contest_detail exam_detail exam_list themes
This commit is contained in:
214
app.py
214
app.py
@@ -151,10 +151,15 @@ def decrypt_questions(encrypted_str):
|
|||||||
return decrypted.decode('utf-8')
|
return decrypted.decode('utf-8')
|
||||||
|
|
||||||
def get_exam_questions(exam):
|
def get_exam_questions(exam):
|
||||||
"""统一获取考试题目(自动处理加密)"""
|
"""统一获取考试题目(自动处理加密,确保每题有id)"""
|
||||||
if exam.is_encrypted and exam.encrypted_questions:
|
if exam.is_encrypted and exam.encrypted_questions:
|
||||||
return json.loads(decrypt_questions(exam.encrypted_questions))
|
questions = json.loads(decrypt_questions(exam.encrypted_questions))
|
||||||
return exam.get_questions()
|
else:
|
||||||
|
questions = exam.get_questions()
|
||||||
|
for i, q in enumerate(questions):
|
||||||
|
if 'id' not in q:
|
||||||
|
q['id'] = i + 1
|
||||||
|
return questions
|
||||||
|
|
||||||
def teacher_required(f):
|
def teacher_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
@@ -580,9 +585,11 @@ def exam_list():
|
|||||||
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_contest_memberships = ContestMembership.query.filter_by(
|
||||||
user_id=user.get('id')).filter(
|
user_id=user.get('id')).filter(
|
||||||
ContestMembership.role.in_(['owner', 'teacher'])).all()]
|
ContestMembership.role.in_(['owner', 'teacher'])).all()
|
||||||
|
cup_member_contest_ids = [m.contest_id for m in user_contest_memberships]
|
||||||
|
cup_owner_contest_ids = [m.contest_id for m in user_contest_memberships if m.role == 'owner']
|
||||||
if cup_member_contest_ids:
|
if cup_member_contest_ids:
|
||||||
base_filter = or_(base_filter, Exam.contest_id.in_(cup_member_contest_ids))
|
base_filter = or_(base_filter, Exam.contest_id.in_(cup_member_contest_ids))
|
||||||
query = query.filter(base_filter)
|
query = query.filter(base_filter)
|
||||||
@@ -595,6 +602,15 @@ def exam_list():
|
|||||||
)
|
)
|
||||||
all_exams = query.all()
|
all_exams = query.all()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
changed = False
|
||||||
|
for exam in all_exams:
|
||||||
|
if exam.status == 'available' and exam.scheduled_end and now > exam.scheduled_end:
|
||||||
|
exam.status = 'closed'
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
all_subjects = db.session.query(Exam.subject).distinct().all()
|
all_subjects = db.session.query(Exam.subject).distinct().all()
|
||||||
all_subjects = [s[0] for s in all_subjects if s[0]]
|
all_subjects = [s[0] for s in all_subjects if s[0]]
|
||||||
|
|
||||||
@@ -612,7 +628,9 @@ def exam_list():
|
|||||||
user_submissions=user_submissions,
|
user_submissions=user_submissions,
|
||||||
search_query=search_query,
|
search_query=search_query,
|
||||||
subject_filter=subject_filter,
|
subject_filter=subject_filter,
|
||||||
all_subjects=all_subjects
|
all_subjects=all_subjects,
|
||||||
|
now=now,
|
||||||
|
cup_owner_contest_ids=cup_owner_contest_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/exams/create')
|
@app.route('/exams/create')
|
||||||
@@ -655,12 +673,15 @@ def exam_detail(exam_id):
|
|||||||
draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
|
draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first()
|
||||||
questions = get_exam_questions(exam)
|
questions = get_exam_questions(exam)
|
||||||
# 检查预定时间
|
# 检查预定时间
|
||||||
now = datetime.utcnow()
|
now = datetime.now()
|
||||||
schedule_status = 'available' # available, not_started, ended
|
schedule_status = 'available' # available, not_started, ended
|
||||||
if exam.scheduled_start and now < exam.scheduled_start:
|
if exam.scheduled_start and now < exam.scheduled_start:
|
||||||
schedule_status = 'not_started'
|
schedule_status = 'not_started'
|
||||||
if exam.scheduled_end and now > exam.scheduled_end:
|
if exam.scheduled_end and now > exam.scheduled_end:
|
||||||
schedule_status = 'ended'
|
schedule_status = 'ended'
|
||||||
|
if exam.status == 'available':
|
||||||
|
exam.status = 'closed'
|
||||||
|
db.session.commit()
|
||||||
return render_template('exam_detail.html', exam=exam, questions=questions,
|
return render_template('exam_detail.html', exam=exam, questions=questions,
|
||||||
existing_submission=existing, draft=draft,
|
existing_submission=existing, draft=draft,
|
||||||
schedule_status=schedule_status, need_password=False)
|
schedule_status=schedule_status, need_password=False)
|
||||||
@@ -677,7 +698,7 @@ def exam_result(exam_id):
|
|||||||
return redirect(url_for('exam_detail', exam_id=exam_id))
|
return redirect(url_for('exam_detail', exam_id=exam_id))
|
||||||
# 检查成绩公布时间
|
# 检查成绩公布时间
|
||||||
score_hidden = False
|
score_hidden = False
|
||||||
if exam.score_release_time and datetime.utcnow() < exam.score_release_time:
|
if exam.score_release_time and datetime.now() < exam.score_release_time:
|
||||||
if user.get('role') != 'teacher' and user.get('role') != 'admin':
|
if user.get('role') != 'teacher' and user.get('role') != 'admin':
|
||||||
score_hidden = True
|
score_hidden = True
|
||||||
questions = get_exam_questions(exam)
|
questions = get_exam_questions(exam)
|
||||||
@@ -1315,6 +1336,11 @@ def api_notifications():
|
|||||||
item['contest_name'] = contest.name if contest else ''
|
item['contest_name'] = contest.name if contest else ''
|
||||||
applicant = User.query.get(ta.user_id)
|
applicant = User.query.get(ta.user_id)
|
||||||
item['applicant_name'] = applicant.name if applicant else ''
|
item['applicant_name'] = applicant.name if applicant else ''
|
||||||
|
if n.type == 'exam_submission' and n.post_id:
|
||||||
|
sub = Submission.query.get(n.post_id)
|
||||||
|
if sub:
|
||||||
|
item['exam_id'] = sub.exam_id
|
||||||
|
item['submission_id'] = sub.id
|
||||||
if n.type == 'friend_request' and n.post_id:
|
if n.type == 'friend_request' and n.post_id:
|
||||||
fr = Friend.query.get(n.post_id)
|
fr = Friend.query.get(n.post_id)
|
||||||
if fr:
|
if fr:
|
||||||
@@ -1362,7 +1388,7 @@ def api_delete_notification(nid):
|
|||||||
@app.route('/notifications')
|
@app.route('/notifications')
|
||||||
@login_required
|
@login_required
|
||||||
def notifications_page():
|
def notifications_page():
|
||||||
return redirect(url_for('chat'))
|
return redirect(url_for('chat_page'))
|
||||||
|
|
||||||
# ========== 系统公告管理 API ==========
|
# ========== 系统公告管理 API ==========
|
||||||
@app.route('/api/system-notifications')
|
@app.route('/api/system-notifications')
|
||||||
@@ -2753,7 +2779,7 @@ def api_submit_exam(exam_id):
|
|||||||
if exam.status == 'closed':
|
if exam.status == 'closed':
|
||||||
return jsonify({'success': False, 'message': '该考试已关闭'}), 400
|
return jsonify({'success': False, 'message': '该考试已关闭'}), 400
|
||||||
# 检查预定时间
|
# 检查预定时间
|
||||||
now = datetime.utcnow()
|
now = datetime.now()
|
||||||
if exam.scheduled_start and now < exam.scheduled_start:
|
if exam.scheduled_start and now < exam.scheduled_start:
|
||||||
return jsonify({'success': False, 'message': '考试尚未开始'}), 400
|
return jsonify({'success': False, 'message': '考试尚未开始'}), 400
|
||||||
if exam.scheduled_end and now > exam.scheduled_end:
|
if exam.scheduled_end and now > exam.scheduled_end:
|
||||||
@@ -2761,73 +2787,78 @@ def api_submit_exam(exam_id):
|
|||||||
if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first():
|
if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first():
|
||||||
return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
|
return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
|
||||||
data = request.get_json(force=True, silent=True)
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'message': '无效的请求数据'}), 400
|
||||||
answers = data.get('answers', {})
|
answers = data.get('answers', {})
|
||||||
score = 0
|
try:
|
||||||
auto_graded = True
|
score = 0
|
||||||
question_scores = {}
|
auto_graded = True
|
||||||
questions = get_exam_questions(exam)
|
question_scores = {}
|
||||||
|
questions = get_exam_questions(exam)
|
||||||
|
|
||||||
# AI批改客观题
|
for q in questions:
|
||||||
for q in questions:
|
qid = str(q.get('id', ''))
|
||||||
qid = str(q['id'])
|
q_score = q.get('score', 0) or 0
|
||||||
if q['type'] == 'choice':
|
if q.get('type') == 'choice':
|
||||||
# 选择题:精确匹配
|
if answers.get(qid) == q.get('answer'):
|
||||||
if answers.get(qid) == q.get('answer'):
|
score += q_score
|
||||||
score += q.get('score', 0)
|
question_scores[qid] = q_score
|
||||||
question_scores[qid] = q.get('score', 0)
|
else:
|
||||||
|
question_scores[qid] = 0
|
||||||
|
elif q.get('type') == 'fill':
|
||||||
|
student_answer = (answers.get(qid) or '').strip()
|
||||||
|
correct_answer = (q.get('answer') or '').strip()
|
||||||
|
|
||||||
|
correct_answers = [a.strip() for a in correct_answer.split('|')] if correct_answer else []
|
||||||
|
if correct_answers and student_answer in correct_answers:
|
||||||
|
score += q_score
|
||||||
|
question_scores[qid] = q_score
|
||||||
|
elif correct_answers and student_answer.lower() in [a.lower() for a in correct_answers]:
|
||||||
|
score += q_score
|
||||||
|
question_scores[qid] = q_score
|
||||||
|
else:
|
||||||
|
auto_graded = False
|
||||||
|
question_scores[qid] = 0
|
||||||
|
elif q.get('type') == 'judge':
|
||||||
|
if answers.get(qid) == q.get('answer'):
|
||||||
|
score += q_score
|
||||||
|
question_scores[qid] = q_score
|
||||||
|
else:
|
||||||
|
question_scores[qid] = 0
|
||||||
else:
|
else:
|
||||||
|
auto_graded = False
|
||||||
question_scores[qid] = 0
|
question_scores[qid] = 0
|
||||||
elif q['type'] == 'fill':
|
|
||||||
# 填空题:使用AI智能判断
|
|
||||||
student_answer = answers.get(qid, '').strip()
|
|
||||||
correct_answer = q.get('answer', '').strip()
|
|
||||||
|
|
||||||
# 先尝试精确匹配(支持多个正确答案,用|分隔)
|
sub = Submission(
|
||||||
correct_answers = [a.strip() for a in correct_answer.split('|')]
|
exam_id=exam_id,
|
||||||
if student_answer in correct_answers:
|
user_id=user.get('id'),
|
||||||
score += q.get('score', 0)
|
score=score,
|
||||||
question_scores[qid] = q.get('score', 0)
|
graded=auto_graded,
|
||||||
else:
|
graded_by='AI自动批改' if auto_graded else ''
|
||||||
# 使用AI判断答案是否正确
|
)
|
||||||
ai_score = ai_grade_fill_question(q.get('content', ''), correct_answer, student_answer, q.get('score', 0))
|
sub.set_answers(answers)
|
||||||
score += ai_score
|
sub.set_question_scores(question_scores)
|
||||||
question_scores[qid] = ai_score
|
db.session.add(sub)
|
||||||
elif q['type'] == 'judge':
|
Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete()
|
||||||
# 判断题:精确匹配
|
db.session.commit()
|
||||||
if answers.get(qid) == q.get('answer'):
|
except Exception as e:
|
||||||
score += q.get('score', 0)
|
db.session.rollback()
|
||||||
question_scores[qid] = q.get('score', 0)
|
app.logger.error(f'考试提交失败: exam_id={exam_id}, user_id={user.get("id")}, error={str(e)}')
|
||||||
else:
|
return jsonify({'success': False, 'message': '提交处理失败,请重试'}), 500
|
||||||
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='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:
|
try:
|
||||||
contest = Contest.query.get(exam.contest_id)
|
if not auto_graded and exam.contest_id:
|
||||||
if contest:
|
contest = Contest.query.get(exam.contest_id)
|
||||||
# 通知杯赛负责人和老师
|
if contest:
|
||||||
memberships = ContestMembership.query.filter_by(contest_id=exam.contest_id).all()
|
memberships = ContestMembership.query.filter_by(contest_id=exam.contest_id).all()
|
||||||
for m in memberships:
|
for m in memberships:
|
||||||
if m.role in ('owner', 'teacher'):
|
if m.role in ('owner', 'teacher'):
|
||||||
add_notification(m.user_id, 'exam_submission',
|
add_notification(m.user_id, 'exam_submission',
|
||||||
f'考生 {user.get("name")} 提交了考试「{exam.title}」,请批改主观题。',
|
f'考生 {user.get("name")} 提交了考试「{exam.title}」,请批改主观题。',
|
||||||
from_user=user.get('name', ''), post_id=sub.id)
|
from_user=user.get('name', ''), post_id=sub.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})
|
return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})
|
||||||
|
|
||||||
@@ -2861,13 +2892,23 @@ def api_grade_submission(exam_id, sub_id):
|
|||||||
@app.route('/api/exams/<int:exam_id>/status', methods=['POST'])
|
@app.route('/api/exams/<int:exam_id>/status', methods=['POST'])
|
||||||
def api_update_exam_status(exam_id):
|
def api_update_exam_status(exam_id):
|
||||||
user = session.get('user')
|
user = session.get('user')
|
||||||
if not user or user.get('role') not in ('teacher', 'admin'):
|
if not user:
|
||||||
return jsonify({'success': False, 'message': '无权限'}), 403
|
return jsonify({'success': False, 'message': '无权限'}), 403
|
||||||
exam = Exam.query.get(exam_id)
|
exam = Exam.query.get(exam_id)
|
||||||
if not exam:
|
if not exam:
|
||||||
return jsonify({'success': False, 'message': '试卷不存在'}), 404
|
return jsonify({'success': False, 'message': '试卷不存在'}), 404
|
||||||
if exam.creator_id != user.get('id'):
|
has_permission = False
|
||||||
return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403
|
if user.get('role') == 'admin':
|
||||||
|
has_permission = True
|
||||||
|
elif user.get('role') == 'teacher' and exam.creator_id == user.get('id'):
|
||||||
|
has_permission = True
|
||||||
|
elif exam.contest_id:
|
||||||
|
membership = ContestMembership.query.filter_by(
|
||||||
|
user_id=user['id'], contest_id=exam.contest_id).first()
|
||||||
|
if membership and membership.role == 'owner':
|
||||||
|
has_permission = True
|
||||||
|
if not has_permission:
|
||||||
|
return jsonify({'success': False, 'message': '无权限修改该试卷状态'}), 403
|
||||||
data = request.get_json(force=True, silent=True)
|
data = request.get_json(force=True, silent=True)
|
||||||
new_status = data.get('status', '')
|
new_status = data.get('status', '')
|
||||||
if new_status not in ('available', 'closed'):
|
if new_status not in ('available', 'closed'):
|
||||||
@@ -2915,13 +2956,23 @@ def api_update_exam_visibility(exam_id):
|
|||||||
@app.route('/api/exams/<int:exam_id>', methods=['DELETE'])
|
@app.route('/api/exams/<int:exam_id>', methods=['DELETE'])
|
||||||
def api_delete_exam(exam_id):
|
def api_delete_exam(exam_id):
|
||||||
user = session.get('user')
|
user = session.get('user')
|
||||||
if not user or user.get('role') not in ('teacher', 'admin'):
|
if not user:
|
||||||
return jsonify({'success': False, 'message': '无权限'}), 403
|
return jsonify({'success': False, 'message': '无权限'}), 403
|
||||||
exam = Exam.query.get(exam_id)
|
exam = Exam.query.get(exam_id)
|
||||||
if not exam:
|
if not exam:
|
||||||
return jsonify({'success': False, 'message': '试卷不存在'}), 404
|
return jsonify({'success': False, 'message': '试卷不存在'}), 404
|
||||||
if exam.creator_id != user.get('id'):
|
has_permission = False
|
||||||
return jsonify({'success': False, 'message': '只能删除自己创建的试卷'}), 403
|
if user.get('role') == 'admin':
|
||||||
|
has_permission = True
|
||||||
|
elif user.get('role') == 'teacher' and exam.creator_id == user.get('id'):
|
||||||
|
has_permission = True
|
||||||
|
elif exam.contest_id:
|
||||||
|
membership = ContestMembership.query.filter_by(
|
||||||
|
user_id=user['id'], contest_id=exam.contest_id).first()
|
||||||
|
if membership and membership.role == 'owner':
|
||||||
|
has_permission = True
|
||||||
|
if not has_permission:
|
||||||
|
return jsonify({'success': False, 'message': '无权限删除该试卷'}), 403
|
||||||
Submission.query.filter_by(exam_id=exam_id).delete()
|
Submission.query.filter_by(exam_id=exam_id).delete()
|
||||||
Draft.query.filter_by(exam_id=exam_id).delete()
|
Draft.query.filter_by(exam_id=exam_id).delete()
|
||||||
db.session.delete(exam)
|
db.session.delete(exam)
|
||||||
@@ -2993,6 +3044,14 @@ def api_contest_exams(contest_id):
|
|||||||
if user:
|
if user:
|
||||||
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
|
membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first()
|
||||||
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
|
is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin'
|
||||||
|
now = datetime.now()
|
||||||
|
changed = False
|
||||||
|
for e in exams:
|
||||||
|
if e.status == 'available' and e.scheduled_end and now > e.scheduled_end:
|
||||||
|
e.status = 'closed'
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
db.session.commit()
|
||||||
data = []
|
data = []
|
||||||
for e in exams:
|
for e in exams:
|
||||||
item = {
|
item = {
|
||||||
@@ -3234,11 +3293,14 @@ def api_create_exam_from_bank(contest_id):
|
|||||||
return jsonify({'success': False, 'message': '请填写标题并选择题目'}), 400
|
return jsonify({'success': False, 'message': '请填写标题并选择题目'}), 400
|
||||||
# 从题库获取题目并转换为考试题目格式
|
# 从题库获取题目并转换为考试题目格式
|
||||||
questions = []
|
questions = []
|
||||||
|
q_idx = 0
|
||||||
for qid in question_ids:
|
for qid in question_ids:
|
||||||
item = QuestionBankItem.query.get(qid)
|
item = QuestionBankItem.query.get(qid)
|
||||||
if not item or item.contest_id != contest_id:
|
if not item or item.contest_id != contest_id:
|
||||||
continue
|
continue
|
||||||
|
q_idx += 1
|
||||||
q = {
|
q = {
|
||||||
|
'id': q_idx,
|
||||||
'type': item.type,
|
'type': item.type,
|
||||||
'content': item.content,
|
'content': item.content,
|
||||||
'score': item.score,
|
'score': item.score,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 聊天视图 -->
|
<!-- 聊天视图 -->
|
||||||
<div id="chatView" class="flex-1 hidden z-10 relative h-full">
|
<div id="chatView" class="flex-1 hidden flex-col z-10 relative h-full">
|
||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<div id="chatHeader" class="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-slate-800/60 backdrop-blur-xl flex-shrink-0 z-20">
|
<div id="chatHeader" class="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-slate-800/60 backdrop-blur-xl flex-shrink-0 z-20">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -413,6 +413,7 @@ function renderNotifications() {
|
|||||||
const isResult = n.type === 'contest_result' || n.type === 'teacher_result';
|
const isResult = n.type === 'contest_result' || n.type === 'teacher_result';
|
||||||
const isNewExam = n.type === 'contest_new_exam';
|
const isNewExam = n.type === 'contest_new_exam';
|
||||||
const isGraded = n.type === 'exam_graded';
|
const isGraded = n.type === 'exam_graded';
|
||||||
|
const isSubmission = n.type === 'exam_submission';
|
||||||
const isSystem = n.type === 'system_announcement';
|
const isSystem = n.type === 'system_announcement';
|
||||||
const isPendingContest = isContestApp && n.application_status === 'pending';
|
const isPendingContest = isContestApp && n.application_status === 'pending';
|
||||||
const isPendingTeacher = isTeacherApp && n.application_status === 'pending';
|
const isPendingTeacher = isTeacherApp && n.application_status === 'pending';
|
||||||
@@ -421,8 +422,8 @@ function renderNotifications() {
|
|||||||
if ((isContestApp || isTeacherApp) && n.application_status === 'approved') statusHtml = '<span class="text-xs text-green-600 font-medium">已批准</span>';
|
if ((isContestApp || isTeacherApp) && n.application_status === 'approved') statusHtml = '<span class="text-xs text-green-600 font-medium">已批准</span>';
|
||||||
else if ((isContestApp || isTeacherApp) && n.application_status === 'rejected') statusHtml = '<span class="text-xs text-red-600 font-medium">已拒绝</span>';
|
else if ((isContestApp || isTeacherApp) && n.application_status === 'rejected') statusHtml = '<span class="text-xs text-red-600 font-medium">已拒绝</span>';
|
||||||
else if (isFriendReq && n.application_status === 'accepted') statusHtml = '<span class="text-xs text-green-600 font-medium">已同意</span>';
|
else if (isFriendReq && n.application_status === 'accepted') statusHtml = '<span class="text-xs text-green-600 font-medium">已同意</span>';
|
||||||
const icon = isContestApp ? '📋' : isTeacherApp ? '👨🏫' : isFriendReq ? '👤' : isResult ? '📢' : isNewExam ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔';
|
const icon = isContestApp ? '📋' : isTeacherApp ? '👨🏫' : isFriendReq ? '👤' : isResult ? '📢' : isNewExam ? '📝' : isSubmission ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔';
|
||||||
const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-100' : isFriendReq ? 'bg-blue-100' : isResult ? 'bg-green-100' : isNewExam ? 'bg-indigo-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100';
|
const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-100' : isFriendReq ? 'bg-blue-100' : isResult ? 'bg-green-100' : isNewExam ? 'bg-indigo-100' : isSubmission ? 'bg-amber-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100';
|
||||||
const clickAction = `showNotifDetail(${n.id})`;
|
const clickAction = `showNotifDetail(${n.id})`;
|
||||||
let actionsHtml = '';
|
let actionsHtml = '';
|
||||||
if (isPendingContest && currentUser.role === 'admin') {
|
if (isPendingContest && currentUser.role === 'admin') {
|
||||||
@@ -584,13 +585,15 @@ function showNotifDetail(nid) {
|
|||||||
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
|
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
|
||||||
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
|
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
|
||||||
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
|
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
|
||||||
'system_announcement': '系统通知', 'friend_request': '好友申请'
|
'exam_submission': '待批改', 'system_announcement': '系统通知',
|
||||||
|
'friend_request': '好友申请'
|
||||||
};
|
};
|
||||||
const typeIcons = {
|
const typeIcons = {
|
||||||
'teacher_application': '👨🏫', 'teacher_result': '🎓',
|
'teacher_application': '👨🏫', 'teacher_result': '🎓',
|
||||||
'contest_application': '📋', 'contest_result': '🏅',
|
'contest_application': '📋', 'contest_result': '🏅',
|
||||||
'contest_new_exam': '📝', 'exam_graded': '✅',
|
'contest_new_exam': '📝', 'exam_graded': '✅',
|
||||||
'system_announcement': '📢', 'friend_request': '👤'
|
'exam_submission': '📝', 'system_announcement': '📢',
|
||||||
|
'friend_request': '👤'
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔';
|
document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔';
|
||||||
@@ -611,8 +614,13 @@ function showNotifDetail(nid) {
|
|||||||
<button onclick="approveTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
|
<button onclick="approveTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
|
||||||
<button onclick="rejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
|
<button onclick="rejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
} else if (n.type === 'exam_submission' && n.exam_id && n.submission_id) {
|
||||||
|
actHtml = `<a href="/exams/${n.exam_id}/grade/${n.submission_id}" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
|
去批改
|
||||||
|
</a>`;
|
||||||
} else if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
|
} else if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
|
||||||
actHtml = `<a href="/exams/${n.post_id}" class="inline-block px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-blue-700">查看考试</a>`;
|
actHtml = `<a href="/exams/${n.post_id}" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium">查看考试</a>`;
|
||||||
} else if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) {
|
} else if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) {
|
||||||
actHtml = `<div class="flex gap-3">
|
actHtml = `<div class="flex gap-3">
|
||||||
<button onclick="acceptFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">同意</button>
|
<button onclick="acceptFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">同意</button>
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ async function loadExams() {
|
|||||||
const statusText = e.status === 'available' ? '进行中' : '已关闭';
|
const statusText = e.status === 'available' ? '进行中' : '已关闭';
|
||||||
const subCount = e.submission_count !== null ? `<span class="text-xs text-slate-400 ml-2">${e.submission_count}人提交</span>` : '';
|
const subCount = e.submission_count !== null ? `<span class="text-xs text-slate-400 ml-2">${e.submission_count}人提交</span>` : '';
|
||||||
const removeBtn = isOwner ? `<button onclick="removeExam(${e.id}, '${escapeHtml(e.title)}')" class="ml-2 px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">移除</button>` : '';
|
const removeBtn = isOwner ? `<button onclick="removeExam(${e.id}, '${escapeHtml(e.title)}')" class="ml-2 px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">移除</button>` : '';
|
||||||
|
const toggleBtn = isOwner ? (e.status === 'available' ? `<button onclick="toggleExamStatus(${e.id}, 'closed')" class="ml-2 px-2 py-1 text-xs text-orange-600 border border-orange-300 rounded hover:bg-orange-50">关闭考试</button>` : `<button onclick="toggleExamStatus(${e.id}, 'available')" class="ml-2 px-2 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50">开放考试</button>`) : '';
|
||||||
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -391,7 +392,7 @@ async function loadExams() {
|
|||||||
<div class="flex items-center gap-2 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>` : ''}
|
${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}
|
${toggleBtn}${removeBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@@ -415,6 +416,20 @@ async function removeExam(examId, title) {
|
|||||||
} catch(e) { alert('网络错误'); }
|
} catch(e) { alert('网络错误'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换考试状态(关闭/开放)
|
||||||
|
function toggleExamStatus(examId, status) {
|
||||||
|
const label = status === 'closed' ? '关闭' : '开放';
|
||||||
|
if (!confirm(`确定${label}该考试?`)) return;
|
||||||
|
fetch(`/api/exams/${examId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ status: status })
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.success) { loadExams(); }
|
||||||
|
else { alert(data.message); }
|
||||||
|
}).catch(() => alert('操作失败'));
|
||||||
|
}
|
||||||
|
|
||||||
// 导入考试弹窗
|
// 导入考试弹窗
|
||||||
function showImportModal() {
|
function showImportModal() {
|
||||||
document.getElementById('import-exam-modal').classList.remove('hidden');
|
document.getElementById('import-exam-modal').classList.remove('hidden');
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ async function examUploadFiles(files, qid) {
|
|||||||
function initAnswers() {
|
function initAnswers() {
|
||||||
// 优先从服务器草稿恢复
|
// 优先从服务器草稿恢复
|
||||||
{% if draft %}
|
{% if draft %}
|
||||||
const serverDraft = {{ draft.answers | tojson }};
|
const serverDraft = {{ draft.get_answers() | tojson }};
|
||||||
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
|
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
|
||||||
answers = serverDraft;
|
answers = serverDraft;
|
||||||
restoreAnswersToForm();
|
restoreAnswersToForm();
|
||||||
|
|||||||
@@ -110,6 +110,8 @@
|
|||||||
|
|
||||||
{% if exam.status == 'closed' %}
|
{% if exam.status == 'closed' %}
|
||||||
<span class="badge-futuristic absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-slate-300 border-slate-700/50">已关闭</span>
|
<span class="badge-futuristic absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-slate-300 border-slate-700/50">已关闭</span>
|
||||||
|
{% elif exam.scheduled_end and now and now > exam.scheduled_end %}
|
||||||
|
<span class="badge-futuristic absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-slate-300 border-slate-700/50">已结束</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge-futuristic absolute top-4 right-4 bg-white/90 backdrop-blur-md text-cyan-600 border-cyan-500/30">
|
<span class="badge-futuristic absolute top-4 right-4 bg-white/90 backdrop-blur-md text-cyan-600 border-cyan-500/30">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500 mr-1.5 animate-pulse"></span>进行中
|
<span class="w-1.5 h-1.5 rounded-full bg-cyan-500 mr-1.5 animate-pulse"></span>进行中
|
||||||
@@ -198,7 +200,7 @@
|
|||||||
<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>
|
||||||
|
|
||||||
{% if user.role == 'admin' or exam.creator_id == user.id %}
|
{% if user.role == 'admin' or exam.creator_id == user.id or (exam.contest_id and exam.contest_id in cup_owner_contest_ids) %}
|
||||||
{% if exam.status == 'available' %}
|
{% if exam.status == 'available' %}
|
||||||
<button onclick="toggleExamStatus({{ exam.id }}, 'closed')" class="px-3 py-1.5 bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 rounded-lg text-xs font-medium transition-colors border border-orange-500/30">关闭考试</button>
|
<button onclick="toggleExamStatus({{ exam.id }}, 'closed')" class="px-3 py-1.5 bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 rounded-lg text-xs font-medium transition-colors border border-orange-500/30">关闭考试</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
Reference in New Issue
Block a user