commit 74f19aad0b5706bfad16d67ace1d043ab2a06cb9 Author: unknown <1251316345@qq.com> Date: Fri Feb 27 10:37:11 2026 +0800 first commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..15f7cb0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "mcp__ide__getDiagnostics", + "Bash(pip install:*)", + "Bash(timeout 10 python:*)", + "Bash(./venv/Scripts/pip install flask-socketio)", + "Bash(ls:*)", + "Bash(venv/Scripts/pip.exe install flask-socketio)", + "Bash(venv/Scripts/python.exe -m pip install:*)", + "Bash(xxd:*)", + "Bash(python:*)", + "WebSearch", + "WebFetch(domain:help.aliyun.com)", + "WebFetch(domain:dashscope.aliyuncs.com)", + "Bash(test:*)", + "Bash(find:*)", + "Bash(python3:*)", + "Bash(/c/Users/HEIHAHA/AppData/Local/Microsoft/WindowsApps/python:*)", + "Bash(cd \"D:/360MoveData/Users/HEIHAHA/Documents/WeChat Files/wxid_k0iaj5miuryq22/FileStorage/File/2026-02/超级大网站2月23号\" && py -3 cleanup.py 2>&1)", + "Bash(cd \"D:/360MoveData/Users/HEIHAHA/Documents/WeChat Files/wxid_k0iaj5miuryq22/FileStorage/File/2026-02/超级大网站2月23号\" && py -3 -c \"\nimport sys, os\nsys.path.insert\\(0, os.path.dirname\\(os.path.abspath\\('app.py'\\)\\)\\)\nfrom app import app\nfrom models import db, User\nwith app.app_context\\(\\):\n users = User.query.all\\(\\)\n for u in users:\n print\\(f'id={u.id}, name={u.name}, email={u.email}, phone={u.phone}, role={u.role}'\\)\n if not users:\n print\\('No users in database'\\)\n\" 2>&1)", + "Bash(cd \"D:/360MoveData/Users/HEIHAHA/Documents/WeChat Files/wxid_k0iaj5miuryq22/FileStorage/File/2026-02/超级大网站2月23号\" && py -3 -c \"\nimport os, glob\ntemplates = glob.glob\\('templates/*.html'\\)\ncount = 0\nfor f in templates:\n with open\\(f, 'r', encoding='utf-8'\\) as fh:\n content = fh.read\\(\\)\n if '联考平台' in content:\n new_content = content.replace\\('联考平台', '智联青云'\\)\n with open\\(f, 'w', encoding='utf-8'\\) as fh:\n fh.write\\(new_content\\)\n count += 1\n print\\(f' replaced in {f}'\\)\nprint\\(f'Done: {count} files updated'\\)\n\" 2>&1)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44d1b60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +venv/ +.venv/ +*.egg-info/ +dist/ +build/ +node_modules/ \ No newline at end of file diff --git a/JesusChrist/app.py b/JesusChrist/app.py new file mode 100644 index 0000000..dfcc7fd --- /dev/null +++ b/JesusChrist/app.py @@ -0,0 +1,369 @@ +import os +import sqlite3 +import random +import string +from datetime import datetime +from flask import Flask, render_template, request, redirect, url_for, g, flash, jsonify + +app = Flask(__name__) +app.secret_key = 'admin-secret-key-2026' + +# 连接主应用的数据库 +DATABASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'instance', 'database.db') + +def get_db(): + db = getattr(g, '_database', None) + if db is None: + db = g._database = sqlite3.connect(DATABASE) + db.row_factory = sqlite3.Row + return db + +@app.teardown_appcontext +def close_connection(exception): + db = getattr(g, '_database', None) + if db is not None: + db.close() + +def init_db(): + """确保 system_notification 和 invite_code 表存在""" + with app.app_context(): + db = get_db() + db.executescript(''' + CREATE TABLE IF NOT EXISTS system_notification ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + author_id INTEGER NOT NULL, + pinned INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS invite_code ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + user_id INTEGER NOT NULL, + application_id INTEGER NOT NULL, + used INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + used_at TIMESTAMP + ); + ''') + db.commit() + +# PLACEHOLDER_ROUTES + +def generate_invite_code(): + return 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + +def add_notification(db, user_id, ntype, content, from_user='', post_id=0): + db.execute('INSERT INTO notification (user_id, type, content, from_user, post_id, read) VALUES (?,?,?,?,?,0)', + (user_id, ntype, content, from_user, post_id)) + +# ========== 后台首页 ========== +@app.route('/admin') +def dashboard(): + db = get_db() + stats = { + 'users': db.execute('SELECT COUNT(*) as c FROM user').fetchone()['c'], + 'contests': db.execute('SELECT COUNT(*) as c FROM contest').fetchone()['c'], + 'exams': db.execute('SELECT COUNT(*) as c FROM exam').fetchone()['c'], + 'posts': db.execute('SELECT COUNT(*) as c FROM post').fetchone()['c'], + 'pending_teacher': db.execute("SELECT COUNT(*) as c FROM teacher_application WHERE status='pending'").fetchone()['c'], + 'pending_contest': db.execute("SELECT COUNT(*) as c FROM contest_application WHERE status='pending'").fetchone()['c'], + } + return render_template('dashboard.html', stats=stats) + +# ========== 杯赛管理 ========== +@app.route('/admin/competitions') +def competitions(): + db = get_db() + contests = db.execute('SELECT * FROM contest ORDER BY created_at DESC').fetchall() + return render_template('competitions.html', competitions=contests) + +@app.route('/admin/competitions/stop/', methods=['POST']) +def stop_competition(id): + db = get_db() + db.execute("UPDATE contest SET status = 'ended' WHERE id = ?", (id,)) + db.commit() + return redirect(url_for('competitions')) + +@app.route('/admin/competitions/activate/', methods=['POST']) +def activate_competition(id): + db = get_db() + db.execute("UPDATE contest SET status = 'upcoming' WHERE id = ?", (id,)) + db.commit() + return redirect(url_for('competitions')) + +# ========== 杯赛申请 ========== +@app.route('/admin/competition_apps') +def competition_apps(): + db = get_db() + apps = db.execute(''' + SELECT ca.*, u.name as applicant_name FROM contest_application ca + JOIN user u ON ca.user_id = u.id + ORDER BY ca.applied_at DESC + ''').fetchall() + return render_template('competition_apps.html', apps=apps) + +@app.route('/admin/competition_apps/approve/', methods=['POST']) +def approve_competition_app(id): + db = get_db() + ca = db.execute('SELECT * FROM contest_application WHERE id = ?', (id,)).fetchone() + if not ca or ca['status'] != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('competition_apps')) + # 创建杯赛 + db.execute('INSERT INTO contest (name, organizer, description, start_date, end_date, status, participants, created_by) VALUES (?,?,?,?,?,?,?,?)', + (ca['name'], ca['organizer'], ca['description'], '待定', '待定', 'upcoming', 0, ca['user_id'])) + contest_id = db.execute('SELECT last_insert_rowid()').fetchone()[0] + # 创建成员关系(owner) + db.execute('INSERT INTO contest_membership (user_id, contest_id, role) VALUES (?,?,?)', (ca['user_id'], contest_id, 'owner')) + # 创建杯赛讨论群 + db.execute('INSERT INTO chat_room (type, name, creator_id, contest_id) VALUES (?,?,?,?)', + ('contest', ca['name'] + ' 讨论群', ca['user_id'], contest_id)) + room_id = db.execute('SELECT last_insert_rowid()').fetchone()[0] + db.execute('INSERT INTO chat_room_member (room_id, user_id, role) VALUES (?,?,?)', (room_id, ca['user_id'], 'admin')) + # 更新申请状态 + db.execute("UPDATE contest_application SET status = 'approved', reviewed_at = ? WHERE id = ?", (datetime.utcnow(), id)) + add_notification(db, ca['user_id'], 'contest_result', f'您申请举办的杯赛「{ca["name"]}」已通过审核!', from_user='系统') + db.commit() + flash('申请已批准,杯赛已创建', 'success') + return redirect(url_for('competition_apps')) + +@app.route('/admin/competition_apps/reject/', methods=['POST']) +def reject_competition_app(id): + db = get_db() + ca = db.execute('SELECT * FROM contest_application WHERE id = ?', (id,)).fetchone() + if not ca or ca['status'] != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('competition_apps')) + db.execute("UPDATE contest_application SET status = 'rejected', reviewed_at = ? WHERE id = ?", (datetime.utcnow(), id)) + add_notification(db, ca['user_id'], 'contest_result', f'您申请举办的杯赛「{ca["name"]}」未通过审核。', from_user='系统') + db.commit() + flash('申请已拒绝', 'success') + return redirect(url_for('competition_apps')) + +# PLACEHOLDER_TEACHER_ROUTES + +# ========== 教师申请(邀请码流程) ========== +@app.route('/admin/teacher_apps') +def teacher_apps(): + db = get_db() + apps = db.execute(''' + SELECT ta.*, u.name as username, c.name as contest_name + FROM teacher_application ta + JOIN user u ON ta.user_id = u.id + JOIN contest c ON ta.contest_id = c.id + ORDER BY ta.applied_at DESC + ''').fetchall() + return render_template('teacher_apps.html', apps=apps) + +@app.route('/admin/teacher_apps/approve/', methods=['POST']) +def approve_teacher_app(id): + db = get_db() + ta = db.execute('SELECT * FROM teacher_application WHERE id = ?', (id,)).fetchone() + if not ta or ta['status'] != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('teacher_apps')) + # 检查是否已是杯赛成员 + existing = db.execute('SELECT id FROM contest_membership WHERE user_id = ? AND contest_id = ?', + (ta['user_id'], ta['contest_id'])).fetchone() + if existing: + db.execute("UPDATE teacher_application SET status = 'rejected', reviewed_at = ? WHERE id = ?", (datetime.utcnow(), id)) + db.commit() + flash('用户已是杯赛成员,申请已拒绝', 'error') + return redirect(url_for('teacher_apps')) + # 生成邀请码 + code = generate_invite_code() + while db.execute('SELECT id FROM invite_code WHERE code = ?', (code,)).fetchone(): + code = generate_invite_code() + db.execute('INSERT INTO invite_code (code, user_id, application_id) VALUES (?,?,?)', (code, ta['user_id'], ta['id'])) + db.execute("UPDATE teacher_application SET status = 'approved', reviewed_at = ? WHERE id = ?", (datetime.utcnow(), id)) + # 获取杯赛名称 + contest = db.execute('SELECT name FROM contest WHERE id = ?', (ta['contest_id'],)).fetchone() + contest_name = contest['name'] if contest else '' + # 查找或创建私聊室,发送邀请码消息 + admin_id = db.execute("SELECT id FROM user WHERE role = 'admin' LIMIT 1").fetchone() + sender_id = admin_id['id'] if admin_id else 1 + # 查找已有私聊 + room = db.execute(''' + SELECT cr.id FROM chat_room cr + WHERE cr.type = 'private' + AND cr.id IN (SELECT room_id FROM chat_room_member WHERE user_id = ?) + AND cr.id IN (SELECT room_id FROM chat_room_member WHERE user_id = ?) + ''', (sender_id, ta['user_id'])).fetchone() + if room: + room_id = room['id'] + else: + db.execute('INSERT INTO chat_room (type, creator_id) VALUES (?,?)', ('private', sender_id)) + room_id = db.execute('SELECT last_insert_rowid()').fetchone()[0] + db.execute('INSERT INTO chat_room_member (room_id, user_id, role) VALUES (?,?,?)', (room_id, sender_id, 'member')) + db.execute('INSERT INTO chat_room_member (room_id, user_id, role) VALUES (?,?,?)', (room_id, ta['user_id'], 'member')) + # 发送私聊消息 + msg = f'恭喜!您申请成为杯赛「{contest_name}」老师已通过审核。\n请使用以下邀请码激活您的教师身份:\n\n🎫 邀请码:{code}\n\n请前往「申请成为老师」页面,在邀请码输入框中输入此码完成激活。\n注意:此邀请码仅限您本人使用,且仅限一次。' + db.execute('INSERT INTO message (room_id, sender_id, type, content) VALUES (?,?,?,?)', (room_id, sender_id, 'system', msg)) + add_notification(db, ta['user_id'], 'teacher_result', + f'您申请成为杯赛「{contest_name}」老师已通过审核,请查看私聊消息获取邀请码。', from_user='系统') + db.commit() + flash(f'申请已批准,邀请码 {code} 已通过私聊发送给老师', 'success') + return redirect(url_for('teacher_apps')) + +@app.route('/admin/teacher_apps/reject/', methods=['POST']) +def reject_teacher_app(id): + db = get_db() + ta = db.execute('SELECT * FROM teacher_application WHERE id = ?', (id,)).fetchone() + if not ta or ta['status'] != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('teacher_apps')) + db.execute("UPDATE teacher_application SET status = 'rejected', reviewed_at = ? WHERE id = ?", (datetime.utcnow(), id)) + contest = db.execute('SELECT name FROM contest WHERE id = ?', (ta['contest_id'],)).fetchone() + contest_name = contest['name'] if contest else '' + add_notification(db, ta['user_id'], 'teacher_result', + f'您申请成为杯赛「{contest_name}」老师未通过审核。', from_user='系统') + db.commit() + flash('申请已拒绝', 'success') + return redirect(url_for('teacher_apps')) + +# PLACEHOLDER_REMAINING_ROUTES + +# ========== 考试管理 ========== +@app.route('/admin/exams') +def exams(): + db = get_db() + exams = db.execute(''' + SELECT e.*, c.name as contest_name, u.name as creator_name + FROM exam e + LEFT JOIN contest c ON e.contest_id = c.id + LEFT JOIN user u ON e.creator_id = u.id + ORDER BY e.created_at DESC + ''').fetchall() + return render_template('exams.html', exams=exams) + +@app.route('/admin/exams/stop/', methods=['POST']) +def stop_exam(id): + db = get_db() + db.execute("UPDATE exam SET status = 'closed' WHERE id = ?", (id,)) + db.commit() + flash('考试已停止', 'success') + return redirect(url_for('exams')) + +@app.route('/admin/exams/activate/', methods=['POST']) +def activate_exam(id): + db = get_db() + db.execute("UPDATE exam SET status = 'available' WHERE id = ?", (id,)) + db.commit() + flash('考试已恢复', 'success') + return redirect(url_for('exams')) + +# ========== 用户管理 ========== +@app.route('/admin/users') +def users(): + db = get_db() + all_users = db.execute('SELECT * FROM user ORDER BY created_at DESC').fetchall() + return render_template('users.html', users=all_users) + +@app.route('/admin/users/toggle_ban/', methods=['POST']) +def toggle_ban_user(id): + db = get_db() + cur = db.execute('SELECT is_banned FROM user WHERE id = ?', (id,)).fetchone() + new_val = 0 if cur['is_banned'] else 1 + db.execute('UPDATE user SET is_banned = ? WHERE id = ?', (new_val, id)) + db.commit() + flash('用户状态已更新', 'success') + return redirect(url_for('users')) + +@app.route('/admin/users/set_role/', methods=['POST']) +def set_user_role(id): + role = request.form.get('role', 'student') + db = get_db() + db.execute('UPDATE user SET role = ? WHERE id = ?', (role, id)) + db.commit() + flash('角色已更新', 'success') + return redirect(url_for('users')) + +# ========== 帖子管理 ========== +@app.route('/admin/posts') +def posts(): + db = get_db() + all_posts = db.execute(''' + SELECT p.*, u.name as author_name FROM post p + LEFT JOIN user u ON p.author_id = u.id + ORDER BY p.created_at DESC + ''').fetchall() + return render_template('posts.html', posts=all_posts) + +@app.route('/admin/posts/delete/', methods=['POST']) +def delete_post(id): + db = get_db() + db.execute('DELETE FROM post WHERE id = ?', (id,)) + db.commit() + flash('帖子已删除', 'success') + return redirect(url_for('posts')) + +@app.route('/admin/posts/toggle_pin/', methods=['POST']) +def toggle_pin_post(id): + db = get_db() + cur = db.execute('SELECT pinned FROM post WHERE id = ?', (id,)).fetchone() + new_val = 0 if cur['pinned'] else 1 + db.execute('UPDATE post SET pinned = ? WHERE id = ?', (new_val, id)) + db.commit() + flash('帖子置顶状态已更新', 'success') + return redirect(url_for('posts')) + +# PLACEHOLDER_NOTIFICATION_ROUTES + +# ========== 公告管理 ========== +@app.route('/admin/notifications') +def notifications(): + db = get_db() + anns = db.execute(''' + SELECT sn.*, u.name as author_name FROM system_notification sn + LEFT JOIN user u ON sn.author_id = u.id + ORDER BY sn.pinned DESC, sn.created_at DESC + ''').fetchall() + return render_template('notifications.html', announcements=anns) + +@app.route('/admin/notifications/create', methods=['POST']) +def create_notification(): + title = request.form.get('title', '').strip() + content = request.form.get('content', '').strip() + pinned = 1 if request.form.get('pinned') else 0 + if not title or not content: + flash('标题和内容不能为空', 'error') + return redirect(url_for('notifications')) + db = get_db() + admin = db.execute("SELECT id FROM user WHERE role = 'admin' LIMIT 1").fetchone() + author_id = admin['id'] if admin else 1 + now = datetime.utcnow() + db.execute('INSERT INTO system_notification (title, content, author_id, pinned, created_at, updated_at) VALUES (?,?,?,?,?,?)', + (title, content, author_id, pinned, now, now)) + db.commit() + flash('公告已发布', 'success') + return redirect(url_for('notifications')) + +@app.route('/admin/notifications/edit/', methods=['POST']) +def edit_notification(id): + title = request.form.get('title', '').strip() + content = request.form.get('content', '').strip() + pinned = 1 if request.form.get('pinned') else 0 + if not title or not content: + flash('标题和内容不能为空', 'error') + return redirect(url_for('notifications')) + db = get_db() + db.execute('UPDATE system_notification SET title=?, content=?, pinned=?, updated_at=? WHERE id=?', + (title, content, pinned, datetime.utcnow(), id)) + db.commit() + flash('公告已更新', 'success') + return redirect(url_for('notifications')) + +@app.route('/admin/notifications/delete/', methods=['POST']) +def delete_notification(id): + db = get_db() + db.execute('DELETE FROM system_notification WHERE id = ?', (id,)) + db.commit() + flash('公告已删除', 'success') + return redirect(url_for('notifications')) + +if __name__ == '__main__': + init_db() + app.run(debug=True, port=5001) diff --git a/JesusChrist/database/database.db b/JesusChrist/database/database.db new file mode 100644 index 0000000..7ecc7ef Binary files /dev/null and b/JesusChrist/database/database.db differ diff --git a/JesusChrist/templates/base.html b/JesusChrist/templates/base.html new file mode 100644 index 0000000..9a907a9 --- /dev/null +++ b/JesusChrist/templates/base.html @@ -0,0 +1,50 @@ + + + + + + 后台管理 - {% block title %}{% endblock %} + + + + + +
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+
+ + + \ No newline at end of file diff --git a/JesusChrist/templates/competition_apps.html b/JesusChrist/templates/competition_apps.html new file mode 100644 index 0000000..27b138a --- /dev/null +++ b/JesusChrist/templates/competition_apps.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}杯赛申请{% endblock %} +{% block content %} +

杯赛申请

+ + + + + + + + + + + + + + {% for app in apps %} + + + + + + + + + + {% endfor %} + +
ID比赛名称申请人描述状态申请时间操作
{{ app.id }}{{ app.name }}{{ app.applicant_name }}{{ app.description }} + {% if app.status == 'pending' %} + 待处理 + {% elif app.status == 'approved' %} + 已批准 + {% else %} + 已驳回 + {% endif %} + {{ app.created_at }} + {% if app.status == 'pending' %} +
+ +
+
+ +
+ {% else %} + 已处理 + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/competitions.html b/JesusChrist/templates/competitions.html new file mode 100644 index 0000000..99c4c67 --- /dev/null +++ b/JesusChrist/templates/competitions.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}杯赛管理{% endblock %} +{% block content %} +

杯赛管理

+ + + + + + + + + + + + + {% for comp in competitions %} + + + + + + + + + {% endfor %} + +
ID名称描述状态创建时间操作
{{ comp.id }}{{ comp.name }}{{ comp.description }} + {% if comp.status == 'active' %} + 进行中 + {% else %} + 已停止 + {% endif %} + {{ comp.created_at }} + {% if comp.status == 'active' %} +
+ +
+ {% else %} + 无操作 + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/dashboard.html b/JesusChrist/templates/dashboard.html new file mode 100644 index 0000000..3cb1c4d --- /dev/null +++ b/JesusChrist/templates/dashboard.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}仪表盘{% endblock %} +{% block content %} +

仪表盘

+
+
+
+
+

{{ stats.users }}

+

注册用户

+ 管理用户 +
+
+
+
+
+
+

{{ stats.contests }}

+

杯赛总数

+ 管理杯赛 +
+
+
+
+
+
+

{{ stats.exams }}

+

考试总数

+ 管理考试 +
+
+
+
+
+
+

{{ stats.pending_teacher }}

+

待审教师申请

+ 处理申请 +
+
+
+
+
+
+

{{ stats.pending_contest }}

+

待审杯赛申请

+ 处理申请 +
+
+
+
+
+
+

{{ stats.posts }}

+

论坛帖子

+ 管理帖子 +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/exams.html b/JesusChrist/templates/exams.html new file mode 100644 index 0000000..a455670 --- /dev/null +++ b/JesusChrist/templates/exams.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}考试管理{% endblock %} +{% block content %} +

考试管理

+ + + + + + + + + + + + + + {% for exam in exams %} + + + + + + + + + + {% endfor %} + +
ID名称科目考试日期状态创建时间操作
{{ exam.id }}{{ exam.name }}{{ exam.subject }}{{ exam.exam_date }} + {% if exam.status == 'active' %} + 进行中 + {% else %} + 已停止 + {% endif %} + {{ exam.created_at }} + {% if exam.status == 'active' %} +
+ +
+ {% else %} + 无操作 + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/posts.html b/JesusChrist/templates/posts.html new file mode 100644 index 0000000..5748637 --- /dev/null +++ b/JesusChrist/templates/posts.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}帖子管理{% endblock %} +{% block content %} +

帖子管理

+ + + + + + + + + + + + + + {% for post in posts %} + + + + + + + + + + {% endfor %} + +
ID标题内容作者封禁状态发布时间操作
{{ post.id }}{{ post.title }}{{ post.content[:50] }}{% if post.content|length > 50 %}...{% endif %}{{ post.username }} + {% if post.banned == 0 %} + 正常 + {% else %} + 已封禁 + {% endif %} + {{ post.created_at }} +
+ {% if post.banned == 0 %} + + {% else %} + + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/teacher_apps.html b/JesusChrist/templates/teacher_apps.html new file mode 100644 index 0000000..314a49e --- /dev/null +++ b/JesusChrist/templates/teacher_apps.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}教师申请{% endblock %} +{% block content %} +

教师申请

+ + + + + + + + + + + + + {% for app in apps %} + + + + + + + + + {% endfor %} + +
ID申请人理由状态申请时间操作
{{ app.id }}{{ app.username }}{{ app.reason }} + {% if app.status == 'pending' %} + 待处理 + {% elif app.status == 'approved' %} + 已批准 + {% else %} + 已驳回 + {% endif %} + {{ app.created_at }} + {% if app.status == 'pending' %} +
+ +
+
+ +
+ {% else %} + 已处理 + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/JesusChrist/templates/users.html b/JesusChrist/templates/users.html new file mode 100644 index 0000000..b9c5e7c --- /dev/null +++ b/JesusChrist/templates/users.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}用户管理{% endblock %} +{% block content %} +

用户管理

+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
ID用户名邮箱角色封禁状态注册时间操作
{{ user.id }}{{ user.username }}{{ user.email }}{{ user.role }} + {% if user.banned == 0 %} + 正常 + {% else %} + 已封禁 + {% endif %} + {{ user.created_at }} +
+ {% if user.banned == 0 %} + + {% else %} + + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c34624 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# 联考平台 (Online Exam Platform) + +基于 Python Flask 的线上联考平台。 + +## 功能 + +- 用户注册/登录(邮箱验证码、手机验证码) +- 杯赛专栏(浏览、报名) +- 考试系统(在线答题、提交) +- 社区论坛(帖子浏览、分类筛选) + +## 技术栈 + +- 后端:Python Flask +- 前端:Jinja2 模板 + TailwindCSS CDN +- 验证码:captcha 库生成图形验证码 +- 邮件:smtplib 发送邮箱验证码 + +## 快速开始 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 运行 +python app.py +``` + +访问 http://localhost:5000 + +## 项目结构 + +``` +app.py # Flask 主应用 +requirements.txt # Python 依赖 +.env # 环境变量配置 +templates/ # Jinja2 HTML 模板 +static/css/ # 静态样式文件 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..d3b38ba --- /dev/null +++ b/app.py @@ -0,0 +1,4606 @@ +# app.py +import os +import time +import json +import random +import string +import smtplib +from datetime import datetime, timedelta +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from functools import wraps + +from flask import ( + Flask, render_template, request, jsonify, session, + redirect, url_for, flash, abort, send_from_directory +) +from flask_caching import Cache +from werkzeug.utils import secure_filename +from dotenv import load_dotenv +from captcha.image import ImageCaptcha +from aliyunsdkcore.client import AcsClient +from aliyunsdkcore.request import CommonRequest +import fitz # PyMuPDF +from openai import OpenAI as DashScopeClient + +# 引入数据库模型(添加 TeacherApplication) +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 + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.urandom(24) +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') + +# 内存存储(用于临时验证码) +captcha_store = {} +email_codes = {} + +# 在线用户追踪(记录用户最后活跃时间) +online_users = {} # {user_id: last_active_timestamp} + +# 数据库配置 +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db.init_app(app) + +# 缓存配置 +app.config['CACHE_TYPE'] = 'SimpleCache' # 使用简单的内存缓存,如果需要可以改成 RedisCache +app.config['CACHE_DEFAULT_TIMEOUT'] = 60 # 默认缓存 60 秒 +cache = Cache(app) + +# 自定义 Jinja2 过滤器:渲染图片标签 +import re as _re +from markupsafe import Markup, escape as _escape + +@app.template_filter('render_images') +def render_images_filter(text): + if not text: + return text + escaped = _escape(text) + result = _re.sub( + r'\[img:(\/static\/uploads\/[^\]]+)\]', + r'', + str(escaped) + ) + return Markup(result) + +# 阿里云号码认证服务客户端 +acs_client = AcsClient( + os.getenv('ALIBABA_CLOUD_ACCESS_KEY_ID', ''), + os.getenv('ALIBABA_CLOUD_ACCESS_KEY_SECRET', ''), + 'cn-hangzhou' +) + +# ========== 辅助函数 ========== + +def get_online_count(): + """获取5分钟内活跃的用户数""" + now = time.time() + cutoff = now - 300 # 5分钟 + # 清理过期记录 + expired = [uid for uid, ts in online_users.items() if ts < cutoff] + for uid in expired: + del online_users[uid] + return len(online_users) + +@app.before_request +def track_online_user(): + user = session.get('user') + if user: + online_users[user['id']] = time.time() + +def generate_captcha_id(): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=16)) + +def generate_code(length=6): + return ''.join(random.choices(string.digits, k=length)) + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'user' not in session: + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + +# ========== 考试加密工具 ========== +import base64 +import hashlib + +EXAM_ENCRYPT_KEY = os.getenv('EXAM_ENCRYPT_KEY', 'default_exam_secret_key_2026') + +def _derive_key(key_str): + return hashlib.sha256(key_str.encode()).digest() + +def encrypt_questions(questions_json): + """使用 XOR + base64 加密试卷内容""" + key = _derive_key(EXAM_ENCRYPT_KEY) + data = questions_json.encode('utf-8') + encrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))]) + return base64.b64encode(encrypted).decode('utf-8') + +def decrypt_questions(encrypted_str): + """解密试卷内容""" + key = _derive_key(EXAM_ENCRYPT_KEY) + data = base64.b64decode(encrypted_str.encode('utf-8')) + decrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))]) + return decrypted.decode('utf-8') + +def get_exam_questions(exam): + """统一获取考试题目(自动处理加密)""" + if exam.is_encrypted and exam.encrypted_questions: + return json.loads(decrypt_questions(exam.encrypted_questions)) + return exam.get_questions() + +def teacher_required(f): + @wraps(f) + def decorated(*args, **kwargs): + user = session.get('user') + if not user: + return redirect(url_for('login')) + if user.get('role') not in ('teacher', 'admin'): + return redirect(url_for('exam_list')) + return f(*args, **kwargs) + return decorated + +def send_email(to_email, subject, html_content): + smtp_host = os.getenv('SMTP_HOST', 'smtp.163.com') + smtp_port = int(os.getenv('SMTP_PORT', 465)) + smtp_user = os.getenv('SMTP_USER', '') + smtp_pass = os.getenv('SMTP_PASS', '') + + msg = MIMEMultipart('alternative') + msg['From'] = f'"验证码" <{smtp_user}>' + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(html_content, 'html', 'utf-8')) + + with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_user, to_email, msg.as_string()) + +def get_current_user(): + return session.get('user') + +def require_login(): + user = get_current_user() + if not user: + abort(401) + return user + +def require_admin_or_teacher(): + user = require_login() + if user['role'] not in ['admin', 'teacher']: + abort(403) + return user +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + user = get_current_user() + if not user or user['role'] != 'admin': + abort(403) + return f(*args, **kwargs) + return decorated + +# 积分等级相关 +LEVEL_TITLES = {1:'新手上路',2:'初出茅庐',3:'小有名气',4:'渐入佳境',5:'驾轻就熟',6:'炉火纯青',7:'学富五车',8:'出类拔萃',9:'登峰造极',10:'一代宗师'} + +def calc_level(points): + if points >= 5000: return 10 + if points >= 3000: return 9 + if points >= 2000: return 8 + if points >= 1200: return 7 + if points >= 800: return 6 + if points >= 500: return 5 + if points >= 300: return 4 + if points >= 150: return 3 + if points >= 50: return 2 + return 1 + +def add_notification(user_id, ntype, content, from_user='', post_id=0): + notif = Notification( + user_id=user_id, + type=ntype, + content=content, + from_user=from_user, + post_id=post_id + ) + db.session.add(notif) + db.session.commit() + +def send_private_message(from_user_id, to_user_id, content, msg_type='system'): + """发送私聊消息(查找或创建私聊室)""" + # 查找已有私聊室 + my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=from_user_id).subquery() + target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=to_user_id).subquery() + room = ChatRoom.query.filter( + ChatRoom.type == 'private', + ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)), + ChatRoom.id.in_(db.session.query(target_rooms.c.room_id)) + ).first() + if not room: + room = ChatRoom(type='private', creator_id=from_user_id) + db.session.add(room) + db.session.flush() + db.session.add(ChatRoomMember(room_id=room.id, user_id=from_user_id, role='member')) + db.session.add(ChatRoomMember(room_id=room.id, user_id=to_user_id, role='member')) + msg = Message(room_id=room.id, sender_id=from_user_id, type=msg_type, content=content) + db.session.add(msg) + db.session.flush() + sender = User.query.get(from_user_id) + socketio.emit('new_message', { + 'id': msg.id, 'room_id': room.id, 'sender_id': from_user_id, + 'sender_name': sender.name if sender else '系统', + 'sender_avatar': (sender.avatar or '') if sender else '', + 'type': msg_type, 'content': content, + 'file_url': None, 'file_name': None, 'reply_to': None, + 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') + }, room=f'room_{room.id}') + +# ========== 杯赛讨论区发帖权限检查 ========== +def can_post_in_contest(user, contest): + """检查用户是否可以在指定杯赛的讨论区发帖""" + if user['role'] in ['admin', 'teacher']: + return True + # 杯赛负责人或老师 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest.id).first() + if membership and membership.role in ['owner', 'teacher']: + return True + # 报名且至少参与一次考试 + registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest.id).first() + if not registration: + return False + # 获取该杯赛下的所有考试 ID + exam_ids = [exam.id for exam in contest.exams] + if not exam_ids: + return False + # 查询用户在这些考试中的提交记录 + submission_count = Submission.query.filter( + Submission.user_id == user['id'], + Submission.exam_id.in_(exam_ids) + ).count() + return submission_count >= 1 + +def can_grade_exam(user, exam): + """检查用户是否可以批改指定考试""" + if not user: + return False + if user.get('role') in ('teacher', 'admin'): + return True + # 杯赛老师可以批改所属杯赛的考试 + if exam.contest_id: + membership = ContestMembership.query.filter_by( + user_id=user['id'], contest_id=exam.contest_id).first() + if membership and membership.role in ('owner', 'teacher'): + return True + return False + +# ========== Jinja2 过滤器 ========== +@app.template_filter('fromjson') +def fromjson_filter(s): + """将 JSON 字符串解析为 Python 对象""" + try: + return json.loads(s) if s else [] + except (json.JSONDecodeError, TypeError): + return [] + +# ========== 上下文处理器 ========== +def get_display_name(user_id, base_name): + """获取用户显示名称,如果是杯赛负责人或老师则附加杯赛信息""" + memberships = ContestMembership.query.filter_by(user_id=user_id).all() + if not memberships: + return base_name + role_names = [] + for m in memberships: + contest = Contest.query.get(m.contest_id) + if contest and contest.status != 'abolished': + if m.role == 'owner': + role_names.append(f'{contest.name}负责人') + elif m.role == 'teacher': + role_names.append(f'{contest.name}老师') + if not role_names: + return base_name + return f"{base_name}({'、'.join(role_names)})" + +@app.context_processor +def inject_user(): + return {'user': get_current_user()} + +@app.context_processor +def inject_display_name(): + def get_user_display_name(): + user = session.get('user') + if not user: + return '' + return get_display_name(user['id'], user['name']) + return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name) + +# ========== 页面路由 ========== +@app.route('/') +def home(): + online_count = get_online_count() + contest_count = Contest.query.count() + return render_template('home.html', online_count=online_count, contest_count=contest_count) + +@app.route('/login', methods=['GET']) +def login(): + return render_template('login.html') + +@app.route('/register', methods=['GET']) +def register(): + return render_template('register.html') + +@app.route('/logout') +def logout(): + session.pop('user', None) + return redirect(url_for('login')) + +@app.route('/contests') +def contest_list(): + return render_template('contest_list.html') + +@app.route('/contests/') +def contest_detail(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return redirect(url_for('contest_list')) + user = get_current_user() + # 未发布的杯赛只有负责人和管理员可见 + if not contest.visible: + if not user: + return redirect(url_for('contest_list')) + if user.get('role') != 'admin': + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not membership: + return redirect(url_for('contest_list')) + registered = False + can_post = False + is_member = False + is_owner = False + if user: + # 检查是否报名 + registered = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() is not None + # 检查是否有发帖权限 + can_post = can_post_in_contest(user, contest) + # 检查是否为成员(负责人或老师) + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + is_member = membership is not None + is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin' + return render_template('contest_detail.html', contest=contest, registered=registered, can_post=can_post, is_member=is_member, is_owner=is_owner) + +@app.route('/contests//question-bank') +@login_required +def contest_question_bank(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return redirect(url_for('contest_list')) + if contest.status == 'abolished': + return redirect(url_for('contest_detail', contest_id=contest_id)) + user = get_current_user() + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not membership and user.get('role') != 'admin': + return redirect(url_for('contest_detail', contest_id=contest_id)) + is_owner = (membership and membership.role == 'owner') or user.get('role') == 'admin' + return render_template('contest_question_bank.html', contest=contest, is_owner=is_owner) + +@app.route('/contests//edit') +@login_required +def contest_edit(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return redirect(url_for('contest_list')) + user = get_current_user() + 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' + if not is_owner: + return redirect(url_for('contest_detail', contest_id=contest_id)) + return render_template('contest_edit.html', contest=contest) + +@app.route('/api/contests//edit', methods=['PUT']) +@login_required +def api_contest_edit(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + user = get_current_user() + 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' + if not is_owner: + return jsonify({'success': False, 'message': '没有权限'}), 403 + data = request.get_json() + if data.get('description') is not None: + contest.description = data['description'] + if data.get('organizer') is not None: + contest.organizer = data['organizer'] + if data.get('start_date') is not None: + contest.start_date = data['start_date'] + if data.get('end_date') is not None: + contest.end_date = data['end_date'] + if data.get('status') in ('upcoming', 'registering', 'ongoing', 'ended'): + contest.status = data['status'] + db.session.commit() + return jsonify({'success': True}) + +ALLOWED_PAPER_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp'} + +@app.route('/api/contests//past-papers', methods=['POST']) +@login_required +def api_contest_upload_paper(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + user = get_current_user() + 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' + if not is_owner: + return jsonify({'success': False, 'message': '没有权限'}), 403 + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else '' + if ext not in ALLOWED_PAPER_EXTENSIONS: + return jsonify({'success': False, 'message': '仅支持 PDF 和图片文件'}), 400 + file.seek(0, 2) + if file.tell() > 10 * 1024 * 1024: + return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400 + file.seek(0) + year = request.form.get('year', '') + title = request.form.get('title', '') + if not year or not title: + return jsonify({'success': False, 'message': '年份和标题不能为空'}), 400 + filename = f"paper_{contest_id}_{int(time.time())}_{secure_filename(file.filename)}" + upload_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads') + os.makedirs(upload_dir, exist_ok=True) + file.save(os.path.join(upload_dir, filename)) + url = f'/static/uploads/{filename}' + papers = contest.get_past_papers() + papers.append({'year': year, 'title': title, 'file': url}) + contest.set_past_papers(papers) + db.session.commit() + return jsonify({'success': True, 'papers': contest.get_past_papers()}) + +@app.route('/api/contests//past-papers/', methods=['DELETE']) +@login_required +def api_contest_delete_paper(contest_id, index): + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + user = get_current_user() + 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' + if not is_owner: + return jsonify({'success': False, 'message': '没有权限'}), 403 + papers = contest.get_past_papers() + if index < 0 or index >= len(papers): + return jsonify({'success': False, 'message': '索引无效'}), 400 + papers.pop(index) + contest.set_past_papers(papers) + db.session.commit() + return jsonify({'success': True, 'papers': contest.get_past_papers()}) + +@app.route('/exams') +@login_required +def exam_list(): + user = session.get('user') + + search_query = request.args.get('q', '').strip() + subject_filter = request.args.get('subject', '').strip() + + query = Exam.query + if subject_filter: + query = query.filter_by(subject=subject_filter) + if search_query: + query = query.filter( + (Exam.title.contains(search_query)) | (Exam.subject.contains(search_query)) + ) + all_exams = query.all() + + all_subjects = db.session.query(Exam.subject).distinct().all() + all_subjects = [s[0] for s in all_subjects if s[0]] + + user_submissions = {} + for sub in Submission.query.filter_by(user_id=user.get('id')).all(): + user_submissions[sub.exam_id] = { + 'id': sub.id, + 'graded': sub.graded, + 'score': sub.score + } + + return render_template( + 'exam_list.html', + exams=all_exams, + user_submissions=user_submissions, + search_query=search_query, + subject_filter=subject_filter, + all_subjects=all_subjects + ) + +@app.route('/exams/create') +@login_required +def exam_create(): + user = session.get('user') + # 允许 teacher/admin 创建任意考试 + if user.get('role') in ('teacher', 'admin'): + return render_template('exam_create.html') + # 允许杯赛负责人创建其杯赛的考试 + contest_id = request.args.get('contest_id', type=int) + if contest_id: + membership = ContestMembership.query.filter_by( + user_id=user['id'], contest_id=contest_id, role='owner').first() + if membership: + return render_template('exam_create.html') + # 检查用户是否是任何杯赛的负责人 + any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first() + if any_ownership: + return render_template('exam_create.html') + return redirect(url_for('exam_list')) + +@app.route('/exams/') +@login_required +def exam_detail(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + user = session.get('user') + # 密码验证 + if exam.access_password: + verified_key = f'exam_verified_{exam_id}' + if not session.get(verified_key): + return render_template('exam_detail.html', exam=exam, need_password=True, + questions=[], existing_submission=None, draft=None, + schedule_status='available') + existing = Submission.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) + # 检查预定时间 + now = datetime.utcnow() + schedule_status = 'available' # available, not_started, ended + if exam.scheduled_start and now < exam.scheduled_start: + schedule_status = 'not_started' + if exam.scheduled_end and now > exam.scheduled_end: + schedule_status = 'ended' + return render_template('exam_detail.html', exam=exam, questions=questions, + existing_submission=existing, draft=draft, + schedule_status=schedule_status, need_password=False) + +@app.route('/exams//result') +@login_required +def exam_result(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + user = session.get('user') + submission = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first() + if not submission: + return redirect(url_for('exam_detail', exam_id=exam_id)) + # 检查成绩公布时间 + score_hidden = False + if exam.score_release_time and datetime.utcnow() < exam.score_release_time: + if user.get('role') != 'teacher' and user.get('role') != 'admin': + score_hidden = True + questions = get_exam_questions(exam) + answers = submission.get_answers() + question_scores = submission.get_question_scores() + return render_template('exam_result.html', exam=exam, submission=submission, + questions=questions, answers=answers, question_scores=question_scores, + score_hidden=score_hidden) + +@app.route('/exams//submissions') +@login_required +def exam_submissions(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + user = session.get('user') + if not can_grade_exam(user, exam): + return redirect(url_for('exam_list')) + subs = Submission.query.filter_by(exam_id=exam_id).all() + stats = {} + if subs: + graded_subs = [s for s in subs if s.graded] + scores = [s.score for s in graded_subs] if graded_subs else [] + stats = { + 'total': len(subs), + 'graded': len(graded_subs), + 'ungraded': len(subs) - len(graded_subs), + 'avg_score': round(sum(scores) / len(scores), 1) if scores else 0, + 'max_score': max(scores) if scores else 0, + 'min_score': min(scores) if scores else 0, + } + next_ungraded = None + for s in subs: + if not s.graded: + next_ungraded = s.id + break + return render_template('exam_submissions.html', exam=exam, submissions=subs, stats=stats, next_ungraded=next_ungraded) + +@app.route('/exams//grade/') +@login_required +def exam_grade(exam_id, sub_id): + exam = Exam.query.get(exam_id) + sub = Submission.query.get(sub_id) + if not exam or not sub: + return redirect(url_for('exam_list')) + user = session.get('user') + if not can_grade_exam(user, exam): + return redirect(url_for('exam_list')) + next_ungraded = None + next_sub = Submission.query.filter_by(exam_id=exam_id, graded=False).filter(Submission.id != sub_id).first() + if next_sub: + next_ungraded = next_sub.id + questions = get_exam_questions(exam) + answers = sub.get_answers() + question_scores = sub.get_question_scores() + return render_template('exam_grade.html', exam=exam, submission=sub, + questions=questions, answers=answers, question_scores=question_scores, + next_ungraded=next_ungraded) + +@app.route('/exams//print') +@login_required +def exam_print(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + questions = get_exam_questions(exam) + return render_template('exam_print.html', exam=exam, questions=questions) + +@app.route('/forum') +def forum(): + return render_template('forum.html') + +@app.route('/profile') +@login_required +def profile(): + user_data = session.get('user') + user = User.query.get(user_data['id']) + post_count = Post.query.filter_by(author_id=user.id).count() + reply_count = Reply.query.filter_by(author_id=user.id).count() + likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0 + points = post_count * 10 + reply_count * 3 + likes_received * 2 + level = calc_level(points) + return render_template('profile.html', profile_user=user, post_count=post_count, + reply_count=reply_count, likes_received=likes_received, + points=points, level=level) + +# ========== 好友系统 API ========== + +@app.route('/api/user/friends') +@login_required +def api_user_friends(): + user_id = session['user']['id'] + friendships = Friend.query.filter( + ((Friend.user_id == user_id) | (Friend.friend_id == user_id)) & + (Friend.status == 'accepted') + ).all() + friends = [] + for f in friendships: + friend_user = User.query.get(f.friend_id if f.user_id == user_id else f.user_id) + if friend_user: + friends.append({ + 'id': friend_user.id, + 'name': friend_user.name, + 'avatar': friend_user.avatar or '', + 'created_at': f.created_at.strftime('%Y-%m-%d') + }) + return jsonify({'success': True, 'friends': friends}) + +@app.route('/api/friend/add', methods=['POST']) +@login_required +def api_add_friend(): + user_id = session['user']['id'] + data = request.get_json() + friend_id = data.get('friend_id') + if not friend_id or friend_id == user_id: + return jsonify({'success': False, 'message': '无效的好友ID'}), 400 + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | + ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) + ).first() + if existing: + return jsonify({'success': False, 'message': '已经是好友或已发送请求'}), 400 + friend_req = Friend(user_id=user_id, friend_id=friend_id, status='pending') + db.session.add(friend_req) + # 发送通知给对方 + sender_name = session['user'].get('name', '未知用户') + notif = Notification(user_id=friend_id, type='friend_request', content=f'{sender_name} 请求添加你为好友', from_user=sender_name) + db.session.add(notif) + db.session.commit() + return jsonify({'success': True, 'message': '好友请求已发送'}) + +@app.route('/api/friend/accept/', methods=['POST']) +@login_required +def api_accept_friend(request_id): + user_id = session['user']['id'] + req = Friend.query.get(request_id) + if not req or req.friend_id != user_id or req.status != 'pending': + return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404 + req.status = 'accepted' + db.session.commit() + return jsonify({'success': True, 'message': '好友已添加'}) + +@app.route('/api/friend/reject/', methods=['POST']) +@login_required +def api_reject_friend(request_id): + user_id = session['user']['id'] + req = Friend.query.get(request_id) + if not req or req.friend_id != user_id or req.status != 'pending': + return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404 + db.session.delete(req) + db.session.commit() + return jsonify({'success': True, 'message': '已拒绝'}) + +@app.route('/api/friend/requests') +@login_required +def api_friend_requests(): + user_id = session['user']['id'] + pending = Friend.query.filter_by(friend_id=user_id, status='pending').all() + result = [] + for f in pending: + u = User.query.get(f.user_id) + if u: + result.append({'id': f.id, 'user_id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'created_at': f.created_at.strftime('%Y-%m-%d')}) + return jsonify({'success': True, 'requests': result}) + +@app.route('/api/users/search') +@login_required +def api_users_search(): + user_id = session['user']['id'] + q = request.args.get('q', '').strip() + if not q: + return jsonify({'success': True, 'users': []}) + users = User.query.filter(User.name.contains(q), User.id != user_id).limit(20).all() + result = [] + for u in users: + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == u.id)) | + ((Friend.user_id == u.id) & (Friend.friend_id == user_id)) + ).first() + status = existing.status if existing else None + result.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'friend_status': status}) + return jsonify({'success': True, 'users': result}) + +# ========== 我的帖子 API ========== + +@app.route('/api/user/posts') +@login_required +def api_user_posts(): + user_id = session['user']['id'] + posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all() + data = [{ + 'id': p.id, + 'title': p.title, + 'content': p.content[:100] + '...' if len(p.content) > 100 else p.content, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + 'replies': p.replies_count, + 'likes': p.likes + } for p in posts] + return jsonify({'success': True, 'posts': data}) + +# ========== 试卷收藏 API ========== + +@app.route('/api/user/exam-bookmarks') +@login_required +def api_user_exam_bookmarks(): + user_id = session['user']['id'] + bookmarks = ExamBookmark.query.filter_by(user_id=user_id).order_by(ExamBookmark.created_at.desc()).all() + data = [] + for bm in bookmarks: + exam = bm.exam + if exam: + data.append({ + 'id': exam.id, + 'title': exam.title, + 'subject': exam.subject, + 'total_score': exam.total_score, + 'duration': exam.duration, + 'status': exam.status, + 'bookmarked_at': bm.created_at.strftime('%Y-%m-%d %H:%M') + }) + return jsonify({'success': True, 'bookmarks': data}) + +@app.route('/api/user/exam-history') +@login_required +def api_user_exam_history(): + user = session['user'] + subs = Submission.query.filter_by(user_id=user['id']).order_by(Submission.submitted_at.desc()).all() + now = datetime.utcnow() + result = [] + for s in subs: + exam = s.exam + if not exam: + continue + contest_name = '' + if exam.contest_id: + contest = Contest.query.get(exam.contest_id) + if contest: + contest_name = contest.name + # 成绩公布时间控制 + score_visible = True + if exam.score_release_time and now < exam.score_release_time and user.get('role') not in ('teacher', 'admin'): + score_visible = False + result.append({ + 'exam_id': exam.id, + 'title': exam.title, + 'subject': exam.subject, + 'total_score': exam.total_score, + 'contest_name': contest_name, + 'submitted_at': s.submitted_at.strftime('%Y-%m-%d %H:%M') if s.submitted_at else '', + 'graded': s.graded, + 'score': s.score if score_visible else None + }) + return jsonify({'success': True, 'history': result}) + +@app.route('/api/exams//bookmark', methods=['POST', 'DELETE']) +@login_required +def api_toggle_exam_bookmark(exam_id): + user_id = session['user']['id'] + if request.method == 'POST': + existing = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first() + if existing: + return jsonify({'success': False, 'message': '已收藏'}), 400 + bm = ExamBookmark(user_id=user_id, exam_id=exam_id) + db.session.add(bm) + db.session.commit() + return jsonify({'success': True, 'message': '收藏成功'}) + else: + bm = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first() + if bm: + db.session.delete(bm) + db.session.commit() + return jsonify({'success': True, 'message': '已取消收藏'}) + +@app.route('/api/user/avatar', methods=['POST']) +@login_required +def api_upload_avatar(): + user_data = session.get('user') + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + if not allowed_file(file.filename): + return jsonify({'success': False, 'message': '不支持的文件格式'}), 400 + file.seek(0, 2) + if file.tell() > MAX_FILE_SIZE: + return jsonify({'success': False, 'message': '文件不能超过10MB'}), 400 + file.seek(0) + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f"avatar_{user_data['id']}_{int(time.time())}.{ext}" + os.makedirs(UPLOAD_FOLDER, exist_ok=True) + filepath = os.path.join(UPLOAD_FOLDER, filename) + file.save(filepath) + url = f'/static/uploads/{filename}' + user = User.query.get(user_data['id']) + user.avatar = url + db.session.commit() + session['user'] = {**user_data, 'avatar': url} + return jsonify({'success': True, 'url': url}) + +@app.route('/api/user/change-name', methods=['POST']) +@login_required +def api_change_name(): + user_data = session['user'] + user = User.query.get(user_data['id']) + if not user: + return jsonify({'success': False, 'message': '用户不存在'}), 404 + # 每月仅可修改一次 + if user.name_changed_at: + from datetime import timedelta + if datetime.utcnow() - user.name_changed_at < timedelta(days=30): + remaining = 30 - (datetime.utcnow() - user.name_changed_at).days + return jsonify({'success': False, 'message': f'每月仅可修改一次用户名,还需等待{remaining}天'}), 400 + data = request.get_json() + new_name = data.get('name', '').strip() + if not new_name or len(new_name) > 80: + return jsonify({'success': False, 'message': '用户名不能为空且不超过80字符'}), 400 + if new_name == user.name: + return jsonify({'success': False, 'message': '新用户名与当前相同'}), 400 + existing = User.query.filter(User.name == new_name, User.id != user.id).first() + if existing: + return jsonify({'success': False, 'message': '该用户名已被使用'}), 400 + user.name = new_name + user.name_changed_at = datetime.utcnow() + db.session.commit() + session['user'] = {**user_data, 'name': new_name} + return jsonify({'success': True, 'message': '用户名修改成功', 'name': new_name}) + +# ========== 杯赛申请相关页面 ========== +@app.route('/apply-contest', methods=['GET', 'POST']) +@login_required +def apply_contest(): + if request.method == 'POST': + name = request.form.get('name') + organizer = request.form.get('organizer') + description = request.form.get('description') + contact = request.form.get('contact') + start_date = request.form.get('start_date') + end_date = request.form.get('end_date') + total_score = request.form.get('total_score', type=int) + responsible_person = request.form.get('responsible_person') + responsible_phone = request.form.get('responsible_phone') + responsible_email = request.form.get('responsible_email') + organization = request.form.get('organization') + if not all([name, organizer, description, contact, start_date, end_date, total_score, + responsible_person, responsible_phone, responsible_email, organization]): + flash('请填写所有必填项') + return redirect(url_for('apply_contest')) + if total_score < 1: + flash('满分分数必须大于0') + return redirect(url_for('apply_contest')) + app = ContestApplication( + user_id=session['user']['id'], + name=name, + organizer=organizer, + description=description, + contact=contact, + start_date=start_date, + end_date=end_date, + total_score=total_score, + responsible_person=responsible_person, + responsible_phone=responsible_phone, + responsible_email=responsible_email, + organization=organization + ) + db.session.add(app) + db.session.commit() + # 通知所有管理员有新的杯赛申请 + admins = User.query.filter_by(role='admin').all() + for admin_user in admins: + add_notification( + admin_user.id, + 'contest_application', + f'用户 {session["user"]["name"]} 申请举办杯赛「{name}」,请审核。', + from_user=session['user']['name'], + post_id=app.id + ) + flash('申请已提交,请等待管理员审核') + return redirect(url_for('contest_list')) + return render_template('apply_contest.html') + +@app.route('/admin/contest-applications') +@admin_required +def admin_contest_applications(): + apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all() + return render_template('admin_contest_applications.html', apps=apps) + +@app.route('/admin/contest-applications//approve', methods=['POST']) +@admin_required +def approve_contest_application(app_id): + app = ContestApplication.query.get_or_404(app_id) + if app.status != 'pending': + flash('该申请已处理') + return redirect(url_for('admin_contest_applications')) + contest = Contest( + name=app.name, + organizer=app.organizer, + description=app.description, + start_date=app.start_date or '待定', + end_date=app.end_date or '待定', + total_score=app.total_score or 150, + visible=False, + status='upcoming', + participants=0, + created_by=session['user']['id'], + responsible_person=app.responsible_person, + responsible_phone=app.responsible_phone, + responsible_email=app.responsible_email, + organization=app.organization + ) + db.session.add(contest) + db.session.flush() + membership = ContestMembership( + user_id=app.user_id, + contest_id=contest.id, + role='owner' + ) + db.session.add(membership) + app.status = 'approved' + app.reviewed_at = datetime.utcnow() + # 自动创建杯赛讨论群 + chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群', + creator_id=app.user_id, contest_id=contest.id) + db.session.add(chatroom) + db.session.flush() + db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=app.user_id, role='admin')) + # 通知申请人审核通过 + add_notification(app.user_id, 'contest_result', + f'您申请举办的杯赛「{app.name}」已通过审核!杯赛当前仅您和管理员可见,完善资料后请手动发布。', from_user='系统') + db.session.commit() + flash('申请已批准,杯赛已创建') + return redirect(url_for('admin_contest_applications')) + +@app.route('/admin/contest-applications//reject', methods=['POST']) +@admin_required +def reject_contest_application(app_id): + app = ContestApplication.query.get_or_404(app_id) + if app.status != 'pending': + flash('该申请已处理') + return redirect(url_for('admin_contest_applications')) + app.status = 'rejected' + app.reviewed_at = datetime.utcnow() + # 通知申请人审核未通过 + add_notification(app.user_id, 'contest_result', + f'您申请举办的杯赛「{app.name}」未通过审核。', from_user='系统') + db.session.commit() + flash('申请已拒绝') + return redirect(url_for('admin_contest_applications')) + +@app.route('/admin/contests/create', methods=['GET', 'POST']) +@admin_required +def admin_create_contest(): + if request.method == 'POST': + name = request.form.get('name', '').strip() + organizer = request.form.get('organizer', '').strip() + description = request.form.get('description', '').strip() + start_date = request.form.get('start_date', '').strip() or '待定' + end_date = request.form.get('end_date', '').strip() or '待定' + total_score = int(request.form.get('total_score', 150) or 150) + status = request.form.get('status', 'upcoming') + responsible_person = request.form.get('responsible_person', '').strip() + responsible_phone = request.form.get('responsible_phone', '').strip() + responsible_email = request.form.get('responsible_email', '').strip() + organization = request.form.get('organization', '').strip() + if not name or not organizer: + flash('杯赛名称和主办方为必填项') + return render_template('admin_create_contest.html') + contest = Contest( + name=name, organizer=organizer, description=description, + start_date=start_date, end_date=end_date, total_score=total_score, + visible=True, status=status, participants=0, + created_by=session['user']['id'], + responsible_person=responsible_person, + responsible_phone=responsible_phone, + responsible_email=responsible_email, + organization=organization + ) + db.session.add(contest) + db.session.flush() + membership = ContestMembership( + user_id=session['user']['id'], + contest_id=contest.id, + role='owner' + ) + db.session.add(membership) + chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群', + creator_id=session['user']['id'], contest_id=contest.id) + db.session.add(chatroom) + db.session.flush() + db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=session['user']['id'], role='admin')) + db.session.commit() + flash('杯赛已创建并发布') + return redirect(url_for('contest_detail', contest_id=contest.id)) + return render_template('admin_create_contest.html') + +# ========== 通知 API ========== +@app.route('/api/notifications') +@login_required +def api_notifications(): + notifs = Notification.query.filter_by(user_id=session['user']['id']).order_by(Notification.created_at.desc()).limit(50).all() + result = [] + for n in notifs: + item = { + 'id': n.id, + 'type': n.type, + 'content': n.content, + 'from_user': n.from_user, + 'post_id': n.post_id, + 'read': n.read, + 'created_at': n.created_at.strftime('%Y-%m-%d %H:%M') + } + if n.type == 'contest_application' and n.post_id: + ca = ContestApplication.query.get(n.post_id) + if ca: + item['application_status'] = ca.status + if n.type == 'teacher_application' and n.post_id: + ta = TeacherApplication.query.get(n.post_id) + if ta: + item['application_status'] = ta.status + item['application_id'] = ta.id + contest = Contest.query.get(ta.contest_id) + item['contest_name'] = contest.name if contest else '' + applicant = User.query.get(ta.user_id) + item['applicant_name'] = applicant.name if applicant else '' + result.append(item) + return jsonify({'success': True, 'notifications': result}) + +@app.route('/api/notifications/unread-count') +@login_required +def api_notifications_unread_count(): + count = Notification.query.filter_by(user_id=session['user']['id'], read=False).count() + return jsonify({'success': True, 'count': count}) + +@app.route('/api/notifications//read', methods=['POST']) +@login_required +def api_mark_notification_read(nid): + n = Notification.query.get_or_404(nid) + if n.user_id != session['user']['id']: + return jsonify({'success': False}), 403 + n.read = True + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/notifications/read-all', methods=['POST']) +@login_required +def api_mark_all_notifications_read(): + Notification.query.filter_by(user_id=session['user']['id'], read=False).update({'read': True}) + db.session.commit() + return jsonify({'success': True}) + +@app.route('/notifications') +@login_required +def notifications_page(): + return redirect(url_for('chat')) + +# ========== 系统公告管理 API ========== +@app.route('/api/system-notifications') +def api_system_notifications(): + """获取系统公告列表(所有人可见)""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + q = SystemNotification.query.order_by(SystemNotification.pinned.desc(), SystemNotification.created_at.desc()) + pagination = q.paginate(page=page, per_page=per_page, error_out=False) + items = [] + for sn in pagination.items: + author = User.query.get(sn.author_id) + items.append({ + 'id': sn.id, 'title': sn.title, 'content': sn.content, + 'pinned': sn.pinned, + 'author_name': author.name if author else '系统', + 'created_at': sn.created_at.strftime('%Y-%m-%d %H:%M'), + 'updated_at': sn.updated_at.strftime('%Y-%m-%d %H:%M') if sn.updated_at else '' + }) + return jsonify({'success': True, 'notifications': items, 'total': pagination.total, 'pages': pagination.pages, 'page': page}) + +@app.route('/api/system-notifications', methods=['POST']) +@admin_required +def api_create_system_notification(): + """管理员发布系统公告""" + data = request.get_json() or {} + title = (data.get('title') or '').strip() + content = (data.get('content') or '').strip() + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + sn = SystemNotification( + title=title, content=content, + author_id=session['user']['id'], + pinned=bool(data.get('pinned', False)) + ) + db.session.add(sn) + db.session.commit() + # 给所有用户发送个人通知 + admin_name = session['user'].get('name', '管理员') + all_users = User.query.all() + for u in all_users: + add_notification(u.id, 'system_announcement', f'【{title}】{content}', from_user=admin_name) + return jsonify({'success': True, 'message': '通知已发布', 'id': sn.id}) + +@app.route('/api/system-notifications/', methods=['PUT']) +@admin_required +def api_update_system_notification(sn_id): + """管理员修改系统公告""" + sn = SystemNotification.query.get_or_404(sn_id) + data = request.get_json() or {} + if 'title' in data: + sn.title = data['title'].strip() + if 'content' in data: + sn.content = data['content'].strip() + if 'pinned' in data: + sn.pinned = bool(data['pinned']) + db.session.commit() + return jsonify({'success': True, 'message': '公告已更新'}) + +@app.route('/api/system-notifications/', methods=['DELETE']) +@admin_required +def api_delete_system_notification(sn_id): + """管理员删除系统公告""" + sn = SystemNotification.query.get_or_404(sn_id) + db.session.delete(sn) + db.session.commit() + return jsonify({'success': True, 'message': '公告已删除'}) + +@app.route('/admin/notifications') +@admin_required +def admin_notifications(): + return render_template('admin_notifications.html') + +@app.route('/api/admin/search-users') +@admin_required +def api_admin_search_users(): + q = request.args.get('q', '').strip() + if not q: + return jsonify({'success': True, 'users': []}) + users = User.query.filter(User.name.contains(q)).limit(20).all() + return jsonify({'success': True, 'users': [ + {'id': u.id, 'name': u.name, 'role': u.role} for u in users + ]}) + +@app.route('/api/admin/send-private-notification', methods=['POST']) +@admin_required +def api_admin_send_private_notification(): + data = request.get_json() + user_ids = data.get('user_ids', []) + content = data.get('content', '').strip() + if not user_ids or not content: + return jsonify({'success': False, 'message': '请选择用户并输入内容'}), 400 + admin_name = session['user'].get('name', '管理员') + for uid in user_ids: + add_notification(uid, 'system_announcement', content, from_user=admin_name) + return jsonify({'success': True, 'message': f'已发送给 {len(user_ids)} 位用户'}) + +@app.route('/api/contest-applications//approve', methods=['POST']) +@admin_required +def api_approve_contest(app_id): + ca = ContestApplication.query.get_or_404(app_id) + if ca.status != 'pending': + return jsonify({'success': False, 'message': '该申请已处理'}), 400 + contest = Contest( + name=ca.name, organizer=ca.organizer, description=ca.description, + start_date='待定', end_date='待定', status='upcoming', + participants=0, created_by=session['user']['id'] + ) + db.session.add(contest) + db.session.flush() + db.session.add(ContestMembership(user_id=ca.user_id, contest_id=contest.id, role='owner')) + ca.status = 'approved' + ca.reviewed_at = datetime.utcnow() + chatroom = ChatRoom(type='contest', name=contest.name + ' 讨论群', + creator_id=ca.user_id, contest_id=contest.id) + db.session.add(chatroom) + db.session.flush() + db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=ca.user_id, role='admin')) + add_notification(ca.user_id, 'contest_result', + f'您申请举办的杯赛「{ca.name}」已通过审核!', from_user='系统') + db.session.commit() + return jsonify({'success': True, 'message': '已批准'}) + +@app.route('/api/teacher-applications//approve', methods=['POST']) +@teacher_required +def api_approve_teacher(app_id): + user = session['user'] + ta = TeacherApplication.query.get_or_404(app_id) + if ta.status != 'pending': + return jsonify({'success': False, 'message': '该申请已处理'}), 400 + # 权限检查:管理员或该杯赛负责人 + if user['role'] != 'admin': + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first() + if not membership: + return jsonify({'success': False, 'message': '无权限'}), 403 + existing = ContestMembership.query.filter_by(user_id=ta.user_id, contest_id=ta.contest_id).first() + if existing: + ta.status = 'rejected' + ta.reviewed_at = datetime.utcnow() + ta.reviewed_by = user['id'] + db.session.commit() + return jsonify({'success': False, 'message': '用户已是杯赛成员'}), 400 + + # 生成一次性邀请码 + code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + while InviteCode.query.filter_by(code=code_str).first(): + code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + + invite = InviteCode(code=code_str, user_id=ta.user_id, application_id=ta.id) + db.session.add(invite) + + ta.status = 'approved' + ta.reviewed_at = datetime.utcnow() + ta.reviewed_by = user['id'] + + contest = Contest.query.get(ta.contest_id) + contest_name = contest.name if contest else '' + + # 通过私聊发送邀请码 + msg_content = ( + f'恭喜!您申请成为杯赛「{contest_name}」老师已通过审核。\n' + f'请使用以下邀请码激活您的教师身份:\n\n' + f'🎫 邀请码:{code_str}\n\n' + f'请前往「申请成为老师」页面,在邀请码输入框中输入此码完成激活。\n' + f'注意:此邀请码仅限您本人使用,且仅限一次。' + ) + send_private_message(user['id'], ta.user_id, msg_content, msg_type='system') + + add_notification(ta.user_id, 'teacher_result', + f'您申请成为杯赛「{contest_name}」老师已通过审核,请查看私聊消息获取邀请码。', + from_user='系统') + db.session.commit() + return jsonify({'success': True, 'message': '已批准,邀请码已通过私聊发送给老师'}) + +@app.route('/api/teacher-applications//reject', methods=['POST']) +@teacher_required +def api_reject_teacher(app_id): + user = session['user'] + ta = TeacherApplication.query.get_or_404(app_id) + if ta.status != 'pending': + return jsonify({'success': False, 'message': '该申请已处理'}), 400 + if user['role'] != 'admin': + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id, role='owner').first() + if not membership: + return jsonify({'success': False, 'message': '无权限'}), 403 + ta.status = 'rejected' + ta.reviewed_at = datetime.utcnow() + ta.reviewed_by = user['id'] + contest = Contest.query.get(ta.contest_id) + add_notification(ta.user_id, 'teacher_result', + f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。', + from_user='系统') + db.session.commit() + return jsonify({'success': True, 'message': '已拒绝'}) + +@app.route('/api/contest-applications//reject', methods=['POST']) +@admin_required +def api_reject_contest(app_id): + ca = ContestApplication.query.get_or_404(app_id) + if ca.status != 'pending': + return jsonify({'success': False, 'message': '该申请已处理'}), 400 + ca.status = 'rejected' + ca.reviewed_at = datetime.utcnow() + add_notification(ca.user_id, 'contest_result', + f'您申请举办的杯赛「{ca.name}」未通过审核。', from_user='系统') + db.session.commit() + return jsonify({'success': True, 'message': '已拒绝'}) + +# ========== 教师申请路由(针对具体杯赛)========== +@app.route('/apply-teacher', methods=['GET', 'POST']) +@login_required +def apply_teacher(): + if request.method == 'POST': + contest_id = request.form.get('contest_id') + name = request.form.get('name', '').strip() + email = request.form.get('email', '').strip() + reason = request.form.get('reason', '').strip() + + if not contest_id or not name or not email or not reason: + flash('请填写完整信息', 'error') + return redirect(url_for('apply_teacher')) + + contest = Contest.query.get(contest_id) + if not contest: + flash('杯赛不存在', 'error') + return redirect(url_for('apply_teacher')) + + user = session['user'] + # 检查是否已经申请过该杯赛且为pending + existing = TeacherApplication.query.filter_by(user_id=user['id'], contest_id=contest_id, status='pending').first() + if existing: + flash('您已提交过该杯赛的申请,请耐心等待审核', 'error') + return redirect(url_for('apply_teacher')) + + # 检查是否已经是该杯赛的老师或负责人 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if membership and membership.role in ['owner', 'teacher']: + flash('您已经是该杯赛的老师或负责人', 'error') + return redirect(url_for('contest_detail', contest_id=contest_id)) + + # 创建申请记录 + appli = TeacherApplication( + user_id=user['id'], + contest_id=contest_id, + name=name, + email=email, + reason=reason + ) + db.session.add(appli) + db.session.commit() + + # 通知杯赛负责人 + owner_memberships = ContestMembership.query.filter_by(contest_id=contest_id, role='owner').all() + for om in owner_memberships: + add_notification(om.user_id, 'teacher_application', + f'用户 {name} 申请成为杯赛「{contest.name}」的老师,请审核。', + from_user=user['name'], post_id=appli.id) + # 通知所有管理员 + admins = User.query.filter_by(role='admin').all() + for admin_user in admins: + add_notification(admin_user.id, 'teacher_application', + f'用户 {name} 申请成为杯赛「{contest.name}」的老师,请审核。', + from_user=user['name'], post_id=appli.id) + + flash('申请已提交,管理员或杯赛负责人会尽快审核', 'success') + return redirect(url_for('contest_detail', contest_id=contest_id)) + + # GET 请求:显示申请表单,可传入 contest_id 预选 + contest_id = request.args.get('contest_id', type=int) + contests = Contest.query.all() + selected_contest = Contest.query.get(contest_id) if contest_id else None + return render_template('apply_teacher.html', contests=contests, selected_contest=selected_contest) + +# ========== 管理后台教师申请审核 ========== +@app.route('/admin/teacher-applications') +@teacher_required +def admin_teacher_applications(): + user = session['user'] + # 管理员可见所有申请,杯赛负责人只能看到自己负责杯赛的申请 + if user['role'] == 'admin': + apps = TeacherApplication.query.filter_by(status='pending').order_by(TeacherApplication.applied_at.desc()).all() + else: + # 获取用户作为负责人的所有杯赛ID + owned_contests = [m.contest_id for m in ContestMembership.query.filter_by(user_id=user['id'], role='owner').all()] + if not owned_contests: + apps = [] + else: + apps = TeacherApplication.query.filter( + TeacherApplication.status == 'pending', + TeacherApplication.contest_id.in_(owned_contests) + ).order_by(TeacherApplication.applied_at.desc()).all() + return render_template('admin_teacher_applications.html', apps=apps) + +@app.route('/admin/teacher-applications//approve', methods=['POST']) +@teacher_required +def approve_teacher_application(app_id): + user = session['user'] + app = TeacherApplication.query.get_or_404(app_id) + if app.status != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('admin_teacher_applications')) + + # 权限检查:管理员或该杯赛负责人 + if user['role'] != 'admin': + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first() + if not membership: + flash('您没有权限审批此申请', 'error') + return redirect(url_for('admin_teacher_applications')) + + # 检查是否已经是该杯赛成员 + existing = ContestMembership.query.filter_by(user_id=app.user_id, contest_id=app.contest_id).first() + if existing: + app.status = 'rejected' # 已存在,无法再次添加 + app.reviewed_at = datetime.utcnow() + app.reviewed_by = user['id'] + db.session.commit() + flash('用户已是杯赛成员,申请已拒绝', 'error') + return redirect(url_for('admin_teacher_applications')) + + # 生成一次性邀请码 TC-XXXXXXXX + code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + while InviteCode.query.filter_by(code=code_str).first(): + code_str = 'TC-' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + + invite = InviteCode(code=code_str, user_id=app.user_id, application_id=app.id) + db.session.add(invite) + + app.status = 'approved' + app.reviewed_at = datetime.utcnow() + app.reviewed_by = user['id'] + + contest = Contest.query.get(app.contest_id) + contest_name = contest.name if contest else '' + + # 通过私聊发送邀请码给老师 + msg_content = ( + f'恭喜!您申请成为杯赛「{contest_name}」老师已通过审核。\n' + f'请使用以下邀请码激活您的教师身份:\n\n' + f'🎫 邀请码:{code_str}\n\n' + f'请前往「申请成为老师」页面,在邀请码输入框中输入此码完成激活。\n' + f'注意:此邀请码仅限您本人使用,且仅限一次。' + ) + send_private_message(user['id'], app.user_id, msg_content, msg_type='system') + + # 通知申请人去查看消息 + add_notification(app.user_id, 'teacher_result', + f'您申请成为杯赛「{contest_name}」老师已通过审核,请查看私聊消息获取邀请码。', + from_user='系统') + db.session.commit() + + flash('申请已批准,邀请码已通过私聊发送给老师', 'success') + return redirect(url_for('admin_teacher_applications')) + +@app.route('/admin/teacher-applications//reject', methods=['POST']) +@teacher_required +def reject_teacher_application(app_id): + user = session['user'] + app = TeacherApplication.query.get_or_404(app_id) + if app.status != 'pending': + flash('该申请已处理', 'error') + return redirect(url_for('admin_teacher_applications')) + + # 权限检查:管理员或该杯赛负责人 + if user['role'] != 'admin': + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=app.contest_id, role='owner').first() + if not membership: + flash('您没有权限审批此申请', 'error') + return redirect(url_for('admin_teacher_applications')) + + app.status = 'rejected' + app.reviewed_at = datetime.utcnow() + app.reviewed_by = user['id'] + # 通知申请人 + contest = Contest.query.get(app.contest_id) + add_notification(app.user_id, 'teacher_result', + f'您申请成为杯赛「{contest.name if contest else ""}」老师未通过审核。', + from_user='系统') + db.session.commit() + + flash('申请已拒绝', 'success') + return redirect(url_for('admin_teacher_applications')) + +# ========== API 路由 ========== + +@app.route('/api/activate-invite-code', methods=['POST']) +@login_required +def api_activate_invite_code(): + """老师输入邀请码激活教师身份""" + user = session['user'] + data = request.get_json() or {} + code_str = (data.get('code') or '').strip() + if not code_str: + return jsonify({'success': False, 'message': '请输入邀请码'}), 400 + + invite = InviteCode.query.filter_by(code=code_str).first() + if not invite: + return jsonify({'success': False, 'message': '邀请码不存在'}), 404 + if invite.user_id != user['id']: + return jsonify({'success': False, 'message': '此邀请码不属于您'}), 403 + if invite.used: + return jsonify({'success': False, 'message': '此邀请码已被使用'}), 400 + + ta = TeacherApplication.query.get(invite.application_id) + if not ta: + return jsonify({'success': False, 'message': '关联的申请记录不存在'}), 404 + + # 检查是否已是杯赛成员 + existing = ContestMembership.query.filter_by(user_id=user['id'], contest_id=ta.contest_id).first() + if existing: + return jsonify({'success': False, 'message': '您已是该杯赛成员'}), 400 + + # 创建杯赛成员(teacher) + membership = ContestMembership(user_id=user['id'], contest_id=ta.contest_id, role='teacher') + db.session.add(membership) + + # 自动加入杯赛讨论群 + chatroom = ChatRoom.query.filter_by(contest_id=ta.contest_id).first() + if chatroom: + existing_chat = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first() + if not existing_chat: + db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member')) + + # 标记邀请码已使用 + invite.used = True + invite.used_at = datetime.utcnow() + db.session.commit() + + contest = Contest.query.get(ta.contest_id) + return jsonify({'success': True, 'message': f'激活成功!您已成为杯赛「{contest.name if contest else ""}」的老师。'}) + +@app.route('/api/captcha') +def api_captcha(): + global captcha_store + captcha_text = ''.join(random.choices(string.digits, k=4)) + captcha_id = generate_captcha_id() + image = ImageCaptcha(width=150, height=50, font_sizes=[40]) + data = image.generate(captcha_text) + import base64 + img_base64 = base64.b64encode(data.read()).decode('utf-8') + captcha_store[captcha_id] = { + 'text': captcha_text.lower(), + 'expires': time.time() + 300 + } + expired = [k for k, v in captcha_store.items() if time.time() > v['expires']] + for k in expired: + del captcha_store[k] + return jsonify({'captchaId': captcha_id, 'img': img_base64}) + +@app.route('/api/send-email-code', methods=['POST']) +def api_send_email_code(): + global captcha_store, email_codes + data = request.get_json() + email = data.get('email', '') + captcha_id = data.get('captchaId', '') + captcha_text = data.get('captchaText', '') + + if not email: + return jsonify({'success': False, 'message': '邮箱不能为空'}), 400 + if not captcha_id or not captcha_text: + return jsonify({'success': False, 'message': '请输入图形验证码'}), 400 + + record = captcha_store.get(captcha_id) + if not record or time.time() > record['expires']: + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码已过期', 'refreshCaptcha': True}), 400 + if record['text'] != captcha_text.lower(): + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400 + captcha_store.pop(captcha_id, None) + + code = generate_code(6) + email_codes[email] = {'code': code, 'expires': time.time() + 600} + + try: + send_email(email, '您的注册验证码', + f'

您的验证码是:{code},10分钟内有效。

') + print(f'邮箱验证码已发送到 {email}: {code}') + return jsonify({'success': True, 'message': '验证码已发送到邮箱'}) + except Exception as e: + print(f'发送邮件失败: {e}') + email_codes.pop(email, None) + return jsonify({'success': False, 'message': f'发送邮件失败: {str(e)}'}), 500 + +# ========== 注册相关 API ========== + +@app.route('/api/register', methods=['POST']) +def api_register(): + """邮箱注册(需绑定手机号)""" + global email_codes + data = request.get_json() + name = data.get('name', '') + email = data.get('email', '') + phone = data.get('phone', '') + password = data.get('password', '') + email_code = data.get('emailCode', '') + + if not name or not email or not password or not phone: + return jsonify({'success': False, 'message': '请填写完整信息'}), 400 + if not email_code: + return jsonify({'success': False, 'message': '请输入邮箱验证码'}), 400 + if not phone.isdigit() or len(phone) != 11: + return jsonify({'success': False, 'message': '手机号格式不正确'}), 400 + + record = email_codes.get(email) + if not record or time.time() > record['expires']: + email_codes.pop(email, None) + return jsonify({'success': False, 'message': '邮箱验证码已过期', 'refreshCaptcha': True}), 400 + if record['code'] != email_code: + record.setdefault('attempts', 0) + record['attempts'] += 1 + if record['attempts'] >= 5: + email_codes.pop(email, None) + return jsonify({'success': False, 'message': '验证码错误次数过多,请重新获取', 'refreshCaptcha': True}), 400 + return jsonify({'success': False, 'message': f'邮箱验证码错误,还可尝试{5 - record["attempts"]}次'}), 400 + email_codes.pop(email, None) + + if User.query.filter_by(email=email).first(): + return jsonify({'success': False, 'message': '该邮箱已注册'}), 400 + if User.query.filter_by(phone=phone).first(): + return jsonify({'success': False, 'message': '该手机号已绑定其他账户'}), 400 + + role = 'student' + user = User(name=name, email=email, phone=phone, password=password, role=role) + db.session.add(user) + db.session.commit() + + user_data = {'name': user.name, 'email': user.email, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''} + session['user'] = user_data + return jsonify({'success': True, 'message': '注册成功', 'user': user_data}) + +@app.route('/api/register-mobile', methods=['POST']) +def api_register_mobile(): + """手机号注册""" + data = request.get_json() + name = data.get('name', '') + phone = data.get('phone', '') + password = data.get('password', '') + sms_code = data.get('smsCode', '') + + if not name or not phone or not password or not sms_code: + return jsonify({'success': False, 'message': '请填写完整信息'}), 400 + if not phone.isdigit() or len(phone) != 11: + return jsonify({'success': False, 'message': '手机号格式不正确'}), 400 + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('CheckSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('VerifyCode', sms_code) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}') + model = result.get('Model', {}) + if not (result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS'): + return jsonify({'success': False, 'message': '短信验证码错误或已过期'}), 400 + except Exception as e: + print(f'验证短信验证码出错: {e}') + return jsonify({'success': False, 'message': '短信验证码验证失败'}), 500 + + if User.query.filter_by(phone=phone).first(): + return jsonify({'success': False, 'message': '该手机号已注册'}), 400 + + role = 'student' + user = User(name=name, phone=phone, password=password, role=role) + db.session.add(user) + db.session.commit() + + user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''} + session['user'] = user_data + return jsonify({'success': True, 'message': '注册成功', 'user': user_data}) + +# ========== 登录相关 API ========== + +@app.route('/api/login', methods=['POST']) +def api_login(): + data = request.get_json() + email = data.get('email', '') + password = data.get('password', '') + + if not email or not password: + return jsonify({'success': False, 'message': '请输入邮箱和密码'}), 400 + + user = User.query.filter_by(email=email).first() + if not user: + return jsonify({'success': False, 'message': '账号不存在,请先注册'}), 400 + if user.password != password: + return jsonify({'success': False, 'message': '密码错误'}), 400 + + user_data = {'name': user.name, 'email': user.email, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''} + session['user'] = user_data + return jsonify({'success': True, 'message': '登录成功', 'user': user_data}) + +@app.route('/api/send-sms', methods=['POST']) +def api_send_sms(): + global captcha_store + data = request.get_json() + phone = data.get('phone', '') + captcha_id = data.get('captchaId', '') + captcha_text = data.get('captchaText', '') + + if not phone: + return jsonify({'success': False, 'message': '手机号不能为空'}), 400 + if not captcha_id or not captcha_text: + return jsonify({'success': False, 'message': '请输入图形验证码'}), 400 + + record = captcha_store.get(captcha_id) + if not record or time.time() > record['expires']: + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码已过期,请刷新', 'refreshCaptcha': True}), 400 + if record['text'] != captcha_text.lower(): + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400 + captcha_store.pop(captcha_id, None) + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('SendSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('SignName', os.getenv('SIGN_NAME', '')) + req.add_query_param('TemplateCode', os.getenv('TEMPLATE_CODE', '')) + req.add_query_param('TemplateParam', json.dumps({'code': '##code##', 'min': '5'})) + req.add_query_param('CountryCode', '86') + req.add_query_param('ValidTime', 300) + req.add_query_param('Interval', 60) + req.add_query_param('ReturnVerifyCode', True) + req.add_query_param('CodeType', 1) + req.add_query_param('CodeLength', 4) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'号码认证服务返回: {json.dumps(result, ensure_ascii=False, indent=2)}') + + if result.get('Code') == 'OK': + resp_data = {'success': True, 'message': '验证码发送成功'} + model = result.get('Model', {}) + if model.get('VerifyCode'): + resp_data['mockCode'] = model['VerifyCode'] + print(f'验证码: {model["VerifyCode"]}') + return jsonify(resp_data) + else: + print(f'发送失败: {result.get("Message")}') + return jsonify({'success': False, 'message': result.get('Message', '发送失败')}), 500 + except Exception as e: + print(f'发送短信出错: {e}') + return jsonify({'success': False, 'message': f'发送短信出错: {str(e)}'}), 500 + +@app.route('/api/verify-code', methods=['POST']) +def api_verify_code(): + data = request.get_json() + phone = data.get('phone', '') + code = data.get('code', '') + + if not phone or not code: + return jsonify({'success': False, 'message': '手机号和验证码不能为空'}), 400 + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('CheckSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('VerifyCode', code) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}') + + model = result.get('Model', {}) + if result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS': + user = User.query.filter_by(phone=phone).first() + if not user: + user = User( + name=f'用户{phone[-4:]}', + phone=phone, + password=generate_code(8), + role='student' + ) + db.session.add(user) + db.session.commit() + user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id, 'avatar': user.avatar or ''} + session['user'] = user_data + return jsonify({'success': True, 'message': '验证成功', 'user': user_data}) + else: + msg = '验证码已过期' if model.get('VerifyResult') == 'UNKNOWN' else '验证码错误' + return jsonify({'success': False, 'message': msg}), 400 + except Exception as e: + print(f'验证出错: {e}') + return jsonify({'success': False, 'message': '验证码错误或已过期'}), 400 + +# ========== 图片上传 API ========== + +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads') +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/api/upload', methods=['POST']) +def api_upload(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + if 'file' not in request.files: + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + file = request.files['file'] + if file.filename == '': + return jsonify({'success': False, 'message': '没有选择文件'}), 400 + if not allowed_file(file.filename): + return jsonify({'success': False, 'message': '不支持的文件格式,请上传 PNG/JPG/GIF/WebP'}), 400 + # 检查文件大小 + file.seek(0, 2) + size = file.tell() + file.seek(0) + if size > MAX_FILE_SIZE: + return jsonify({'success': False, 'message': '文件大小不能超过10MB'}), 400 + # 生成唯一文件名 + ext = file.filename.rsplit('.', 1)[1].lower() + filename = f"{int(time.time())}_{random.randint(1000,9999)}.{ext}" + os.makedirs(UPLOAD_FOLDER, exist_ok=True) + filepath = os.path.join(UPLOAD_FOLDER, filename) + file.save(filepath) + url = f'/static/uploads/{filename}' + return jsonify({'success': True, 'url': url, 'filename': filename}) + +# ========== AI 诊断接口(测试完可删除) ========== +@app.route('/api/ai-diagnose') +def api_ai_diagnose(): + """诊断 DashScope API 连通性""" + import httpx + results = {} + + # 1. 检查 API key 配置 + api_key = os.getenv('DASHSCOPE_API_KEY', '') + if not api_key or api_key == 'sk-xxxxxxxxxxxxx': + results['api_key'] = '未配置' + return jsonify(results) + results['api_key'] = f'{api_key[:8]}...{api_key[-4:]}' if len(api_key) > 12 else '已配置(太短,可能无效)' + + # 2. 测试网络连通性(跳过SSL验证) + try: + resp = httpx.get('https://dashscope.aliyuncs.com', timeout=10, follow_redirects=True, verify=False) + results['network'] = f'连通,状态码 {resp.status_code}' + except Exception as e: + results['network'] = f'不通: {str(e)}' + return jsonify(results) + + # 3. 测试 API 调用(最简单的请求) + try: + client = DashScopeClient( + api_key=api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + http_client=httpx.Client(verify=False) + ) + response = client.chat.completions.create( + model="qwen-turbo", + messages=[{"role": "user", "content": "回复OK"}], + max_tokens=10 + ) + results['api_call'] = f'成功: {response.choices[0].message.content.strip()}' + except Exception as e: + results['api_call'] = f'失败: {type(e).__name__}: {str(e)}' + + # 4. 测试视觉模型 + try: + client2 = DashScopeClient( + api_key=api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + http_client=httpx.Client(verify=False) + ) + response2 = client2.chat.completions.create( + model="qwen-vl-plus", + messages=[{"role": "user", "content": [{"type": "text", "text": "回复OK"}]}], + max_tokens=10 + ) + results['vision_model'] = f'成功: {response2.choices[0].message.content.strip()}' + except Exception as e: + results['vision_model'] = f'失败: {type(e).__name__}: {str(e)}' + + return jsonify(results) + +# ========== PDF 智能识别 ========== + +@app.route('/api/parse-pdf', methods=['POST']) +def api_parse_pdf(): + user = session.get('user') + if not user or user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '仅教师可使用此功能'}), 403 + if 'file' not in request.files: + return jsonify({'success': False, 'message': '请选择PDF文件'}), 400 + file = request.files['file'] + if not file.filename.lower().endswith('.pdf'): + return jsonify({'success': False, 'message': '仅支持PDF格式文件'}), 400 + # 检查文件大小 ≤ 20MB + file.seek(0, 2) + size = file.tell() + file.seek(0) + if size > 20 * 1024 * 1024: + return jsonify({'success': False, 'message': '文件大小不能超过20MB'}), 400 + + api_key = os.getenv('DASHSCOPE_API_KEY', '') + if not api_key or api_key == 'sk-xxxxxxxxxxxxx': + return jsonify({'success': False, 'message': '未配置AI接口密钥,请联系管理员在.env中设置DASHSCOPE_API_KEY'}), 500 + + import base64 as _b64 + import re as _pdf_re + import uuid as _uuid + + # 读取PDF,将每页渲染为图片 + 提取文本 + try: + pdf_bytes = file.read() + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + page_images = [] # base64 编码的页面图片(发给AI) + page_texts = [] # 每页提取的文本 + page_image_urls = [] # 保存到磁盘的页面图片URL + page_sizes = [] # 每页的像素尺寸 (w, h) + max_pages = 20 # 最多处理20页 + _dpi_scale = 150 / 72 + for i, page in enumerate(doc): + if i >= max_pages: + break + # 提取文本 + page_texts.append(page.get_text("text", sort=True)) + # 渲染页面为图片(150 DPI,平衡清晰度和大小) + mat = fitz.Matrix(_dpi_scale, _dpi_scale) + pix = page.get_pixmap(matrix=mat) + img_bytes = pix.tobytes("png") + img_b64 = _b64.b64encode(img_bytes).decode('utf-8') + page_images.append(img_b64) + page_sizes.append((pix.width, pix.height)) + # 保存页面图片到磁盘,供题目引用 + img_filename = f"pdf_page_{_uuid.uuid4().hex[:8]}_{i+1}.png" + img_path = os.path.join(UPLOAD_FOLDER, img_filename) + with open(img_path, 'wb') as f: + f.write(img_bytes) + page_image_urls.append(f'/static/uploads/{img_filename}') + doc.close() + except Exception as e: + return jsonify({'success': False, 'message': f'PDF读取失败: {str(e)}'}), 400 + + all_text = "\n".join(page_texts) + has_text = bool(all_text.strip()) + + # 判断是否包含图形(文本量少或有几何关键词) + has_figures = not has_text or len(all_text.strip()) < 200 + figure_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形', + '圆', '棱', '锥', '柱', '球', '坐标', '函数图', '图像', '图形', + '电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图', + '表格', '数轴', '向量', '抛物线', '双曲线', '椭圆', '正弦', '余弦', + '结构式', '分子式', '装置图', '流程图', '框图', '韦恩图'] + if any(kw in all_text for kw in figure_keywords): + has_figures = True + + # 构造系统提示 + system_prompt = """你是一个顶级专业的试卷解析助手,擅长处理包含几何图形、数学公式、物理电路图、化学结构式等的各类试卷。你必须做到100%准确识别每一道题目,不遗漏、不合并、不截断。 + +关键规则: +- 仔细逐页观察每页图片,识别所有题目(包括大题下的小题),包括图片中的几何图形、坐标图、函数图像等 +- 如果题目包含图形(如立体几何、平面几何、函数图像、电路图、实验装置图等),在content中用文字非常详细地描述图形内容,例如:"(图:正三棱柱ABC-A₁B₁C₁,其中AB=2,AA₁=3,M为BB₁中点,连接AM、CM)" +- 所有数学公式必须用LaTeX格式,并用 $ 符号包裹。例如: + - 行内公式:$x^2+2x+1=0$、$\\sqrt{2}$、$\\frac{a}{b}$、$\\int_0^1 f(x)dx$ + - 分段函数:$f(x)=\\begin{cases} 2^x+1, & x>1 \\\\ ax^2+(b-3)x, & x\\leq 1 \\end{cases}$ + - 希腊字母:$\\alpha$、$\\beta$、$\\theta$、$\\pi$ + - 集合符号:$\\in$、$\\subset$、$\\cup$、$\\cap$、$\\emptyset$ + - 几何符号:$\\perp$、$\\parallel$、$\\triangle$、$\\angle$ + - 不等号:$\\geq$、$\\leq$、$\\neq$、$\\pm$、$\\infty$ + - 求和/积分:$\\sum_{i=1}^{n}$、$\\int$、$\\lim$ + - 分数:$\\frac{a}{b}$ 指数:$x^{2}$ 下标:$a_{1}$ + - 根号:$\\sqrt{3}$ 向量:$\\vec{a}$ 绝对值:$|x|$ + - 矩阵:$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$ + - 对数:$\\log$、$\\ln$、$\\lg$ +- 化学式也用LaTeX:$\\text{H}_2\\text{O}$、$\\text{CO}_2$、$\\text{Fe}_2\\text{O}_3$ +- 选项中的公式也必须用 $ 包裹 +- 每道题必须完整,不要截断或合并 +- 注意区分大题和小题:如果一道大题下有(1)(2)(3)等小题,每个小题单独作为一个题目输出,content中注明属于哪道大题 +- 严格只输出JSON,不要有任何其他文字 +- 每道题必须标注 has_figure 字段:如果该题包含或引用了图形(几何图、函数图像、坐标图、电路图、实验装置图、结构式图等),设为 true;纯文字题设为 false +- 如果 has_figure 为 true,必须提供 figure_bbox 字段,格式为 [x0, y0, x1, y1],表示图形在该页图片中的大致位置比例(0~1之间的小数),其中 (x0,y0) 是左上角,(x1,y1) 是右下角。例如图形在页面右半部分中间:[0.5, 0.3, 0.95, 0.7] + +【题型识别指南】 +- choice: 有A/B/C/D选项的选择题(含单选和多选) +- fill: 填空题(含有____或括号需要填写的) +- judge: 判断题(判断对错/正误/是否) +- text: 解答题、证明题、计算题、简答题、论述题、作图题等 + +【输出格式强制要求】 +- 你的回复必须是一个合法的JSON数组,以 [ 开头,以 ] 结尾 +- 禁止输出任何解释、注释、markdown标记(如```)、前言或后语 +- 直接输出JSON,不要包裹在代码块中 + +示例输出格式: +[{"type":"choice","content":"已知函数 $f(x)=x^2+2x+1$,则 $f(0)$ 的值为","options":["$0$","$1$","$2$","$3$"],"answer":"B","score":5,"page":1,"has_figure":false},{"type":"fill","content":"计算 $\\\\sin 30°=$ ____","options":[],"answer":"$\\\\frac{1}{2}$","score":5,"page":1,"has_figure":false},{"type":"text","content":"(第18题第(1)小题)已知数列 $\\\\{a_n\\\\}$ 满足 $a_1=1$,$a_{n+1}=2a_n+1$,求通项公式 $a_n$","options":[],"answer":"","score":6,"page":2,"has_figure":false}]""" + + user_content_parts = [] + + # 如果有图形或扫描件,使用视觉模型发送图片 + use_vision = has_figures or not has_text + if use_vision: + user_content_parts.append({ + "type": "text", + "text": """请仔细查看以下试卷页面图片,识别所有题目并解析为JSON数组。 +特别注意: +1. 识别图片中的几何图形、坐标系、函数图像等,用文字描述在content中 +2. 不要遗漏任何题目 +3. 保留所有数学符号和公式 +4. 如果题目包含或引用了图形,has_figure设为true,并用figure_bbox标注图形在该页中的位置比例[x0,y0,x1,y1](0~1之间) + +每个元素格式: +{"type":"choice/fill/text/judge", "content":"题目内容(含图形描述)", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false, "figure_bbox":[x0,y0,x1,y1]} +- options 仅选择题需要 +- answer: 选择题A/B/C/D,判断题"A"=正确/"B"=错误,其他填参考答案或"" +- score: 按试卷标注,未标注则选择题5、填空题5、判断题3、解答题10 +- page: 该题所在的PDF页码(从1开始),必须填写 +- has_figure: 该题是否包含图形,true/false +- figure_bbox: 仅has_figure为true时需要,图形在页面中的位置比例[x0,y0,x1,y1]""" + }) + # 添加页面图片(最多10页发给视觉模型) + for i, img_b64 in enumerate(page_images[:10]): + user_content_parts.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{img_b64}"} + }) + # 如果有提取到的文本,也附上作为辅助 + if has_text: + clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:20000] + user_content_parts.append({ + "type": "text", + "text": f"\n以下是PDF提取的文字内容作为辅助参考:\n{clean_text}" + }) + else: + # 纯文本模式 + clean_text = _pdf_re.sub(r'\n{3,}', '\n\n', all_text)[:30000] + user_content_parts.append({ + "type": "text", + "text": f"""请将以下试卷内容解析为JSON数组。每个元素格式: +{{"type":"choice/fill/text/judge", "content":"题目内容", "options":["A","B","C","D"], "answer":"答案", "score":分值, "page":页码, "has_figure":true/false}} +- options 仅选择题需要 +- answer: 选择题A/B/C/D,判断题"A"=正确/"B"=错误,其他填参考答案或"" +- score: 按试卷标注,未标注则选择题5、填空题5、判断题3、解答题10 +- page: 该题所在的大致页码(从1开始),必须填写 +- has_figure: 该题是否包含或引用了图形(如"如图所示"),true/false + +试卷内容: +{clean_text}""" + }) + + # --- 健壮的 JSON 清洗函数 --- + def _clean_and_parse_json(raw_text): + """尝试从 AI 返回文本中提取合法 JSON 数组""" + text = raw_text.strip() + # 去除 BOM 头 + text = text.lstrip('\ufeff') + # 去除所有 markdown 代码围栏(支持多段) + text = _pdf_re.sub(r'```(?:json)?[ \t]*\n?', '', text) + text = _pdf_re.sub(r'```', '', text) + text = text.strip() + # 尝试直接解析 + parsed = _try_json_loads(text) + if parsed is not None: + return parsed + # 找到第一个 [ 和最后一个 ] 之间的内容 + first_bracket = text.find('[') + last_bracket = text.rfind(']') + if first_bracket != -1 and last_bracket > first_bracket: + subset = text[first_bracket:last_bracket + 1] + parsed = _try_json_loads(subset) + if parsed is not None: + return parsed + # 尝试修复尾部多余逗号: ,] -> ] + fixed = _pdf_re.sub(r',\s*([}\]])', r'\1', subset) + parsed = _try_json_loads(fixed) + if parsed is not None: + return parsed + return None + + def _try_json_loads(text): + """解析 JSON,返回题目列表或 None""" + try: + obj = json.loads(text) + except (json.JSONDecodeError, ValueError): + return None + if isinstance(obj, list) and len(obj) > 0: + return obj + if isinstance(obj, dict): + for key in ['questions', 'data', 'items', 'result']: + if key in obj and isinstance(obj[key], list): + return obj[key] + return None + + # --- 调用 AI --- + try: + import httpx + client = DashScopeClient( + api_key=api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + http_client=httpx.Client(verify=False, timeout=httpx.Timeout(120.0, connect=30.0)) + ) + model = "qwen-vl-plus" if use_vision else "qwen-turbo" + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content_parts} + ] + call_kwargs = dict(model=model, messages=messages, temperature=0.01, max_tokens=8192) + # qwen-plus 支持 response_format,视觉模型不支持 + if not use_vision: + call_kwargs["response_format"] = {"type": "json_object"} + response = client.chat.completions.create(**call_kwargs) + result_text = response.choices[0].message.content.strip() + except Exception as e: + return jsonify({'success': False, 'message': f'AI解析失败: {str(e)}'}), 500 + + # --- 解析 + 自动重试 --- + questions = _clean_and_parse_json(result_text) + + if questions is None: + # 解析失败,打印调试日志 + app.logger.warning("AI首次返回解析失败,原始内容前500字符: %s", result_text[:500]) + # 自动重试:把原始文本发给 AI 做格式修正(仅 1 次) + try: + retry_messages = [ + {"role": "system", "content": "你是一个JSON格式修正助手。用户会给你一段包含试卷题目信息但格式不正确的文本,请将其转换为合法的JSON数组。只输出JSON数组,以[开头,以]结尾,禁止输出任何其他文字。"}, + {"role": "user", "content": f"请将以下文本转换为合法JSON数组:\n{result_text[:16000]}"} + ] + retry_kwargs = dict(model="qwen-turbo", messages=retry_messages, temperature=0.01, + max_tokens=8192, response_format={"type": "json_object"}) + retry_resp = client.chat.completions.create(**retry_kwargs) + retry_text = retry_resp.choices[0].message.content.strip() + questions = _clean_and_parse_json(retry_text) + if questions is None: + app.logger.warning("AI重试返回仍解析失败,原始内容前500字符: %s", retry_text[:500]) + except Exception as e: + app.logger.warning("AI重试请求异常: %s", str(e)) + + if not questions or not isinstance(questions, list) or len(questions) == 0: + return jsonify({'success': False, 'message': 'AI返回格式异常,请重试或手动录入'}), 500 + + # 图形关键词兜底检测 + _fig_keywords = ['如图', '图示', '图中', '所示', '示意图', '几何体', '三角形', '四边形', + '圆', '棱', '锥', '柱', '球', '坐标', '函数图', '图像', '图形', + '电路', '示波器', '实验装置', '曲线', '折线', '直方图', '散点图', + '表格', '数轴', '向量', '抛物线', '双曲线', '椭圆', + '结构式', '分子式', '装置图', '流程图', '框图'] + + def _detect_has_figure(q_item): + """判断题目是否含图形:优先用AI返回的has_figure,否则关键词检测""" + val = q_item.get('has_figure') + if val is True or val == 'true' or val == 1: + return True + if val is False or val == 'false' or val == 0: + return False + # AI 没返回该字段,用关键词兜底 + content = q_item.get('content', '') + return any(kw in content for kw in _fig_keywords) + + def _crop_page_image(page_idx, bbox): + """用 fitz 从页面图片中裁剪指定区域,返回保存后的 URL 或 None""" + if page_idx < 0 or page_idx >= len(page_images): + return None + if not bbox or not isinstance(bbox, list) or len(bbox) != 4: + return None + try: + w, h = page_sizes[page_idx] + x0 = max(0, int(bbox[0] * w) - 20) + y0 = max(0, int(bbox[1] * h) - 20) + x1 = min(w, int(bbox[2] * w) + 20) + y1 = min(h, int(bbox[3] * h) + 20) + if x1 - x0 < 10 or y1 - y0 < 10: + return None + # 用 fitz 从 base64 解码后裁剪 + img_data = _b64.b64decode(page_images[page_idx]) + src_pix = fitz.Pixmap(img_data) + clip = fitz.IRect(x0, y0, x1, y1) + cropped_pix = fitz.Pixmap(src_pix, clip) + crop_bytes = cropped_pix.tobytes("png") + crop_filename = f"pdf_crop_{_uuid.uuid4().hex[:8]}.png" + crop_path = os.path.join(UPLOAD_FOLDER, crop_filename) + with open(crop_path, 'wb') as f: + f.write(crop_bytes) + return f'/static/uploads/{crop_filename}' + except Exception: + return None + + # 只为含图形的题目附加图片(裁剪优先,整页兜底) + referenced_urls = set() + for q in questions: + has_fig = _detect_has_figure(q) + page_num = q.get('page') + bbox = q.get('figure_bbox') + + if has_fig and page_num and isinstance(page_num, (int, float)): + idx = int(page_num) - 1 + # 尝试裁剪 + crop_url = _crop_page_image(idx, bbox) + if crop_url: + q.setdefault('images', []) + q['images'].append(crop_url) + referenced_urls.add(crop_url) + elif 0 <= idx < len(page_image_urls): + # 裁剪失败,回退整页 + q.setdefault('images', []) + if page_image_urls[idx] not in q['images']: + q['images'].append(page_image_urls[idx]) + referenced_urls.add(page_image_urls[idx]) + + # 清理前端不需要的字段 + q.pop('page', None) + q.pop('has_figure', None) + q.pop('figure_bbox', None) + + # 清理未被引用的整页图片文件 + for url in page_image_urls: + if url not in referenced_urls: + try: + unused_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), url.lstrip('/')) + if os.path.exists(unused_path): + os.remove(unused_path) + except Exception: + pass + + return jsonify({'success': True, 'questions': questions}) + +# ========== 考试系统 API ========== + +@app.route('/api/exams', methods=['POST']) +def api_create_exam(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + contest_id = data.get('contest_id') + + # 杯赛考试:只有杯赛负责人或系统管理员可以创建 + if contest_id: + contest = Contest.query.get(contest_id) + if not contest or contest.status == 'abolished': + return jsonify({'success': False, 'message': '杯赛不存在或已废止'}), 400 + membership = ContestMembership.query.filter_by( + user_id=user['id'], contest_id=contest_id, role='owner').first() + if not membership and user.get('role') != 'admin': + return jsonify({'success': False, 'message': '只有杯赛负责人才能组织考试'}), 403 + else: + # 普通考试:需要 teacher 或 admin 角色 + if user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权限'}), 403 + + title = data.get('title', '') + subject = data.get('subject', '') + duration = data.get('duration', 120) + questions = data.get('questions', []) + scheduled_start = data.get('scheduled_start', '') + scheduled_end = data.get('scheduled_end', '') + score_release_time = data.get('score_release_time', '') + if not title or not questions: + return jsonify({'success': False, 'message': '请填写试卷标题和题目'}), 400 + total_score = sum(q.get('score', 0) for q in questions) + exam = Exam( + title=title, + subject=subject, + duration=duration, + total_score=total_score, + creator_id=user.get('id'), + contest_id=contest_id + ) + # 解析预定时间 + if scheduled_start: + try: + exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M') + except ValueError: + pass + if scheduled_end: + try: + exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M') + except ValueError: + pass + if score_release_time: + try: + release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M') + # 验证公布时间必须晚于考试结束时间 + if exam.scheduled_end and release_dt <= exam.scheduled_end: + return jsonify({'success': False, 'message': '成绩公布时间必须晚于考试结束时间'}), 400 + exam.score_release_time = release_dt + except ValueError: + pass + # 考试密码 + access_password = data.get('access_password', '').strip() + if access_password: + exam.access_password = access_password + # 加密存储试卷内容 + questions_json = json.dumps(questions) + exam.encrypted_questions = encrypt_questions(questions_json) + exam.is_encrypted = True + exam.set_questions(questions) # 同时保留明文用于兼容 + db.session.add(exam) + db.session.commit() + # 通知杯赛已报名用户 + if contest_id: + registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all() + for reg in registrations: + if reg.user_id != user['id']: + add_notification(reg.user_id, 'contest_new_exam', + f'您报名的杯赛「{contest.name}」发布了新考试「{title}」,快去查看吧!', + from_user=user.get('name', ''), post_id=exam.id) + return jsonify({'success': True, 'message': '试卷创建成功', 'exam_id': exam.id}) + +@app.route('/api/exams//verify-password', methods=['POST']) +@login_required +def api_verify_exam_password(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '考试不存在'}), 404 + data = request.get_json() + password = data.get('password', '') + if password == exam.access_password: + session[f'exam_verified_{exam_id}'] = True + return jsonify({'success': True, 'message': '验证通过'}) + return jsonify({'success': False, 'message': '密码错误'}), 403 + +@app.route('/api/exams//save-draft', methods=['POST']) +def api_save_draft(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 + data = request.get_json() + answers = data.get('answers', {}) + draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first() + if not draft: + draft = Draft(exam_id=exam_id, user_id=user.get('id')) + draft.set_answers(answers) + db.session.add(draft) + db.session.commit() + return jsonify({'success': True, 'message': '草稿已保存'}) + +@app.route('/api/exams//submit', methods=['POST']) +def api_submit_exam(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 exam.status == 'closed': + return jsonify({'success': False, 'message': '该考试已关闭'}), 400 + # 检查预定时间 + now = datetime.utcnow() + if exam.scheduled_start and now < exam.scheduled_start: + return jsonify({'success': False, 'message': '考试尚未开始'}), 400 + if exam.scheduled_end and now > exam.scheduled_end: + return jsonify({'success': False, 'message': '考试已结束'}), 400 + if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first(): + return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400 + data = request.get_json() + answers = data.get('answers', {}) + score = 0 + auto_graded = True + question_scores = {} + questions = get_exam_questions(exam) + for q in questions: + qid = str(q['id']) + if q['type'] == 'choice': + if answers.get(qid) == q.get('answer'): + score += q.get('score', 0) + question_scores[qid] = q.get('score', 0) + else: + question_scores[qid] = 0 + elif q['type'] == 'fill': + student_answer = answers.get(qid, '').strip() + correct_answers = [a.strip() for a in q.get('answer', '').split('|')] + if student_answer in correct_answers: + score += q.get('score', 0) + question_scores[qid] = q.get('score', 0) + else: + question_scores[qid] = 0 + else: + auto_graded = False + question_scores[qid] = 0 + sub = Submission( + exam_id=exam_id, + user_id=user.get('id'), + score=score, + graded=auto_graded, + graded_by='系统自动' if auto_graded else '' + ) + 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() + return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id}) + +@app.route('/api/exams//grade/', methods=['POST']) +def api_grade_submission(exam_id, sub_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + sub = Submission.query.get(sub_id) + if not sub or sub.exam_id != exam_id: + return jsonify({'success': False, 'message': '提交记录不存在'}), 404 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '考试不存在'}), 404 + if not can_grade_exam(user, exam): + return jsonify({'success': False, 'message': '无权限'}), 403 + data = request.get_json() + scores = data.get('scores', {}) + total = sum(scores.values()) + sub.score = total + sub.set_question_scores(scores) + sub.graded = True + sub.graded_by = user.get('name', '') + db.session.commit() + # 通知学生批改完成 + add_notification(sub.user_id, 'exam_graded', + f'您在考试「{exam.title}」中的答卷已被批改,得分:{total}/{exam.total_score}', + from_user=user.get('name', ''), post_id=exam.id) + return jsonify({'success': True, 'message': '批改完成', 'total_score': total}) + +@app.route('/api/exams//status', methods=['POST']) +def api_update_exam_status(exam_id): + user = session.get('user') + if not user or user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权限'}), 403 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '试卷不存在'}), 404 + if exam.creator_id != user.get('id'): + return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403 + data = request.get_json() + new_status = data.get('status', '') + if new_status not in ('available', 'closed'): + return jsonify({'success': False, 'message': '无效状态'}), 400 + exam.status = new_status + db.session.commit() + return jsonify({'success': True, 'message': f'试卷已{"发布" if new_status == "available" else "关闭"}'}) + +@app.route('/api/exams/', methods=['DELETE']) +def api_delete_exam(exam_id): + user = session.get('user') + if not user or user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权限'}), 403 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '试卷不存在'}), 404 + if exam.creator_id != user.get('id'): + return jsonify({'success': False, 'message': '只能删除自己创建的试卷'}), 403 + Submission.query.filter_by(exam_id=exam_id).delete() + Draft.query.filter_by(exam_id=exam_id).delete() + db.session.delete(exam) + db.session.commit() + return jsonify({'success': True, 'message': '试卷已删除'}) + +# ========== 杯赛相关 API ========== + +@app.route('/api/contests/search') +@cache.cached(timeout=60, query_string=True) +def api_search_contests(): + keyword = request.args.get('q', '').strip().lower() + query = Contest.query + if keyword: + query = query.filter( + (Contest.name.contains(keyword)) | + (Contest.organizer.contains(keyword)) | + (Contest.description.contains(keyword)) + ) + # 普通用户只能看到已发布的杯赛,负责人和管理员可以看到自己的隐藏杯赛 + user = get_current_user() + all_contests = query.all() + filtered = [] + for c in all_contests: + if c.visible: + filtered.append(c) + elif user: + if user.get('role') == 'admin': + filtered.append(c) + elif ContestMembership.query.filter_by(user_id=user['id'], contest_id=c.id).first(): + filtered.append(c) + contests = filtered + data = [{ + 'id': c.id, + 'name': c.name, + 'organizer': c.organizer, + 'description': c.description, + 'start_date': c.start_date, + 'end_date': c.end_date, + 'status': c.status, + 'participants': c.participants, + 'created_by': c.created_by + } for c in contests] + return jsonify({'success': True, 'data': data}) + +@app.route('/api/contests//publish', methods=['PUT']) +@login_required +def api_publish_contest(contest_id): + user = get_current_user() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + 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' + if not is_owner: + return jsonify({'success': False, 'message': '无权操作'}), 403 + contest.visible = not contest.visible + db.session.commit() + return jsonify({'success': True, 'visible': contest.visible}) + +@app.route('/api/contests//exams', methods=['GET']) +def api_contest_exams(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + exams = Exam.query.filter_by(contest_id=contest_id).all() + user = get_current_user() + is_owner = False + if user: + 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' + data = [] + for e in exams: + item = { + 'id': e.id, + 'title': e.title, + 'subject': e.subject or '', + 'total_score': e.total_score or 0, + 'status': e.status, + 'duration': e.duration, + 'created_at': e.created_at.strftime('%Y-%m-%d %H:%M'), + 'submission_count': Submission.query.filter_by(exam_id=e.id).count() if is_owner else None + } + data.append(item) + return jsonify({'success': True, 'exams': data}) + +@app.route('/api/contests//import-exam', methods=['POST']) +@login_required +def api_import_exam_to_contest(contest_id): + """将已有考试导入到杯赛""" + user = get_current_user() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + 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' + if not is_owner: + return jsonify({'success': False, 'message': '无权操作'}), 403 + data = request.get_json() + exam_id = data.get('exam_id') + if not exam_id: + return jsonify({'success': False, 'message': '请选择考试'}), 400 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '考试不存在'}), 404 + if exam.contest_id and exam.contest_id != contest_id: + return jsonify({'success': False, 'message': '该考试已关联到其他杯赛'}), 400 + exam.contest_id = contest_id + db.session.commit() + return jsonify({'success': True, 'message': '导入成功'}) + +@app.route('/api/contests//remove-exam', methods=['POST']) +@login_required +def api_remove_exam_from_contest(contest_id): + """将考试从杯赛移除(不删除考试)""" + user = get_current_user() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + 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' + if not is_owner: + return jsonify({'success': False, 'message': '无权操作'}), 403 + data = request.get_json() + exam_id = data.get('exam_id') + exam = Exam.query.get(exam_id) + if not exam or exam.contest_id != contest_id: + return jsonify({'success': False, 'message': '考试不存在或不属于该杯赛'}), 404 + exam.contest_id = None + db.session.commit() + return jsonify({'success': True, 'message': '已移除'}) + +@app.route('/api/contests//available-exams', methods=['GET']) +@login_required +def api_available_exams_for_contest(contest_id): + """获取可导入到杯赛的考试列表(当前用户创建的、未关联杯赛的考试)""" + user = get_current_user() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + 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' + if not is_owner: + return jsonify({'success': False, 'message': '无权操作'}), 403 + # 查找未关联杯赛的考试(负责人自己创建的,或管理员可看所有) + query = Exam.query.filter((Exam.contest_id == None) | (Exam.contest_id == 0)) + if user.get('role') != 'admin': + query = query.filter_by(creator_id=user['id']) + exams = query.all() + data = [{ + 'id': e.id, + 'title': e.title, + 'subject': e.subject or '', + 'total_score': e.total_score or 0, + 'status': e.status, + 'created_at': e.created_at.strftime('%Y-%m-%d %H:%M') + } for e in exams] + return jsonify({'success': True, 'exams': data}) + +@app.route('/api/contests//leaderboard', methods=['GET']) +def api_contest_leaderboard(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + exam_ids = [e.id for e in Exam.query.filter_by(contest_id=contest_id).all()] + if not exam_ids: + return jsonify({'success': True, 'leaderboard': []}) + from sqlalchemy import func + results = db.session.query( + Submission.user_id, + func.sum(Submission.score).label('total_score'), + func.count(Submission.id).label('exam_count') + ).filter( + Submission.exam_id.in_(exam_ids), + Submission.graded == True + ).group_by(Submission.user_id).order_by(func.sum(Submission.score).desc()).limit(50).all() + leaderboard = [] + for rank, (user_id, total_score, exam_count) in enumerate(results, 1): + u = User.query.get(user_id) + leaderboard.append({ + 'rank': rank, + 'user_name': u.name if u else '未知用户', + 'total_score': total_score or 0, + 'exam_count': exam_count + }) + return jsonify({'success': True, 'leaderboard': leaderboard}) + +@app.route('/api/contests', methods=['POST']) +def api_create_contest(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + contest = Contest( + name=data.get('name'), + organizer=data.get('organizer'), + description=data.get('description'), + start_date=data.get('start_date'), + end_date=data.get('end_date'), + status=data.get('status', 'upcoming'), + participants=data.get('participants', 0), + created_by=user.get('id') + ) + db.session.add(contest) + db.session.commit() + return jsonify({'success': True, 'message': '杯赛创建成功', 'contest_id': contest.id}) + +# ========== 题库 API ========== + +@app.route('/api/contests//question-bank', methods=['GET']) +@login_required +def api_get_question_bank(contest_id): + """获取杯赛题库列表""" + user = session['user'] + 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 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not membership and user.get('role') != 'admin': + return jsonify({'success': False, 'message': '无权限访问题库'}), 403 + items = QuestionBankItem.query.filter_by(contest_id=contest_id).order_by(QuestionBankItem.created_at.desc()).all() + data = [] + for item in items: + contributor = User.query.get(item.contributor_id) + data.append({ + 'id': item.id, + 'type': item.type, + 'content': item.content, + 'options': json.loads(item.options) if item.options else [], + 'answer': item.answer, + 'score': item.score, + 'contributor_id': item.contributor_id, + 'contributor_name': contributor.name if contributor else '未知', + 'created_at': item.created_at.strftime('%Y-%m-%d %H:%M') + }) + return jsonify({'success': True, 'questions': data}) + +@app.route('/api/contests//question-bank', methods=['POST']) +@login_required +def api_add_question_bank(contest_id): + """添加题目到题库(杯赛老师和负责人)""" + user = session['user'] + 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 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not membership and user.get('role') != 'admin': + return jsonify({'success': False, 'message': '无权限'}), 403 + data = request.get_json() + qtype = data.get('type', '') + content = data.get('content', '') + if not qtype or not content: + return jsonify({'success': False, 'message': '题目类型和内容不能为空'}), 400 + item = QuestionBankItem( + contest_id=contest_id, + contributor_id=user['id'], + type=qtype, + content=content, + options=json.dumps(data.get('options', [])), + answer=data.get('answer', ''), + score=data.get('score', 10) + ) + db.session.add(item) + db.session.commit() + return jsonify({'success': True, 'message': '题目已添加', 'id': item.id}) + +@app.route('/api/contests//question-bank/', methods=['DELETE']) +@login_required +def api_delete_question_bank(contest_id, qid): + """删除题库题目(负责人或题目贡献者)""" + user = session['user'] + item = QuestionBankItem.query.get(qid) + if not item or item.contest_id != contest_id: + return jsonify({'success': False, 'message': '题目不存在'}), 404 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first() + is_owner = membership is not None + is_contributor = item.contributor_id == user['id'] + is_admin = user.get('role') == 'admin' + if not is_owner and not is_contributor and not is_admin: + return jsonify({'success': False, 'message': '无权限删除'}), 403 + db.session.delete(item) + db.session.commit() + return jsonify({'success': True, 'message': '题目已删除'}) + +@app.route('/api/contests//create-exam-from-bank', methods=['POST']) +@login_required +def api_create_exam_from_bank(contest_id): + """负责人从题库选题创建考试""" + user = session['user'] + 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 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest_id, role='owner').first() + if not membership and user.get('role') != 'admin': + return jsonify({'success': False, 'message': '只有杯赛负责人才能组卷'}), 403 + data = request.get_json() + title = data.get('title', '') + duration = data.get('duration', 120) + question_ids = data.get('question_ids', []) + scheduled_start = data.get('scheduled_start', '') + scheduled_end = data.get('scheduled_end', '') + score_release_time = data.get('score_release_time', '') + if not title or not question_ids: + return jsonify({'success': False, 'message': '请填写标题并选择题目'}), 400 + # 从题库获取题目并转换为考试题目格式 + questions = [] + for qid in question_ids: + item = QuestionBankItem.query.get(qid) + if not item or item.contest_id != contest_id: + continue + q = { + 'type': item.type, + 'content': item.content, + 'score': item.score, + 'answer': item.answer or '' + } + if item.type == 'choice': + q['options'] = json.loads(item.options) if item.options else [] + questions.append(q) + if not questions: + return jsonify({'success': False, 'message': '未找到有效题目'}), 400 + total_score = sum(q.get('score', 0) for q in questions) + exam = Exam( + title=title, + duration=duration, + total_score=total_score, + creator_id=user['id'], + contest_id=contest_id + ) + if scheduled_start: + try: + exam.scheduled_start = datetime.strptime(scheduled_start, '%Y-%m-%dT%H:%M') + except ValueError: + pass + if scheduled_end: + try: + exam.scheduled_end = datetime.strptime(scheduled_end, '%Y-%m-%dT%H:%M') + except ValueError: + pass + if score_release_time: + try: + release_dt = datetime.strptime(score_release_time, '%Y-%m-%dT%H:%M') + if not exam.scheduled_end or release_dt > exam.scheduled_end: + exam.score_release_time = release_dt + except ValueError: + pass + # 加密存储试卷内容 + questions_json = json.dumps(questions) + exam.encrypted_questions = encrypt_questions(questions_json) + exam.is_encrypted = True + exam.set_questions(questions) # 同时保留明文用于兼容 + db.session.add(exam) + db.session.commit() + # 通知杯赛已报名用户 + registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all() + for reg in registrations: + if reg.user_id != user['id']: + add_notification(reg.user_id, 'contest_new_exam', + f'您报名的杯赛「{contest.name}」发布了新考试「{title}」,快去查看吧!', + from_user=user.get('name', ''), post_id=exam.id) + return jsonify({'success': True, 'message': '考试创建成功', 'exam_id': exam.id}) + +# ========== 杯赛报名 API ========== + +@app.route('/api/contests//register', methods=['POST']) +@login_required +def api_register_contest(contest_id): + """用户报名杯赛""" + user = session['user'] + 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 + + existing = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if existing: + return jsonify({'success': False, 'message': '您已报名该杯赛'}), 400 + + registration = ContestRegistration(user_id=user['id'], contest_id=contest_id) + db.session.add(registration) + contest.participants += 1 + # 自动加入杯赛讨论群 + chatroom = ChatRoom.query.filter_by(contest_id=contest_id).first() + if chatroom: + existing_member = ChatRoomMember.query.filter_by(room_id=chatroom.id, user_id=user['id']).first() + if not existing_member: + db.session.add(ChatRoomMember(room_id=chatroom.id, user_id=user['id'], role='member')) + db.session.commit() + + return jsonify({'success': True, 'message': '报名成功', 'participants': contest.participants}) + +@app.route('/api/contests//unregister', methods=['POST']) +@login_required +def api_unregister_contest(contest_id): + """用户取消报名""" + user = session['user'] + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + + registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not registration: + return jsonify({'success': False, 'message': '您尚未报名该杯赛'}), 400 + + db.session.delete(registration) + if contest.participants > 0: + contest.participants -= 1 + db.session.commit() + + return jsonify({'success': True, 'message': '已取消报名', 'participants': contest.participants}) + +# ========== 杯赛讨论区 API ========== + +@app.route('/api/contests//posts', methods=['GET']) +def api_contest_posts(contest_id): + """获取杯赛讨论区帖子列表""" + contest = Contest.query.get_or_404(contest_id) + posts = Post.query.filter_by(contest_id=contest_id).order_by(Post.created_at.desc()).all() + user = get_current_user() + uid = user['id'] if user else None + data = [] + for p in posts: + p_dict = { + 'id': p.id, + 'title': p.title, + 'content': p.content[:200] + ('...' if len(p.content) > 200 else ''), + 'author': p.author.name, + 'author_id': p.author_id, + 'tag': p.tag, + 'is_official': p.is_official, + 'pinned': p.pinned, + 'likes': p.likes, + 'replies': p.replies_count, + 'views': p.views, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + } + if uid: + p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None + data.append(p_dict) + return jsonify({'success': True, 'data': data}) + +@app.route('/api/contests//posts', methods=['POST']) +@login_required +def api_create_contest_post(contest_id): + """在杯赛讨论区发布帖子""" + user = session['user'] + contest = Contest.query.get_or_404(contest_id) + + # 权限检查 + if not can_post_in_contest(user, contest): + return jsonify({'success': False, 'message': '您没有权限在此杯赛讨论区发帖'}), 403 + + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + if len(title) > 100: + return jsonify({'success': False, 'message': '标题不能超过100字'}), 400 + + post = Post( + title=title, + content=content, + author_id=user['id'], + tag='杯赛讨论', # 可自定义 + contest_id=contest_id, + is_official=(user['role'] in ['admin', 'teacher'] and data.get('is_official', False)) + ) + db.session.add(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + +# ========== 论坛相关 API ========== + +@app.route('/api/posts/search') +@cache.cached(timeout=30, query_string=True) +def api_search_posts(): + keyword = request.args.get('q', '').strip().lower() + tag = request.args.get('tag', '').strip() + sort = request.args.get('sort', 'newest') + + # 只获取非杯赛的帖子(contest_id 为 NULL) + query = Post.query.filter_by(contest_id=None) + + if keyword: + query = query.filter( + (Post.title.contains(keyword)) | (Post.content.contains(keyword)) + ) + if tag: + query = query.filter_by(tag=tag) + + if sort == 'hottest': + query = query.order_by(Post.likes.desc()) + elif sort == 'most_replies': + query = query.order_by(Post.replies_count.desc()) + else: + query = query.order_by(Post.created_at.desc()) + + posts = query.all() + user = session.get('user') + uid = user.get('id') if user else None + data = [] + for p in posts: + p_dict = { + 'id': p.id, + 'title': p.title, + 'content': p.content[:200] + ('...' if len(p.content) > 200 else ''), + 'author': p.author.name, + 'author_id': p.author_id, + 'tag': p.tag, + 'is_official': p.is_official, + 'pinned': p.pinned, + 'likes': p.likes, + 'replies': p.replies_count, + 'views': p.views, + 'has_poll': p.has_poll, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + } + if uid: + p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None + p_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=p.id).first() is not None + data.append(p_dict) + return jsonify({'success': True, 'data': data}) + +@app.route('/api/posts', methods=['POST']) +def api_create_post(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + tag = data.get('tag', '全部') + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + if len(title) > 100: + return jsonify({'success': False, 'message': '标题不能超过100字'}), 400 + post = Post( + title=title, + content=content, + author_id=user.get('id'), + tag=tag, + is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False)) + ) + db.session.add(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + +@app.route('/api/posts/') +def api_get_post(post_id): + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + post.views += 1 + db.session.commit() + + replies = Reply.query.filter_by(post_id=post_id).order_by(Reply.created_at).all() + user = session.get('user') + uid = user.get('id') if user else None + post_dict = { + 'id': post.id, + 'title': post.title, + 'content': post.content, + 'author': post.author.name, + 'author_id': post.author_id, + 'tag': post.tag, + 'is_official': post.is_official, + 'pinned': post.pinned, + 'likes': post.likes, + 'replies': post.replies_count, + 'views': post.views, + 'has_poll': post.has_poll, + 'created_at': post.created_at.strftime('%Y-%m-%d %H:%M'), + 'edited': (post.updated_at > post.created_at), + } + if uid: + post_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=post.id).first() is not None + post_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=post.id).first() is not None + replies_data = [] + for r in replies: + r_dict = { + 'id': r.id, + 'content': r.content, + 'author': r.author.name, + 'author_id': r.author_id, + 'likes': r.likes, + 'reply_to': r.reply_to, + 'created_at': r.created_at.strftime('%Y-%m-%d %H:%M'), + 'edited': (r.updated_at > r.created_at), + } + if uid: + r_dict['liked'] = Reaction.query.filter_by(user_id=uid, reply_id=r.id).first() is not None + replies_data.append(r_dict) + return jsonify({'success': True, 'data': post_dict, 'replies': replies_data}) + +@app.route('/api/posts/', methods=['DELETE']) +def api_delete_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权删除'}), 403 + db.session.delete(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子已删除'}) + +@app.route('/api/posts//like', methods=['POST']) +def api_like_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first() + if reaction: + db.session.delete(reaction) + post.likes -= 1 + db.session.commit() + return jsonify({'success': True, 'liked': False, 'likes': post.likes}) + else: + reaction = Reaction(user_id=uid, post_id=post_id, reaction='like') + db.session.add(reaction) + post.likes += 1 + db.session.commit() + return jsonify({'success': True, 'liked': True, 'likes': post.likes}) + +@app.route('/api/posts//bookmark', methods=['POST']) +def api_bookmark_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + uid = user.get('id') + bookmark = Bookmark.query.filter_by(user_id=uid, post_id=post_id).first() + if bookmark: + db.session.delete(bookmark) + db.session.commit() + return jsonify({'success': True, 'bookmarked': False}) + else: + bookmark = Bookmark(user_id=uid, post_id=post_id) + db.session.add(bookmark) + db.session.commit() + return jsonify({'success': True, 'bookmarked': True}) + +@app.route('/api/posts//pin', methods=['POST']) +def api_pin_post(post_id): + user = session.get('user') + if not user or user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权限'}), 403 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + post.pinned = not post.pinned + db.session.commit() + return jsonify({'success': True, 'pinned': post.pinned}) + +@app.route('/api/posts//replies', methods=['POST']) +def api_create_reply(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + data = request.get_json() + content = data.get('content', '').strip() + if not content: + return jsonify({'success': False, 'message': '回复内容不能为空'}), 400 + reply = Reply( + post_id=post_id, + author_id=user.get('id'), + content=content, + reply_to=data.get('reply_to', '') + ) + db.session.add(reply) + post.replies_count += 1 + db.session.commit() + return jsonify({'success': True, 'message': '回复成功', 'reply': { + 'id': reply.id, + 'content': reply.content, + 'author': user.get('name'), + 'author_id': user.get('id'), + 'likes': 0, + 'reply_to': reply.reply_to, + 'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M') + }}) + +@app.route('/api/replies//like', methods=['POST']) +def api_like_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, reply_id=reply_id).first() + if reaction: + db.session.delete(reaction) + reply.likes -= 1 + db.session.commit() + return jsonify({'success': True, 'liked': False, 'likes': reply.likes}) + else: + reaction = Reaction(user_id=uid, reply_id=reply_id, reaction='like') + db.session.add(reaction) + reply.likes += 1 + db.session.commit() + return jsonify({'success': True, 'liked': True, 'likes': reply.likes}) + +@app.route('/api/replies/', methods=['DELETE']) +def api_delete_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + if reply.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权删除'}), 403 + post = Post.query.get(reply.post_id) + if post: + post.replies_count -= 1 + db.session.delete(reply) + db.session.commit() + return jsonify({'success': True, 'message': '回复已删除'}) + +@app.route('/api/user/bookmarks') +def api_user_bookmarks(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + uid = user.get('id') + bookmarks = Bookmark.query.filter_by(user_id=uid).all() + post_ids = [b.post_id for b in bookmarks] + posts = Post.query.filter(Post.id.in_(post_ids)).all() + data = [{ + 'id': p.id, + 'title': p.title, + 'author': p.author.name, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M') + } for p in posts] + return jsonify({'success': True, 'data': data}) + +# ========== 论坛增强 API ========== + +@app.route('/api/posts//edit', methods=['POST']) +def api_edit_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id') and user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '无权编辑'}), 403 + data = request.get_json() + new_title = data.get('title', '').strip() + new_content = data.get('content', '').strip() + new_tag = data.get('tag', post.tag) + if not new_title or not new_content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + post.title = new_title + post.content = new_content + post.tag = new_tag + db.session.commit() + return jsonify({'success': True, 'message': '编辑成功'}) + +@app.route('/api/replies//edit', methods=['POST']) +def api_edit_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + if reply.author_id != user.get('id'): + return jsonify({'success': False, 'message': '无权编辑'}), 403 + data = request.get_json() + new_content = data.get('content', '').strip() + if not new_content: + return jsonify({'success': False, 'message': '内容不能为空'}), 400 + reply.content = new_content + db.session.commit() + return jsonify({'success': True, 'message': '编辑成功'}) + +@app.route('/api/posts//poll', methods=['POST']) +def api_create_poll(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id'): + return jsonify({'success': False, 'message': '只有作者可以创建投票'}), 403 + if post.poll: + return jsonify({'success': False, 'message': '该帖子已有投票'}), 400 + data = request.get_json() + question = data.get('question', '').strip() + options = data.get('options', []) + multi = data.get('multi', False) + if not question or len(options) < 2: + return jsonify({'success': False, 'message': '请填写问题和至少2个选项'}), 400 + poll = Poll( + post_id=post_id, + question=question, + multi=multi + ) + poll.set_options([{'text': o, 'votes': 0} for o in options]) + poll.set_voters({}) + db.session.add(poll) + post.has_poll = True + db.session.commit() + return jsonify({'success': True, 'message': '投票创建成功'}) + +@app.route('/api/posts//vote', methods=['POST']) +def api_vote_poll(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + poll = Poll.query.filter_by(post_id=post_id).first() + if not poll: + return jsonify({'success': False, 'message': '投票不存在'}), 404 + uid = user.get('id') + voters = poll.get_voters() + if str(uid) in voters: + return jsonify({'success': False, 'message': '您已投过票'}), 400 + data = request.get_json() + choices = data.get('choices', []) + if not choices: + return jsonify({'success': False, 'message': '请选择选项'}), 400 + if not poll.multi and len(choices) > 1: + return jsonify({'success': False, 'message': '该投票为单选'}), 400 + options = poll.get_options() + for idx in choices: + if 0 <= idx < len(options): + options[idx]['votes'] += 1 + voters[str(uid)] = choices + poll.set_options(options) + poll.set_voters(voters) + poll.total_votes += 1 + db.session.commit() + return jsonify({'success': True, 'message': '投票成功', 'poll': { + 'options': options, + 'total_votes': poll.total_votes + }}) + +@app.route('/api/posts//poll') +def api_get_poll(post_id): + poll = Poll.query.filter_by(post_id=post_id).first() + if not poll: + return jsonify({'success': False, 'message': '投票不存在'}), 404 + user = session.get('user') + voted = False + my_choices = [] + if user: + voters = poll.get_voters() + if str(user.get('id')) in voters: + voted = True + my_choices = voters[str(user.get('id'))] + return jsonify({ + 'success': True, + 'poll': { + 'question': poll.question, + 'options': poll.get_options(), + 'total_votes': poll.total_votes, + 'multi': poll.multi + }, + 'voted': voted, + 'my_choices': my_choices + }) + +@app.route('/api/report', methods=['POST']) +def api_report(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + rtype = data.get('type', '') + target_id = data.get('target_id', 0) + reason = data.get('reason', '') + detail = data.get('detail', '') + if not rtype or not target_id or not reason: + return jsonify({'success': False, 'message': '请填写举报信息'}), 400 + report = Report( + type=rtype, + target_id=target_id, + reporter_id=user.get('id'), + reason=reason, + detail=detail + ) + db.session.add(report) + db.session.commit() + return jsonify({'success': True, 'message': '举报已提交,管理员将尽快处理'}) + +@app.route('/api/posts//react', methods=['POST']) +def api_react_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + data = request.get_json() + reaction_type = data.get('reaction', '') + if reaction_type not in ('like', 'love', 'haha', 'wow', 'sad', 'angry'): + return jsonify({'success': False, 'message': '无效反应'}), 400 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first() + if reaction: + if reaction.reaction == reaction_type: + db.session.delete(reaction) + else: + reaction.reaction = reaction_type + else: + reaction = Reaction(user_id=uid, post_id=post_id, reaction=reaction_type) + db.session.add(reaction) + db.session.commit() + counts = {} + for r in Reaction.query.filter_by(post_id=post_id).all(): + counts[r.reaction] = counts.get(r.reaction, 0) + 1 + return jsonify({'success': True, 'reactions': counts}) + +@app.route('/api/user/profile/') +def api_user_profile(user_id): + user = User.query.get(int(user_id)) if user_id.isdigit() else None + if not user: + return jsonify({'success': False, 'message': '用户不存在'}), 404 + post_count = Post.query.filter_by(author_id=user.id).count() + reply_count = Reply.query.filter_by(author_id=user.id).count() + likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0 + points = post_count * 10 + reply_count * 3 + likes_received * 2 + level = calc_level(points) + recent_posts = Post.query.filter_by(author_id=user.id).order_by(Post.created_at.desc()).limit(5).all() + recent_posts_data = [{'id': p.id, 'title': p.title} for p in recent_posts] + badges = [] + if post_count >= 1: badges.append({'icon': '✍️', 'name': '初次发帖', 'desc': '发布第一篇帖子'}) + if post_count >= 10: badges.append({'icon': '📝', 'name': '笔耕不辍', 'desc': '发布10篇帖子'}) + if post_count >= 50: badges.append({'icon': '📚', 'name': '著作等身', 'desc': '发布50篇帖子'}) + if reply_count >= 10: badges.append({'icon': '💬', 'name': '热心回复', 'desc': '回复10次'}) + if reply_count >= 100: badges.append({'icon': '🗣️', 'name': '话题达人', 'desc': '回复100次'}) + if likes_received >= 10: badges.append({'icon': '👍', 'name': '小有人气', 'desc': '获得10个赞'}) + if likes_received >= 100: badges.append({'icon': '🌟', 'name': '人气之星', 'desc': '获得100个赞'}) + if likes_received >= 500: badges.append({'icon': '👑', 'name': '万人迷', 'desc': '获得500个赞'}) + return jsonify({ + 'success': True, + 'profile': { + 'user_id': user.id, + 'name': user.name, + 'points': points, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, ''), + 'posts_count': post_count, + 'replies_count': reply_count, + 'likes_received': likes_received, + 'badges': badges, + 'recent_posts': recent_posts_data + } + }) + +@app.route('/api/forum/hot') +@cache.cached(timeout=120) +def api_hot_posts(): + posts = Post.query.order_by((Post.likes*3 + Post.replies_count*2 + Post.views).desc()).limit(10).all() + data = [{'id': p.id, 'title': p.title, 'likes': p.likes, 'replies': p.replies_count} for p in posts] + return jsonify({'success': True, 'data': data}) + +@app.route('/api/forum/stats') +@cache.cached(timeout=60) +def api_forum_stats(): + total_posts = Post.query.count() + total_replies = Reply.query.count() + total_users = User.query.count() + today = datetime.utcnow().date() + today_posts = Post.query.filter(db.func.date(Post.created_at) == today).count() + today_replies = Reply.query.filter(db.func.date(Reply.created_at) == today).count() + online_count = get_online_count() + tag_counts = {} + for tag in db.session.query(Post.tag, db.func.count(Post.tag)).group_by(Post.tag).all(): + tag_counts[tag[0]] = tag[1] + active_users = [] + recent_posts_users = Post.query.order_by(Post.created_at.desc()).limit(10).all() + seen = set() + for p in recent_posts_users: + if p.author_id not in seen and len(active_users) < 5: + seen.add(p.author_id) + points = (Post.query.filter_by(author_id=p.author_id).count()*10 + + Reply.query.filter_by(author_id=p.author_id).count()*3) + level = calc_level(points) + active_users.append({ + 'user_id': p.author_id, + 'name': p.author.name, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, '') + }) + return jsonify({ + 'success': True, + 'stats': { + 'total_posts': total_posts, + 'total_replies': total_replies, + 'total_users': total_users, + 'today_posts': today_posts, + 'today_replies': today_replies, + 'online_count': online_count, + 'tag_counts': tag_counts, + 'active_users': active_users + } + }) + +@app.route('/api/forum/leaderboard') +@cache.cached(timeout=300) +def api_leaderboard(): + users = User.query.all() + board = [] + for u in users: + post_count = Post.query.filter_by(author_id=u.id).count() + reply_count = Reply.query.filter_by(author_id=u.id).count() + likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=u.id).scalar() or 0 + points = post_count * 10 + reply_count * 3 + likes_received * 2 + level = calc_level(points) + board.append({ + 'user_id': u.id, + 'name': u.name, + 'points': points, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, ''), + 'posts_count': post_count, + 'likes_received': likes_received + }) + board.sort(key=lambda x: x['points'], reverse=True) + return jsonify({'success': True, 'data': board[:20]}) + +@app.route('/api/posts/with-poll', methods=['POST']) +def api_create_post_with_poll(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + tag = data.get('tag', '全部') + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + post = Post( + title=title, + content=content, + author_id=user.get('id'), + tag=tag, + is_official=(user.get('role') in ('teacher', 'admin') and data.get('is_official', False)) + ) + db.session.add(post) + db.session.flush() + poll_data = data.get('poll') + if poll_data and poll_data.get('question') and len(poll_data.get('options', [])) >= 2: + poll = Poll( + post_id=post.id, + question=poll_data['question'], + multi=poll_data.get('multi', False) + ) + poll.set_options([{'text': o, 'votes': 0} for o in poll_data['options']]) + poll.set_voters({}) + db.session.add(poll) + post.has_poll = True + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + +@app.route('/api/posts//share', methods=['POST']) +def api_share_post(post_id): + return jsonify({'success': True}) + +# ========== 聊天系统页面 ========== +@app.route('/chat') +@login_required +def chat_page(): + return render_template('chat.html') + +# ========== 聊天系统 REST API ========== + +@app.route('/api/chat/rooms') +@login_required +def api_chat_rooms(): + """获取当前用户所有聊天室(含最后一条消息、未读数)""" + user_id = session['user']['id'] + memberships = ChatRoomMember.query.filter_by(user_id=user_id).all() + rooms_data = [] + for m in memberships: + room = m.room + last_msg = Message.query.filter_by(room_id=room.id).order_by(Message.created_at.desc()).first() + unread = Message.query.filter( + Message.room_id == room.id, + Message.created_at > m.last_read_at, + Message.sender_id != user_id + ).count() + # 私聊显示对方名字和头像 + room_name = room.name + room_avatar = room.avatar + if room.type == 'private': + other_member = ChatRoomMember.query.filter( + ChatRoomMember.room_id == room.id, + ChatRoomMember.user_id != user_id + ).first() + if other_member: + other_user = User.query.get(other_member.user_id) + if other_user: + room_name = other_user.name + room_avatar = other_user.avatar or '' + rooms_data.append({ + 'id': room.id, + 'type': room.type, + 'name': room_name or '未命名群聊', + 'avatar': room_avatar or '', + 'muted': m.muted, + 'unread': unread, + 'last_message': { + 'content': ('撤回了一条消息' if last_msg.recalled else (last_msg.content[:50] if last_msg.content else '[文件]')) if last_msg else '', + 'sender_name': User.query.get(last_msg.sender_id).name if last_msg else '', + 'created_at': last_msg.created_at.strftime('%Y-%m-%d %H:%M') if last_msg else '', + 'type': last_msg.type if last_msg else 'text' + } if last_msg else None, + 'member_count': ChatRoomMember.query.filter_by(room_id=room.id).count() + }) + rooms_data.sort(key=lambda r: r['last_message']['created_at'] if r['last_message'] else '', reverse=True) + return jsonify({'success': True, 'rooms': rooms_data}) + +@app.route('/api/chat/rooms', methods=['POST']) +@login_required +def api_create_chat_room(): + """创建群聊""" + user_id = session['user']['id'] + data = request.get_json() + name = data.get('name', '').strip() + member_ids = data.get('member_ids', []) + if not name: + return jsonify({'success': False, 'message': '请输入群名称'}), 400 + room = ChatRoom(type='group', name=name, creator_id=user_id) + db.session.add(room) + db.session.flush() + # 创建者为管理员 + db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='admin')) + # 添加成员 + for mid in member_ids: + if mid != user_id: + db.session.add(ChatRoomMember(room_id=room.id, user_id=mid, role='member')) + # 系统消息 + db.session.add(Message(room_id=room.id, sender_id=user_id, type='system', content=f'{session["user"]["name"]} 创建了群聊')) + db.session.commit() + return jsonify({'success': True, 'room_id': room.id}) + +@app.route('/api/chat/rooms/', methods=['PUT']) +@login_required +def api_update_chat_room(room_id): + """修改群名/群头像(仅管理员)""" + user_id = session['user']['id'] + room = ChatRoom.query.get_or_404(room_id) + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member or member.role != 'admin': + return jsonify({'success': False, 'message': '无权限'}), 403 + data = request.get_json() + if 'name' in data: + room.name = data['name'] + if 'avatar' in data: + room.avatar = data['avatar'] + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id, 'name': room.name, 'avatar': room.avatar}, room=f'room_{room_id}') + return jsonify({'success': True}) + +@app.route('/api/chat/private/', methods=['POST']) +@login_required +def api_get_or_create_private_chat(target_user_id): + """获取或创建与某好友的私聊室""" + user_id = session['user']['id'] + if target_user_id == user_id: + return jsonify({'success': False, 'message': '不能和自己聊天'}), 400 + # 查找已有私聊 + my_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=user_id).subquery() + target_rooms = db.session.query(ChatRoomMember.room_id).filter_by(user_id=target_user_id).subquery() + existing = ChatRoom.query.filter( + ChatRoom.type == 'private', + ChatRoom.id.in_(db.session.query(my_rooms.c.room_id)), + ChatRoom.id.in_(db.session.query(target_rooms.c.room_id)) + ).first() + if existing: + return jsonify({'success': True, 'room_id': existing.id}) + # 创建新私聊 + room = ChatRoom(type='private', creator_id=user_id) + db.session.add(room) + db.session.flush() + db.session.add(ChatRoomMember(room_id=room.id, user_id=user_id, role='member')) + db.session.add(ChatRoomMember(room_id=room.id, user_id=target_user_id, role='member')) + db.session.commit() + return jsonify({'success': True, 'room_id': room.id}) + +@app.route('/api/chat/rooms//messages') +@login_required +def api_chat_messages(room_id): + """分页获取历史消息""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + before_id = request.args.get('before_id', type=int) + limit = request.args.get('limit', 30, type=int) + query = Message.query.filter_by(room_id=room_id) + if before_id: + query = query.filter(Message.id < before_id) + messages = query.order_by(Message.created_at.desc()).limit(limit).all() + messages.reverse() + data = [] + for msg in messages: + sender = User.query.get(msg.sender_id) + msg_data = { + 'id': msg.id, + 'room_id': msg.room_id, + 'sender_id': msg.sender_id, + 'sender_name': sender.name if sender else '未知用户', + 'sender_avatar': (sender.avatar or '') if sender else '', + 'type': msg.type, + 'content': msg.content, + 'file_url': msg.file_url, + 'file_name': msg.file_name, + 'recalled': msg.recalled, + 'reply_to': None, + 'mentions': msg.mentions or '', + 'reactions': _get_reactions_summary(msg.id) if not msg.recalled else {}, + 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') + } + if msg.reply_to_id: + reply_msg = Message.query.get(msg.reply_to_id) + if reply_msg: + reply_sender = User.query.get(reply_msg.sender_id) + msg_data['reply_to'] = { + 'id': reply_msg.id, + 'sender_name': reply_sender.name if reply_sender else '未知', + 'content': reply_msg.content[:50] if reply_msg.content else '[文件]' + } + data.append(msg_data) + return jsonify({'success': True, 'messages': data}) + +@app.route('/api/chat/rooms//members') +@login_required +def api_chat_room_members(room_id): + """获取聊天室成员列表""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + room = ChatRoom.query.get_or_404(room_id) + members = ChatRoomMember.query.filter_by(room_id=room_id).all() + data = [] + for m in members: + u = User.query.get(m.user_id) + if u: + data.append({'id': u.id, 'name': u.name, 'avatar': u.avatar or '', 'role': m.role, + 'nickname': m.nickname or '', 'muted': m.muted, + 'is_creator': u.id == room.creator_id, + 'joined_at': m.joined_at.strftime('%Y-%m-%d %H:%M')}) + return jsonify({'success': True, 'members': data, 'creator_id': room.creator_id}) + +@app.route('/api/chat/rooms//members', methods=['POST']) +@login_required +def api_invite_chat_members(room_id): + """邀请成员入群""" + user_id = session['user']['id'] + room = ChatRoom.query.get_or_404(room_id) + if room.type == 'private': + return jsonify({'success': False, 'message': '私聊不能邀请成员'}), 400 + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + data = request.get_json() + user_ids = data.get('user_ids', []) + added = [] + for uid in user_ids: + if not ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first(): + db.session.add(ChatRoomMember(room_id=room_id, user_id=uid, role='member')) + u = User.query.get(uid) + if u: + added.append(u.name) + if added: + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{session["user"]["name"]} 邀请了 {", ".join(added)} 加入群聊')) + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}') + return jsonify({'success': True, 'message': f'已邀请 {len(added)} 人'}) + +@app.route('/api/chat/rooms//members/', methods=['DELETE']) +@login_required +def api_remove_chat_member(room_id, uid): + """移除成员 / 退出群聊""" + user_id = session['user']['id'] + room = ChatRoom.query.get_or_404(room_id) + if room.type == 'private': + return jsonify({'success': False, 'message': '不能退出私聊'}), 400 + my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not my_member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + if uid == user_id: + target_user = User.query.get(user_id) + db.session.delete(my_member) + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{target_user.name} 退出了群聊')) + db.session.commit() + return jsonify({'success': True, 'message': '已退出群聊'}) + if my_member.role != 'admin': + return jsonify({'success': False, 'message': '无权限'}), 403 + target_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first() + if target_member: + target_user = User.query.get(uid) + db.session.delete(target_member) + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{target_user.name} 被移出了群聊')) + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}') + return jsonify({'success': True}) + +@app.route('/api/chat/rooms//read', methods=['POST']) +@login_required +def api_mark_chat_read(room_id): + """标记已读""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if member: + member.last_read_at = datetime.utcnow() + db.session.commit() + socketio.emit('read_update', { + 'room_id': room_id, 'user_id': user_id, + 'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S') + }, room=f'room_{room_id}') + return jsonify({'success': True}) + +@app.route('/api/chat/unread-total') +@login_required +def api_chat_unread_total(): + """获取所有聊天室未读总数""" + user_id = session['user']['id'] + memberships = ChatRoomMember.query.filter_by(user_id=user_id).all() + total = 0 + for m in memberships: + if not m.muted: + total += Message.query.filter( + Message.room_id == m.room_id, + Message.created_at > m.last_read_at, + Message.sender_id != user_id + ).count() + return jsonify({'success': True, 'total': total}) + +@app.route('/api/chat/messages//recall', methods=['POST']) +@login_required +def api_recall_message(msg_id): + """撤回消息(2分钟内,仅发送者)""" + user_id = session['user']['id'] + msg = Message.query.get_or_404(msg_id) + if msg.sender_id != user_id: + return jsonify({'success': False, 'message': '只能撤回自己的消息'}), 403 + if (datetime.utcnow() - msg.created_at).total_seconds() > 120: + return jsonify({'success': False, 'message': '超过2分钟无法撤回'}), 400 + msg.recalled = True + msg.content = '' + msg.file_url = None + msg.file_name = None + db.session.commit() + socketio.emit('message_recalled', {'message_id': msg.id, 'room_id': msg.room_id}, room=f'room_{msg.room_id}') + return jsonify({'success': True}) + +# ========== SocketIO 事件处理 ========== + +@socketio.on('connect') +def handle_connect(): + user = session.get('user') + if not user: + return False + memberships = ChatRoomMember.query.filter_by(user_id=user['id']).all() + for m in memberships: + sio_join(f'room_{m.room_id}') + +@socketio.on('send_message') +def handle_send_message(data): + user = session.get('user') + if not user: + return + user_id = user['id'] + room_id = data.get('room_id') + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return + # 检查是否被禁言 + if member.muted: + emit('error', {'message': '您已被禁言'}) + return + mentions_raw = data.get('mentions', '') + msg = Message( + room_id=room_id, sender_id=user_id, + type=data.get('type', 'text'), content=data.get('content', ''), + file_url=data.get('file_url'), file_name=data.get('file_name'), + reply_to_id=data.get('reply_to_id'), + mentions=mentions_raw if mentions_raw else '' + ) + db.session.add(msg) + db.session.commit() + sender = User.query.get(user_id) + reply_to_data = None + if msg.reply_to_id: + reply_msg = Message.query.get(msg.reply_to_id) + if reply_msg: + rs = User.query.get(reply_msg.sender_id) + reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知', + 'content': reply_msg.content[:50] if reply_msg.content else '[文件]'} + emit('new_message', { + 'id': msg.id, 'room_id': room_id, 'sender_id': user_id, + 'sender_name': sender.name if sender else user['name'], + 'sender_avatar': (sender.avatar or '') if sender else '', + 'type': msg.type, 'content': msg.content, + 'file_url': msg.file_url, 'file_name': msg.file_name, + 'reply_to': reply_to_data, + 'mentions': mentions_raw, + 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') + }, room=f'room_{room_id}') + +@socketio.on('typing') +def handle_typing(data): + user = session.get('user') + if not user: + return + emit('user_typing', { + 'room_id': data.get('room_id'), 'user_id': user['id'], 'user_name': user['name'] + }, room=f'room_{data.get("room_id")}', include_self=False) + +@socketio.on('mark_read') +def handle_mark_read(data): + user = session.get('user') + if not user: + return + room_id = data.get('room_id') + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user['id']).first() + if member: + member.last_read_at = datetime.utcnow() + db.session.commit() + emit('read_update', { + 'room_id': room_id, 'user_id': user['id'], + 'last_read_at': member.last_read_at.strftime('%Y-%m-%d %H:%M:%S') + }, room=f'room_{room_id}') + +# ========== 群聊增强 API ========== + +@app.route('/api/chat/rooms//announcement', methods=['GET']) +@login_required +def api_get_announcement(room_id): + """获取群公告""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + room = ChatRoom.query.get_or_404(room_id) + announcer = User.query.get(room.announcement_by) if room.announcement_by else None + return jsonify({'success': True, 'announcement': room.announcement or '', + 'by': announcer.name if announcer else '', 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M') if room.announcement_at else ''}) + +@app.route('/api/chat/rooms//announcement', methods=['POST']) +@login_required +def api_set_announcement(room_id): + """设置群公告(仅管理员)""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member or member.role != 'admin': + return jsonify({'success': False, 'message': '无权限'}), 403 + room = ChatRoom.query.get_or_404(room_id) + data = request.get_json() + room.announcement = data.get('content', '').strip() + room.announcement_by = user_id + room.announcement_at = datetime.utcnow() + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{session["user"]["name"]} 更新了群公告')) + db.session.commit() + socketio.emit('announcement_updated', { + 'room_id': room_id, 'content': room.announcement, + 'by': session['user']['name'], 'at': room.announcement_at.strftime('%Y-%m-%d %H:%M') + }, room=f'room_{room_id}') + return jsonify({'success': True}) + +@app.route('/api/chat/rooms//search') +@login_required +def api_chat_search(room_id): + """搜索聊天记录""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + q = request.args.get('q', '').strip() + if not q: + return jsonify({'success': True, 'messages': []}) + msgs = Message.query.filter( + Message.room_id == room_id, Message.recalled == False, + Message.type.in_(['text', 'file']), + Message.content.ilike(f'%{q}%') + ).order_by(Message.created_at.desc()).limit(50).all() + data = [] + for m in msgs: + sender = User.query.get(m.sender_id) + data.append({'id': m.id, 'sender_name': sender.name if sender else '未知', + 'content': m.content, 'type': m.type, + 'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')}) + return jsonify({'success': True, 'messages': data}) + +@app.route('/api/chat/rooms//files') +@login_required +def api_chat_files(room_id): + """获取群文件列表""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + msgs = Message.query.filter( + Message.room_id == room_id, Message.recalled == False, + Message.type.in_(['file', 'image']), Message.file_url.isnot(None) + ).order_by(Message.created_at.desc()).limit(100).all() + data = [] + for m in msgs: + sender = User.query.get(m.sender_id) + data.append({'id': m.id, 'type': m.type, 'file_url': m.file_url, + 'file_name': m.file_name or '未命名', 'sender_name': sender.name if sender else '未知', + 'created_at': m.created_at.strftime('%Y-%m-%d %H:%M')}) + return jsonify({'success': True, 'files': data}) + +@app.route('/api/chat/rooms//mute/', methods=['POST']) +@login_required +def api_mute_member(room_id, uid): + """禁言/解禁成员(仅管理员)""" + user_id = session['user']['id'] + my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not my_member or my_member.role != 'admin': + return jsonify({'success': False, 'message': '无权限'}), 403 + target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first() + if not target: + return jsonify({'success': False, 'message': '成员不存在'}), 404 + if target.role == 'admin': + return jsonify({'success': False, 'message': '不能禁言管理员'}), 400 + target.muted = not target.muted + target_user = User.query.get(uid) + action = '禁言' if target.muted else '解除禁言' + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{session["user"]["name"]} {action}了 {target_user.name}')) + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}') + return jsonify({'success': True, 'muted': target.muted}) + +@app.route('/api/chat/rooms//transfer/', methods=['POST']) +@login_required +def api_transfer_admin(room_id, uid): + """转让群主""" + user_id = session['user']['id'] + room = ChatRoom.query.get_or_404(room_id) + if room.creator_id != user_id: + return jsonify({'success': False, 'message': '只有群主可以转让'}), 403 + my_member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first() + if not target: + return jsonify({'success': False, 'message': '成员不存在'}), 404 + room.creator_id = uid + target.role = 'admin' + my_member.role = 'member' + target_user = User.query.get(uid) + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{session["user"]["name"]} 将群主转让给了 {target_user.name}')) + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}') + return jsonify({'success': True}) + +@app.route('/api/chat/rooms//set-admin/', methods=['POST']) +@login_required +def api_set_admin(room_id, uid): + """设置/取消管理员""" + user_id = session['user']['id'] + room = ChatRoom.query.get_or_404(room_id) + if room.creator_id != user_id: + return jsonify({'success': False, 'message': '只有群主可以设置管理员'}), 403 + target = ChatRoomMember.query.filter_by(room_id=room_id, user_id=uid).first() + if not target: + return jsonify({'success': False, 'message': '成员不存在'}), 404 + target.role = 'admin' if target.role == 'member' else 'member' + target_user = User.query.get(uid) + action = '设为管理员' if target.role == 'admin' else '取消管理员' + db.session.add(Message(room_id=room_id, sender_id=user_id, type='system', + content=f'{session["user"]["name"]} 将 {target_user.name} {action}')) + db.session.commit() + socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}') + return jsonify({'success': True, 'role': target.role}) + +@app.route('/api/chat/rooms//nickname', methods=['POST']) +@login_required +def api_set_nickname(room_id): + """设置群内昵称""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '您不是该聊天室成员'}), 403 + data = request.get_json() + member.nickname = data.get('nickname', '').strip()[:50] + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/chat/messages//reactions', methods=['POST']) +@login_required +def api_toggle_reaction(msg_id): + """添加/取消表情回应""" + user_id = session['user']['id'] + data = request.get_json() + emoji = data.get('emoji', '') + if not emoji: + return jsonify({'success': False, 'message': '请选择表情'}), 400 + msg = Message.query.get_or_404(msg_id) + member = ChatRoomMember.query.filter_by(room_id=msg.room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '无权限'}), 403 + existing = MessageReaction.query.filter_by(message_id=msg_id, user_id=user_id, emoji=emoji).first() + if existing: + db.session.delete(existing) + action = 'removed' + else: + db.session.add(MessageReaction(message_id=msg_id, user_id=user_id, emoji=emoji)) + action = 'added' + db.session.commit() + # 获取该消息所有 reactions 汇总 + reactions = _get_reactions_summary(msg_id) + socketio.emit('reaction_updated', { + 'message_id': msg_id, 'room_id': msg.room_id, 'reactions': reactions + }, room=f'room_{msg.room_id}') + return jsonify({'success': True, 'action': action, 'reactions': reactions}) + +def _get_reactions_summary(msg_id): + """汇总消息的表情回应""" + all_r = MessageReaction.query.filter_by(message_id=msg_id).all() + summary = {} + for r in all_r: + if r.emoji not in summary: + summary[r.emoji] = {'count': 0, 'users': []} + summary[r.emoji]['count'] += 1 + u = User.query.get(r.user_id) + if u: + summary[r.emoji]['users'].append({'id': u.id, 'name': u.name}) + return summary + +@app.route('/api/chat/rooms//voice', methods=['POST']) +@login_required +def api_upload_voice(room_id): + """上传语音消息""" + user_id = session['user']['id'] + member = ChatRoomMember.query.filter_by(room_id=room_id, user_id=user_id).first() + if not member: + return jsonify({'success': False, 'message': '无权限'}), 403 + if member.muted: + return jsonify({'success': False, 'message': '您已被禁言'}), 403 + file = request.files.get('file') + if not file: + return jsonify({'success': False, 'message': '无文件'}), 400 + filename = secure_filename(f'voice_{user_id}_{int(time.time()*1000)}.webm') + upload_dir = os.path.join('static', 'uploads', 'voice') + os.makedirs(upload_dir, exist_ok=True) + filepath = os.path.join(upload_dir, filename) + file.save(filepath) + url = '/' + filepath.replace('\\', '/') + duration = request.form.get('duration', '0') + msg = Message(room_id=room_id, sender_id=user_id, type='voice', + content=f'{duration}s', file_url=url) + db.session.add(msg) + db.session.commit() + sender = User.query.get(user_id) + socketio.emit('new_message', { + 'id': msg.id, 'room_id': room_id, 'sender_id': user_id, + 'sender_name': sender.name if sender else '', 'sender_avatar': (sender.avatar or '') if sender else '', + 'type': 'voice', 'content': msg.content, 'file_url': url, + 'file_name': None, 'reply_to': None, 'mentions': '', + 'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S') + }, room=f'room_{room_id}') + return jsonify({'success': True}) + +# ========== 管理后台页面 ========== +@app.route('/admin') +def admin_dashboard(): + user = require_admin_or_teacher() + return render_template('admin_dashboard.html') + +@app.route('/admin/contests') +def admin_contests(): + user = require_admin_or_teacher() + return render_template('admin_contests.html') + +@app.route('/admin/exams') +def admin_exams(): + user = require_admin_or_teacher() + return render_template('admin_exams.html') + +@app.route('/admin/posts') +def admin_posts(): + user = require_admin_or_teacher() + return render_template('admin_posts.html') + +@app.route('/admin/users') +def admin_users(): + user = require_admin_or_teacher() + return render_template('admin_users.html') + +# ========== 管理后台 API ========== +@app.route('/api/admin/contests', methods=['GET', 'POST']) +def admin_contests_api(): + user = require_admin_or_teacher() + if request.method == 'GET': + contests = Contest.query.all() + data = [{ + 'id': c.id, + 'name': c.name, + 'organizer': c.organizer, + 'start_date': c.start_date, + 'description': c.description, + 'contact': c.contact if hasattr(c, 'contact') else '', + 'status': c.status + } for c in contests] + return jsonify({'success': True, 'contests': data}) + elif request.method == 'POST': + data = request.get_json() + contest = Contest( + name=data['name'], + organizer=data['organizer'], + start_date=data['start_date'], + description=data['description'], + contact=data.get('contact', ''), + status=data['status'] + ) + db.session.add(contest) + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/admin/contests/', methods=['GET', 'PUT', 'DELETE']) +def admin_contest_detail(contest_id): + user = require_admin_or_teacher() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}) + if request.method == 'GET': + return jsonify({'success': True, 'contest': { + 'id': contest.id, + 'name': contest.name, + 'organizer': contest.organizer, + 'start_date': contest.start_date, + 'description': contest.description, + 'contact': contest.contact if hasattr(contest, 'contact') else '', + 'status': contest.status + }}) + elif request.method == 'PUT': + data = request.get_json() + contest.name = data.get('name', contest.name) + contest.organizer = data.get('organizer', contest.organizer) + contest.start_date = data.get('start_date', contest.start_date) + contest.description = data.get('description', contest.description) + contest.contact = data.get('contact', contest.contact if hasattr(contest, 'contact') else '') + contest.status = data.get('status', contest.status) + db.session.commit() + return jsonify({'success': True}) + elif request.method == 'DELETE': + db.session.delete(contest) + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/admin/contests//abolish', methods=['POST']) +@admin_required +def admin_abolish_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 + contest.status = 'abolished' + # 关闭该杯赛下所有考试 + for exam in contest.exams: + exam.status = 'closed' + # 通知杯赛负责人和老师 + for member in contest.members: + add_notification(member.user_id, 'contest_result', + f'杯赛「{contest.name}」已被管理员废止。', from_user='系统') + # 通知所有已报名用户 + registrations = ContestRegistration.query.filter_by(contest_id=contest_id).all() + for reg in registrations: + add_notification(reg.user_id, 'contest_result', + f'您报名的杯赛「{contest.name}」已被废止。', from_user='系统') + db.session.commit() + return jsonify({'success': True, 'message': '杯赛已废止'}) + +@app.route('/api/admin/users', methods=['GET']) +def admin_users_api(): + user = require_admin_or_teacher() + q = request.args.get('q', '') + role = request.args.get('role', '') + query = User.query + if q: + query = query.filter((User.name.contains(q)) | (User.email.contains(q))) + if role: + query = query.filter_by(role=role) + users = query.all() + data = [{ + 'id': u.id, + 'name': u.name, + 'email': u.email, + 'role': u.role, + 'is_banned': u.is_banned, + 'created_at': u.created_at.strftime('%Y-%m-%d %H:%M') + } for u in users] + return jsonify({'success': True, 'users': data}) + +@app.route('/api/admin/users/', methods=['DELETE']) +def admin_delete_user(user_id): + user = require_admin_or_teacher() + if user_id == session.get('user', {}).get('id'): + return jsonify({'success': False, 'message': '不能删除自己'}) + target = User.query.get(user_id) + if not target: + return jsonify({'success': False, 'message': '用户不存在'}) + if target.role == 'admin': + return jsonify({'success': False, 'message': '不能删除管理员'}) + db.session.delete(target) + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/admin/posts', methods=['GET']) +def admin_posts_api(): + user = require_admin_or_teacher() + q = request.args.get('q', '') + tag = request.args.get('tag', '') + query = Post.query + if q: + query = query.filter((Post.title.contains(q)) | (Post.content.contains(q))) + if tag: + query = query.filter_by(tag=tag) + posts = query.all() + data = [{ + 'id': p.id, + 'title': p.title, + 'author': p.author.name if p.author else '未知用户', + 'tag': p.tag, + 'pinned': p.pinned, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M') + } for p in posts] + return jsonify({'success': True, 'posts': data}) + +@app.route('/api/admin/posts/', methods=['DELETE']) +def admin_delete_post(post_id): + user = require_admin_or_teacher() + post = Post.query.get(post_id) + if post: + db.session.delete(post) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '帖子不存在'}) + +@app.route('/api/admin/exams', methods=['GET']) +def admin_exams_api(): + user = require_admin_or_teacher() + exams = Exam.query.all() + data = [{ + 'id': e.id, + 'title': e.title, + 'subject': e.subject, + 'creator_name': e.creator.name if e.creator else '', + 'status': e.status, + 'created_at': e.created_at.strftime('%Y-%m-%d %H:%M') + } for e in exams] + return jsonify({'success': True, 'exams': data}) + +@app.route('/api/admin/exams//status', methods=['PUT']) +def admin_update_exam_status(exam_id): + user = require_admin_or_teacher() + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '考试不存在'}) + data = request.get_json() + exam.status = data.get('status', exam.status) + db.session.commit() + return jsonify({'success': True}) + +@app.route('/api/admin/exams/', methods=['DELETE']) +def admin_delete_exam(exam_id): + user = require_admin_or_teacher() + exam = Exam.query.get(exam_id) + if exam: + db.session.delete(exam) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '考试不存在'}) + +@app.route('/api/admin/users//ban', methods=['PUT']) +@admin_required +def admin_ban_user(user_id): + current = get_current_user() + if user_id == current['id']: + return jsonify({'success': False, 'message': '不能封禁自己'}), 400 + target = User.query.get(user_id) + if not target: + return jsonify({'success': False, 'message': '用户不存在'}), 404 + target.is_banned = not target.is_banned + db.session.commit() + return jsonify({'success': True, 'is_banned': target.is_banned}) + +@app.route('/api/admin/posts//pin', methods=['PUT']) +def admin_pin_post(post_id): + user = require_admin_or_teacher() + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + post.pinned = not post.pinned + db.session.commit() + return jsonify({'success': True, 'pinned': post.pinned}) + +@app.route('/api/admin/contest-applications', methods=['GET']) +@admin_required +def admin_contest_applications_api(): + apps = ContestApplication.query.order_by(ContestApplication.applied_at.desc()).all() + data = [{ + 'id': a.id, + 'user_name': a.user.name if a.user else '未知用户', + 'name': a.name, + 'organizer': a.organizer, + 'description': a.description, + 'contact': a.contact, + 'start_date': a.start_date or '', + 'end_date': a.end_date or '', + 'total_score': a.total_score or 150, + 'responsible_person': a.responsible_person or '', + 'responsible_phone': a.responsible_phone or '', + 'responsible_email': a.responsible_email or '', + 'organization': a.organization or '', + 'status': a.status, + 'applied_at': a.applied_at.strftime('%Y-%m-%d %H:%M') if a.applied_at else '' + } for a in apps] + return jsonify({'success': True, 'applications': data}) + +@app.route('/api/admin/stats', methods=['GET']) +def admin_stats(): + user = require_admin_or_teacher() + stats = { + 'users': User.query.count(), + 'posts': Post.query.count(), + 'exams': Exam.query.count(), + 'contests': Contest.query.count(), + 'pending_teacher_apps': TeacherApplication.query.filter_by(status='pending').count(), + 'pending_contest_apps': ContestApplication.query.filter_by(status='pending').count() + } + return jsonify({'success': True, 'stats': stats}) + +@app.route('/api/admin/recent-activities', methods=['GET']) +def admin_recent_activities(): + user = require_admin_or_teacher() + activities = [] + for s in Submission.query.order_by(Submission.submitted_at.desc()).limit(5).all(): + activities.append({ + 'time': s.submitted_at.strftime('%Y-%m-%d %H:%M'), + 'user': s.user.name if s.user else '未知用户', + 'action': f'提交了试卷 ID {s.exam_id}' + }) + for p in Post.query.order_by(Post.created_at.desc()).limit(5).all(): + activities.append({ + 'time': p.created_at.strftime('%Y-%m-%d %H:%M'), + 'user': p.author.name if p.author else '未知用户', + 'action': f'发布了帖子《{p.title}》' + }) + activities.sort(key=lambda x: x['time'], reverse=True) + return jsonify({'success': True, 'activities': activities[:10]}) + +# ========== 初始化测试数据 ========== +with app.app_context(): + db.create_all() + # 迁移:添加 avatar 列(如果不存在) + try: + from sqlalchemy import inspect as _inspect, text as _text + insp = _inspect(db.engine) + cols = [c['name'] for c in insp.get_columns('user')] + if 'avatar' not in cols: + with db.engine.connect() as conn: + conn.execute(_text('ALTER TABLE user ADD COLUMN avatar VARCHAR(200) DEFAULT ""')) + conn.commit() + except Exception: + pass + # 迁移:添加 score_release_time 列(如果不存在) + try: + from sqlalchemy import inspect as _inspect2, text as _text2 + insp2 = _inspect2(db.engine) + exam_cols = [c['name'] for c in insp2.get_columns('exam')] + if 'score_release_time' not in exam_cols: + with db.engine.connect() as conn: + conn.execute(_text2('ALTER TABLE exam ADD COLUMN score_release_time DATETIME')) + conn.commit() + except Exception: + pass + # 迁移:添加 name_changed_at 列(如果不存在) + try: + from sqlalchemy import inspect as _inspect_nc, text as _text_nc + insp_nc = _inspect_nc(db.engine) + user_cols = [c['name'] for c in insp_nc.get_columns('user')] + if 'name_changed_at' not in user_cols: + with db.engine.connect() as conn: + conn.execute(_text_nc('ALTER TABLE user ADD COLUMN name_changed_at DATETIME')) + conn.commit() + except Exception: + pass + # 迁移:为已有杯赛创建讨论群 + try: + for contest in Contest.query.all(): + if not ChatRoom.query.filter_by(contest_id=contest.id).first(): + cr = ChatRoom(type='contest', name=contest.name + ' 讨论群', + creator_id=contest.created_by if isinstance(contest.created_by, int) else None, + contest_id=contest.id) + db.session.add(cr) + db.session.flush() + if isinstance(contest.created_by, int): + db.session.add(ChatRoomMember(room_id=cr.id, user_id=contest.created_by, role='admin')) + db.session.commit() + except Exception: + db.session.rollback() + # 迁移:ContestApplication 添加 total_score, start_date, end_date + try: + from sqlalchemy import inspect as _inspect_ca, text as _text_ca + insp_ca = _inspect_ca(db.engine) + ca_cols = [c['name'] for c in insp_ca.get_columns('contest_application')] + with db.engine.connect() as conn: + if 'total_score' not in ca_cols: + conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN total_score INTEGER DEFAULT 150')) + if 'start_date' not in ca_cols: + conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN start_date VARCHAR(20)')) + if 'end_date' not in ca_cols: + conn.execute(_text_ca('ALTER TABLE contest_application ADD COLUMN end_date VARCHAR(20)')) + conn.commit() + except Exception: + pass + # 迁移:Contest 添加 total_score, visible + try: + from sqlalchemy import inspect as _inspect_ct, text as _text_ct + insp_ct = _inspect_ct(db.engine) + ct_cols = [c['name'] for c in insp_ct.get_columns('contest')] + with db.engine.connect() as conn: + if 'total_score' not in ct_cols: + conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN total_score INTEGER DEFAULT 150')) + if 'visible' not in ct_cols: + conn.execute(_text_ct('ALTER TABLE contest ADD COLUMN visible BOOLEAN DEFAULT 1')) + conn.commit() + except Exception: + pass + # 迁移:ContestApplication 添加报备字段 + try: + from sqlalchemy import inspect as _inspect_ca2, text as _text_ca2 + insp_ca2 = _inspect_ca2(db.engine) + ca2_cols = [c['name'] for c in insp_ca2.get_columns('contest_application')] + with db.engine.connect() as conn: + if 'responsible_person' not in ca2_cols: + conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_person VARCHAR(80)')) + if 'responsible_phone' not in ca2_cols: + conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_phone VARCHAR(20)')) + if 'responsible_email' not in ca2_cols: + conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN responsible_email VARCHAR(120)')) + if 'organization' not in ca2_cols: + conn.execute(_text_ca2('ALTER TABLE contest_application ADD COLUMN organization VARCHAR(100)')) + conn.commit() + except Exception: + pass + # 迁移:Contest 添加报备字段 + try: + from sqlalchemy import inspect as _inspect_ct2, text as _text_ct2 + insp_ct2 = _inspect_ct2(db.engine) + ct2_cols = [c['name'] for c in insp_ct2.get_columns('contest')] + with db.engine.connect() as conn: + if 'responsible_person' not in ct2_cols: + conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_person VARCHAR(80)')) + if 'responsible_phone' not in ct2_cols: + conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_phone VARCHAR(20)')) + if 'responsible_email' not in ct2_cols: + conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN responsible_email VARCHAR(120)')) + if 'organization' not in ct2_cols: + conn.execute(_text_ct2('ALTER TABLE contest ADD COLUMN organization VARCHAR(100)')) + conn.commit() + except Exception: + pass + # 迁移:Exam 添加加密字段 + try: + from sqlalchemy import inspect as _inspect_ex, text as _text_ex + insp_ex = _inspect_ex(db.engine) + ex_cols = [c['name'] for c in insp_ex.get_columns('exam')] + with db.engine.connect() as conn: + if 'access_password' not in ex_cols: + conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN access_password VARCHAR(128)')) + if 'encrypted_questions' not in ex_cols: + conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN encrypted_questions TEXT')) + if 'is_encrypted' not in ex_cols: + conn.execute(_text_ex('ALTER TABLE exam ADD COLUMN is_encrypted BOOLEAN DEFAULT 0')) + conn.commit() + except Exception: + pass +if __name__ == '__main__': + socketio.run(app, debug=True, port=5000) \ No newline at end of file diff --git a/clean_data.py b/clean_data.py new file mode 100644 index 0000000..392f8be --- /dev/null +++ b/clean_data.py @@ -0,0 +1,49 @@ +import os +from app import app, db +from models import ( + Contest, ContestMembership, ContestApplication, ContestRegistration, + Exam, Submission, Draft, ExamBookmark, + Post, Reply, Poll, Reaction, Bookmark, Report, + QuestionBankItem, Notification, SystemNotification, + TeacherApplication, InviteCode, ChatRoom, ChatRoomMember, Message +) + +def clean_test_data(): + with app.app_context(): + # 删除所有跟杯赛相关的记录 + ContestMembership.query.delete() + ContestApplication.query.delete() + ContestRegistration.query.delete() + QuestionBankItem.query.delete() + TeacherApplication.query.delete() + InviteCode.query.delete() + Contest.query.delete() + + # 删除所有跟考试相关的记录 + Submission.query.delete() + Draft.query.delete() + ExamBookmark.query.delete() + Exam.query.delete() + + # 删除所有跟帖子相关的记录 + Reply.query.delete() + Poll.query.delete() + Reaction.query.delete() + Bookmark.query.delete() + Report.query.delete() + Post.query.delete() + + # 删除聊天系统相关的记录(因为有自动为杯赛建群的功能,顺便清一下比较干净) + Message.query.delete() + ChatRoomMember.query.delete() + ChatRoom.query.delete() + + # 也可以清理系统通知、普通通知 + Notification.query.delete() + SystemNotification.query.delete() + + db.session.commit() + print("所有测试的杯赛、考试、帖子及相关数据已成功删除!") + +if __name__ == '__main__': + clean_test_data() diff --git a/dontshushme/app(4).py b/dontshushme/app(4).py new file mode 100644 index 0000000..6df7ed7 --- /dev/null +++ b/dontshushme/app(4).py @@ -0,0 +1,2067 @@ +# app.py +import os +import time +import json +import random +import string +import smtplib +from datetime import datetime, timedelta +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from functools import wraps + +from flask import ( + Flask, render_template, request, jsonify, session, + redirect, url_for, flash, abort +) +from dotenv import load_dotenv +from captcha.image import ImageCaptcha +from aliyunsdkcore.client import AcsClient +from aliyunsdkcore.request import CommonRequest + +# 引入所有数据库模型 +from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark + +load_dotenv() + +app = Flask(__name__) +app.secret_key = os.urandom(24) + +# 内存存储(用于临时验证码) +captcha_store = {} +email_codes = {} + +# 数据库配置 +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db.init_app(app) + +# 阿里云号码认证服务客户端 +acs_client = AcsClient( + os.getenv('ALIBABA_CLOUD_ACCESS_KEY_ID', ''), + os.getenv('ALIBABA_CLOUD_ACCESS_KEY_SECRET', ''), + 'cn-hangzhou' +) + +# ========== 辅助函数 ========== + +def generate_captcha_id(): + return ''.join(random.choices(string.ascii_lowercase + string.digits, k=16)) + +def generate_code(length=6): + return ''.join(random.choices(string.digits, k=length)) + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if 'user' not in session: + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + +def teacher_required(f): + @wraps(f) + def decorated(*args, **kwargs): + user = session.get('user') + if not user: + return redirect(url_for('login')) + if user.get('role') != 'teacher': + return redirect(url_for('exam_list')) + return f(*args, **kwargs) + return decorated + +def send_email(to_email, subject, html_content): + smtp_host = os.getenv('SMTP_HOST', 'smtp.163.com') + smtp_port = int(os.getenv('SMTP_PORT', 465)) + smtp_user = os.getenv('SMTP_USER', '') + smtp_pass = os.getenv('SMTP_PASS', '') + + msg = MIMEMultipart('alternative') + msg['From'] = f'"验证码" <{smtp_user}>' + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(html_content, 'html', 'utf-8')) + + with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: + server.login(smtp_user, smtp_pass) + server.sendmail(smtp_user, to_email, msg.as_string()) + +def get_current_user(): + return session.get('user') + +def require_login(): + user = get_current_user() + if not user: + abort(401) + return user + +def require_admin_or_teacher(): + user = require_login() + if user['role'] not in ['admin', 'teacher']: + abort(403) + return user + +# 积分等级相关 +LEVEL_TITLES = {1:'新手上路',2:'初出茅庐',3:'小有名气',4:'渐入佳境',5:'驾轻就熟',6:'炉火纯青',7:'学富五车',8:'出类拔萃',9:'登峰造极',10:'一代宗师'} + +def calc_level(points): + if points >= 5000: return 10 + if points >= 3000: return 9 + if points >= 2000: return 8 + if points >= 1200: return 7 + if points >= 800: return 6 + if points >= 500: return 5 + if points >= 300: return 4 + if points >= 150: return 3 + if points >= 50: return 2 + return 1 + +def add_notification(user_id, ntype, content, from_user='', post_id=0): + notif = Notification( + user_id=user_id, + type=ntype, + content=content, + from_user=from_user, + post_id=post_id + ) + db.session.add(notif) + db.session.commit() + +# ========== 杯赛讨论区发帖权限检查 ========== +def can_post_in_contest(user, contest): + """检查用户是否可以在指定杯赛的讨论区发帖""" + if user['role'] in ['admin', 'teacher']: + return True + # 杯赛负责人或老师 + membership = ContestMembership.query.filter_by(user_id=user['id'], contest_id=contest.id).first() + if membership and membership.role in ['owner', 'teacher']: + return True + # 报名且至少参与一次考试 + registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest.id).first() + if not registration: + return False + # 获取该杯赛下的所有考试 ID + exam_ids = [exam.id for exam in contest.exams] + if not exam_ids: + return False + # 查询用户在这些考试中的提交记录 + submission_count = Submission.query.filter( + Submission.user_id == user['id'], + Submission.exam_id.in_(exam_ids) + ).count() + return submission_count >= 1 + +# ========== 上下文处理器 ========== +@app.context_processor +def inject_user(): + return {'user': get_current_user()} + +# ========== 页面路由 ========== +@app.route('/') +def home(): + return render_template('home.html') + +@app.route('/login', methods=['GET']) +def login(): + return render_template('login.html') + +@app.route('/register', methods=['GET']) +def register(): + return render_template('register.html') + +@app.route('/logout') +def logout(): + session.pop('user', None) + return redirect(url_for('login')) + +@app.route('/contests') +def contest_list(): + return render_template('contest_list.html') + +@app.route('/contests/') +def contest_detail(contest_id): + contest = Contest.query.get(contest_id) + if not contest: + return redirect(url_for('contest_list')) + user = get_current_user() + registered = False + can_post = False + if user: + # 检查是否报名 + registered = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() is not None + # 检查是否有发帖权限 + can_post = can_post_in_contest(user, contest) + return render_template('contest_detail.html', contest=contest, registered=registered, can_post=can_post) + +@app.route('/exams') +@login_required +def exam_list(): + user = session.get('user') + + search_query = request.args.get('q', '').strip() + subject_filter = request.args.get('subject', '').strip() + + query = Exam.query + if subject_filter: + query = query.filter_by(subject=subject_filter) + if search_query: + query = query.filter( + (Exam.title.contains(search_query)) | (Exam.subject.contains(search_query)) + ) + all_exams = query.all() + + all_subjects = db.session.query(Exam.subject).distinct().all() + all_subjects = [s[0] for s in all_subjects if s[0]] + + user_submissions = {} + for sub in Submission.query.filter_by(user_id=user.get('id')).all(): + user_submissions[sub.exam_id] = { + 'id': sub.id, + 'graded': sub.graded, + 'score': sub.score + } + + return render_template( + 'exam_list.html', + exams=all_exams, + user_submissions=user_submissions, + search_query=search_query, + subject_filter=subject_filter, + all_subjects=all_subjects + ) + +@app.route('/exams/create') +@teacher_required +def exam_create(): + return render_template('exam_create.html') + +@app.route('/exams/') +@login_required +def exam_detail(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + user = session.get('user') + existing = Submission.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() + return render_template('exam_detail.html', exam=exam, existing_submission=existing, draft=draft) + +@app.route('/exams//result') +@login_required +def exam_result(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + user = session.get('user') + submission = Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first() + if not submission: + return redirect(url_for('exam_detail', exam_id=exam_id)) + return render_template('exam_result.html', exam=exam, submission=submission) + +@app.route('/exams//submissions') +@teacher_required +def exam_submissions(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + subs = Submission.query.filter_by(exam_id=exam_id).all() + stats = {} + if subs: + graded_subs = [s for s in subs if s.graded] + scores = [s.score for s in graded_subs] if graded_subs else [] + stats = { + 'total': len(subs), + 'graded': len(graded_subs), + 'ungraded': len(subs) - len(graded_subs), + 'avg_score': round(sum(scores) / len(scores), 1) if scores else 0, + 'max_score': max(scores) if scores else 0, + 'min_score': min(scores) if scores else 0, + } + next_ungraded = None + for s in subs: + if not s.graded: + next_ungraded = s.id + break + return render_template('exam_submissions.html', exam=exam, submissions=subs, stats=stats, next_ungraded=next_ungraded) + +@app.route('/exams//grade/') +@teacher_required +def exam_grade(exam_id, sub_id): + exam = Exam.query.get(exam_id) + sub = Submission.query.get(sub_id) + if not exam or not sub: + return redirect(url_for('exam_list')) + next_ungraded = None + next_sub = Submission.query.filter_by(exam_id=exam_id, graded=False).filter(Submission.id != sub_id).first() + if next_sub: + next_ungraded = next_sub.id + return render_template('exam_grade.html', exam=exam, submission=sub, next_ungraded=next_ungraded) + +@app.route('/exams//print') +@login_required +def exam_print(exam_id): + exam = Exam.query.get(exam_id) + if not exam: + return redirect(url_for('exam_list')) + return render_template('exam_print.html', exam=exam) + +@app.route('/forum') +def forum(): + return render_template('forum.html') + +# ========== 个人中心页面 ========== +@app.route('/profile') +@login_required +def profile(): + return render_template('profile.html') + + +# ========== 个人中心 API ========== +@app.route('/api/user/friends') +@login_required +def api_user_friends(): + user_id = session['user']['id'] + friendships = Friend.query.filter( + ((Friend.user_id == user_id) | (Friend.friend_id == user_id)) & + (Friend.status == 'accepted') + ).all() + friends = [] + for f in friendships: + if f.user_id == user_id: + friend_user = User.query.get(f.friend_id) + else: + friend_user = User.query.get(f.user_id) + if friend_user: + friends.append({ + 'id': friend_user.id, + 'name': friend_user.name, + 'avatar': friend_user.avatar if hasattr(friend_user, 'avatar') else '', + 'created_at': f.created_at.strftime('%Y-%m-%d') + }) + return jsonify({'success': True, 'friends': friends}) + + +@app.route('/api/user/posts') +@login_required +def api_user_posts(): + user_id = session['user']['id'] + posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all() + data = [{ + 'id': p.id, + 'title': p.title, + 'content': p.content[:100] + '...' if len(p.content) > 100 else p.content, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + 'replies': p.replies_count, + 'likes': p.likes + } for p in posts] + return jsonify({'success': True, 'posts': data}) + + +@app.route('/api/user/exam-bookmarks') +@login_required +def api_user_exam_bookmarks(): + user_id = session['user']['id'] + bookmarks = ExamBookmark.query.filter_by(user_id=user_id).order_by(ExamBookmark.created_at.desc()).all() + data = [] + for bm in bookmarks: + exam = bm.exam + if exam: + data.append({ + 'id': exam.id, + 'title': exam.title, + 'subject': exam.subject, + 'total_score': exam.total_score, + 'duration': exam.duration, + 'status': exam.status, + 'bookmarked_at': bm.created_at.strftime('%Y-%m-%d %H:%M') + }) + return jsonify({'success': True, 'bookmarks': data}) + + +@app.route('/api/exams//bookmark', methods=['POST', 'DELETE']) +@login_required +def api_toggle_exam_bookmark(exam_id): + user_id = session['user']['id'] + if request.method == 'POST': + existing = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first() + if existing: + return jsonify({'success': False, 'message': '已经收藏过了'}), 400 + bookmark = ExamBookmark(user_id=user_id, exam_id=exam_id) + db.session.add(bookmark) + db.session.commit() + return jsonify({'success': True, 'message': '收藏成功', 'bookmarked': True}) + elif request.method == 'DELETE': + bookmark = ExamBookmark.query.filter_by(user_id=user_id, exam_id=exam_id).first() + if not bookmark: + return jsonify({'success': False, 'message': '未收藏'}), 400 + db.session.delete(bookmark) + db.session.commit() + return jsonify({'success': True, 'message': '已取消收藏', 'bookmarked': False}) + + +@app.route('/api/friend/add', methods=['POST']) +@login_required +def api_add_friend(): + user_id = session['user']['id'] + data = request.get_json() + friend_id = data.get('friend_id') + if not friend_id or friend_id == user_id: + return jsonify({'success': False, 'message': '无效的好友ID'}), 400 + existing = Friend.query.filter( + ((Friend.user_id == user_id) & (Friend.friend_id == friend_id)) | + ((Friend.user_id == friend_id) & (Friend.friend_id == user_id)) + ).first() + if existing: + return jsonify({'success': False, 'message': '已经是好友或已发送请求'}), 400 + friend_req = Friend(user_id=user_id, friend_id=friend_id, status='pending') + db.session.add(friend_req) + db.session.commit() + return jsonify({'success': True, 'message': '好友请求已发送'}) + + +@app.route('/api/friend/accept/', methods=['POST']) +@login_required +def api_accept_friend(request_id): + user_id = session['user']['id'] + req = Friend.query.get(request_id) + if not req or req.friend_id != user_id or req.status != 'pending': + return jsonify({'success': False, 'message': '请求不存在或无权操作'}), 404 + req.status = 'accepted' + db.session.commit() + return jsonify({'success': True, 'message': '好友已添加'}) + + +# ========== 教师申请路由 ========== +@app.route('/apply-teacher', methods=['GET', 'POST']) +@login_required +def apply_teacher(): + if request.method == 'POST': + name = request.form.get('name', '').strip() + email = request.form.get('email', '').strip() + reason = request.form.get('reason', '').strip() + if not name or not email or not reason: + flash('请填写完整信息', 'error') + return redirect(url_for('apply_teacher')) + user = session['user'] + # 检查是否已经申请过且为pending + existing = TeacherApplication.query.filter_by(user_id=user['id'], status='pending').first() + if existing: + flash('您已提交过申请,请耐心等待审核', 'error') + return redirect(url_for('apply_teacher')) + # 创建申请记录 + appli = TeacherApplication( + user_id=user['id'], + name=name, + email=email, + reason=reason + ) + db.session.add(appli) + db.session.commit() + flash('申请已提交,管理员会尽快审核', 'success') + return redirect(url_for('home')) + return render_template('apply_teacher.html') + + +# ========== 杯赛申请路由 ========== +@app.route('/apply-contest', methods=['GET', 'POST']) +@login_required +def apply_contest(): + if request.method == 'POST': + # 这里可以扩展为保存申请到数据库(需创建 ContestApplication 模型) + flash('申请已提交,管理员会尽快审核', 'success') + return redirect(url_for('contest_list')) + return render_template('apply_contest.html') + + +# ========== API 路由 ========== + +@app.route('/api/captcha') +def api_captcha(): + global captcha_store + captcha_text = ''.join(random.choices(string.digits, k=4)) + captcha_id = generate_captcha_id() + image = ImageCaptcha(width=150, height=50, font_sizes=[40]) + data = image.generate(captcha_text) + import base64 + img_base64 = base64.b64encode(data.read()).decode('utf-8') + captcha_store[captcha_id] = { + 'text': captcha_text.lower(), + 'expires': time.time() + 300 + } + expired = [k for k, v in captcha_store.items() if time.time() > v['expires']] + for k in expired: + del captcha_store[k] + return jsonify({'captchaId': captcha_id, 'img': img_base64}) + + +@app.route('/api/send-email-code', methods=['POST']) +def api_send_email_code(): + global captcha_store, email_codes + data = request.get_json() + email = data.get('email', '') + captcha_id = data.get('captchaId', '') + captcha_text = data.get('captchaText', '') + + if not email: + return jsonify({'success': False, 'message': '邮箱不能为空'}), 400 + if not captcha_id or not captcha_text: + return jsonify({'success': False, 'message': '请输入图形验证码'}), 400 + + record = captcha_store.get(captcha_id) + if not record or time.time() > record['expires']: + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码已过期', 'refreshCaptcha': True}), 400 + if record['text'] != captcha_text.lower(): + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400 + captcha_store.pop(captcha_id, None) + + code = generate_code(6) + email_codes[email] = {'code': code, 'expires': time.time() + 300} + + try: + send_email(email, '您的注册验证码', + f'

您的验证码是:{code},5分钟内有效。

') + print(f'邮箱验证码已发送到 {email}: {code}') + return jsonify({'success': True, 'message': '验证码已发送到邮箱'}) + except Exception as e: + print(f'发送邮件失败: {e}') + email_codes.pop(email, None) + return jsonify({'success': False, 'message': f'发送邮件失败: {str(e)}'}), 500 + + +# ========== 注册相关 API ========== + +@app.route('/api/register', methods=['POST']) +def api_register(): + """邮箱注册(需绑定手机号)""" + global email_codes + data = request.get_json() + name = data.get('name', '') + email = data.get('email', '') + phone = data.get('phone', '') + password = data.get('password', '') + invite_code = data.get('inviteCode', '') + email_code = data.get('emailCode', '') + + if not name or not email or not password or not phone: + return jsonify({'success': False, 'message': '请填写完整信息'}), 400 + if not email_code: + return jsonify({'success': False, 'message': '请输入邮箱验证码'}), 400 + if not phone.isdigit() or len(phone) != 11: + return jsonify({'success': False, 'message': '手机号格式不正确'}), 400 + + record = email_codes.get(email) + if not record or time.time() > record['expires']: + email_codes.pop(email, None) + return jsonify({'success': False, 'message': '邮箱验证码已过期', 'refreshCaptcha': True}), 400 + if record['code'] != email_code: + email_codes.pop(email, None) + return jsonify({'success': False, 'message': '邮箱验证码错误', 'refreshCaptcha': True}), 400 + email_codes.pop(email, None) + + if User.query.filter_by(email=email).first(): + return jsonify({'success': False, 'message': '该邮箱已注册'}), 400 + if User.query.filter_by(phone=phone).first(): + return jsonify({'success': False, 'message': '该手机号已绑定其他账户'}), 400 + + role = 'teacher' if invite_code else 'student' + user = User(name=name, email=email, phone=phone, password=password, role=role) + db.session.add(user) + db.session.commit() + + user_data = {'name': user.name, 'email': user.email, 'phone': user.phone, 'role': user.role, 'id': user.id} + session['user'] = user_data + return jsonify({'success': True, 'message': '注册成功', 'user': user_data}) + + +@app.route('/api/register-mobile', methods=['POST']) +def api_register_mobile(): + """手机号注册""" + data = request.get_json() + name = data.get('name', '') + phone = data.get('phone', '') + password = data.get('password', '') + invite_code = data.get('inviteCode', '') + sms_code = data.get('smsCode', '') + + if not name or not phone or not password or not sms_code: + return jsonify({'success': False, 'message': '请填写完整信息'}), 400 + if not phone.isdigit() or len(phone) != 11: + return jsonify({'success': False, 'message': '手机号格式不正确'}), 400 + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('CheckSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('VerifyCode', sms_code) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}') + model = result.get('Model', {}) + if not (result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS'): + return jsonify({'success': False, 'message': '短信验证码错误或已过期'}), 400 + except Exception as e: + print(f'验证短信验证码出错: {e}') + return jsonify({'success': False, 'message': '短信验证码验证失败'}), 500 + + if User.query.filter_by(phone=phone).first(): + return jsonify({'success': False, 'message': '该手机号已注册'}), 400 + + role = 'teacher' if invite_code else 'student' + user = User(name=name, phone=phone, password=password, role=role) + db.session.add(user) + db.session.commit() + + user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id} + session['user'] = user_data + return jsonify({'success': True, 'message': '注册成功', 'user': user_data}) + + +# ========== 登录相关 API ========== + +@app.route('/api/login', methods=['POST']) +def api_login(): + data = request.get_json() + email = data.get('email', '') + password = data.get('password', '') + + if not email or not password: + return jsonify({'success': False, 'message': '请输入邮箱和密码'}), 400 + + user = User.query.filter_by(email=email).first() + if not user: + return jsonify({'success': False, 'message': '账号不存在,请先注册'}), 400 + if user.password != password: + return jsonify({'success': False, 'message': '密码错误'}), 400 + + user_data = {'name': user.name, 'email': user.email, 'role': user.role, 'id': user.id} + session['user'] = user_data + return jsonify({'success': True, 'message': '登录成功', 'user': user_data}) + + +@app.route('/api/send-sms', methods=['POST']) +def api_send_sms(): + global captcha_store + data = request.get_json() + phone = data.get('phone', '') + captcha_id = data.get('captchaId', '') + captcha_text = data.get('captchaText', '') + + if not phone: + return jsonify({'success': False, 'message': '手机号不能为空'}), 400 + if not captcha_id or not captcha_text: + return jsonify({'success': False, 'message': '请输入图形验证码'}), 400 + + record = captcha_store.get(captcha_id) + if not record or time.time() > record['expires']: + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码已过期,请刷新', 'refreshCaptcha': True}), 400 + if record['text'] != captcha_text.lower(): + captcha_store.pop(captcha_id, None) + return jsonify({'success': False, 'message': '图形验证码错误', 'refreshCaptcha': True}), 400 + captcha_store.pop(captcha_id, None) + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('SendSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('SignName', os.getenv('SIGN_NAME', '')) + req.add_query_param('TemplateCode', os.getenv('TEMPLATE_CODE', '')) + req.add_query_param('TemplateParam', json.dumps({'code': '##code##', 'min': '5'})) + req.add_query_param('CountryCode', '86') + req.add_query_param('ValidTime', 300) + req.add_query_param('Interval', 60) + req.add_query_param('ReturnVerifyCode', True) + req.add_query_param('CodeType', 1) + req.add_query_param('CodeLength', 4) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'号码认证服务返回: {json.dumps(result, ensure_ascii=False, indent=2)}') + + if result.get('Code') == 'OK': + resp_data = {'success': True, 'message': '验证码发送成功'} + model = result.get('Model', {}) + if model.get('VerifyCode'): + resp_data['mockCode'] = model['VerifyCode'] + print(f'验证码: {model["VerifyCode"]}') + return jsonify(resp_data) + else: + print(f'发送失败: {result.get("Message")}') + return jsonify({'success': False, 'message': result.get('Message', '发送失败')}), 500 + except Exception as e: + print(f'发送短信出错: {e}') + return jsonify({'success': False, 'message': f'发送短信出错: {str(e)}'}), 500 + + +@app.route('/api/verify-code', methods=['POST']) +def api_verify_code(): + data = request.get_json() + phone = data.get('phone', '') + code = data.get('code', '') + + if not phone or not code: + return jsonify({'success': False, 'message': '手机号和验证码不能为空'}), 400 + + try: + req = CommonRequest() + req.set_accept_format('json') + req.set_domain('dypnsapi.aliyuncs.com') + req.set_method('POST') + req.set_protocol_type('https') + req.set_version('2017-05-25') + req.set_action_name('CheckSmsVerifyCode') + req.add_query_param('PhoneNumber', phone) + req.add_query_param('VerifyCode', code) + + response = acs_client.do_action_with_exception(req) + result = json.loads(response) + print(f'验证结果: {json.dumps(result, ensure_ascii=False, indent=2)}') + + model = result.get('Model', {}) + if result.get('Code') == 'OK' and model.get('VerifyResult') == 'PASS': + user = User.query.filter_by(phone=phone).first() + if not user: + user = User( + name=f'用户{phone[-4:]}', + phone=phone, + password=generate_code(8), + role='student' + ) + db.session.add(user) + db.session.commit() + user_data = {'name': user.name, 'phone': user.phone, 'role': user.role, 'id': user.id} + session['user'] = user_data + return jsonify({'success': True, 'message': '验证成功', 'user': user_data}) + else: + msg = '验证码已过期' if model.get('VerifyResult') == 'UNKNOWN' else '验证码错误' + return jsonify({'success': False, 'message': msg}), 400 + except Exception as e: + print(f'验证出错: {e}') + return jsonify({'success': False, 'message': '验证码错误或已过期'}), 400 + + +# ========== 考试系统 API ========== + +@app.route('/api/exams', methods=['POST']) +def api_create_exam(): + user = session.get('user') + if not user or user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权限'}), 403 + data = request.get_json() + title = data.get('title', '') + subject = data.get('subject', '') + duration = data.get('duration', 120) + questions = data.get('questions', []) + if not title or not questions: + return jsonify({'success': False, 'message': '请填写试卷标题和题目'}), 400 + total_score = sum(q.get('score', 0) for q in questions) + exam = Exam( + title=title, + subject=subject, + duration=duration, + total_score=total_score, + creator_id=user.get('id') + ) + exam.set_questions(questions) + db.session.add(exam) + db.session.commit() + return jsonify({'success': True, 'message': '试卷创建成功', 'exam_id': exam.id}) + + +@app.route('/api/exams//save-draft', methods=['POST']) +def api_save_draft(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 + data = request.get_json() + answers = data.get('answers', {}) + draft = Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first() + if not draft: + draft = Draft(exam_id=exam_id, user_id=user.get('id')) + draft.set_answers(answers) + db.session.add(draft) + db.session.commit() + return jsonify({'success': True, 'message': '草稿已保存'}) + + +@app.route('/api/exams//submit', methods=['POST']) +def api_submit_exam(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 exam.status == 'closed': + return jsonify({'success': False, 'message': '该考试已关闭'}), 400 + if Submission.query.filter_by(exam_id=exam_id, user_id=user.get('id')).first(): + return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400 + data = request.get_json() + answers = data.get('answers', {}) + score = 0 + auto_graded = True + question_scores = {} + questions = exam.get_questions() + for q in questions: + qid = str(q['id']) + if q['type'] == 'choice': + if answers.get(qid) == q.get('answer'): + score += q.get('score', 0) + question_scores[qid] = q.get('score', 0) + else: + question_scores[qid] = 0 + elif q['type'] == 'fill': + student_answer = answers.get(qid, '').strip() + correct_answers = [a.strip() for a in q.get('answer', '').split('|')] + if student_answer in correct_answers: + score += q.get('score', 0) + question_scores[qid] = q.get('score', 0) + else: + question_scores[qid] = 0 + else: + auto_graded = False + question_scores[qid] = 0 + sub = Submission( + exam_id=exam_id, + user_id=user.get('id'), + score=score, + graded=auto_graded, + graded_by='系统自动' if auto_graded else '' + ) + 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() + return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id}) + + +@app.route('/api/exams//grade/', methods=['POST']) +def api_grade_submission(exam_id, sub_id): + user = session.get('user') + if not user or user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权限'}), 403 + sub = Submission.query.get(sub_id) + if not sub or sub.exam_id != exam_id: + return jsonify({'success': False, 'message': '提交记录不存在'}), 404 + data = request.get_json() + scores = data.get('scores', {}) + total = sum(scores.values()) + sub.score = total + sub.set_question_scores(scores) + sub.graded = True + sub.graded_by = user.get('name', '') + db.session.commit() + return jsonify({'success': True, 'message': '批改完成', 'total_score': total}) + + +@app.route('/api/exams//status', methods=['POST']) +def api_update_exam_status(exam_id): + user = session.get('user') + if not user or user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权限'}), 403 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '试卷不存在'}), 404 + if exam.creator_id != user.get('id'): + return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403 + data = request.get_json() + new_status = data.get('status', '') + if new_status not in ('available', 'closed'): + return jsonify({'success': False, 'message': '无效状态'}), 400 + exam.status = new_status + db.session.commit() + return jsonify({'success': True, 'message': f'试卷已{"发布" if new_status == "available" else "关闭"}'}) + + +@app.route('/api/exams/', methods=['DELETE']) +def api_delete_exam(exam_id): + user = session.get('user') + if not user or user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权限'}), 403 + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '试卷不存在'}), 404 + if exam.creator_id != user.get('id'): + return jsonify({'success': False, 'message': '只能删除自己创建的试卷'}), 403 + Submission.query.filter_by(exam_id=exam_id).delete() + Draft.query.filter_by(exam_id=exam_id).delete() + db.session.delete(exam) + db.session.commit() + return jsonify({'success': True, 'message': '试卷已删除'}) + + +# ========== 杯赛相关 API ========== + +@app.route('/api/contests/search') +def api_search_contests(): + keyword = request.args.get('q', '').strip().lower() + query = Contest.query + if keyword: + query = query.filter( + (Contest.name.contains(keyword)) | + (Contest.organizer.contains(keyword)) | + (Contest.description.contains(keyword)) + ) + contests = query.all() + data = [{ + 'id': c.id, + 'name': c.name, + 'organizer': c.organizer, + 'description': c.description, + 'start_date': c.start_date, + 'end_date': c.end_date, + 'status': c.status, + 'participants': c.participants, + 'created_by': c.created_by + } for c in contests] + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/contests', methods=['POST']) +def api_create_contest(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + contest = Contest( + name=data.get('name'), + organizer=data.get('organizer'), + description=data.get('description'), + start_date=data.get('start_date'), + end_date=data.get('end_date'), + status=data.get('status', 'upcoming'), + participants=data.get('participants', 0), + created_by=user.get('id') + ) + db.session.add(contest) + db.session.commit() + return jsonify({'success': True, 'message': '杯赛创建成功', 'contest_id': contest.id}) + + +# ========== 杯赛报名 API ========== + +@app.route('/api/contests//register', methods=['POST']) +@login_required +def api_register_contest(contest_id): + """用户报名杯赛""" + user = session['user'] + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + + existing = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if existing: + return jsonify({'success': False, 'message': '您已报名该杯赛'}), 400 + + registration = ContestRegistration(user_id=user['id'], contest_id=contest_id) + db.session.add(registration) + contest.participants += 1 + db.session.commit() + + return jsonify({'success': True, 'message': '报名成功', 'participants': contest.participants}) + + +@app.route('/api/contests//unregister', methods=['POST']) +@login_required +def api_unregister_contest(contest_id): + """用户取消报名""" + user = session['user'] + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}), 404 + + registration = ContestRegistration.query.filter_by(user_id=user['id'], contest_id=contest_id).first() + if not registration: + return jsonify({'success': False, 'message': '您尚未报名该杯赛'}), 400 + + db.session.delete(registration) + if contest.participants > 0: + contest.participants -= 1 + db.session.commit() + + return jsonify({'success': True, 'message': '已取消报名', 'participants': contest.participants}) + + +# ========== 杯赛讨论区 API ========== + +@app.route('/api/contests//posts', methods=['GET']) +def api_contest_posts(contest_id): + """获取杯赛讨论区帖子列表""" + contest = Contest.query.get_or_404(contest_id) + posts = Post.query.filter_by(contest_id=contest_id).order_by(Post.created_at.desc()).all() + user = get_current_user() + uid = user['id'] if user else None + data = [] + for p in posts: + p_dict = { + 'id': p.id, + 'title': p.title, + 'content': p.content[:200] + ('...' if len(p.content) > 200 else ''), + 'author': p.author.name, + 'author_id': p.author_id, + 'tag': p.tag, + 'is_official': p.is_official, + 'pinned': p.pinned, + 'likes': p.likes, + 'replies': p.replies_count, + 'views': p.views, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + } + if uid: + p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None + data.append(p_dict) + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/contests//posts', methods=['POST']) +@login_required +def api_create_contest_post(contest_id): + """在杯赛讨论区发布帖子""" + user = session['user'] + contest = Contest.query.get_or_404(contest_id) + + # 权限检查 + if not can_post_in_contest(user, contest): + return jsonify({'success': False, 'message': '您没有权限在此杯赛讨论区发帖'}), 403 + + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + if len(title) > 100: + return jsonify({'success': False, 'message': '标题不能超过100字'}), 400 + + post = Post( + title=title, + content=content, + author_id=user['id'], + tag='杯赛讨论', # 可自定义 + contest_id=contest_id, + is_official=(user['role'] in ['admin', 'teacher'] and data.get('is_official', False)) + ) + db.session.add(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + + +# ========== 论坛相关 API ========== + +@app.route('/api/posts/search') +def api_search_posts(): + keyword = request.args.get('q', '').strip().lower() + tag = request.args.get('tag', '').strip() + sort = request.args.get('sort', 'newest') + + # 只获取非杯赛的帖子(contest_id 为 NULL) + query = Post.query.filter_by(contest_id=None) + + if keyword: + query = query.filter( + (Post.title.contains(keyword)) | (Post.content.contains(keyword)) + ) + if tag: + query = query.filter_by(tag=tag) + + if sort == 'hottest': + query = query.order_by(Post.likes.desc()) + elif sort == 'most_replies': + query = query.order_by(Post.replies_count.desc()) + else: + query = query.order_by(Post.created_at.desc()) + + posts = query.all() + user = session.get('user') + uid = user.get('id') if user else None + data = [] + for p in posts: + p_dict = { + 'id': p.id, + 'title': p.title, + 'content': p.content[:200] + ('...' if len(p.content) > 200 else ''), + 'author': p.author.name, + 'author_id': p.author_id, + 'tag': p.tag, + 'is_official': p.is_official, + 'pinned': p.pinned, + 'likes': p.likes, + 'replies': p.replies_count, + 'views': p.views, + 'has_poll': p.has_poll, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M'), + } + if uid: + p_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=p.id).first() is not None + p_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=p.id).first() is not None + data.append(p_dict) + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/posts', methods=['POST']) +def api_create_post(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + tag = data.get('tag', '全部') + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + if len(title) > 100: + return jsonify({'success': False, 'message': '标题不能超过100字'}), 400 + post = Post( + title=title, + content=content, + author_id=user.get('id'), + tag=tag, + is_official=(user.get('role') == 'teacher' and data.get('is_official', False)) + ) + db.session.add(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + + +@app.route('/api/posts/') +def api_get_post(post_id): + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + post.views += 1 + db.session.commit() + + replies = Reply.query.filter_by(post_id=post_id).order_by(Reply.created_at).all() + user = session.get('user') + uid = user.get('id') if user else None + post_dict = { + 'id': post.id, + 'title': post.title, + 'content': post.content, + 'author': post.author.name, + 'author_id': post.author_id, + 'tag': post.tag, + 'is_official': post.is_official, + 'pinned': post.pinned, + 'likes': post.likes, + 'replies': post.replies_count, + 'views': post.views, + 'has_poll': post.has_poll, + 'created_at': post.created_at.strftime('%Y-%m-%d %H:%M'), + 'edited': (post.updated_at > post.created_at), + } + if uid: + post_dict['liked'] = Reaction.query.filter_by(user_id=uid, post_id=post.id).first() is not None + post_dict['bookmarked'] = Bookmark.query.filter_by(user_id=uid, post_id=post.id).first() is not None + replies_data = [] + for r in replies: + r_dict = { + 'id': r.id, + 'content': r.content, + 'author': r.author.name, + 'author_id': r.author_id, + 'likes': r.likes, + 'reply_to': r.reply_to, + 'created_at': r.created_at.strftime('%Y-%m-%d %H:%M'), + 'edited': (r.updated_at > r.created_at), + } + if uid: + r_dict['liked'] = Reaction.query.filter_by(user_id=uid, reply_id=r.id).first() is not None + replies_data.append(r_dict) + return jsonify({'success': True, 'data': post_dict, 'replies': replies_data}) + + +@app.route('/api/posts/', methods=['DELETE']) +def api_delete_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id') and user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权删除'}), 403 + db.session.delete(post) + db.session.commit() + return jsonify({'success': True, 'message': '帖子已删除'}) + + +@app.route('/api/posts//like', methods=['POST']) +def api_like_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first() + if reaction: + db.session.delete(reaction) + post.likes -= 1 + db.session.commit() + return jsonify({'success': True, 'liked': False, 'likes': post.likes}) + else: + reaction = Reaction(user_id=uid, post_id=post_id, reaction='like') + db.session.add(reaction) + post.likes += 1 + db.session.commit() + return jsonify({'success': True, 'liked': True, 'likes': post.likes}) + + +@app.route('/api/posts//bookmark', methods=['POST']) +def api_bookmark_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + uid = user.get('id') + bookmark = Bookmark.query.filter_by(user_id=uid, post_id=post_id).first() + if bookmark: + db.session.delete(bookmark) + db.session.commit() + return jsonify({'success': True, 'bookmarked': False}) + else: + bookmark = Bookmark(user_id=uid, post_id=post_id) + db.session.add(bookmark) + db.session.commit() + return jsonify({'success': True, 'bookmarked': True}) + + +@app.route('/api/posts//pin', methods=['POST']) +def api_pin_post(post_id): + user = session.get('user') + if not user or user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权限'}), 403 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + post.pinned = not post.pinned + db.session.commit() + return jsonify({'success': True, 'pinned': post.pinned}) + + +@app.route('/api/posts//replies', methods=['POST']) +def api_create_reply(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + data = request.get_json() + content = data.get('content', '').strip() + if not content: + return jsonify({'success': False, 'message': '回复内容不能为空'}), 400 + reply = Reply( + post_id=post_id, + author_id=user.get('id'), + content=content, + reply_to=data.get('reply_to', '') + ) + db.session.add(reply) + post.replies_count += 1 + db.session.commit() + return jsonify({'success': True, 'message': '回复成功', 'reply': { + 'id': reply.id, + 'content': reply.content, + 'author': user.get('name'), + 'author_id': user.get('id'), + 'likes': 0, + 'reply_to': reply.reply_to, + 'created_at': reply.created_at.strftime('%Y-%m-%d %H:%M') + }}) + + +@app.route('/api/replies//like', methods=['POST']) +def api_like_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, reply_id=reply_id).first() + if reaction: + db.session.delete(reaction) + reply.likes -= 1 + db.session.commit() + return jsonify({'success': True, 'liked': False, 'likes': reply.likes}) + else: + reaction = Reaction(user_id=uid, reply_id=reply_id, reaction='like') + db.session.add(reaction) + reply.likes += 1 + db.session.commit() + return jsonify({'success': True, 'liked': True, 'likes': reply.likes}) + + +@app.route('/api/replies/', methods=['DELETE']) +def api_delete_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + if reply.author_id != user.get('id') and user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权删除'}), 403 + post = Post.query.get(reply.post_id) + if post: + post.replies_count -= 1 + db.session.delete(reply) + db.session.commit() + return jsonify({'success': True, 'message': '回复已删除'}) + + +@app.route('/api/user/bookmarks') +def api_user_bookmarks(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + uid = user.get('id') + bookmarks = Bookmark.query.filter_by(user_id=uid).all() + post_ids = [b.post_id for b in bookmarks] + posts = Post.query.filter(Post.id.in_(post_ids)).all() + data = [{ + 'id': p.id, + 'title': p.title, + 'author': p.author.name, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M') + } for p in posts] + return jsonify({'success': True, 'data': data}) + + +# ========== 论坛增强 API ========== + +@app.route('/api/posts//edit', methods=['POST']) +def api_edit_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id') and user.get('role') != 'teacher': + return jsonify({'success': False, 'message': '无权编辑'}), 403 + data = request.get_json() + new_title = data.get('title', '').strip() + new_content = data.get('content', '').strip() + new_tag = data.get('tag', post.tag) + if not new_title or not new_content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + post.title = new_title + post.content = new_content + post.tag = new_tag + db.session.commit() + return jsonify({'success': True, 'message': '编辑成功'}) + + +@app.route('/api/replies//edit', methods=['POST']) +def api_edit_reply(reply_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + reply = Reply.query.get(reply_id) + if not reply: + return jsonify({'success': False, 'message': '回复不存在'}), 404 + if reply.author_id != user.get('id'): + return jsonify({'success': False, 'message': '无权编辑'}), 403 + data = request.get_json() + new_content = data.get('content', '').strip() + if not new_content: + return jsonify({'success': False, 'message': '内容不能为空'}), 400 + reply.content = new_content + db.session.commit() + return jsonify({'success': True, 'message': '编辑成功'}) + + +@app.route('/api/posts//poll', methods=['POST']) +def api_create_poll(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + if post.author_id != user.get('id'): + return jsonify({'success': False, 'message': '只有作者可以创建投票'}), 403 + if post.poll: + return jsonify({'success': False, 'message': '该帖子已有投票'}), 400 + data = request.get_json() + question = data.get('question', '').strip() + options = data.get('options', []) + multi = data.get('multi', False) + if not question or len(options) < 2: + return jsonify({'success': False, 'message': '请填写问题和至少2个选项'}), 400 + poll = Poll( + post_id=post_id, + question=question, + multi=multi + ) + poll.set_options([{'text': o, 'votes': 0} for o in options]) + poll.set_voters({}) + db.session.add(poll) + post.has_poll = True + db.session.commit() + return jsonify({'success': True, 'message': '投票创建成功'}) + + +@app.route('/api/posts//vote', methods=['POST']) +def api_vote_poll(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + poll = Poll.query.filter_by(post_id=post_id).first() + if not poll: + return jsonify({'success': False, 'message': '投票不存在'}), 404 + uid = user.get('id') + voters = poll.get_voters() + if str(uid) in voters: + return jsonify({'success': False, 'message': '您已投过票'}), 400 + data = request.get_json() + choices = data.get('choices', []) + if not choices: + return jsonify({'success': False, 'message': '请选择选项'}), 400 + if not poll.multi and len(choices) > 1: + return jsonify({'success': False, 'message': '该投票为单选'}), 400 + options = poll.get_options() + for idx in choices: + if 0 <= idx < len(options): + options[idx]['votes'] += 1 + voters[str(uid)] = choices + poll.set_options(options) + poll.set_voters(voters) + poll.total_votes += 1 + db.session.commit() + return jsonify({'success': True, 'message': '投票成功', 'poll': { + 'options': options, + 'total_votes': poll.total_votes + }}) + + +@app.route('/api/posts//poll') +def api_get_poll(post_id): + poll = Poll.query.filter_by(post_id=post_id).first() + if not poll: + return jsonify({'success': False, 'message': '投票不存在'}), 404 + user = session.get('user') + voted = False + my_choices = [] + if user: + voters = poll.get_voters() + if str(user.get('id')) in voters: + voted = True + my_choices = voters[str(user.get('id'))] + return jsonify({ + 'success': True, + 'poll': { + 'question': poll.question, + 'options': poll.get_options(), + 'total_votes': poll.total_votes, + 'multi': poll.multi + }, + 'voted': voted, + 'my_choices': my_choices + }) + + +@app.route('/api/report', methods=['POST']) +def api_report(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + rtype = data.get('type', '') + target_id = data.get('target_id', 0) + reason = data.get('reason', '') + detail = data.get('detail', '') + if not rtype or not target_id or not reason: + return jsonify({'success': False, 'message': '请填写举报信息'}), 400 + report = Report( + type=rtype, + target_id=target_id, + reporter_id=user.get('id'), + reason=reason, + detail=detail + ) + db.session.add(report) + db.session.commit() + return jsonify({'success': True, 'message': '举报已提交,管理员将尽快处理'}) + + +@app.route('/api/posts//react', methods=['POST']) +def api_react_post(post_id): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + post = Post.query.get(post_id) + if not post: + return jsonify({'success': False, 'message': '帖子不存在'}), 404 + data = request.get_json() + reaction_type = data.get('reaction', '') + if reaction_type not in ('like', 'love', 'haha', 'wow', 'sad', 'angry'): + return jsonify({'success': False, 'message': '无效反应'}), 400 + uid = user.get('id') + reaction = Reaction.query.filter_by(user_id=uid, post_id=post_id).first() + if reaction: + if reaction.reaction == reaction_type: + db.session.delete(reaction) + else: + reaction.reaction = reaction_type + else: + reaction = Reaction(user_id=uid, post_id=post_id, reaction=reaction_type) + db.session.add(reaction) + db.session.commit() + counts = {} + for r in Reaction.query.filter_by(post_id=post_id).all(): + counts[r.reaction] = counts.get(r.reaction, 0) + 1 + return jsonify({'success': True, 'reactions': counts}) + + +@app.route('/api/user/profile/') +def api_user_profile(user_id): + user = User.query.get(int(user_id)) if user_id.isdigit() else None + if not user: + return jsonify({'success': False, 'message': '用户不存在'}), 404 + post_count = Post.query.filter_by(author_id=user.id).count() + reply_count = Reply.query.filter_by(author_id=user.id).count() + likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=user.id).scalar() or 0 + points = post_count * 10 + reply_count * 3 + likes_received * 2 + level = calc_level(points) + recent_posts = Post.query.filter_by(author_id=user.id).order_by(Post.created_at.desc()).limit(5).all() + recent_posts_data = [{'id': p.id, 'title': p.title} for p in recent_posts] + badges = [] + if post_count >= 1: badges.append({'icon': '✍️', 'name': '初次发帖', 'desc': '发布第一篇帖子'}) + if post_count >= 10: badges.append({'icon': '📝', 'name': '笔耕不辍', 'desc': '发布10篇帖子'}) + if post_count >= 50: badges.append({'icon': '📚', 'name': '著作等身', 'desc': '发布50篇帖子'}) + if reply_count >= 10: badges.append({'icon': '💬', 'name': '热心回复', 'desc': '回复10次'}) + if reply_count >= 100: badges.append({'icon': '🗣️', 'name': '话题达人', 'desc': '回复100次'}) + if likes_received >= 10: badges.append({'icon': '👍', 'name': '小有人气', 'desc': '获得10个赞'}) + if likes_received >= 100: badges.append({'icon': '🌟', 'name': '人气之星', 'desc': '获得100个赞'}) + if likes_received >= 500: badges.append({'icon': '👑', 'name': '万人迷', 'desc': '获得500个赞'}) + return jsonify({ + 'success': True, + 'profile': { + 'user_id': user.id, + 'name': user.name, + 'points': points, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, ''), + 'posts_count': post_count, + 'replies_count': reply_count, + 'likes_received': likes_received, + 'badges': badges, + 'recent_posts': recent_posts_data + } + }) + + +@app.route('/api/forum/hot') +def api_hot_posts(): + posts = Post.query.order_by((Post.likes*3 + Post.replies_count*2 + Post.views).desc()).limit(10).all() + data = [{'id': p.id, 'title': p.title, 'likes': p.likes, 'replies': p.replies_count} for p in posts] + return jsonify({'success': True, 'data': data}) + + +@app.route('/api/forum/stats') +def api_forum_stats(): + total_posts = Post.query.count() + total_replies = Reply.query.count() + total_users = User.query.count() + today = datetime.utcnow().date() + today_posts = Post.query.filter(db.func.date(Post.created_at) == today).count() + today_replies = Reply.query.filter(db.func.date(Reply.created_at) == today).count() + online_count = random.randint(max(15, total_users), max(50, total_users * 3)) + tag_counts = {} + for tag in db.session.query(Post.tag, db.func.count(Post.tag)).group_by(Post.tag).all(): + tag_counts[tag[0]] = tag[1] + active_users = [] + recent_posts_users = Post.query.order_by(Post.created_at.desc()).limit(10).all() + seen = set() + for p in recent_posts_users: + if p.author_id not in seen and len(active_users) < 5: + seen.add(p.author_id) + points = (Post.query.filter_by(author_id=p.author_id).count()*10 + + Reply.query.filter_by(author_id=p.author_id).count()*3) + level = calc_level(points) + active_users.append({ + 'user_id': p.author_id, + 'name': p.author.name, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, '') + }) + return jsonify({ + 'success': True, + 'stats': { + 'total_posts': total_posts, + 'total_replies': total_replies, + 'total_users': total_users, + 'today_posts': today_posts, + 'today_replies': today_replies, + 'online_count': online_count, + 'tag_counts': tag_counts, + 'active_users': active_users + } + }) + + +@app.route('/api/forum/leaderboard') +def api_leaderboard(): + users = User.query.all() + board = [] + for u in users: + post_count = Post.query.filter_by(author_id=u.id).count() + reply_count = Reply.query.filter_by(author_id=u.id).count() + likes_received = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=u.id).scalar() or 0 + points = post_count * 10 + reply_count * 3 + likes_received * 2 + level = calc_level(points) + board.append({ + 'user_id': u.id, + 'name': u.name, + 'points': points, + 'level': level, + 'level_title': LEVEL_TITLES.get(level, ''), + 'posts_count': post_count, + 'likes_received': likes_received + }) + board.sort(key=lambda x: x['points'], reverse=True) + return jsonify({'success': True, 'data': board[:20]}) + + +@app.route('/api/posts/with-poll', methods=['POST']) +def api_create_post_with_poll(): + user = session.get('user') + if not user: + return jsonify({'success': False, 'message': '请先登录'}), 401 + data = request.get_json() + title = data.get('title', '').strip() + content = data.get('content', '').strip() + tag = data.get('tag', '全部') + if not title or not content: + return jsonify({'success': False, 'message': '标题和内容不能为空'}), 400 + post = Post( + title=title, + content=content, + author_id=user.get('id'), + tag=tag, + is_official=(user.get('role') == 'teacher' and data.get('is_official', False)) + ) + db.session.add(post) + db.session.flush() + poll_data = data.get('poll') + if poll_data and poll_data.get('question') and len(poll_data.get('options', [])) >= 2: + poll = Poll( + post_id=post.id, + question=poll_data['question'], + multi=poll_data.get('multi', False) + ) + poll.set_options([{'text': o, 'votes': 0} for o in poll_data['options']]) + poll.set_voters({}) + db.session.add(poll) + post.has_poll = True + db.session.commit() + return jsonify({'success': True, 'message': '帖子发布成功', 'post_id': post.id}) + + +@app.route('/api/posts//share', methods=['POST']) +def api_share_post(post_id): + return jsonify({'success': True}) + + +# ========== 管理后台页面 ========== +@app.route('/admin') +def admin_dashboard(): + user = require_admin_or_teacher() + return render_template('admin_dashboard.html') + +@app.route('/admin/contests') +def admin_contests(): + user = require_admin_or_teacher() + return render_template('admin_contests.html') + +@app.route('/admin/exams') +def admin_exams(): + user = require_admin_or_teacher() + return render_template('admin_exams.html') + +@app.route('/admin/posts') +def admin_posts(): + user = require_admin_or_teacher() + return render_template('admin_posts.html') + +@app.route('/admin/users') +def admin_users(): + user = require_admin_or_teacher() + return render_template('admin_users.html') + + +# ========== 管理后台 API ========== +@app.route('/api/admin/contests', methods=['GET', 'POST']) +def admin_contests_api(): + user = require_admin_or_teacher() + if request.method == 'GET': + contests = Contest.query.all() + data = [{ + 'id': c.id, + 'name': c.name, + 'organizer': c.organizer, + 'start_date': c.start_date, + 'description': c.description, + 'contact': c.contact if hasattr(c, 'contact') else '', + 'status': c.status + } for c in contests] + return jsonify({'success': True, 'contests': data}) + elif request.method == 'POST': + data = request.get_json() + contest = Contest( + name=data['name'], + organizer=data['organizer'], + start_date=data['start_date'], + description=data['description'], + contact=data.get('contact', ''), + status=data['status'] + ) + db.session.add(contest) + db.session.commit() + return jsonify({'success': True}) + + +@app.route('/api/admin/contests/', methods=['GET', 'PUT', 'DELETE']) +def admin_contest_detail(contest_id): + user = require_admin_or_teacher() + contest = Contest.query.get(contest_id) + if not contest: + return jsonify({'success': False, 'message': '杯赛不存在'}) + if request.method == 'GET': + return jsonify({'success': True, 'contest': { + 'id': contest.id, + 'name': contest.name, + 'organizer': contest.organizer, + 'start_date': contest.start_date, + 'description': contest.description, + 'contact': contest.contact if hasattr(contest, 'contact') else '', + 'status': contest.status + }}) + elif request.method == 'PUT': + data = request.get_json() + contest.name = data.get('name', contest.name) + contest.organizer = data.get('organizer', contest.organizer) + contest.start_date = data.get('start_date', contest.start_date) + contest.description = data.get('description', contest.description) + contest.contact = data.get('contact', contest.contact if hasattr(contest, 'contact') else '') + contest.status = data.get('status', contest.status) + db.session.commit() + return jsonify({'success': True}) + elif request.method == 'DELETE': + db.session.delete(contest) + db.session.commit() + return jsonify({'success': True}) + + +@app.route('/api/admin/users', methods=['GET']) +def admin_users_api(): + user = require_admin_or_teacher() + q = request.args.get('q', '') + role = request.args.get('role', '') + query = User.query + if q: + query = query.filter((User.name.contains(q)) | (User.email.contains(q))) + if role: + query = query.filter_by(role=role) + users = query.all() + data = [{ + 'id': u.id, + 'name': u.name, + 'email': u.email, + 'role': u.role, + 'created_at': u.created_at.strftime('%Y-%m-%d %H:%M') + } for u in users] + return jsonify({'success': True, 'users': data}) + + +@app.route('/api/admin/users/', methods=['DELETE']) +def admin_delete_user(user_id): + user = require_admin_or_teacher() + if user_id == session.get('user', {}).get('id'): + return jsonify({'success': False, 'message': '不能删除自己'}) + target = User.query.get(user_id) + if target: + db.session.delete(target) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '用户不存在'}) + + +@app.route('/api/admin/posts', methods=['GET']) +def admin_posts_api(): + user = require_admin_or_teacher() + q = request.args.get('q', '') + tag = request.args.get('tag', '') + query = Post.query + if q: + query = query.filter((Post.title.contains(q)) | (Post.content.contains(q))) + if tag: + query = query.filter_by(tag=tag) + posts = query.all() + data = [{ + 'id': p.id, + 'title': p.title, + 'author': p.author.name, + 'tag': p.tag, + 'created_at': p.created_at.strftime('%Y-%m-%d %H:%M') + } for p in posts] + return jsonify({'success': True, 'posts': data}) + + +@app.route('/api/admin/posts/', methods=['DELETE']) +def admin_delete_post(post_id): + user = require_admin_or_teacher() + post = Post.query.get(post_id) + if post: + db.session.delete(post) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '帖子不存在'}) + + +@app.route('/api/admin/exams', methods=['GET']) +def admin_exams_api(): + user = require_admin_or_teacher() + exams = Exam.query.all() + data = [{ + 'id': e.id, + 'title': e.title, + 'subject': e.subject, + 'creator_name': e.creator.name if e.creator else '', + 'status': e.status, + 'created_at': e.created_at.strftime('%Y-%m-%d %H:%M') + } for e in exams] + return jsonify({'success': True, 'exams': data}) + + +@app.route('/api/admin/exams//status', methods=['PUT']) +def admin_update_exam_status(exam_id): + user = require_admin_or_teacher() + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '考试不存在'}) + data = request.get_json() + exam.status = data.get('status', exam.status) + db.session.commit() + return jsonify({'success': True}) + + +@app.route('/api/admin/exams/', methods=['DELETE']) +def admin_delete_exam(exam_id): + user = require_admin_or_teacher() + exam = Exam.query.get(exam_id) + if exam: + db.session.delete(exam) + db.session.commit() + return jsonify({'success': True}) + return jsonify({'success': False, 'message': '考试不存在'}) + + +@app.route('/api/admin/stats', methods=['GET']) +def admin_stats(): + user = require_admin_or_teacher() + stats = { + 'users': User.query.count(), + 'posts': Post.query.count(), + 'exams': Exam.query.count(), + 'contests': Contest.query.count() + } + return jsonify({'success': True, 'stats': stats}) + + +@app.route('/api/admin/recent-activities', methods=['GET']) +def admin_recent_activities(): + user = require_admin_or_teacher() + activities = [] + for s in Submission.query.order_by(Submission.submitted_at.desc()).limit(5).all(): + activities.append({ + 'time': s.submitted_at.strftime('%Y-%m-%d %H:%M'), + 'user': s.user_name, + 'action': f'提交了试卷 ID {s.exam_id}' + }) + for p in Post.query.order_by(Post.created_at.desc()).limit(5).all(): + activities.append({ + 'time': p.created_at.strftime('%Y-%m-%d %H:%M'), + 'user': p.author.name, + 'action': f'发布了帖子《{p.title}》' + }) + activities.sort(key=lambda x: x['time'], reverse=True) + return jsonify({'success': True, 'activities': activities[:10]}) + + +# ========== 初始化测试数据 ========== +with app.app_context(): + db.create_all() + # 仅当数据库为空时添加测试数据 + if Contest.query.count() == 0: + contests = [ + Contest( + name='2026年"星火杯"全国中学生数学联考', + organizer='星火杯组委会', + description='本届星火杯旨在选拔优秀数学人才,试题难度对标全国高中数学联赛。', + start_date='2026-03-15', + end_date='2026-03-15', + status='registering', + participants=12500, + created_by='system', + _past_papers=json.dumps([ + {"year": "2025", "title": "2025年星火杯真题(含答案)", "file": "/static/papers/2025_spark.pdf"}, + {"year": "2024", "title": "2024年星火杯真题", "file": "/static/papers/2024_spark.pdf"}, + {"year": "2023", "title": "2023年星火杯真题解析", "file": "/static/papers/2023_spark_analysis.pdf"} + ]) + ), + Contest( + name='第十一届"圆梦杯"综合能力测试', + organizer='圆梦教育基金会', + description='圆梦杯已成功举办十届,是国内最具影响力的综合素质评价赛事之一。', + start_date='2026-04-01', + end_date='2026-04-01', + status='upcoming', + participants=8000, + created_by='system', + _past_papers=json.dumps([ + {"year": "2025", "title": "第十届圆梦杯真题(语数英综合)", "file": "/static/papers/2025_dream.pdf"}, + {"year": "2024", "title": "第九届圆梦杯真题及详解", "file": "/static/papers/2024_dream.pdf"} + ]) + ), + Contest( + name='2025年冬季联考(已结束)', + organizer='联考联盟', + description='回顾2025年冬季联考精彩瞬间,查看优秀试卷与解析。', + start_date='2025-12-20', + end_date='2025-12-20', + status='ended', + participants=15000, + created_by='system', + _past_papers=json.dumps([ + {"year": "2025", "title": "2025冬季联考数学试卷", "file": "/static/papers/2025_winter_math.pdf"}, + {"year": "2025", "title": "2025冬季联考英语试卷", "file": "/static/papers/2025_winter_english.pdf"}, + {"year": "2024", "title": "2024冬季联考理综试卷", "file": "/static/papers/2024_winter_science.pdf"} + ]) + ) + ] + db.session.add_all(contests) + + if Post.query.count() == 0: + # 确保有一个测试用户 + test_user = User.query.filter_by(email='test@example.com').first() + if not test_user: + test_user = User(name='测试用户', email='test@example.com', password='123456', role='student') + db.session.add(test_user) + db.session.commit() + + posts = [ + Post( + title='关于2026年星火杯初赛第12题的讨论', + content='大家觉得第12题的第二问是不是有点超纲了?我用洛必达法则算出来的结果是1/2...', + author_id=test_user.id, + tag='题目讨论', + is_official=False, + likes=45, + replies_count=5, + views=320, + has_poll=True + ), + Post( + title='【官方通知】关于圆梦杯报名延期的通知', + content='因系统维护,原定于2月20日截止的报名时间延长至2月28日...', + author_id=test_user.id, + tag='官方公告', + is_official=True, + pinned=True, + likes=128, + replies_count=3, + views=1560 + ), + Post( + title='分享一些备考资料和心得(持续更新)', + content='大家好!我是去年星火杯一等奖获得者,整理了一些备考资料和心得分享给大家...', + author_id=test_user.id, + tag='经验分享', + is_official=False, + likes=89, + replies_count=8, + views=890 + ), + Post( + title='求助:数列通项公式怎么求?', + content='已知数列 {aₙ} 满足 a₁=1, aₙ₊₁ = 2aₙ + 1,求通项公式 aₙ', + author_id=test_user.id, + tag='求助答疑', + is_official=False, + likes=12, + replies_count=6, + views=156 + ), + Post( + title='今天的模拟考大家考得怎么样?', + content='刚考完今天的模拟考,感觉难度比去年真题大不少啊!...', + author_id=test_user.id, + tag='闲聊灌水', + is_official=False, + likes=67, + replies_count=15, + views=445, + has_poll=True + ) + ] + db.session.add_all(posts) + db.session.commit() + + replies = [ + Reply(post_id=1, author_id=test_user.id, content='这道题确实用泰勒展开更简单。答案是1/120', likes=23), + Reply(post_id=1, author_id=test_user.id, content='谢谢!原来是泰勒展开到第五阶', likes=5, reply_to='学霸本霸'), + Reply(post_id=3, author_id=test_user.id, content='感谢分享!错题本真的很重要', likes=12), + Reply(post_id=4, author_id=test_user.id, content='令 bₙ = aₙ + 1,则 bₙ₊₁ = 2bₙ,所以 aₙ = 2ⁿ - 1', likes=18) + ] + db.session.add_all(replies) + db.session.commit() + + poll1 = Poll( + post_id=1, + question='你觉得第12题的答案是什么?', + multi=False + ) + poll1.set_options([{'text': '1/2', 'votes': 15}, {'text': '1/3', 'votes': 8}, {'text': '1/120', 'votes': 42}, {'text': '其他', 'votes': 3}]) + poll1.set_voters({'test3': [2], 'test4': [0], 'test5': [2]}) + poll1.total_votes = 68 + db.session.add(poll1) + + poll5 = Poll( + post_id=5, + question='今天模拟考你觉得难度如何?', + multi=False + ) + poll5.set_options([{'text': '简单', 'votes': 5}, {'text': '适中', 'votes': 18}, {'text': '偏难', 'votes': 35}, {'text': '太难', 'votes': 22}]) + poll5.set_voters({'test1': [2], 'test3': [1], 'test4': [3]}) + poll5.total_votes = 80 + db.session.add(poll5) + db.session.commit() + + +if __name__ == '__main__': + app.run(debug=True, port=5000) \ No newline at end of file diff --git a/dontshushme/models(1).py b/dontshushme/models(1).py new file mode 100644 index 0000000..d0b2a44 --- /dev/null +++ b/dontshushme/models(1).py @@ -0,0 +1,288 @@ +# models.py +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime +import json + +db = SQLAlchemy() + +class User(db.Model): + __tablename__ = 'user' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=True) + phone = db.Column(db.String(20), unique=True, nullable=True) + password = db.Column(db.String(128), nullable=False) + role = db.Column(db.String(20), default='student') # student, teacher, admin + created_at = db.Column(db.DateTime, default=datetime.utcnow) + avatar = db.Column(db.String(200), nullable=True) + + exams_created = db.relationship('Exam', backref='creator', lazy=True) + submissions = db.relationship('Submission', backref='user', lazy=True) + drafts = db.relationship('Draft', backref='user', lazy=True) + posts = db.relationship('Post', backref='author', lazy=True) + replies = db.relationship('Reply', backref='author', lazy=True) + reactions = db.relationship('Reaction', backref='user', lazy=True) + bookmarks = db.relationship('Bookmark', backref='user', lazy=True) + notifications = db.relationship('Notification', backref='user', lazy=True) + contest_registrations = db.relationship('ContestRegistration', backref='user', lazy=True) + teacher_applications = db.relationship('TeacherApplication', backref='user', lazy=True) + # exam_bookmarks 关系将由 ExamBookmark 中的 backref 自动创建,此处不再定义 + + +class Exam(db.Model): + __tablename__ = 'exam' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + subject = db.Column(db.String(50)) + duration = db.Column(db.Integer, default=120) + total_score = db.Column(db.Integer, default=100) + status = db.Column(db.String(20), default='available') + creator_id = db.Column(db.Integer, db.ForeignKey('user.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + _questions = db.Column(db.Text, default='[]') + + submissions = db.relationship('Submission', backref='exam', lazy=True, cascade='all, delete-orphan') + drafts = db.relationship('Draft', backref='exam', lazy=True, cascade='all, delete-orphan') + # bookmarked_by 关系将由 ExamBookmark 中的 backref 自动创建,此处不再定义 + + def set_questions(self, questions): + self._questions = json.dumps(questions, ensure_ascii=False) + + def get_questions(self): + return json.loads(self._questions) if self._questions else [] + + +class Submission(db.Model): + __tablename__ = 'submission' + id = db.Column(db.Integer, primary_key=True) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + _answers = db.Column(db.Text, default='{}') + _question_scores = db.Column(db.Text, default='{}') + score = db.Column(db.Integer, default=0) + graded = db.Column(db.Boolean, default=False) + graded_by = db.Column(db.String(80), default='') + submitted_at = db.Column(db.DateTime, default=datetime.utcnow) + + def set_answers(self, answers): + self._answers = json.dumps(answers, ensure_ascii=False) + + def get_answers(self): + return json.loads(self._answers) if self._answers else {} + + def set_question_scores(self, scores): + self._question_scores = json.dumps(scores, ensure_ascii=False) + + def get_question_scores(self): + return json.loads(self._question_scores) if self._question_scores else {} + + @property + def user_name(self): + return self.user.name if self.user else '' + + +class Draft(db.Model): + __tablename__ = 'draft' + id = db.Column(db.Integer, primary_key=True) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + _answers = db.Column(db.Text, default='{}') + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def set_answers(self, answers): + self._answers = json.dumps(answers, ensure_ascii=False) + + def get_answers(self): + return json.loads(self._answers) if self._answers else {} + + +class Contest(db.Model): + __tablename__ = 'contest' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False) + organizer = db.Column(db.String(100)) + description = db.Column(db.Text) + start_date = db.Column(db.String(20)) + end_date = db.Column(db.String(20)) + status = db.Column(db.String(20), default='upcoming') + participants = db.Column(db.Integer, default=0) + created_by = db.Column(db.String(50)) + contact = db.Column(db.String(100)) + _past_papers = db.Column(db.Text, default='[]') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + registrations = db.relationship('ContestRegistration', backref='contest', lazy=True) + posts = db.relationship('Post', backref='contest', lazy=True) + + def set_past_papers(self, papers): + self._past_papers = json.dumps(papers, ensure_ascii=False) + + def get_past_papers(self): + return json.loads(self._past_papers) if self._past_papers else [] + + +class ContestRegistration(db.Model): + __tablename__ = 'contest_registration' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + registered_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class ContestMembership(db.Model): + __tablename__ = 'contest_membership' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + role = db.Column(db.String(20), default='member') + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Post(db.Model): + __tablename__ = 'post' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text, nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True) + tag = db.Column(db.String(50), default='全部') + is_official = db.Column(db.Boolean, default=False) + pinned = db.Column(db.Boolean, default=False) + likes = db.Column(db.Integer, default=0) + replies_count = db.Column(db.Integer, default=0) + views = db.Column(db.Integer, default=0) + has_poll = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + replies = db.relationship('Reply', backref='post', lazy=True, cascade='all, delete-orphan') + reactions = db.relationship('Reaction', backref='post', lazy=True, cascade='all, delete-orphan') + bookmarks = db.relationship('Bookmark', backref='post', lazy=True, cascade='all, delete-orphan') + poll = db.relationship('Poll', backref='post', uselist=False, cascade='all, delete-orphan') + + +class Reply(db.Model): + __tablename__ = 'reply' + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + reply_to = db.Column(db.String(80), nullable=True) + likes = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + reactions = db.relationship('Reaction', backref='reply', lazy=True, cascade='all, delete-orphan') + + +class Poll(db.Model): + __tablename__ = 'poll' + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False, unique=True) + question = db.Column(db.String(200), nullable=False) + multi = db.Column(db.Boolean, default=False) + total_votes = db.Column(db.Integer, default=0) + _options = db.Column(db.Text, default='[]') + _voters = db.Column(db.Text, default='{}') + + def set_options(self, options): + self._options = json.dumps(options, ensure_ascii=False) + + def get_options(self): + return json.loads(self._options) if self._options else [] + + def set_voters(self, voters): + self._voters = json.dumps(voters, ensure_ascii=False) + + def get_voters(self): + return json.loads(self._voters) if self._voters else {} + + +class Reaction(db.Model): + __tablename__ = 'reaction' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + reply_id = db.Column(db.Integer, db.ForeignKey('reply.id'), nullable=True) + reaction = db.Column(db.String(20), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Bookmark(db.Model): + __tablename__ = 'bookmark' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Notification(db.Model): + __tablename__ = 'notification' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + type = db.Column(db.String(50)) + content = db.Column(db.String(200)) + from_user = db.Column(db.String(80)) + post_id = db.Column(db.Integer, nullable=True) + read = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class EditHistory(db.Model): + __tablename__ = 'edit_history' + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + reply_id = db.Column(db.Integer, db.ForeignKey('reply.id'), nullable=True) + editor_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + old_content = db.Column(db.Text) + new_content = db.Column(db.Text) + edited_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Report(db.Model): + __tablename__ = 'report' + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(20)) + target_id = db.Column(db.Integer, nullable=False) + reporter_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + reason = db.Column(db.String(100)) + detail = db.Column(db.Text) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class TeacherApplication(db.Model): + __tablename__ = 'teacher_application' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(80), nullable=False) + email = db.Column(db.String(120), nullable=False) + reason = db.Column(db.Text, nullable=False) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Friend(db.Model): + __tablename__ = 'friend' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + friend_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('friends_initiated', lazy='dynamic')) + friend = db.relationship('User', foreign_keys=[friend_id], backref=db.backref('friends_received', lazy='dynamic')) + + +class ExamBookmark(db.Model): + __tablename__ = 'exam_bookmark' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # 使用 backref 自动在 User 和 Exam 上创建 'exam_bookmarks' 和 'bookmarked_by' 属性 + user = db.relationship('User', backref=db.backref('exam_bookmarks', lazy='dynamic', cascade='all, delete-orphan')) + exam = db.relationship('Exam', backref=db.backref('bookmarked_by', lazy='dynamic', cascade='all, delete-orphan')) + + __table_args__ = (db.UniqueConstraint('user_id', 'exam_id', name='unique_exam_bookmark'),) \ No newline at end of file diff --git a/dontshushme/profile.html b/dontshushme/profile.html new file mode 100644 index 0000000..f901464 --- /dev/null +++ b/dontshushme/profile.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} + +{% block title %}个人中心 - 联考平台{% endblock %} + +{% block content %} +
+ +
+
+
+ {{ user.name[0] | upper }} +
+
+

{{ user.name }}

+

{{ user.email }} · {{ user.role | capitalize }}

+

注册时间:{{ user.created_at if user.created_at else '2025-01-01' }}

+
+ +
+
+ + +
+ 如需切换账户,请先退出当前登录,然后重新登录其他账号。 + 切换账户 +
+ + +
+ +
+

+ + 好友列表 +

+
+
加载中...
+
+ +
+ + +
+

+ + 我的帖子 +

+
+
加载中...
+
+ +
+ + +
+

+ + 收藏的试卷 +

+
+
加载中...
+
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/instance/database.db b/instance/database.db new file mode 100644 index 0000000..72519e4 Binary files /dev/null and b/instance/database.db differ diff --git a/models.py b/models.py new file mode 100644 index 0000000..4aaeb67 --- /dev/null +++ b/models.py @@ -0,0 +1,470 @@ +# models.py +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime +import json + +db = SQLAlchemy() + +# 用户表 +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=True) + phone = db.Column(db.String(20), unique=True, nullable=True) + password = db.Column(db.String(128), nullable=False) + role = db.Column(db.String(20), default='student') # admin, teacher, student + avatar = db.Column(db.String(200), default='') # 头像URL + is_banned = db.Column(db.Boolean, default=False) + completed_exams = db.Column(db.Integer, default=0) # 已完成并批改的考试次数 + name_changed_at = db.Column(db.DateTime, nullable=True) # 上次修改用户名时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # 关系 + exams_created = db.relationship('Exam', back_populates='creator', lazy=True) + submissions = db.relationship('Submission', back_populates='user', lazy=True) + drafts = db.relationship('Draft', back_populates='user', lazy=True) + posts = db.relationship('Post', back_populates='author', lazy=True) + replies = db.relationship('Reply', back_populates='author', lazy=True) + reactions = db.relationship('Reaction', back_populates='user', lazy=True) + bookmarks = db.relationship('Bookmark', back_populates='user', lazy=True) + reports = db.relationship('Report', backref='reporter', lazy=True) + notifications = db.relationship('Notification', backref='user', lazy=True) + contest_memberships = db.relationship('ContestMembership', back_populates='user', lazy=True) + contest_registrations = db.relationship('ContestRegistration', backref='user', lazy=True) + # teacher_applications 将通过 TeacherApplication 的 back_populates 自动创建 + +# 杯赛成员表(记录用户在特定杯赛中的角色) +class ContestMembership(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + role = db.Column(db.String(20), nullable=False) # 'owner' 或 'teacher' + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', back_populates='contest_memberships') + contest = db.relationship('Contest', back_populates='members') + + __table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest'),) + +# 杯赛申请表(用于用户申请举办新杯赛) +class ContestApplication(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + organizer = db.Column(db.String(100)) + description = db.Column(db.Text) + contact = db.Column(db.String(100)) + status = db.Column(db.String(20), default='pending') # pending, approved, rejected + applied_at = db.Column(db.DateTime, default=datetime.utcnow) + reviewed_at = db.Column(db.DateTime) + total_score = db.Column(db.Integer, default=150) # 杯赛默认满分 + start_date = db.Column(db.String(20)) # 申请时填写的开始日期 + end_date = db.Column(db.String(20)) # 申请时填写的结束日期 + # 报备信息 + responsible_person = db.Column(db.String(80)) # 责任人姓名 + responsible_phone = db.Column(db.String(20)) # 责任人电话 + responsible_email = db.Column(db.String(120)) # 责任人邮箱 + organization = db.Column(db.String(100)) # 所属机构/学校 + + user = db.relationship('User', backref='contest_applications') + +# 杯赛报名表(用户报名参加杯赛) +class ContestRegistration(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + registered_at = db.Column(db.DateTime, default=datetime.utcnow) + + __table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest_reg'),) + +# 杯赛表 +class Contest(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + organizer = db.Column(db.String(100)) + description = db.Column(db.Text) + start_date = db.Column(db.String(20)) + end_date = db.Column(db.String(20)) + status = db.Column(db.String(20), default='upcoming') + participants = db.Column(db.Integer, default=0) + created_by = db.Column(db.Integer, db.ForeignKey('user.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + past_papers = db.Column(db.Text) # JSON 存储历年真题 [{year, title, file}] + total_score = db.Column(db.Integer, default=150) # 杯赛默认考试满分 + visible = db.Column(db.Boolean, default=True) # 是否对普通用户可见 + # 报备信息 + responsible_person = db.Column(db.String(80)) # 责任人姓名 + responsible_phone = db.Column(db.String(20)) # 责任人电话 + responsible_email = db.Column(db.String(120)) # 责任人邮箱 + organization = db.Column(db.String(100)) # 所属机构/学校 + + creator = db.relationship('User', backref='contests_created') + members = db.relationship('ContestMembership', back_populates='contest', lazy=True, cascade='all, delete-orphan') + exams = db.relationship('Exam', backref='contest', lazy=True) + posts = db.relationship('Post', back_populates='contest', lazy=True, cascade='all, delete-orphan') + teacher_applications = db.relationship('TeacherApplication', back_populates='contest', lazy=True, cascade='all, delete-orphan') + + def get_past_papers(self): + return json.loads(self.past_papers) if self.past_papers else [] + + def set_past_papers(self, papers): + self.past_papers = json.dumps(papers) + +# 题库表(杯赛老师和负责人往题库添加题目,负责人选题组卷) +class QuestionBankItem(db.Model): + id = db.Column(db.Integer, primary_key=True) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + contributor_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + type = db.Column(db.String(20), nullable=False) # 'choice', 'fill', 'essay' + content = db.Column(db.Text, nullable=False) # 题目内容 + options = db.Column(db.Text) # JSON,选择题选项 + answer = db.Column(db.Text) # 答案 + score = db.Column(db.Integer, default=10) # 建议分值 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + contest = db.relationship('Contest', backref='question_bank_items') + contributor = db.relationship('User', backref='contributed_questions') + +# 考试表 +class Exam(db.Model): + id = db.Column(db.Integer, primary_key=True) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True) + title = db.Column(db.String(200), nullable=False) + subject = db.Column(db.String(50)) + duration = db.Column(db.Integer) + total_score = db.Column(db.Integer) + creator_id = db.Column(db.Integer, db.ForeignKey('user.id')) + questions = db.Column(db.Text) # JSON + encrypted_questions = db.Column(db.Text) # 加密后的试卷内容 + is_encrypted = db.Column(db.Boolean, default=False) # 是否加密 + access_password = db.Column(db.String(128)) # 考试访问密码 + scheduled_start = db.Column(db.DateTime, nullable=True) # 预定开始时间 + scheduled_end = db.Column(db.DateTime, nullable=True) # 预定结束时间 + score_release_time = db.Column(db.DateTime, nullable=True) # 成绩公布时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + status = db.Column(db.String(20), default='available') + + creator = db.relationship('User', back_populates='exams_created') + submissions = db.relationship('Submission', back_populates='exam', lazy=True, cascade='all, delete-orphan') + drafts = db.relationship('Draft', back_populates='exam', lazy=True, cascade='all, delete-orphan') + + def get_questions(self): + return json.loads(self.questions) if self.questions else [] + + def set_questions(self, questions): + self.questions = json.dumps(questions) + +# 提交记录表 +class Submission(db.Model): + id = db.Column(db.Integer, primary_key=True) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id')) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + answers = db.Column(db.Text) # JSON + score = db.Column(db.Integer, default=0) + question_scores = db.Column(db.Text) # JSON + graded = db.Column(db.Boolean, default=False) + graded_by = db.Column(db.String(80)) + submitted_at = db.Column(db.DateTime, default=datetime.utcnow) + + exam = db.relationship('Exam', back_populates='submissions') + user = db.relationship('User', back_populates='submissions') + + def get_answers(self): + return json.loads(self.answers) if self.answers else {} + + def set_answers(self, answers): + self.answers = json.dumps(answers) + + def get_question_scores(self): + return json.loads(self.question_scores) if self.question_scores else {} + + def set_question_scores(self, scores): + self.question_scores = json.dumps(scores) + +# 草稿表 +class Draft(db.Model): + id = db.Column(db.Integer, primary_key=True) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id')) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + answers = db.Column(db.Text) # JSON + saved_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + exam = db.relationship('Exam', back_populates='drafts') + user = db.relationship('User', back_populates='drafts') + + def get_answers(self): + return json.loads(self.answers) if self.answers else {} + + def set_answers(self, answers): + self.answers = json.dumps(answers) + +# 论坛帖子表 +class Post(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text, nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('user.id')) + tag = db.Column(db.String(50), default='全部') + is_official = db.Column(db.Boolean, default=False) + pinned = db.Column(db.Boolean, default=False) + likes = db.Column(db.Integer, default=0) + replies_count = db.Column(db.Integer, default=0) + views = db.Column(db.Integer, default=0) + has_poll = db.Column(db.Boolean, default=False) + images = db.Column(db.Text) # JSON + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + author = db.relationship('User', back_populates='posts') + replies = db.relationship('Reply', back_populates='post', lazy=True, cascade='all, delete-orphan') + poll = db.relationship('Poll', back_populates='post', uselist=False, cascade='all, delete-orphan') + reactions = db.relationship('Reaction', back_populates='post', lazy=True, cascade='all, delete-orphan') + bookmarks = db.relationship('Bookmark', back_populates='post', lazy=True, cascade='all, delete-orphan') + contest = db.relationship('Contest', back_populates='posts') + + def get_images(self): + return json.loads(self.images) if self.images else [] + + def set_images(self, images): + self.images = json.dumps(images) + +# 回复表 +class Reply(db.Model): + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + author_id = db.Column(db.Integer, db.ForeignKey('user.id')) + content = db.Column(db.Text, nullable=False) + likes = db.Column(db.Integer, default=0) + reply_to = db.Column(db.String(80)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + post = db.relationship('Post', back_populates='replies') + author = db.relationship('User', back_populates='replies') + reactions = db.relationship('Reaction', back_populates='reply', lazy=True, cascade='all, delete-orphan') + +# 投票表 +class Poll(db.Model): + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + question = db.Column(db.String(200), nullable=False) + options = db.Column(db.Text) # JSON + voters = db.Column(db.Text) # JSON + multi = db.Column(db.Boolean, default=False) + total_votes = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + post = db.relationship('Post', back_populates='poll') + + def get_options(self): + return json.loads(self.options) if self.options else [] + + def set_options(self, options): + self.options = json.dumps(options) + + def get_voters(self): + return json.loads(self.voters) if self.voters else {} + + def set_voters(self, voters): + self.voters = json.dumps(voters) + +# 表情反应表 +class Reaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True) + reply_id = db.Column(db.Integer, db.ForeignKey('reply.id'), nullable=True) + reaction = db.Column(db.String(20)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', back_populates='reactions') + post = db.relationship('Post', back_populates='reactions') + reply = db.relationship('Reply', back_populates='reactions') + + __table_args__ = ( + db.UniqueConstraint('user_id', 'post_id', name='uq_user_post_reaction'), + db.UniqueConstraint('user_id', 'reply_id', name='uq_user_reply_reaction'), + ) + +# 收藏表 +class Bookmark(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', back_populates='bookmarks') + post = db.relationship('Post', back_populates='bookmarks') + + __table_args__ = (db.UniqueConstraint('user_id', 'post_id', name='uq_user_post_bookmark'),) + +# 举报表 +class Report(db.Model): + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(20)) + target_id = db.Column(db.Integer) + reporter_id = db.Column(db.Integer, db.ForeignKey('user.id')) + reason = db.Column(db.String(100)) + detail = db.Column(db.Text) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # reporter 已在 User 中通过 backref 定义 + +# 通知表 +class Notification(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + type = db.Column(db.String(50)) + content = db.Column(db.Text) + from_user = db.Column(db.String(80)) + post_id = db.Column(db.Integer, nullable=True) + read = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # user 已在 User 中通过 backref 定义 + +# 系统公告表(管理员发布的全站通知) +class SystemNotification(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + content = db.Column(db.Text, nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + pinned = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + author = db.relationship('User', backref='system_notifications') + +# 编辑历史表(可选) +class EditHistory(db.Model): + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey('post.id')) + title = db.Column(db.String(200)) + content = db.Column(db.Text) + edited_by = db.Column(db.String(80)) + edited_at = db.Column(db.DateTime, default=datetime.utcnow) + + post = db.relationship('Post') + +# 教师申请表(针对特定杯赛) +class TeacherApplication(db.Model): + """教师申请记录(针对特定杯赛)""" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) + name = db.Column(db.String(80), nullable=False) # 申请人姓名 + email = db.Column(db.String(120), nullable=False) # 申请人邮箱 + reason = db.Column(db.Text, nullable=False) # 申请理由 + status = db.Column(db.String(20), default='pending') # pending, approved, rejected + applied_at = db.Column(db.DateTime, default=datetime.utcnow) + reviewed_at = db.Column(db.DateTime) + reviewed_by = db.Column(db.Integer, db.ForeignKey('user.id')) + + # 关系 + user = db.relationship('User', foreign_keys=[user_id], backref='teacher_applications') + contest = db.relationship('Contest', back_populates='teacher_applications') + reviewer = db.relationship('User', foreign_keys=[reviewed_by]) + + __table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest_application'),) + +# 好友表 +class Friend(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + friend_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + status = db.Column(db.String(20), default='pending') # pending, accepted + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('friends_initiated', lazy='dynamic')) + friend = db.relationship('User', foreign_keys=[friend_id], backref=db.backref('friends_received', lazy='dynamic')) + +# 试卷收藏表 +class ExamBookmark(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref=db.backref('exam_bookmarks', lazy='dynamic', cascade='all, delete-orphan')) + exam = db.relationship('Exam', backref=db.backref('bookmarked_by', lazy='dynamic', cascade='all, delete-orphan')) + + __table_args__ = (db.UniqueConstraint('user_id', 'exam_id', name='uq_user_exam_bookmark'),) + +# 聊天室表 +class ChatRoom(db.Model): + id = db.Column(db.Integer, primary_key=True) + type = db.Column(db.String(20), nullable=False) # 'private' | 'group' | 'contest' + name = db.Column(db.String(100), nullable=True) + avatar = db.Column(db.String(200), default='') + creator_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), unique=True, nullable=True) + announcement = db.Column(db.Text, default='') + announcement_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + announcement_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + creator = db.relationship('User', foreign_keys=[creator_id], backref='chatrooms_created') + announcement_user = db.relationship('User', foreign_keys=[announcement_by]) + contest = db.relationship('Contest', backref=db.backref('chatroom', uselist=False)) + members = db.relationship('ChatRoomMember', backref='room', lazy=True, cascade='all, delete-orphan') + messages = db.relationship('Message', backref='room', lazy=True, cascade='all, delete-orphan') + +# 聊天室成员表 +class ChatRoomMember(db.Model): + id = db.Column(db.Integer, primary_key=True) + room_id = db.Column(db.Integer, db.ForeignKey('chat_room.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + role = db.Column(db.String(20), default='member') # 'admin' | 'member' + nickname = db.Column(db.String(50), default='') + muted = db.Column(db.Boolean, default=False) + last_read_at = db.Column(db.DateTime, default=datetime.utcnow) + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref='chat_memberships') + + __table_args__ = (db.UniqueConstraint('room_id', 'user_id', name='uq_room_user'),) + +# 消息表 +class Message(db.Model): + id = db.Column(db.Integer, primary_key=True) + room_id = db.Column(db.Integer, db.ForeignKey('chat_room.id'), nullable=False) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + type = db.Column(db.String(20), default='text') # 'text' | 'image' | 'file' | 'system' | 'voice' + content = db.Column(db.Text, default='') + file_url = db.Column(db.String(300), nullable=True) + file_name = db.Column(db.String(200), nullable=True) + recalled = db.Column(db.Boolean, default=False) + reply_to_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=True) + mentions = db.Column(db.Text, default='') # JSON array of user IDs, e.g. "[1,2,3]" or "all" + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + sender = db.relationship('User', backref='messages_sent') + reply_to = db.relationship('Message', remote_side='Message.id', backref='replies') + reactions = db.relationship('MessageReaction', backref='message', lazy=True, cascade='all, delete-orphan') + +# 消息表情回应表 +class MessageReaction(db.Model): + id = db.Column(db.Integer, primary_key=True) + message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + emoji = db.Column(db.String(10), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + user = db.relationship('User', backref='message_reactions') + + __table_args__ = (db.UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_msg_user_emoji'),) + +# 邀请码表(教师申请审批通过后生成,老师输入邀请码激活身份) +class InviteCode(db.Model): + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(32), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + application_id = db.Column(db.Integer, db.ForeignKey('teacher_application.id'), nullable=False) + used = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + used_at = db.Column(db.DateTime, nullable=True) + + user = db.relationship('User', backref='invite_codes') + application = db.relationship('TeacherApplication', backref='invite_code') \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e535964 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "超级大网站2月22号下午", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e4c5dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +flask==3.0.0 +python-dotenv==1.0.0 +captcha==0.5.0 +aliyun-python-sdk-core==2.16.0 +aliyun-python-sdk-dypnsapi>=1.1.13 +flask-socketio>=5.3.0 +PyMuPDF>=1.24.0 +openai>=1.14.0 +Flask-Caching>=2.3.0 diff --git a/static/css/rich-editor.css b/static/css/rich-editor.css new file mode 100644 index 0000000..109cbff --- /dev/null +++ b/static/css/rich-editor.css @@ -0,0 +1,454 @@ +/* ========== Rich Editor 样式 ========== */ + +/* 工具栏 */ +.re-toolbar { + display: flex; + align-items: center; + gap: 2px; + padding: 6px 8px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; +} +.re-toolbar.re-compact { + padding: 4px 6px; + gap: 1px; +} +.re-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 28px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #475569; + transition: all 0.15s; +} +.re-btn:hover { + background: #e2e8f0; + color: #1e293b; +} +.re-compact .re-btn { + width: 26px; + height: 24px; + font-size: 12px; +} +.re-bold { font-weight: 700; } +.re-italic { font-style: italic; } +.re-code { font-family: Consolas, monospace; font-size: 11px; } +.re-sep { + width: 1px; + height: 18px; + background: #e2e8f0; + margin: 0 4px; +} +.re-dropdown-wrap { + position: relative; +} + +/* 面板通用 */ +.re-panel { + position: absolute; + top: 100%; + left: 0; + z-index: 100; + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 10px 40px -4px rgba(0,0,0,0.12); + margin-top: 4px; +} +/* 颜色面板 */ +.re-color-panel { + padding: 10px; + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 4px; + width: 240px; +} +.re-swatch { + width: 20px; + height: 20px; + border-radius: 4px; + border: none; + cursor: pointer; + transition: transform 0.15s; +} +.re-swatch:hover { + transform: scale(1.3); + box-shadow: 0 2px 8px rgba(0,0,0,0.2); +} + +/* 字体面板 */ +.re-font-panel { + padding: 6px; + width: 180px; + max-height: 320px; + overflow-y: auto; +} +.re-font-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #334155; + transition: background 0.15s; +} +.re-font-item:hover { + background: #f1f5f9; +} + +/* Emoji 面板 */ +.re-emoji-panel { + width: 300px; + padding: 0; +} +.re-emoji-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid #e2e8f0; + overflow-x: auto; + padding: 0 4px; +} +.re-emoji-tab { + padding: 8px 10px; + border: none; + background: transparent; + font-size: 12px; + color: #64748b; + cursor: pointer; + white-space: nowrap; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} +.re-emoji-tab:hover { color: #334155; } +.re-emoji-tab.active { + color: #3b82f6; + border-bottom-color: #3b82f6; +} +.re-emoji-body { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 2px; + padding: 8px; +} +.re-emoji-item { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 16px; + transition: all 0.15s; +} +.re-emoji-item:hover { + background: #f1f5f9; + transform: scale(1.2); +} + +/* 数学公式面板 */ +.re-math-panel { + width: 380px; + padding: 0; + max-height: 420px; + overflow-y: auto; +} +.re-math-hint { + font-size: 11px; + color: #94a3b8; + padding: 8px 10px 4px; +} +.re-math-panel .re-emoji-tabs { + padding: 0 4px; +} +.re-math-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 4px; + padding: 8px; +} +.re-math-item { + padding: 6px 4px; + border: 1px solid #e2e8f0; + background: white; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + text-align: center; + color: #334155; + transition: all 0.15s; +} +.re-math-item:hover { + background: #eff6ff; + border-color: #93c5fd; + color: #1d4ed8; +} +.re-math-wrap-btn { + display: block; + width: calc(100% - 16px); + margin: 0 8px 8px; + padding: 6px; + border: 1px dashed #93c5fd; + background: #eff6ff; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + color: #3b82f6; + text-align: center; + transition: all 0.15s; +} +.re-math-wrap-btn:hover { + background: #dbeafe; +} +.re-math-preview { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 8px; + margin: 0 8px 8px; +} +.re-math-preview-label { + font-size: 11px; + color: #94a3b8; + margin-bottom: 4px; +} +.re-math-preview-content { + min-height: 24px; + font-size: 14px; + color: #334155; + overflow-x: auto; +} + +/* 行内代码样式 */ +.re-inline-code { + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 1px 5px; + font-family: Consolas, 'Courier New', monospace; + font-size: 0.9em; + color: #e11d48; +} + +/* 隐藏类 */ +.re-panel.hidden { + display: none !important; +} + +/* 实时预览面板 */ +.re-live-preview { + border-top: 1px solid #e2e8f0; + background: #f8fafc; + padding: 0; + max-height: 180px; + overflow-y: auto; +} +.re-pv-label { + font-size: 11px; + color: #94a3b8; + padding: 6px 10px 2px; + font-weight: 500; + user-select: none; +} +.re-pv-body { + padding: 4px 10px 8px; + font-size: 14px; + color: #334155; + line-height: 1.7; + word-break: break-word; + white-space: pre-wrap; +} +.re-pv-body img { + max-width: 100%; + max-height: 120px; + border-radius: 6px; +} +.re-pv-body strong { font-weight: 700; } +.re-pv-body em { font-style: italic; } +.re-pv-body .katex { font-size: 1.05em; } + +/* ========== 数学公式编辑器弹窗 ========== */ +.re-math-editor-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 9990; + display: flex; + align-items: center; + justify-content: center; + animation: reMathFadeIn 0.2s ease; +} +.re-math-editor-overlay.hidden { + display: none !important; +} +@keyframes reMathFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes reMathScaleIn { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } +} +.re-math-editor-modal { + background: white; + border-radius: 16px; + box-shadow: 0 20px 60px -12px rgba(0,0,0,0.25); + width: 100%; + max-width: 672px; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: reMathScaleIn 0.25s ease; +} +.re-math-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; +} +.re-math-editor-close { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 8px; + font-size: 20px; + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.re-math-editor-close:hover { + background: #f1f5f9; + color: #475569; +} +.re-math-editor-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} +.re-math-editor-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-family: Consolas, 'Courier New', monospace; + font-size: 14px; + color: #334155; + resize: vertical; + transition: border-color 0.15s; + box-sizing: border-box; +} +.re-math-editor-input:focus { + outline: none; + border-color: #93c5fd; + box-shadow: 0 0 0 3px rgba(59,130,246,0.1); +} +.re-math-editor-preview { + min-height: 60px; + padding: 14px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + overflow-x: auto; + font-size: 16px; + color: #334155; +} +.re-math-editor-preview .katex-display { + margin: 0; +} +.re-math-editor-templates { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.re-math-editor-tpl-card { + padding: 10px 8px; + border: 1px solid #e2e8f0; + background: white; + border-radius: 8px; + cursor: pointer; + text-align: center; + transition: all 0.15s; +} +.re-math-editor-tpl-card:hover { + border-color: #93c5fd; + background: #eff6ff; + box-shadow: 0 2px 8px rgba(59,130,246,0.1); +} +.re-math-editor-tpl-tex { + font-size: 13px; + color: #334155; + overflow: hidden; + min-height: 24px; + display: flex; + align-items: center; + justify-content: center; +} +.re-math-editor-tpl-tex .katex { + font-size: 0.85em; +} +.re-math-editor-tpl-label { + font-size: 11px; + color: #94a3b8; + margin-top: 4px; +} +.re-math-editor-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid #e2e8f0; +} +.re-math-editor-btn-cancel { + padding: 8px 20px; + border: 1px solid #e2e8f0; + background: white; + border-radius: 8px; + font-size: 14px; + color: #64748b; + cursor: pointer; + transition: all 0.15s; +} +.re-math-editor-btn-cancel:hover { + background: #f1f5f9; +} +.re-math-editor-btn-confirm { + padding: 8px 20px; + border: none; + background: #3b82f6; + border-radius: 8px; + font-size: 14px; + color: white; + cursor: pointer; + font-weight: 500; + transition: all 0.15s; +} +.re-math-editor-btn-confirm:hover { + background: #2563eb; +} +.re-fx-btn { + font-family: Georgia, 'Times New Roman', serif; + font-style: italic; + font-weight: 700; + font-size: 12px; +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..92a6859 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,184 @@ +/* 自定义样式 */ +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background-color: rgba(148, 163, 184, 0.4); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background-color: rgba(148, 163, 184, 0.7); +} + +/* 高级玻璃拟态效果 */ +.glass-panel { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05); +} +.glass-panel-dark { + background: rgba(15, 23, 42, 0.7); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); +} + +/* 高级阴影与悬浮动效 */ +.hover-card-up { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} +.hover-card-up:hover { + transform: translateY(-4px) scale(1.01); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} +.hover-glow { + transition: all 0.3s ease; +} +.hover-glow:hover { + box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); +} + +/* 文本渐变发光 */ +.text-gradient-glow { + background-clip: text; + -webkit-background-clip: text; + color: transparent; + text-shadow: 0 0 20px rgba(255, 255, 255, 0.3); +} + +/* 渐变动画边框 */ +.animated-border { + position: relative; + border-radius: inherit; +} +.animated-border::before { + content: ""; + position: absolute; + inset: -2px; + border-radius: inherit; + background: linear-gradient(45deg, #3b82f6, #8b5cf6, #ec4899, #3b82f6); + background-size: 200% 200%; + animation: gradient-move 3s linear infinite; + z-index: -1; + opacity: 0; + transition: opacity 0.3s ease; +} +.animated-border:hover::before { + opacity: 1; +} +@keyframes gradient-move { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +/* 背景流动网格 */ +.bg-grid-pattern { + background-size: 40px 40px; + background-image: linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px); +} +.bg-grid-pattern-dark { + background-size: 40px 40px; + background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); +} + +/* 通用过渡动画 */ +.transition-all-200 { + transition: all 0.2s ease-in-out; +} +.transition-all-300 { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* 拖拽排序 */ +.question-item { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.question-item[draggable="true"]:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} +.question-item.dragging { + opacity: 0.5; +} +.drag-handle { + font-size: 18px; + line-height: 1; + user-select: none; +} +.drag-handle:active { + cursor: grabbing; +} + +/* 选项选中高亮 */ +.option-label:has(input:checked) { + border-color: #3b82f6; + background-color: #eff6ff; +} + +/* 计时器闪烁 */ +@keyframes timer-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +.animate-pulse { + animation: timer-pulse 1s ease-in-out infinite; +} + +/* 题号导航按钮 */ +#nav-panel button, +#nav-panel-mobile button { + transition: all 0.15s ease; +} + +/* 进度条动画 */ +#progress-bar { + transition: width 0.3s ease; +} + +/* 批改页面底部栏 */ +.sticky.bottom-4 { + backdrop-filter: blur(8px); + background-color: rgba(255,255,255,0.95); +} + +/* 提交列表左侧颜色条 */ +.border-l-4.border-l-green-400 { + border-left: 4px solid #4ade80; +} +.border-l-4.border-l-yellow-400 { + border-left: 4px solid #facc15; +} + +/* 快速给分按钮 */ +button[onclick^="quickScore"] { + transition: all 0.1s ease; +} +button[onclick^="quickScore"]:active { + transform: scale(0.95); +} + +/* 响应式优化 */ +@media (max-width: 640px) { + .sticky.top-0 { + position: sticky; + top: 0; + } + #nav-panel-mobile { + max-height: 120px; + overflow-y: auto; + } +} \ No newline at end of file diff --git a/static/js/rich-editor.js b/static/js/rich-editor.js new file mode 100644 index 0000000..bcd638b --- /dev/null +++ b/static/js/rich-editor.js @@ -0,0 +1,736 @@ +/** + * RichEditor - 共享富文本工具栏组件 + * 用于聊天和论坛的输入区域 + */ + +// ========== 常量 ========== +const RE_FONTS = [ + { label: '默认', value: '' }, + { label: '宋体', value: '宋体,SimSun' }, + { label: '黑体', value: '黑体,SimHei' }, + { label: '楷体', value: '楷体,KaiTi' }, + { label: '仿宋', value: '仿宋,FangSong' }, + { label: '微软雅黑', value: '微软雅黑,Microsoft YaHei' }, + { label: '华文行楷', value: '华文行楷,STXingkai' }, + { label: '华文新魏', value: '华文新魏,STXinwei' }, + { label: '隶书', value: '隶书,LiSu' }, + { label: '幼圆', value: '幼圆,YouYuan' }, + { label: 'Consolas', value: 'Consolas,monospace' }, + { label: 'Georgia', value: 'Georgia,serif' }, + { label: 'Comic Sans', value: 'Comic Sans MS,cursive' }, + { label: 'Times New Roman', value: 'Times New Roman,serif' }, +]; + +const RE_EMOJIS = { + '表情': ['😀','😁','😂','🤣','😊','😍','🥰','😘','😜','🤪'], + '手势': ['👍','👎','👏','🙌','🤝','✌️','🤞','👌','🫶','🙏'], + '心形': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💖','💝'], + '动物': ['🐱','🐶','🐼','🦊','🐰','🐸','🐵','🦁','🐯','🐮'], + '物品': ['🎉','🎊','🔥','⭐','💡','📚','✏️','🏆','🎯','💻'], + '符号': ['✅','❌','⚠️','💯','🔴','🟢','🔵','⬆️','⬇️','➡️'], +}; + +const RE_COLORS = [ + '#ef4444','#f97316','#eab308','#22c55e','#06b6d4', + '#3b82f6','#8b5cf6','#ec4899','#6b7280','#000000', + '#dc2626','#ea580c','#ca8a04','#16a34a','#0891b2', + '#2563eb','#7c3aed','#db2777','#9ca3af','#ffffff', +]; + +const RE_MATH_SYMBOLS = { + '基础运算': [ + { label: '+', tex: '+' }, + { label: '−', tex: '-' }, + { label: '±', tex: '\\pm' }, + { label: '∓', tex: '\\mp' }, + { label: '×', tex: '\\times' }, + { label: '÷', tex: '\\div' }, + { label: '·', tex: '\\cdot' }, + { label: '=', tex: '=' }, + { label: '≠', tex: '\\neq' }, + { label: '≈', tex: '\\approx' }, + { label: '≡', tex: '\\equiv' }, + { label: '<', tex: '<' }, + { label: '>', tex: '>' }, + { label: '≤', tex: '\\leq' }, + { label: '≥', tex: '\\geq' }, + { label: '≪', tex: '\\ll' }, + { label: '≫', tex: '\\gg' }, + { label: '∝', tex: '\\propto' }, + ], + '上下标': [ + { label: 'x²', tex: '^{2}' }, + { label: 'x³', tex: '^{3}' }, + { label: 'xⁿ', tex: '^{}', cursor: -1 }, + { label: 'x₁', tex: '_{}', cursor: -1 }, + { label: 'xₙ', tex: '_{n}' }, + { label: 'x₁²', tex: '_{}^{}', cursor: -4 }, + ], + '分数根号': [ + { label: '分数', tex: '\\frac{}{}', cursor: -3 }, + { label: '√', tex: '\\sqrt{}', cursor: -1 }, + { label: 'ⁿ√', tex: '\\sqrt[n]{}', cursor: -1 }, + { label: 'ᵃ⁄ᵦ', tex: '\\dfrac{}{}', cursor: -3 }, + ], + '希腊字母': [ + { label: 'α', tex: '\\alpha' }, + { label: 'β', tex: '\\beta' }, + { label: 'γ', tex: '\\gamma' }, + { label: 'δ', tex: '\\delta' }, + { label: 'ε', tex: '\\epsilon' }, + { label: 'ζ', tex: '\\zeta' }, + { label: 'η', tex: '\\eta' }, + { label: 'θ', tex: '\\theta' }, + { label: 'λ', tex: '\\lambda' }, + { label: 'μ', tex: '\\mu' }, + { label: 'ξ', tex: '\\xi' }, + { label: 'π', tex: '\\pi' }, + { label: 'ρ', tex: '\\rho' }, + { label: 'σ', tex: '\\sigma' }, + { label: 'τ', tex: '\\tau' }, + { label: 'φ', tex: '\\varphi' }, + { label: 'ω', tex: '\\omega' }, + { label: 'Δ', tex: '\\Delta' }, + { label: 'Σ', tex: '\\Sigma' }, + { label: 'Ω', tex: '\\Omega' }, + { label: 'Φ', tex: '\\Phi' }, + { label: 'Π', tex: '\\Pi' }, + { label: 'Λ', tex: '\\Lambda' }, + { label: 'Γ', tex: '\\Gamma' }, + ], + '高等数学': [ + { label: '∑', tex: '\\sum_{i=1}^{n}', cursor: 0 }, + { label: '∏', tex: '\\prod_{i=1}^{n}', cursor: 0 }, + { label: '∫', tex: '\\int_{a}^{b}', cursor: 0 }, + { label: '∬', tex: '\\iint' }, + { label: '∮', tex: '\\oint' }, + { label: 'lim', tex: '\\lim_{x \\to }', cursor: -1 }, + { label: '∞', tex: '\\infty' }, + { label: 'log', tex: '\\log_{}', cursor: -1 }, + { label: 'ln', tex: '\\ln' }, + { label: 'sin', tex: '\\sin' }, + { label: 'cos', tex: '\\cos' }, + { label: 'tan', tex: '\\tan' }, + { label: 'arcsin', tex: '\\arcsin' }, + { label: 'arccos', tex: '\\arccos' }, + { label: 'arctan', tex: '\\arctan' }, + { label: '∂', tex: '\\partial' }, + { label: '∇', tex: '\\nabla' }, + { label: 'dx', tex: '\\mathrm{d}x' }, + ], + '集合逻辑': [ + { label: '∈', tex: '\\in' }, + { label: '∉', tex: '\\notin' }, + { label: '⊂', tex: '\\subset' }, + { label: '⊃', tex: '\\supset' }, + { label: '⊆', tex: '\\subseteq' }, + { label: '⊇', tex: '\\supseteq' }, + { label: '∪', tex: '\\cup' }, + { label: '∩', tex: '\\cap' }, + { label: '∅', tex: '\\emptyset' }, + { label: '∀', tex: '\\forall' }, + { label: '∃', tex: '\\exists' }, + { label: '¬', tex: '\\neg' }, + { label: '∧', tex: '\\land' }, + { label: '∨', tex: '\\lor' }, + { label: '⇒', tex: '\\Rightarrow' }, + { label: '⇔', tex: '\\Leftrightarrow' }, + { label: '→', tex: '\\to' }, + { label: '↦', tex: '\\mapsto' }, + ], + '括号矩阵': [ + { label: '()', tex: '\\left( \\right)', cursor: -8 }, + { label: '[]', tex: '\\left[ \\right]', cursor: -8 }, + { label: '{}', tex: '\\left\\{ \\right\\}', cursor: -9 }, + { label: '||', tex: '\\left| \\right|', cursor: -8 }, + { label: '⌈⌉', tex: '\\lceil \\rceil', cursor: -7 }, + { label: '⌊⌋', tex: '\\lfloor \\rfloor', cursor: -8 }, + { label: '矩阵', tex: '\\begin{pmatrix} \\\\ \\end{pmatrix}', cursor: -18 }, + { label: '行列式', tex: '\\begin{vmatrix} \\\\ \\end{vmatrix}', cursor: -18 }, + { label: '方程组', tex: '\\begin{cases} \\\\ \\end{cases}', cursor: -15 }, + ], + '其他符号': [ + { label: '…', tex: '\\cdots' }, + { label: '⋮', tex: '\\vdots' }, + { label: '⋱', tex: '\\ddots' }, + { label: '°', tex: '^{\\circ}' }, + { label: '‰', tex: '\\permil' }, + { label: '→', tex: '\\vec{}', cursor: -1 }, + { label: 'â', tex: '\\hat{}', cursor: -1 }, + { label: 'ā', tex: '\\bar{}', cursor: -1 }, + { label: 'ã', tex: '\\tilde{}', cursor: -1 }, + { label: '⊥', tex: '\\perp' }, + { label: '∥', tex: '\\parallel' }, + { label: '∠', tex: '\\angle' }, + { label: '△', tex: '\\triangle' }, + { label: '□', tex: '\\square' }, + { label: '⊕', tex: '\\oplus' }, + { label: '⊗', tex: '\\otimes' }, + { label: '★', tex: '\\star' }, + { label: '♠', tex: '\\spadesuit' }, + ], +}; + +const RE_FORMULA_TEMPLATES = [ + { label: '二次公式', tex: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}' }, + { label: '勾股定理', tex: 'a^2 + b^2 = c^2' }, + { label: '求和公式', tex: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}' }, + { label: '二项式定理', tex: '(a+b)^n = \\sum_{k=0}^{n} \\binom{n}{k} a^{n-k} b^k' }, + { label: '欧拉公式', tex: 'e^{i\\pi} + 1 = 0' }, + { label: '三角恒等式', tex: '\\sin^2\\theta + \\cos^2\\theta = 1' }, + { label: '对数换底', tex: '\\log_a b = \\frac{\\ln b}{\\ln a}' }, + { label: '导数定义', tex: "f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}" }, + { label: '定积分', tex: '\\int_a^b f(x)\\,dx = F(b) - F(a)' }, + { label: '矩阵乘法', tex: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\begin{pmatrix} e \\\\ f \\end{pmatrix} = \\begin{pmatrix} ae+bf \\\\ ce+df \\end{pmatrix}' }, + { label: '方程组', tex: '\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}' }, + { label: '极限', tex: '\\lim_{x \\to \\infty} \\left(1 + \\frac{1}{x}\\right)^x = e' }, +]; + +// ========== RichEditor 类 ========== +class RichEditor { + constructor(textareaId, options = {}) { + this.ta = document.getElementById(textareaId); + if (!this.ta) return; + this.compact = options.compact || false; // 紧凑模式(聊天用) + this.onEnter = options.onEnter || null; + this._buildToolbar(); + this._buildPreview(); + this._bindKeys(); + this._bindPreviewUpdate(); + } + + _buildToolbar() { + const bar = document.createElement('div'); + bar.className = 're-toolbar' + (this.compact ? ' re-compact' : ''); + + // 粗体 + bar.appendChild(this._btn('B', '粗体', 're-bold', () => this._wrap('**', '**'))); + // 斜体 + bar.appendChild(this._btn('I', '斜体', 're-italic', () => this._wrap('*', '*'))); + // 代码 + bar.appendChild(this._btn('<>', '代码', 're-code', () => this._wrap('`', '`'))); + bar.appendChild(this._sep()); + + // 颜色 + const colorWrap = document.createElement('div'); + colorWrap.className = 're-dropdown-wrap'; + const colorBtn = this._btn('A', '文字颜色', 're-color-btn', () => this._togglePanel(colorWrap)); + colorBtn.innerHTML = 'A'; + colorWrap.appendChild(colorBtn); + colorWrap.appendChild(this._buildColorPanel(colorBtn)); + bar.appendChild(colorWrap); + + // 字体 + const fontWrap = document.createElement('div'); + fontWrap.className = 're-dropdown-wrap'; + fontWrap.appendChild(this._btn('字', '字体', 're-font-btn', () => this._togglePanel(fontWrap))); + fontWrap.appendChild(this._buildFontPanel()); + bar.appendChild(fontWrap); + bar.appendChild(this._sep()); + + // Emoji + const emojiWrap = document.createElement('div'); + emojiWrap.className = 're-dropdown-wrap'; + emojiWrap.appendChild(this._btn('😊', 'Emoji', 're-emoji-btn', () => this._togglePanel(emojiWrap))); + emojiWrap.appendChild(this._buildEmojiPanel()); + bar.appendChild(emojiWrap); + + // 公式编辑器按钮 + bar.appendChild(this._sep()); + const self = this; + bar.appendChild(this._btn('fx', '公式编辑器', 're-fx-btn', () => openMathEditor(self.ta))); + + this.ta.parentNode.insertBefore(bar, this.ta); + this.toolbar = bar; + + // 点击外部关闭面板 + document.addEventListener('click', (e) => { + if (!bar.contains(e.target)) { + bar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden')); + } + }); + } + + _btn(html, title, cls, onclick) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 're-btn ' + (cls || ''); + b.title = title; + b.innerHTML = html; + b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); }); + return b; + } + + _sep() { + const s = document.createElement('span'); + s.className = 're-sep'; + return s; + } + + _togglePanel(wrap) { + const panel = wrap.querySelector('.re-panel'); + const wasHidden = panel.classList.contains('hidden'); + this.toolbar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden')); + if (wasHidden) panel.classList.remove('hidden'); + } + + _wrap(pre, suf) { + const s = this.ta.selectionStart, e = this.ta.selectionEnd; + const txt = this.ta.value; + const sel = txt.substring(s, e) || '文本'; + this.ta.value = txt.substring(0, s) + pre + sel + suf + txt.substring(e); + this.ta.focus(); + this.ta.selectionStart = s + pre.length; + this.ta.selectionEnd = s + pre.length + sel.length; + this._refreshPreview(); + } + + _insertAt(text, cursorOffset) { + const s = this.ta.selectionStart; + const v = this.ta.value; + this.ta.value = v.substring(0, s) + text + v.substring(s); + this.ta.focus(); + this.ta.selectionStart = this.ta.selectionEnd = s + text.length + (cursorOffset || 0); + this._refreshPreview(); + } + + _bindKeys() { + if (!this.onEnter) return; + this.ta.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.onEnter(); + } + }); + } + + _buildPreview() { + // 实时预览面板,插在 textarea 后面 + const pv = document.createElement('div'); + pv.className = 're-live-preview'; + pv.innerHTML = '
预览
'; + // 插到 textarea 的父容器末尾(border 容器内) + this.ta.parentNode.appendChild(pv); + this.previewEl = pv.querySelector('.re-pv-body'); + } + + _bindPreviewUpdate() { + let timer = null; + const update = () => { + if (!this.previewEl) return; + const val = this.ta.value.trim(); + if (!val) { this.previewEl.innerHTML = '输入内容后这里会显示实时预览...'; return; } + this.previewEl.innerHTML = renderRichContent(val); + }; + this.ta.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 150); }); + update(); + } + + _refreshPreview() { + if (!this.previewEl) return; + const val = this.ta.value.trim(); + if (!val) { this.previewEl.innerHTML = '输入内容后这里会显示实时预览...'; return; } + this.previewEl.innerHTML = renderRichContent(val); + } + + // ===== 颜色面板 ===== + _buildColorPanel(colorBtn) { + const panel = document.createElement('div'); + panel.className = 're-panel re-color-panel hidden'; + RE_COLORS.forEach(c => { + const swatch = document.createElement('button'); + swatch.type = 'button'; + swatch.className = 're-swatch'; + swatch.style.background = c; + if (c === '#ffffff') swatch.style.border = '1px solid #e2e8f0'; + swatch.addEventListener('click', (e) => { + e.stopPropagation(); + this._wrap(`[color:${c}]`, '[/color]'); + colorBtn.querySelector('span').style.borderBottomColor = c; + panel.classList.add('hidden'); + }); + panel.appendChild(swatch); + }); + return panel; + } + + // ===== 字体面板 ===== + _buildFontPanel() { + const panel = document.createElement('div'); + panel.className = 're-panel re-font-panel hidden'; + RE_FONTS.forEach(f => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 're-font-item'; + item.textContent = f.label; + if (f.value) item.style.fontFamily = f.value; + item.addEventListener('click', (e) => { + e.stopPropagation(); + if (f.value) { + this._wrap(`[font:${f.label}]`, '[/font]'); + } + panel.classList.add('hidden'); + }); + panel.appendChild(item); + }); + return panel; + } + + // ===== Emoji 面板 ===== + _buildEmojiPanel() { + const panel = document.createElement('div'); + panel.className = 're-panel re-emoji-panel hidden'; + const tabs = document.createElement('div'); + tabs.className = 're-emoji-tabs'; + const body = document.createElement('div'); + body.className = 're-emoji-body'; + + const categories = Object.keys(RE_EMOJIS); + categories.forEach((cat, i) => { + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 're-emoji-tab' + (i === 0 ? ' active' : ''); + tab.textContent = cat; + tab.addEventListener('click', (e) => { + e.stopPropagation(); + tabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + this._showEmojiCat(body, cat); + }); + tabs.appendChild(tab); + }); + + panel.appendChild(tabs); + panel.appendChild(body); + this._showEmojiCat(body, categories[0]); + return panel; + } + + _showEmojiCat(body, cat) { + body.innerHTML = ''; + RE_EMOJIS[cat].forEach(em => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 're-emoji-item'; + btn.textContent = em; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this._insertAt(em); + }); + body.appendChild(btn); + }); + } +} + +// ========== MathFormulaEditor 单例类 ========== +class MathFormulaEditor { + constructor() { + if (MathFormulaEditor._instance) return MathFormulaEditor._instance; + MathFormulaEditor._instance = this; + this._built = false; + this._target = null; + this._onInsert = null; + this._debounceTimer = null; + } + + open(targetTextarea, onInsert) { + this._target = targetTextarea; + this._onInsert = onInsert || null; + if (!this._built) this._buildDOM(); + this._input.value = ''; + this._previewEl.innerHTML = '在上方输入 LaTeX 公式,这里实时预览'; + this._overlay.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + // 激活第一个符号分类 tab + const firstTab = this._symbolTabs.querySelector('.re-emoji-tab'); + if (firstTab) firstTab.click(); + setTimeout(() => this._input.focus(), 50); + } + + close() { + this._overlay.classList.add('hidden'); + document.body.style.overflow = ''; + } + + _buildDOM() { + this._built = true; + const overlay = document.createElement('div'); + overlay.className = 're-math-editor-overlay hidden'; + overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); }); + + const modal = document.createElement('div'); + modal.className = 're-math-editor-modal'; + + // Header + const header = document.createElement('div'); + header.className = 're-math-editor-header'; + header.innerHTML = '数学公式编辑器'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 're-math-editor-close'; + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', () => this.close()); + header.appendChild(closeBtn); + modal.appendChild(header); + + // Body + const body = document.createElement('div'); + body.className = 're-math-editor-body'; + + // LaTeX input + const inputLabel = document.createElement('div'); + inputLabel.style.cssText = 'font-size:12px;color:#64748b;margin-bottom:4px;font-weight:500'; + inputLabel.textContent = 'LaTeX 公式'; + body.appendChild(inputLabel); + const input = document.createElement('textarea'); + input.className = 're-math-editor-input'; + input.rows = 3; + input.placeholder = '输入 LaTeX 公式,如 \\frac{a}{b}、x^2 + y^2 = r^2'; + input.addEventListener('input', () => this._updatePreview()); + this._input = input; + body.appendChild(input); + + // Preview + const pvLabel = document.createElement('div'); + pvLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500'; + pvLabel.textContent = '实时预览'; + body.appendChild(pvLabel); + const preview = document.createElement('div'); + preview.className = 're-math-editor-preview'; + this._previewEl = preview; + body.appendChild(preview); + + // Symbol panel + const symLabel = document.createElement('div'); + symLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500'; + symLabel.textContent = '符号面板'; + body.appendChild(symLabel); + // PLACEHOLDER_SYMBOL_PANEL + const symTabs = document.createElement('div'); + symTabs.className = 're-emoji-tabs'; + symTabs.style.cssText = 'border:1px solid #e2e8f0;border-radius:8px 8px 0 0'; + this._symbolTabs = symTabs; + const symGrid = document.createElement('div'); + symGrid.className = 're-math-grid'; + symGrid.style.cssText = 'border:1px solid #e2e8f0;border-top:none;border-radius:0 0 8px 8px;margin-bottom:0'; + const categories = Object.keys(RE_MATH_SYMBOLS); + categories.forEach((cat, i) => { + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 're-emoji-tab' + (i === 0 ? ' active' : ''); + tab.textContent = cat; + tab.addEventListener('click', (e) => { + e.stopPropagation(); + symTabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + this._showSymCat(symGrid, cat); + }); + symTabs.appendChild(tab); + }); + body.appendChild(symTabs); + body.appendChild(symGrid); + this._showSymCat(symGrid, categories[0]); + + // Template section + const tplLabel = document.createElement('div'); + tplLabel.style.cssText = 'font-size:12px;color:#64748b;margin:14px 0 6px;font-weight:500'; + tplLabel.textContent = '常用公式模板'; + body.appendChild(tplLabel); + const tplGrid = document.createElement('div'); + tplGrid.className = 're-math-editor-templates'; + RE_FORMULA_TEMPLATES.forEach(tpl => { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 're-math-editor-tpl-card'; + const texDiv = document.createElement('div'); + texDiv.className = 're-math-editor-tpl-tex'; + if (window.katex) { + try { texDiv.innerHTML = katex.renderToString(tpl.tex, { throwOnError: false, displayMode: false }); } + catch(e) { texDiv.textContent = tpl.tex; } + } else { texDiv.textContent = tpl.tex; } + const labelDiv = document.createElement('div'); + labelDiv.className = 're-math-editor-tpl-label'; + labelDiv.textContent = tpl.label; + card.appendChild(texDiv); + card.appendChild(labelDiv); + card.addEventListener('click', () => this._applyTemplate(tpl.tex)); + tplGrid.appendChild(card); + }); + body.appendChild(tplGrid); + modal.appendChild(body); + + // Footer + const footer = document.createElement('div'); + footer.className = 're-math-editor-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 're-math-editor-btn-cancel'; + cancelBtn.textContent = '取消'; + cancelBtn.addEventListener('click', () => this.close()); + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 're-math-editor-btn-confirm'; + confirmBtn.textContent = '插入公式'; + confirmBtn.addEventListener('click', () => this._confirm()); + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + this._overlay = overlay; + + // ESC to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !this._overlay.classList.contains('hidden')) this.close(); + }); + } + + _updatePreview() { + clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(() => { + const tex = this._input.value.trim(); + if (!tex) { + this._previewEl.innerHTML = '在上方输入 LaTeX 公式,这里实时预览'; + return; + } + if (window.katex) { + try { + this._previewEl.innerHTML = katex.renderToString(tex, { throwOnError: true, displayMode: true }); + } catch(e) { + this._previewEl.innerHTML = '语法错误: ' + e.message.replace(/'; + } + } else { + this._previewEl.textContent = tex; + } + }, 150); + } + + _insertSymbol(tex, cursor) { + const start = this._input.selectionStart; + const val = this._input.value; + this._input.value = val.substring(0, start) + tex + val.substring(start); + this._input.focus(); + this._input.selectionStart = this._input.selectionEnd = start + tex.length + (cursor || 0); + this._updatePreview(); + } + + _applyTemplate(tex) { + this._input.value = tex; + this._input.focus(); + this._input.selectionStart = this._input.selectionEnd = tex.length; + this._updatePreview(); + } + + _confirm() { + const tex = this._input.value.trim(); + if (!tex) { this.close(); return; } + const formula = '$' + tex + '$'; + if (this._target) { + const start = this._target.selectionStart || this._target.value.length; + const val = this._target.value; + this._target.value = val.substring(0, start) + formula + val.substring(start); + this._target.selectionStart = this._target.selectionEnd = start + formula.length; + this._target.dispatchEvent(new Event('input', { bubbles: true })); + } + if (typeof this._onInsert === 'function') this._onInsert(formula); + this.close(); + } + + _showSymCat(grid, cat) { + grid.innerHTML = ''; + RE_MATH_SYMBOLS[cat].forEach(sym => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 're-math-item'; + btn.textContent = sym.label; + btn.title = sym.tex; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this._insertSymbol(sym.tex, sym.cursor || 0); + }); + grid.appendChild(btn); + }); + } +} + +function openMathEditor(textarea, callback) { + new MathFormulaEditor().open(textarea, callback); +} + +// ========== 全局渲染函数 ========== +const RE_FONT_MAP = {}; +RE_FONTS.forEach(f => { if (f.value) RE_FONT_MAP[f.label] = f.value; }); + +function _reEsc(text) { + const d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; +} + +function renderRichContent(text) { + if (!text) return ''; + + // 1. 先提取公式,避免 HTML 转义破坏 LaTeX + const formulas = []; + let raw = text.replace(/\$([^$\n]+?)\$/g, function(m, tex) { + const idx = formulas.length; + formulas.push(tex); + return '\x00MATH' + idx + '\x00'; + }); + + // 2. HTML 转义 + let h = _reEsc(raw); + + // 3. 图片标签 [img:url] + h = h.replace(/\[img:(\/static\/uploads\/[^\]]+)\]/g, + ''); + + // 4. 还原公式并用 KaTeX 渲染 + h = h.replace(/\x00MATH(\d+)\x00/g, function(m, idx) { + const tex = formulas[parseInt(idx)]; + if (window.katex) { + try { + return katex.renderToString(tex, { throwOnError: false }); + } catch(e) { return '$' + _reEsc(tex) + '$'; } + } + // KaTeX 未加载时显示原始公式并标记 class 供后续渲染 + return '$' + _reEsc(tex) + '$'; + }); + + // 5. 粗体 **...** + h = h.replace(/\*\*(.+?)\*\*/g, '$1'); + // 6. 斜体 *...* + h = h.replace(/(?$1'); + // 7. 行内代码 `...` + h = h.replace(/`([^`]+?)`/g, '$1'); + + // 8. 颜色 [color:#hex]...[/color] + h = h.replace(/\[color:(#[0-9a-fA-F]{3,8})\]([\s\S]*?)\[\/color\]/g, + '$2'); + + // 9. 字体 [font:name]...[/font] + h = h.replace(/\[font:([^\]]+)\]([\s\S]*?)\[\/font\]/g, function(m, name, content) { + const family = RE_FONT_MAP[name]; + if (family) return '' + content + ''; + return content; + }); + + return h; +} + +// KaTeX 延迟加载后,重新渲染页面上未渲染的公式 +function _reRenderPendingMath() { + if (!window.katex) return; + document.querySelectorAll('.re-pending-math').forEach(el => { + try { + el.innerHTML = katex.renderToString(el.dataset.tex, { throwOnError: false }); + el.classList.remove('re-pending-math'); + } catch(e) {} + }); +} +// 定期检查 KaTeX 是否加载完成 +(function _waitKatex() { + if (window.katex) { _reRenderPendingMath(); return; } + setTimeout(_waitKatex, 500); +})(); diff --git a/static/uploads/1771815228_6668.jpg b/static/uploads/1771815228_6668.jpg new file mode 100644 index 0000000..aebf06b Binary files /dev/null and b/static/uploads/1771815228_6668.jpg differ diff --git a/static/uploads/1771817391_3644.jpg b/static/uploads/1771817391_3644.jpg new file mode 100644 index 0000000..aebf06b Binary files /dev/null and b/static/uploads/1771817391_3644.jpg differ diff --git a/static/uploads/pdf_page_03738e6c_3.png b/static/uploads/pdf_page_03738e6c_3.png new file mode 100644 index 0000000..c4d2c91 Binary files /dev/null and b/static/uploads/pdf_page_03738e6c_3.png differ diff --git a/static/uploads/pdf_page_12f39cbe_4.png b/static/uploads/pdf_page_12f39cbe_4.png new file mode 100644 index 0000000..219b22e Binary files /dev/null and b/static/uploads/pdf_page_12f39cbe_4.png differ diff --git a/static/uploads/pdf_page_165af37b_2.png b/static/uploads/pdf_page_165af37b_2.png new file mode 100644 index 0000000..bad2f65 Binary files /dev/null and b/static/uploads/pdf_page_165af37b_2.png differ diff --git a/static/uploads/pdf_page_1d5a8760_1.png b/static/uploads/pdf_page_1d5a8760_1.png new file mode 100644 index 0000000..95e155a Binary files /dev/null and b/static/uploads/pdf_page_1d5a8760_1.png differ diff --git a/static/uploads/pdf_page_20e063d5_1.png b/static/uploads/pdf_page_20e063d5_1.png new file mode 100644 index 0000000..01454ff Binary files /dev/null and b/static/uploads/pdf_page_20e063d5_1.png differ diff --git a/static/uploads/pdf_page_221de6aa_3.png b/static/uploads/pdf_page_221de6aa_3.png new file mode 100644 index 0000000..ac0ac8e Binary files /dev/null and b/static/uploads/pdf_page_221de6aa_3.png differ diff --git a/static/uploads/pdf_page_24549e30_2.png b/static/uploads/pdf_page_24549e30_2.png new file mode 100644 index 0000000..3e33e2d Binary files /dev/null and b/static/uploads/pdf_page_24549e30_2.png differ diff --git a/static/uploads/pdf_page_28918fb1_1.png b/static/uploads/pdf_page_28918fb1_1.png new file mode 100644 index 0000000..44403c0 Binary files /dev/null and b/static/uploads/pdf_page_28918fb1_1.png differ diff --git a/static/uploads/pdf_page_2f7774e7_3.png b/static/uploads/pdf_page_2f7774e7_3.png new file mode 100644 index 0000000..b329e09 Binary files /dev/null and b/static/uploads/pdf_page_2f7774e7_3.png differ diff --git a/static/uploads/pdf_page_4139831a_2.png b/static/uploads/pdf_page_4139831a_2.png new file mode 100644 index 0000000..3e33e2d Binary files /dev/null and b/static/uploads/pdf_page_4139831a_2.png differ diff --git a/static/uploads/pdf_page_450cf3b5_2.png b/static/uploads/pdf_page_450cf3b5_2.png new file mode 100644 index 0000000..bad2f65 Binary files /dev/null and b/static/uploads/pdf_page_450cf3b5_2.png differ diff --git a/static/uploads/pdf_page_582668b4_2.png b/static/uploads/pdf_page_582668b4_2.png new file mode 100644 index 0000000..3e33e2d Binary files /dev/null and b/static/uploads/pdf_page_582668b4_2.png differ diff --git a/static/uploads/pdf_page_5a3c6d3a_4.png b/static/uploads/pdf_page_5a3c6d3a_4.png new file mode 100644 index 0000000..219b22e Binary files /dev/null and b/static/uploads/pdf_page_5a3c6d3a_4.png differ diff --git a/static/uploads/pdf_page_6ae215b4_2.png b/static/uploads/pdf_page_6ae215b4_2.png new file mode 100644 index 0000000..fdbbd00 Binary files /dev/null and b/static/uploads/pdf_page_6ae215b4_2.png differ diff --git a/static/uploads/pdf_page_74ee682a_4.png b/static/uploads/pdf_page_74ee682a_4.png new file mode 100644 index 0000000..1565c5e Binary files /dev/null and b/static/uploads/pdf_page_74ee682a_4.png differ diff --git a/static/uploads/pdf_page_8d7c735e_4.png b/static/uploads/pdf_page_8d7c735e_4.png new file mode 100644 index 0000000..1565c5e Binary files /dev/null and b/static/uploads/pdf_page_8d7c735e_4.png differ diff --git a/static/uploads/pdf_page_8de9cc81_3.png b/static/uploads/pdf_page_8de9cc81_3.png new file mode 100644 index 0000000..ac0ac8e Binary files /dev/null and b/static/uploads/pdf_page_8de9cc81_3.png differ diff --git a/static/uploads/pdf_page_9132729b_3.png b/static/uploads/pdf_page_9132729b_3.png new file mode 100644 index 0000000..ac0ac8e Binary files /dev/null and b/static/uploads/pdf_page_9132729b_3.png differ diff --git a/static/uploads/pdf_page_9932d23b_2.png b/static/uploads/pdf_page_9932d23b_2.png new file mode 100644 index 0000000..bad2f65 Binary files /dev/null and b/static/uploads/pdf_page_9932d23b_2.png differ diff --git a/static/uploads/pdf_page_9e8a03f5_1.png b/static/uploads/pdf_page_9e8a03f5_1.png new file mode 100644 index 0000000..44403c0 Binary files /dev/null and b/static/uploads/pdf_page_9e8a03f5_1.png differ diff --git a/static/uploads/pdf_page_b2e7c6a9_1.png b/static/uploads/pdf_page_b2e7c6a9_1.png new file mode 100644 index 0000000..95e155a Binary files /dev/null and b/static/uploads/pdf_page_b2e7c6a9_1.png differ diff --git a/static/uploads/pdf_page_cde8ab54_1.png b/static/uploads/pdf_page_cde8ab54_1.png new file mode 100644 index 0000000..95e155a Binary files /dev/null and b/static/uploads/pdf_page_cde8ab54_1.png differ diff --git a/static/uploads/pdf_page_cf136549_3.png b/static/uploads/pdf_page_cf136549_3.png new file mode 100644 index 0000000..b329e09 Binary files /dev/null and b/static/uploads/pdf_page_cf136549_3.png differ diff --git a/static/uploads/pdf_page_d7bd4cc9_4.png b/static/uploads/pdf_page_d7bd4cc9_4.png new file mode 100644 index 0000000..219b22e Binary files /dev/null and b/static/uploads/pdf_page_d7bd4cc9_4.png differ diff --git a/static/uploads/pdf_page_e824a2ea_3.png b/static/uploads/pdf_page_e824a2ea_3.png new file mode 100644 index 0000000..b329e09 Binary files /dev/null and b/static/uploads/pdf_page_e824a2ea_3.png differ diff --git a/static/uploads/pdf_page_f07773ac_4.png b/static/uploads/pdf_page_f07773ac_4.png new file mode 100644 index 0000000..1565c5e Binary files /dev/null and b/static/uploads/pdf_page_f07773ac_4.png differ diff --git a/static/uploads/pdf_page_f54ea912_1.png b/static/uploads/pdf_page_f54ea912_1.png new file mode 100644 index 0000000..44403c0 Binary files /dev/null and b/static/uploads/pdf_page_f54ea912_1.png differ diff --git a/static/uploads/pdf_page_fe066e89_4.png b/static/uploads/pdf_page_fe066e89_4.png new file mode 100644 index 0000000..e14c612 Binary files /dev/null and b/static/uploads/pdf_page_fe066e89_4.png differ diff --git a/templates/admin_base.html b/templates/admin_base.html new file mode 100644 index 0000000..90490e1 --- /dev/null +++ b/templates/admin_base.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} +{% block content %} +
+ + + + +
+
+
+ {% block admin_content %}{% endblock %} +
+
+
+ + +{% endblock %} diff --git a/templates/admin_contest_applications.html b/templates/admin_contest_applications.html new file mode 100644 index 0000000..be768b9 --- /dev/null +++ b/templates/admin_contest_applications.html @@ -0,0 +1,151 @@ +{% extends "admin_base.html" %} + +{% block title %}杯赛申请管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

杯赛申请管理

+ +
+ + + + + + + + + + + + + + + + + + + + + +
ID申请人杯赛名称主办方描述联系方式责任人责任人电话责任人邮箱所属机构申请时间状态操作
+ + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_contests.html b/templates/admin_contests.html new file mode 100644 index 0000000..af50d6d --- /dev/null +++ b/templates/admin_contests.html @@ -0,0 +1,91 @@ +{% extends "admin_base.html" %} + +{% block title %}杯赛管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

杯赛管理

+ +
+ + + + + + + + + + + + + +
ID名称主办方状态开始日期操作
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_create_contest.html b/templates/admin_create_contest.html new file mode 100644 index 0000000..9fe13b5 --- /dev/null +++ b/templates/admin_create_contest.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block title %}发布新杯赛 - 智联青云{% endblock %} + +{% block content %} +
+
+

发布新杯赛

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+

报备信息

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + 取消 + + +
+
+
+
+{% endblock %} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..fa71bbb --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,230 @@ +{% extends "admin_base.html" %} + +{% block title %}管理后台 - 智联青云{% endblock %} + +{% block admin_content %} +
+
+
+

数据概览

+

查看平台运行状态与核心数据

+
+ +
+ + +
+
+
+
+
+ +
+
总用户数
+
+
-
+
+ +
+
+
+
+ +
+
赛事总数
+
+
-
+
+ +
+
+
+
+ +
+
考试总数
+
+
-
+
+ +
+
+
+
+ +
+
社区帖子
+
+
-
+
+
+ + +
+ +
+
+
+ +
+

待处理事项

+
+
+
+ + 加载中... +
+
+
+ + +
+
+
+ +
+

最近活动日志

+
+
+
+
+ + 加载中... +
+
+
+
+ + + +
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_exams.html b/templates/admin_exams.html new file mode 100644 index 0000000..3fe7974 --- /dev/null +++ b/templates/admin_exams.html @@ -0,0 +1,113 @@ +{% extends "admin_base.html" %} + +{% block title %}考试管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

考试管理

+ +
+ + + + + + + + + + + + + +
ID标题科目出题人状态创建时间操作
+ + +
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_notifications.html b/templates/admin_notifications.html new file mode 100644 index 0000000..c5e0438 --- /dev/null +++ b/templates/admin_notifications.html @@ -0,0 +1,239 @@ +{% extends "admin_base.html" %} +{% block title %}通知管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+
+

通知管理

+
+ + +
+
+ +
+
加载中...
+
+
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_posts.html b/templates/admin_posts.html new file mode 100644 index 0000000..d5980f5 --- /dev/null +++ b/templates/admin_posts.html @@ -0,0 +1,126 @@ +{% extends "admin_base.html" %} + +{% block title %}帖子管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

帖子管理

+ +
+ + + +
+ +
+ + + + + + + + + + + + + +
ID标题作者标签置顶发布时间操作
+ + +
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/admin_teacher_applications.html b/templates/admin_teacher_applications.html new file mode 100644 index 0000000..7c69399 --- /dev/null +++ b/templates/admin_teacher_applications.html @@ -0,0 +1,51 @@ +{% extends "admin_base.html" %} + +{% block title %}教师申请审核 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

教师申请审核

+ +
+ + + + + + + + + + + + + + + {% for app in apps %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ID申请人杯赛姓名邮箱申请理由申请时间操作
{{ app.id }}{{ app.user.name }}{{ app.contest.name }}{{ app.name }}{{ app.email }}{{ app.reason }}{{ app.applied_at.strftime('%Y-%m-%d %H:%M') }} +
+ +
+
+ +
+
暂无待审核的教师申请
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..a337790 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,127 @@ +{% extends "admin_base.html" %} + +{% block title %}用户管理 - 智联青云管理后台{% endblock %} + +{% block admin_content %} +
+

用户管理

+ +
+ + + +
+ +
+ + + + + + + + + + + + + +
ID用户名邮箱角色状态注册时间操作
+ + +
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/apply_contest.html b/templates/apply_contest.html new file mode 100644 index 0000000..70682b7 --- /dev/null +++ b/templates/apply_contest.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}申请举办杯赛 - 智联青云{% endblock %} + +{% block content %} +
+
+

申请举办杯赛

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +

用于管理员与您联系,不会公开显示

+
+
+
+ + +
+
+ + +
+
+
+ + +

杯赛考试的默认满分分数

+
+ +
+

报备信息

+
+
+ + +
+
+ + +

请填写有效的手机号码,审核通过后将公开展示

+
+
+ + +
+
+ + +
+
+

以上报备信息将在杯赛详情页公开展示,请确保信息真实有效。

+
+
+ + 取消 + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/apply_teacher.html b/templates/apply_teacher.html new file mode 100644 index 0000000..f065e6a --- /dev/null +++ b/templates/apply_teacher.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}申请成为杯赛老师 - 智联青云{% endblock %} + +{% block content %} +
+ +
+

🎫 已有邀请码?在此激活

+

审核通过后,您会在私聊消息中收到邀请码。输入邀请码即可正式成为杯赛老师。

+
+ + +
+ +
+ +
+

申请成为杯赛老师

+

请选择您希望担任老师的杯赛,并填写申请理由。

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 取消 + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..8762eee --- /dev/null +++ b/templates/base.html @@ -0,0 +1,439 @@ + + + + + + {% block title %}智联青云{% endblock %} + + + + + + + + + + + + + + + + {% block navbar %} + + {% endblock %} + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + {% if user %} + + + + +
+ +
+ + + + + + + {% endif %} + + \ No newline at end of file diff --git a/templates/chat.html b/templates/chat.html new file mode 100644 index 0000000..7c508fd --- /dev/null +++ b/templates/chat.html @@ -0,0 +1,1307 @@ +{% extends "base.html" %} +{% block title %}消息 - 智联青云{% endblock %} + +{% block content %} +
+ +
+ +
+ + +
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+
+ + +
+ +
+
+ +
+
+
+
+ 💬 +
+

欢迎来到联考消息中心

+

在左侧选择一个聊天,或发起新的对话

+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} + diff --git a/templates/contest_detail.html b/templates/contest_detail.html new file mode 100644 index 0000000..5b2cd9a --- /dev/null +++ b/templates/contest_detail.html @@ -0,0 +1,491 @@ +{% extends "base.html" %} +{% block title %}{{ contest.name }} - 智联青云{% endblock %} +{% block content %} +
+ {% if contest.status == 'abolished' %} +
+ ⚠️ 该杯赛已被废止,所有考试已关闭,无法报名或参加考试。 +
+ {% endif %} + + {% if not contest.visible and is_owner %} +
+ 该杯赛尚未发布,仅负责人和管理员可见。完善资料后请点击发布。 + +
+ {% endif %} +
+
+
+

+ {{ contest.name }} + {% if contest.status == 'abolished' %} + 已废止 + {% endif %} +

+
+ + + {{ contest.start_date }} + + + + {{ contest.participants }}人已报名 + +
+
+
+ {% if contest.status != 'abolished' %} + {% if user %} + + {% if not is_member %} + + 申请成为本杯赛老师 + + {% endif %} + {% if is_member %} + + 题库管理 + + {% endif %} + {% if is_owner %} + + 创建考试 + + + 审批老师申请 + + + 编辑主页 + + {% endif %} + {% else %} + + 登录后报名 + + {% endif %} + {% endif %} +
+
+
+ + +
+ +
+ + + +
+

+ + + + 历年真题 +

+ {% set papers = contest.get_past_papers() %} + {% if papers %} +
+ {% for paper in papers %} +
+
+ {{ paper.year }} + {{ paper.title }} +
+ + + + + 下载 + +
+ {% endfor %} +
+ {% else %} +
暂无历年真题,敬请期待!
+ {% endif %} +
+ + +
+
+

+ + 考试列表 +

+ {% if is_owner %} + + {% endif %} +
+
+
加载中...
+
+
+
+ + +
+
+

主办方信息

+
+
+
{{ contest.organizer }}
+

{{ contest.description[:100] + '...' if contest.description|length > 100 else contest.description }}

+
+ {% if contest.responsible_person %} +
+
报备信息
+
+
+ 责任人 + {{ contest.responsible_person }} +
+
+ 电话 + {{ contest.responsible_phone }} +
+
+ 邮箱 + {{ contest.responsible_email }} +
+
+ 机构 + {{ contest.organization }} +
+
+
+ {% endif %} + {% if contest.contact %} +
+
联系方式
+
{{ contest.contact }}
+
+ {% endif %} +
+
+ + +
+

+ + 成绩排行榜 +

+
+
加载中...
+
+
+
+
+ + +
+

+ + 讨论区 +

+ + + {% if user and can_post %} +
+
+ + +
+ +
+
+
+ {% elif user and not can_post %} +
+ ⚠️ 您需要报名该杯赛并至少参与一次考试,才能参与讨论。 +
+ {% endif %} + + +
+
加载中...
+
+
+
+ + +{% if is_owner %} + +{% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/contest_edit.html b/templates/contest_edit.html new file mode 100644 index 0000000..c491bdb --- /dev/null +++ b/templates/contest_edit.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} +{% block title %}编辑杯赛 - {{ contest.name }}{% endblock %} +{% block content %} +
+
+

编辑杯赛主页

+ 返回杯赛 +
+ + +
+

基本信息

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+ + +
+

往届真题管理

+
+ {% for paper in contest.get_past_papers() %} +
+
+ {{ paper.year }} + {{ paper.title }} + 下载 +
+ +
+ {% endfor %} +
+

添加真题

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/contest_list.html b/templates/contest_list.html new file mode 100644 index 0000000..d8f84a0 --- /dev/null +++ b/templates/contest_list.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block title %}杯赛专栏 - 智联青云{% endblock %} +{% block content %} +
+ +
+
+

+ 🏆 + 杯赛专栏 +

+

参与官方联考,检验学习成果,赢取荣誉与奖励。

+
+
+ + + 申请举办杯赛 + + {% if user and (user.role == 'admin' or user.role == 'teacher') %} + + + 发布新杯赛 + + {% endif %} +
+
+ + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+ + +
+
+ + +
+
+ +

正在加载精彩杯赛...

+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/contest_question_bank.html b/templates/contest_question_bank.html new file mode 100644 index 0000000..2cb07a7 --- /dev/null +++ b/templates/contest_question_bank.html @@ -0,0 +1,259 @@ +{% extends "base.html" %} +{% block title %}题库管理 - {{ contest.name }} - 智联青云{% endblock %} +{% block content %} +
+
+
+

📚 题库管理

+

{{ contest.name }}

+
+
+ 返回杯赛 + {% if is_owner %} + + {% endif %} +
+
+ + +
+

添加题目

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

题库列表 (0题)

+
+
加载中...
+
+
+
+ + +{% if is_owner %} + +{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/exam_create.html b/templates/exam_create.html new file mode 100644 index 0000000..368ea23 --- /dev/null +++ b/templates/exam_create.html @@ -0,0 +1,638 @@ +{% extends "base.html" %} +{% block title %}创建试卷 - 智联青云{% endblock %} +{% block content %} +
+
+

创建试卷

+ ← 返回列表 +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +

设置后,考生只能在此时间之后开始考试

+
+
+ + +

设置后,到时间自动截止,考生无法再提交

+
+
+ + +

设置后,考生只能在此时间之后查看成绩(必须晚于考试结束时间)

+
+
+
+
+ + +
+
+
+ + +

设置密码后考生需输入密码才能进入考试,试卷内容将加密存储

+
+
+
+ + +
+
+ 0 + 总分 0 + 选择题 0 + 填空题 0 + 解答题 0 + 判断题 0 +
+
+ +
+ +
+ + + + + + + + + +
+ +
+
提示:拖拽题目卡片可调整顺序
+ +
+
+ + + + + + + + +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/exam_detail.html b/templates/exam_detail.html new file mode 100644 index 0000000..fdfb9af --- /dev/null +++ b/templates/exam_detail.html @@ -0,0 +1,536 @@ +{% extends "base.html" %} +{% block title %}{{ exam.title }} - 智联青云{% endblock %} +{% block content %} +
+ {% if need_password %} +
+
+ +

{{ exam.title }}

+

该考试需要输入密码才能进入

+
+
+ + + + 返回列表 +
+
+ + {% elif existing_submission %} +
+

您已提交过该试卷

+ 查看结果 +
+ {% elif exam.status == 'closed' %} +
+

该考试已关闭

+ 返回列表 +
+ {% elif schedule_status == 'not_started' %} +
+

⏰ 考试尚未开始

+

预定开始时间:{{ exam.scheduled_start.strftime('%Y-%m-%d %H:%M') }}

+ {% if exam.scheduled_end %} +

预定结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}

+ {% endif %} +
+

距离开考还有:

+
+
+ 返回列表 +
+ + {% elif schedule_status == 'ended' %} +
+

该考试已结束

+

结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}

+ 返回列表 +
+ {% else %} + +
+
+
+

{{ exam.title }}

+
+ {{ exam.subject }} · {{ exam.duration }}分钟 · 满分{{ exam.total_score }}分 + {% if exam.scheduled_end %} + · 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }} + {% endif %} +
+
+
+
+ 0/{{ questions|length }} 已答 +
+
+ + --:--:-- +
+ +
+
+ +
+
+
+
+ +
+ + + + +
+ +
+ +
+ +
+ {% for q in questions %} +
+
+ {{ loop.index }} +
+
+
+ + {% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %} + +

{{ q.content }}

+ {% if q.get('images') %} +
+ {% for img in q.images %} + 题目图片 + {% endfor %} +
+ {% endif %} +
+ ({{ q.score }}分) +
+ {% if q.type == 'choice' %} +
+ {% for opt in q.options %} + + {% endfor %} +
+ {% elif q.type == 'fill' %} + + {% else %} + +
+ + +
+
+ {% endif %} +
+
+
+ {% endfor %} + + +
+ + + 第 1 / {{ questions|length }} 题 + + + +
+
+
+
+ {% endif %} +
+{% endblock %} +{% block scripts %} +{% if not existing_submission and exam.status != 'closed' and schedule_status == 'available' %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/exam_grade.html b/templates/exam_grade.html new file mode 100644 index 0000000..9109684 --- /dev/null +++ b/templates/exam_grade.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% block title %}批改试卷 - 智联青云{% endblock %} +{% block content %} +
+
+
+

批改试卷

+

{{ exam.title }} · 考生:{{ submission.user.name if submission.user else '未知' }} · 提交时间:{{ submission.submitted_at }}

+
+
+ {% if next_ungraded %} + + 下一个未批改 → + + {% endif %} + ← 返回提交列表 +
+
+ + + {% if submission.graded %} +
+ 该试卷已批改完成,得分:{{ submission.score }}/{{ exam.total_score }},批改人:{{ submission.graded_by }} + 可重新批改覆盖 +
+ {% endif %} + + {% for q in questions %} +
+
+ {{ loop.index }} +
+
+
+ + {% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %} + +

{{ q.content }}

+ {% if q.get('images') %} +
+ {% for img in q.images %} + 题目图片 + {% endfor %} +
+ {% endif %} +
+ ({{ q.score }}分) +
+ + {% if q.type == 'choice' %} +
+ {% for opt in q.options %} + {% set letter = ['A','B','C','D'][loop.index0] %} + {% set is_answer = letter == q.get('answer','') %} + {% set is_selected = letter == answers.get(q.id|string,'') %} +
+ {{ letter }}. {{ opt }} + {% if is_selected %}← 考生选择{% endif %} + {% if is_answer %}✓ 正确{% endif %} +
+ {% endfor %} +
+ 自动判分:{% if answers.get(q.id|string,'') == q.get('answer','') %} + +{{ q.score }}分 + {% else %} + 0分 + {% endif %} +
+
+ {% else %} +
+
考生答案:
+
{{ answers.get(q.id|string, '(未作答)') | render_images }}
+
+ {% if q.get('answer') %} +
+
参考答案:
+
{{ q.answer }}
+
+ {% endif %} +
+ + + / {{ q.score }} + +
+ + + +
+
+ {% endif %} + {% if q.get('explanation') %} +
+
题目解析:
+
{{ q.explanation }}
+
+ {% endif %} +
+ +
+
总分:0 / {{ exam.total_score }}
+
+ {% if next_ungraded %} + 批改后自动跳转下一个 + {% endif %} + +
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/exam_list.html b/templates/exam_list.html new file mode 100644 index 0000000..9da48a0 --- /dev/null +++ b/templates/exam_list.html @@ -0,0 +1,249 @@ +{% extends "base.html" %} + +{% block title %}考试系统 - 智联青云{% endblock %} + +{% block content %} +
+ +
+
+

+ 📝 + 考试中心 +

+

海量真题与模拟卷,随时随地进行练习与自测。

+
+
+ {% if user and (user.role == 'admin' or user.role == 'teacher') %} + + + 创建新试卷 + + {% endif %} +
+
+ + +
+
+
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + + + {% if search_query or subject_filter %} + + 重置 + + {% endif %} +
+ {% if search_query or subject_filter %} +
+ 筛选结果: + {% if search_query %}包含 "{{ search_query }}"{% endif %} + {% if subject_filter %}{% if search_query %},{% endif %}科目为 "{{ subject_filter }}"{% endif %} + 共找到 {{ exams|length }} 份试卷 +
+ {% endif %} +
+ + + {% if exams|length == 0 %} +
+ +

暂无符合条件的试卷

+ {% if user and (user.role == 'admin' or user.role == 'teacher') %} +

点击上方"创建新试卷"按钮开始命题吧

+ {% endif %} +
+ {% else %} +
+ {% for exam in exams %} + {% set subjectColor = 'blue' %} + {% set subjectGradient = 'from-blue-500 to-cyan-400' %} + {% set subjectIcon = 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' %} + + {% if exam.subject == '数学' %} + {% set subjectColor = 'indigo' %} + {% set subjectGradient = 'from-indigo-500 to-purple-400' %} + {% set subjectIcon = 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z' %} + {% elif exam.subject == '英语' %} + {% set subjectColor = 'rose' %} + {% set subjectGradient = 'from-rose-500 to-pink-400' %} + {% set subjectIcon = 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129' %} + {% elif exam.subject in ['物理', '化学', '生物'] %} + {% set subjectColor = 'emerald' %} + {% set subjectGradient = 'from-emerald-500 to-teal-400' %} + {% set subjectIcon = 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z' %} + {% elif exam.subject in ['历史', '地理', '政治'] %} + {% set subjectColor = 'amber' %} + {% set subjectGradient = 'from-amber-500 to-orange-400' %} + {% set subjectIcon = 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z' %} + {% endif %} + +
+ +
+
+
+ + {% if exam.status == 'closed' %} + 已关闭 + {% else %} + + 进行中 + + {% endif %} +
+ + +
+
+ +
+
+ +
+
+ + {{ exam.subject }} + +
+ +

{{ exam.title }}

+ +
+
+ 满分 / 题目 + {{ exam.total_score }}分 / {{ exam.questions|fromjson|length }}题 +
+
+ 考试时长 + {{ exam.duration }} 分钟 +
+
+ + {% if exam.scheduled_start or exam.scheduled_end %} +
+ {% if exam.scheduled_start %} +
+ + 开始:{{ exam.scheduled_start.strftime('%m-%d %H:%M') }} +
+ {% endif %} + {% if exam.scheduled_end %} +
+ + 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }} +
+ {% endif %} +
+ {% endif %} + +
+ {% set sub = user_submissions.get(exam.id) %} + {% if sub %} +
+ {% if sub.graded %} + 已批改 + {{ sub.score }} / {{ exam.total_score }} + {% else %} + + + 待批改 + + {% endif %} +
+ + 查看试卷 + + {% else %} +
+ {{ exam.creator.name[0] if exam.creator else '?' }} + {{ exam.creator.name if exam.creator else '未知出题人' }} +
+ {% if exam.status != 'closed' %} + + 开始考试 + + {% else %} + 已关闭 + {% endif %} + {% endif %} +
+ + {% if user and (user.role == 'admin' or user.role == 'teacher') %} +
+ 提交情况 + 打印试卷 + + {% if user.role == 'admin' or exam.creator_id == user.id %} + {% if exam.status == 'available' %} + + {% else %} + + {% endif %} + + {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/exam_print.html b/templates/exam_print.html new file mode 100644 index 0000000..ba346a8 --- /dev/null +++ b/templates/exam_print.html @@ -0,0 +1,105 @@ + + + + + + {{ exam.title }} - 打印版 + + + + +
+

{{ exam.title }}

+
+ 科目:{{ exam.subject }} + 考试时长:{{ exam.duration }}分钟 + 满分:{{ exam.total_score }}分 + 出题人:{{ exam.creator.name if exam.creator else '' }} +
+
+
+ 姓名: + 考号: + 得分: +
+ + {% set ns = namespace(choice_idx=0, fill_idx=0, text_idx=0) %} + {% set choices = [] %} + {% set fills = [] %} + {% set texts = [] %} + {% for q in questions %} + {% if q.type == 'choice' %}{% if choices.append(q) %}{% endif %} + {% elif q.type == 'fill' %}{% if fills.append(q) %}{% endif %} + {% else %}{% if texts.append(q) %}{% endif %} + {% endif %} + {% endfor %} + + {% if choices|length > 0 %} +
一、选择题(共{{ choices|length }}题)
+ {% for q in choices %} +
+ {{ loop.index }}. {{ q.content }} + ({{ q.score }}分) + {% if q.options %} +
+ {% for opt in q.options %} +
{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}
+ {% endfor %} +
+ {% endif %} +
+ {% endfor %} + {% endif %} + + {% if fills|length > 0 %} +
二、填空题(共{{ fills|length }}题)
+ {% for q in fills %} +
+ {{ loop.index }}. {{ q.content }} + ({{ q.score }}分) +
+
+
+
+ {% endfor %} + {% endif %} + + {% if texts|length > 0 %} +
三、解答题(共{{ texts|length }}题)
+ {% for q in texts %} +
+ {{ loop.index }}. {{ q.content }} + ({{ q.score }}分) +
+ {% for i in range(8) %} +
+ {% endfor %} +
+
+ {% endfor %} + {% endif %} + + + + \ No newline at end of file diff --git a/templates/exam_result.html b/templates/exam_result.html new file mode 100644 index 0000000..2745636 --- /dev/null +++ b/templates/exam_result.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}考试结果 - 智联青云{% endblock %} +{% block content %} +
+
+

考试结果

+ ← 返回列表 +
+
+

{{ exam.title }}

+
+ {{ exam.subject }} + 满分{{ exam.total_score }}分 + 提交时间:{{ submission.submitted_at }} +
+
+ {% if score_hidden %} +
成绩尚未公布
+
成绩将于 {{ exam.score_release_time.strftime('%Y年%m月%d日 %H:%M') }} 公布
+ {% elif submission.graded %} +
{{ submission.score }} / {{ exam.total_score }}
+
批改人:{{ submission.graded_by }}
+ {% else %} +
待批改
+
选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改
+ {% endif %} +
+
+ {% for q in questions %} +
+
+ {{ loop.index }} +
+
+

{{ q.content }}

+ ({{ q.score }}分) +
+ {% if q.get('images') %} +
+ {% for img in q.images %} + 题目图片 + {% endfor %} +
+ {% endif %} + {% if q.type == 'choice' %} +
+ {% for opt in q.options %} + {% set letter = ['A','B','C','D'][loop.index0] %} + {% set is_answer = letter == q.get('answer','') %} + {% set is_selected = letter == answers.get(q.id|string,'') %} +
+ {% if is_selected %} + + {% else %} +
+ {% endif %} + {{ letter }}. {{ opt }} + {% if is_answer %}✓ 正确答案{% endif %} +
+ {% endfor %} +
+ {% else %} +
+
您的答案:
+
{{ answers.get(q.id|string, '(未作答)') | render_images }}
+
+ {% if q.get('answer') %} +
+
参考答案:
+
{{ q.answer }}
+
+ {% endif %} + {% endif %} + {% if q.get('explanation') %} +
+
解析:
+
{{ q.explanation }}
+
+ {% endif %} +
+
+
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/exam_submissions.html b/templates/exam_submissions.html new file mode 100644 index 0000000..e7ea2cc --- /dev/null +++ b/templates/exam_submissions.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}提交列表 - 智联青云{% endblock %} +{% block content %} +
+
+
+

提交列表

+

{{ exam.title }} · 共{{ submissions|length }}份提交

+
+
+ {% if next_ungraded %} + + 开始批改 → + + {% endif %} + ← 返回列表 +
+
+ + + {% if stats %} +
+
+
{{ stats.total }}
+
总提交
+
+
+
{{ stats.graded }}
+
已批改
+
+
+
{{ stats.ungraded }}
+
待批改
+
+
+
{{ stats.avg_score }}
+
平均分
+
+
+
{{ stats.max_score }}
+
最高分
+
+
+
{{ stats.min_score }}
+
最低分
+
+
+ {% endif %} + + {% if submissions|length == 0 %} +
暂无提交
+ {% else %} +
+
    + {% for sub in submissions %} +
  • +
    +
    +
    +

    {{ sub.user_name }}

    +
    提交时间:{{ sub.submitted_at }}
    +
    +
    + {% if sub.graded %} +
    + {{ sub.score }}/{{ exam.totalScore }}分 +
    批改人:{{ sub.graded_by }}
    +
    + {% else %} + 待批改 + {% endif %} + + {% if sub.graded %}查看{% else %}批改{% endif %} + +
    +
    +
    +
  • + {% endfor %} +
+
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/forum.html b/templates/forum.html new file mode 100644 index 0000000..1c6ea91 --- /dev/null +++ b/templates/forum.html @@ -0,0 +1,936 @@ + {% extends "base.html" %} +{% block title %}社区论坛 - 智联青云{% endblock %} +{% block content %} + + +
+
+ +
+
+
+
+
0
+
+ + 总帖子数 +
+
+
+
+
+
+
0
+
+ + 总回复数 +
+
+
+
+
+
+
0
+
+ + 今日新帖 +
+
+
+
+
+
+
+ + + + +
0
+
+
+ + 当前在线人数 +
+
+
+
+ + +
+
+
+ +
+

+ 💬 + 社区论坛 +

+

分享经验、交流问题、结识志同道合的学习伙伴。

+
+
+ {% if user %} + + + + {% else %} + 登录后发帖 + {% endif %} +
+
+ + +
+
+ +
+ + + + + + +
+ + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..af954f6 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,346 @@ +{% extends "base.html" %} +{% block title %}智联青云 - 首页{% endblock %} +{% block content %} +
+ +
+
+
+
+ +
+ + +
+ +
+ + + + + 汇九智,步青云 +
+ +

+ + 智联青云 + + 汇九智,步青云 +

+ +

+ 打破空间限制,重塑考试体验。集成智能防作弊、自动阅卷与沉浸式备考社区的现代化教育解决方案。 +

+ +
+ + 浏览全部杯赛 + + + {% if not user %} + + 免费注册账号 + + {% elif user.role == 'student' %} + + 👨‍🏫 申请成为教师 + + {% elif user.role == 'teacher' %} + + 👨‍🏫 进入教师后台 + + {% endif %} +
+ + +
+
+
+
+
0
+
+
实时在线
+
+
+
+
+
0
+
+
杯赛总数
+
+
+
+
+
+ + +
+
+

核心优势

+

重新定义考试标准

+
+ +
+ +
+
+
+
+
+ +
+

全矩阵杯赛专栏

+

汇聚各类大型模拟考与学科竞赛。提供一键报名、成绩预测、历年真题库及专家解析,为您打造最硬核的升学备考阵地。

+
+ + 探索赛事 + +
+
+ + +
+
+
+
+
+
+ +
+

极致防作弊监控

+

切屏检测、人脸核验与实时录屏,打造无懈可击的在线考场。

+
+
+
+ + +
+
+
+ +
+

秒级智能判卷

+

客观题自动批改,主观题支持教师流水线协同阅卷,效率提升300%。

+
+
+ + +
+
+ +
+
+
+ +
+

高活跃沉浸式社区

+

与数万名同龄人探讨难题,获取名师独家冲刺资料。支持 Markdown 与公式渲染,交流毫无障碍。

+
+
+
+
+ + +
+

平台数据一览

+
+
+
+
+
0
+
+
实时在线
+
+
+
+
+
0
+
+
杯赛总数
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c83acd3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} +{% block title %}登录 - 智联青云{% endblock %} +{% block navbar %}{% endblock %} +{% block content %} +
+
+

登录您的账户

+

+ 或者 注册新账户 +

+
+
+
+
+ + +
+ +
+
+ +
+ +
+
+
+ +
+ + 验证码 + +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+ 提示:手机号登录默认为学生权限 +
+
+
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/notifications.html b/templates/notifications.html new file mode 100644 index 0000000..23f7646 --- /dev/null +++ b/templates/notifications.html @@ -0,0 +1,298 @@ +{% extends "base.html" %} +{% block title %}通知中心 - 智联青云{% endblock %} + +{% block content %} +
+ +
+
+
+
+ +
+
+

通知中心

+

查看系统通知、审核结果及最新公告

+
+
+ +
+ + +
+ + + + + +
+ + +
+ + + + +
+
+ + 正在加载通知... +
+
+
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..264d716 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,548 @@ +{% extends "base.html" %} +{% block title %}个人中心 - 智联青云{% endblock %} +{% block content %} +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ {% if profile_user.avatar %} + + {% else %} + {{ profile_user.name[0]|upper }} + {% endif %} +
+ {% if profile_user.id == user.id %} +
+ +
+ {% endif %} + + +
+ Lv.{{ level }} +
+
+ + +
+

+ {{ get_display_name(profile_user.id, profile_user.name) if profile_user.id == user.id else profile_user.name }} + {% if profile_user.role == 'admin' %} + 管理员 + {% elif profile_user.role == 'teacher' %} + 👨‍🏫 认证教师 + {% else %} + 🎓 学生 + {% endif %} +

+ +
+ {% if profile_user.email %} + {{ profile_user.email }} + {% endif %} + {% if profile_user.phone %} + {{ profile_user.phone }} + {% endif %} + 加入于 {{ profile_user.created_at.strftime('%Y-%m-%d') }} +
+
+
+
+
+ +
+ +
+ +
+
+

+ 📊 + 活跃数据 +

+ + +
+
+ 当前等级进度 + {{ points }} / {{ (level * 100) }} XP +
+
+ {% set progress = (points / (level * 100) * 100) | int %} + {% set p_width = progress if progress <= 100 else 100 %} +
+
+
+
+
+ +
+
+
{{ points }}
+
总积分
+
+
+
{{ post_count }}
+
发帖数
+
+
+
{{ reply_count }}
+
回复数
+
+
+
{{ likes_received }}
+
获赞数
+
+
+
+ + + {% if profile_user.id == user.id %} +
+

+ + 账号设置 +

+
+
+
+
用户名
+
{{ profile_user.name }}
+
+ +
+ {% if user.role == 'student' %} + +
+
申请成为老师
+
获取发布赛事和考试的权限
+
+ +
+ {% endif %} + {% if user.role == 'admin' or user.role == 'teacher' %} + +
+
进入管理后台
+
管理系统各项数据
+
+ +
+ {% endif %} +
+
+ {% endif %} +
+ + +
+ + {% if profile_user.id == user.id %} +
+ +
+ +
+
通知中心
+
+ +
+ +
+
我的消息
+
+
+
+ +
+
考试记录
+
+
+
+ +
+
我的收藏
+
+
+ {% endif %} + + +
+ +
+
+ + + + +
+
+ +
+ +
+
+
+ + 加载中... +
+
+
+ + + + + + + + + +
+
+
+
+ +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..701aa81 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,264 @@ +{% extends "base.html" %} +{% block title %}注册 - 智联青云{% endblock %} +{% block navbar %}{% endblock %} +{% block content %} +
+
+

注册新账户

+

+ 已有账户? 立即登录 +

+
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 验证码 + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+ + + +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file