mobile app admin_base admin_contests admin_dashboard chat contest_detail exam_create exam_detail exam_grade exam_list profile themes
This commit is contained in:
293
app.py
293
app.py
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from io import BytesIO
|
||||
import random
|
||||
import string
|
||||
import smtplib
|
||||
@@ -28,6 +29,7 @@ from openai import OpenAI as DashScopeClient
|
||||
from models import db, User, Exam, Submission, Draft, Contest, ContestMembership, ContestApplication, Post, Reply, Poll, Report, Bookmark, Reaction, Notification, EditHistory, ContestRegistration, TeacherApplication, Friend, ExamBookmark, ChatRoom, ChatRoomMember, Message, MessageReaction, QuestionBankItem, InviteCode, SystemNotification
|
||||
from flask_socketio import SocketIO, emit, join_room as sio_join, leave_room as sio_leave
|
||||
from excel_export import export_users_to_excel, export_exams_to_excel, export_submissions_to_excel, export_contests_to_excel, export_posts_to_excel
|
||||
from backup_utils import create_backup_zip, export_all_data
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -46,6 +48,20 @@ email_codes = {}
|
||||
# 在线用户追踪(记录用户最后活跃时间)
|
||||
online_users = {} # {user_id: last_active_timestamp}
|
||||
|
||||
# 正在考试中的用户 {user_id: {exam_id, exam_title, end_time}}
|
||||
users_in_exam = {}
|
||||
|
||||
# SocketIO 用户与连接映射 {user_id: set(sids)},用于向好友推送考试状态
|
||||
user_sids = {}
|
||||
|
||||
def _prune_expired_exam_users():
|
||||
"""移除已过期的考试用户"""
|
||||
now = int(time.time())
|
||||
expired = [uid for uid, info in users_in_exam.items() if info.get('end_time', 0) < now]
|
||||
for uid in expired:
|
||||
del users_in_exam[uid]
|
||||
_notify_friends_exam_status(uid, False)
|
||||
|
||||
# 数据库配置
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
@@ -379,13 +395,14 @@ def inject_display_name():
|
||||
if not user:
|
||||
return ''
|
||||
return get_display_name(user['id'], user['name'])
|
||||
return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name)
|
||||
return dict(get_user_display_name=get_user_display_name, get_display_name=get_display_name, can_grade_exam=can_grade_exam)
|
||||
|
||||
# ========== 页面路由 ==========
|
||||
@app.route('/')
|
||||
def home():
|
||||
online_count = get_online_count()
|
||||
contest_count = Contest.query.count()
|
||||
# 首页杯赛数量:排除已废止和已删除的杯赛
|
||||
contest_count = Contest.query.filter(Contest.status != 'abolished').count()
|
||||
return render_template('home.html', online_count=online_count, contest_count=contest_count)
|
||||
|
||||
@app.route('/login', methods=['GET'])
|
||||
@@ -556,14 +573,19 @@ def exam_list():
|
||||
|
||||
query = Exam.query
|
||||
|
||||
# 只显示公开考试或用户自己创建的私有考试
|
||||
# 只显示公开考试、用户自己创建的私有考试、或用户作为杯赛成员可批改的杯赛考试
|
||||
from sqlalchemy import or_
|
||||
query = query.filter(
|
||||
or_(
|
||||
Exam.visibility == 'public',
|
||||
Exam.creator_id == user.get('id')
|
||||
)
|
||||
base_filter = or_(
|
||||
Exam.visibility == 'public',
|
||||
Exam.creator_id == user.get('id')
|
||||
)
|
||||
# 杯赛负责人和杯赛老师可见其杯赛下的所有考试(用于批改)
|
||||
cup_member_contest_ids = [m.contest_id for m in ContestMembership.query.filter_by(
|
||||
user_id=user.get('id')).filter(
|
||||
ContestMembership.role.in_(['owner', 'teacher'])).all()]
|
||||
if cup_member_contest_ids:
|
||||
base_filter = or_(base_filter, Exam.contest_id.in_(cup_member_contest_ids))
|
||||
query = query.filter(base_filter)
|
||||
|
||||
if subject_filter:
|
||||
query = query.filter_by(subject=subject_filter)
|
||||
@@ -597,20 +619,22 @@ def exam_list():
|
||||
@login_required
|
||||
def exam_create():
|
||||
user = session.get('user')
|
||||
contest_id = request.args.get('contest_id', type=int)
|
||||
is_cup_teacher = False
|
||||
# 允许 teacher/admin 创建任意考试
|
||||
if user.get('role') in ('teacher', 'admin'):
|
||||
return render_template('exam_create.html')
|
||||
# 允许杯赛负责人创建其杯赛的考试
|
||||
contest_id = request.args.get('contest_id', type=int)
|
||||
return render_template('exam_create.html', contest_id=contest_id or None, is_cup_teacher=False)
|
||||
# 允许杯赛负责人或杯赛老师创建其杯赛的考试
|
||||
if contest_id:
|
||||
membership = ContestMembership.query.filter_by(
|
||||
user_id=user['id'], contest_id=contest_id, role='owner').first()
|
||||
user_id=user['id'], contest_id=contest_id).first()
|
||||
if membership:
|
||||
return render_template('exam_create.html')
|
||||
is_cup_teacher = (membership.role == 'teacher')
|
||||
return render_template('exam_create.html', contest_id=contest_id, is_cup_teacher=is_cup_teacher)
|
||||
# 检查用户是否是任何杯赛的负责人
|
||||
any_ownership = ContestMembership.query.filter_by(user_id=user['id'], role='owner').first()
|
||||
if any_ownership:
|
||||
return render_template('exam_create.html')
|
||||
return render_template('exam_create.html', contest_id=contest_id or None, is_cup_teacher=False)
|
||||
return redirect(url_for('exam_list'))
|
||||
|
||||
@app.route('/exams/<int:exam_id>')
|
||||
@@ -1587,13 +1611,13 @@ def apply_teacher():
|
||||
|
||||
# 如果存在申请,检查拒绝次数和冷却期
|
||||
if existing:
|
||||
# 检查是否被拒绝5次且在冷却期内
|
||||
if existing.rejection_count >= 5 and existing.last_rejected_at:
|
||||
# 检查是否被拒绝3次且在冷却期内
|
||||
if existing.rejection_count >= 3 and existing.last_rejected_at:
|
||||
from datetime import timedelta
|
||||
cooldown_end = existing.last_rejected_at + timedelta(days=30)
|
||||
cooldown_end = existing.last_rejected_at + timedelta(days=10)
|
||||
if datetime.utcnow() < cooldown_end:
|
||||
remaining_days = (cooldown_end - datetime.utcnow()).days + 1
|
||||
flash(f'您对该杯赛的申请已被拒绝5次,需等待 {remaining_days} 天后才能再次申请', 'error')
|
||||
flash(f'您对该杯赛的申请已被拒绝3次,需等待 {remaining_days} 天后才能再次申请', 'error')
|
||||
return redirect(url_for('contest_detail', contest_id=contest_id))
|
||||
|
||||
# 如果存在申请,更新它;否则创建新申请
|
||||
@@ -2544,9 +2568,16 @@ def api_create_exam():
|
||||
# 检查是否是负责人、老师或管理员
|
||||
if not membership and user.get('role') != 'admin':
|
||||
return jsonify({'success': False, 'message': '只有杯赛成员才能创建杯赛考试'}), 403
|
||||
# 杯赛考试默认为公开(杯赛内可见)
|
||||
if visibility == 'private':
|
||||
visibility = 'public'
|
||||
# 权限:仅管理员和杯赛负责人可创建公开杯赛,杯赛老师仅可创建私有(自己可见)
|
||||
is_owner = membership and membership.role == 'owner'
|
||||
is_admin = user.get('role') == 'admin'
|
||||
is_cup_teacher = membership and membership.role == 'teacher'
|
||||
if visibility == 'public':
|
||||
if not is_owner and not is_admin:
|
||||
return jsonify({'success': False, 'message': '只有管理员和杯赛负责人才可以创建公开杯赛,杯赛老师仅可创建自己可见的杯赛'}), 403
|
||||
# 杯赛老师创建的强制为私有
|
||||
if is_cup_teacher:
|
||||
visibility = 'private'
|
||||
else:
|
||||
# 普通考试:所有登录用户都可以创建私有考试
|
||||
# 只有 teacher 或 admin 可以创建公开考试
|
||||
@@ -2626,6 +2657,73 @@ def api_verify_exam_password(exam_id):
|
||||
return jsonify({'success': True, 'message': '验证通过'})
|
||||
return jsonify({'success': False, 'message': '密码错误'}), 403
|
||||
|
||||
def _notify_friends_exam_status(user_id, in_exam, exam_id=None, exam_title=None, end_time=None):
|
||||
"""通知好友用户的考试状态变化"""
|
||||
friendships = Friend.query.filter(
|
||||
((Friend.user_id == user_id) | (Friend.friend_id == user_id)) &
|
||||
(Friend.status == 'accepted')
|
||||
).all()
|
||||
friend_ids = []
|
||||
for f in friendships:
|
||||
fid = f.friend_id if f.user_id == user_id else f.user_id
|
||||
friend_ids.append(fid)
|
||||
user = User.query.get(user_id)
|
||||
user_name = user.name if user else ''
|
||||
payload = {
|
||||
'user_id': user_id, 'user_name': user_name,
|
||||
'in_exam': in_exam, 'exam_id': exam_id, 'exam_title': exam_title, 'end_time': end_time
|
||||
}
|
||||
for fid in friend_ids:
|
||||
sids = user_sids.get(fid, set())
|
||||
for sid in sids:
|
||||
socketio.emit('friend_exam_status', payload, room=sid)
|
||||
|
||||
@app.route('/api/exams/<int:exam_id>/enter', methods=['POST'])
|
||||
@login_required
|
||||
def api_exam_enter(exam_id):
|
||||
"""用户进入考试,标记状态并通知好友"""
|
||||
user = session.get('user')
|
||||
if not user:
|
||||
return jsonify({'success': False, 'message': '请先登录'}), 401
|
||||
exam = Exam.query.get(exam_id)
|
||||
if not exam:
|
||||
return jsonify({'success': False, 'message': '试卷不存在'}), 404
|
||||
if Submission.query.filter_by(exam_id=exam_id, user_id=user['id']).first():
|
||||
return jsonify({'success': False, 'message': '您已提交过该试卷'}), 400
|
||||
duration_min = exam.duration or 120
|
||||
end_ts = int(time.time()) + duration_min * 60
|
||||
users_in_exam[user['id']] = {
|
||||
'exam_id': exam_id, 'exam_title': exam.title or '', 'end_time': end_ts
|
||||
}
|
||||
_notify_friends_exam_status(user['id'], True, exam_id, exam.title, end_ts)
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/exams/<int:exam_id>/exit', methods=['POST'])
|
||||
@login_required
|
||||
def api_exam_exit(exam_id):
|
||||
"""用户退出考试(提交后或离开页面时调用)"""
|
||||
user = session.get('user')
|
||||
if not user:
|
||||
return jsonify({'success': False}), 401
|
||||
if user['id'] in users_in_exam and users_in_exam[user['id']].get('exam_id') == exam_id:
|
||||
del users_in_exam[user['id']]
|
||||
_notify_friends_exam_status(user['id'], False)
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/user/<int:user_id>/exam-status')
|
||||
@login_required
|
||||
def api_user_exam_status(user_id):
|
||||
"""获取用户是否正在考试(供好友在聊天界面查询)"""
|
||||
_prune_expired_exam_users()
|
||||
info = users_in_exam.get(user_id)
|
||||
if not info:
|
||||
return jsonify({'success': True, 'in_exam': False})
|
||||
return jsonify({
|
||||
'success': True, 'in_exam': True,
|
||||
'exam_id': info.get('exam_id'), 'exam_title': info.get('exam_title', ''),
|
||||
'end_time': info.get('end_time')
|
||||
})
|
||||
|
||||
@app.route('/api/exams/<int:exam_id>/save-draft', methods=['POST'])
|
||||
def api_save_draft(exam_id):
|
||||
user = session.get('user')
|
||||
@@ -2668,37 +2766,69 @@ def api_submit_exam(exam_id):
|
||||
auto_graded = True
|
||||
question_scores = {}
|
||||
questions = get_exam_questions(exam)
|
||||
|
||||
# AI批改客观题
|
||||
for q in questions:
|
||||
qid = str(q['id'])
|
||||
if q['type'] == 'choice':
|
||||
# 选择题:精确匹配
|
||||
if answers.get(qid) == q.get('answer'):
|
||||
score += q.get('score', 0)
|
||||
question_scores[qid] = q.get('score', 0)
|
||||
else:
|
||||
question_scores[qid] = 0
|
||||
elif q['type'] == 'fill':
|
||||
# 填空题:使用AI智能判断
|
||||
student_answer = answers.get(qid, '').strip()
|
||||
correct_answers = [a.strip() for a in q.get('answer', '').split('|')]
|
||||
correct_answer = q.get('answer', '').strip()
|
||||
|
||||
# 先尝试精确匹配(支持多个正确答案,用|分隔)
|
||||
correct_answers = [a.strip() for a in correct_answer.split('|')]
|
||||
if student_answer in correct_answers:
|
||||
score += q.get('score', 0)
|
||||
question_scores[qid] = q.get('score', 0)
|
||||
else:
|
||||
# 使用AI判断答案是否正确
|
||||
ai_score = ai_grade_fill_question(q.get('content', ''), correct_answer, student_answer, q.get('score', 0))
|
||||
score += ai_score
|
||||
question_scores[qid] = ai_score
|
||||
elif q['type'] == 'judge':
|
||||
# 判断题:精确匹配
|
||||
if answers.get(qid) == q.get('answer'):
|
||||
score += q.get('score', 0)
|
||||
question_scores[qid] = q.get('score', 0)
|
||||
else:
|
||||
question_scores[qid] = 0
|
||||
else:
|
||||
# 主观题需要人工批改
|
||||
auto_graded = False
|
||||
question_scores[qid] = 0
|
||||
|
||||
sub = Submission(
|
||||
exam_id=exam_id,
|
||||
user_id=user.get('id'),
|
||||
score=score,
|
||||
graded=auto_graded,
|
||||
graded_by='系统自动' if auto_graded else ''
|
||||
graded_by='AI自动批改' if auto_graded else ''
|
||||
)
|
||||
sub.set_answers(answers)
|
||||
sub.set_question_scores(question_scores)
|
||||
db.session.add(sub)
|
||||
Draft.query.filter_by(exam_id=exam_id, user_id=user.get('id')).delete()
|
||||
db.session.commit()
|
||||
|
||||
# 如果有主观题,通知老师批改
|
||||
if not auto_graded and exam.contest_id:
|
||||
contest = Contest.query.get(exam.contest_id)
|
||||
if contest:
|
||||
# 通知杯赛负责人和老师
|
||||
memberships = ContestMembership.query.filter_by(contest_id=exam.contest_id).all()
|
||||
for m in memberships:
|
||||
if m.role in ('owner', 'teacher'):
|
||||
add_notification(m.user_id, 'exam_submission',
|
||||
f'考生 {user.get("name")} 提交了考试「{exam.title}」,请批改主观题。',
|
||||
from_user=user.get('name', ''), post_id=sub.id)
|
||||
|
||||
return jsonify({'success': True, 'message': '提交成功', 'submission_id': sub.id})
|
||||
|
||||
@app.route('/api/exams/<int:exam_id>/grade/<int:sub_id>', methods=['POST'])
|
||||
@@ -2762,18 +2892,21 @@ def api_update_exam_visibility(exam_id):
|
||||
if new_visibility not in ('private', 'public'):
|
||||
return jsonify({'success': False, 'message': '无效的可见性设置'}), 400
|
||||
|
||||
# 检查权限:教师、管理员、杯赛负责人和杯赛老师可以设置为公开
|
||||
# 检查权限:仅管理员和杯赛负责人可设置为公开,杯赛老师仅可设为私有
|
||||
if new_visibility == 'public':
|
||||
has_permission = user.get('role') in ('teacher', 'admin')
|
||||
# 如果是杯赛考试,检查是否为杯赛负责人或老师
|
||||
has_permission = user.get('role') == 'admin'
|
||||
# 如果是杯赛考试,杯赛负责人也可以设置为公开
|
||||
if not has_permission and exam.contest_id:
|
||||
membership = ContestMembership.query.filter_by(
|
||||
user_id=user['id'], contest_id=exam.contest_id).first()
|
||||
if membership and membership.role in ('owner', 'teacher'):
|
||||
if membership and membership.role == 'owner':
|
||||
has_permission = True
|
||||
# 系统教师可设置普通考试为公开(非杯赛考试)
|
||||
if not has_permission and not exam.contest_id and user.get('role') == 'teacher':
|
||||
has_permission = True
|
||||
|
||||
if not has_permission:
|
||||
return jsonify({'success': False, 'message': '只有教师、管理员或杯赛负责人/老师可以创建公开考试'}), 403
|
||||
return jsonify({'success': False, 'message': '只有管理员和杯赛负责人才可以将杯赛考试设为公开'}), 403
|
||||
|
||||
exam.visibility = new_visibility
|
||||
db.session.commit()
|
||||
@@ -2801,7 +2934,7 @@ def api_delete_exam(exam_id):
|
||||
@cache.cached(timeout=60, query_string=True)
|
||||
def api_search_contests():
|
||||
keyword = request.args.get('q', '').strip().lower()
|
||||
query = Contest.query
|
||||
query = Contest.query.filter(Contest.status != 'abolished')
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
(Contest.name.contains(keyword)) |
|
||||
@@ -3911,12 +4044,14 @@ def api_chat_rooms():
|
||||
# 私聊显示对方名字和头像
|
||||
room_name = room.name
|
||||
room_avatar = room.avatar
|
||||
peer_id = None
|
||||
if room.type == 'private':
|
||||
other_member = ChatRoomMember.query.filter(
|
||||
ChatRoomMember.room_id == room.id,
|
||||
ChatRoomMember.user_id != user_id
|
||||
).first()
|
||||
if other_member:
|
||||
peer_id = other_member.user_id
|
||||
other_user = User.query.get(other_member.user_id)
|
||||
if other_user:
|
||||
room_name = other_user.name
|
||||
@@ -3924,6 +4059,7 @@ def api_chat_rooms():
|
||||
rooms_data.append({
|
||||
'id': room.id,
|
||||
'type': room.type,
|
||||
'peer_id': peer_id,
|
||||
'name': room_name or '未命名群聊',
|
||||
'avatar': room_avatar or '',
|
||||
'muted': m.muted,
|
||||
@@ -4204,10 +4340,24 @@ def handle_connect():
|
||||
user = session.get('user')
|
||||
if not user:
|
||||
return False
|
||||
memberships = ChatRoomMember.query.filter_by(user_id=user['id']).all()
|
||||
user_id = user['id']
|
||||
if user_id not in user_sids:
|
||||
user_sids[user_id] = set()
|
||||
user_sids[user_id].add(request.sid)
|
||||
memberships = ChatRoomMember.query.filter_by(user_id=user_id).all()
|
||||
for m in memberships:
|
||||
sio_join(f'room_{m.room_id}')
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
user = session.get('user')
|
||||
if user:
|
||||
uid = user['id']
|
||||
if uid in user_sids:
|
||||
user_sids[uid].discard(request.sid)
|
||||
if not user_sids[uid]:
|
||||
del user_sids[uid]
|
||||
|
||||
@socketio.on('send_message')
|
||||
def handle_send_message(data):
|
||||
user = session.get('user')
|
||||
@@ -4240,7 +4390,7 @@ def handle_send_message(data):
|
||||
rs = User.query.get(reply_msg.sender_id)
|
||||
reply_to_data = {'id': reply_msg.id, 'sender_name': rs.name if rs else '未知',
|
||||
'content': reply_msg.content[:50] if reply_msg.content else '[文件]'}
|
||||
emit('new_message', {
|
||||
payload = {
|
||||
'id': msg.id, 'room_id': room_id, 'sender_id': user_id,
|
||||
'sender_name': sender.name if sender else user['name'],
|
||||
'sender_avatar': (sender.avatar or '') if sender else '',
|
||||
@@ -4249,7 +4399,24 @@ def handle_send_message(data):
|
||||
'reply_to': reply_to_data,
|
||||
'mentions': mentions_raw,
|
||||
'created_at': msg.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}, room=f'room_{room_id}')
|
||||
}
|
||||
# 考试期间不向考生推送聊天消息(排除正在考试的用户)
|
||||
_prune_expired_exam_users()
|
||||
members = ChatRoomMember.query.filter_by(room_id=room_id).all()
|
||||
has_exam_user = any(m.user_id in users_in_exam for m in members)
|
||||
if has_exam_user:
|
||||
for m in members:
|
||||
if m.user_id not in users_in_exam:
|
||||
for sid in user_sids.get(m.user_id, set()):
|
||||
socketio.emit('new_message', payload, room=sid)
|
||||
# 私聊时通知发送者:对方已收到,但考试期间不推送
|
||||
room = ChatRoom.query.get(room_id)
|
||||
if room and room.type == 'private':
|
||||
exam_member = next((m for m in members if m.user_id in users_in_exam), None)
|
||||
if exam_member and exam_member.user_id != user_id:
|
||||
emit('peer_in_exam', {'message': '对方正在考试中,消息将在考试结束后送达'}, room=request.sid)
|
||||
else:
|
||||
emit('new_message', payload, room=f'room_{room_id}')
|
||||
|
||||
@socketio.on('typing')
|
||||
def handle_typing(data):
|
||||
@@ -4595,6 +4762,33 @@ def admin_contest_detail(contest_id):
|
||||
db.session.commit()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/admin/contests/<int:contest_id>/delete', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_delete_contest(contest_id):
|
||||
"""管理员彻底删除杯赛(需先废止)。删除后杯赛将不再显示,不计入首页数量"""
|
||||
contest = Contest.query.get(contest_id)
|
||||
if not contest:
|
||||
return jsonify({'success': False, 'message': '杯赛不存在'}), 404
|
||||
if contest.status != 'abolished':
|
||||
return jsonify({'success': False, 'message': '请先废止杯赛再删除'}), 400
|
||||
# 清理关联数据(无 cascade 的需手动处理)
|
||||
ContestRegistration.query.filter_by(contest_id=contest_id).delete()
|
||||
ContestApplication.query.filter_by(contest_id=contest_id).delete()
|
||||
QuestionBankItem.query.filter_by(contest_id=contest_id).delete()
|
||||
# 删除该杯赛教师申请的邀请码
|
||||
ta_ids = [ta.id for ta in TeacherApplication.query.filter_by(contest_id=contest_id).all()]
|
||||
if ta_ids:
|
||||
InviteCode.query.filter(InviteCode.application_id.in_(ta_ids)).delete(synchronize_session=False)
|
||||
# 解除考试与杯赛的关联(保留考试数据)
|
||||
Exam.query.filter_by(contest_id=contest_id).update({Exam.contest_id: None})
|
||||
# 解除聊天室与杯赛的关联
|
||||
chat_room = ChatRoom.query.filter_by(contest_id=contest_id).first()
|
||||
if chat_room:
|
||||
chat_room.contest_id = None
|
||||
db.session.delete(contest)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '杯赛已彻底删除'})
|
||||
|
||||
@app.route('/api/admin/contests/<int:contest_id>/abolish', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_abolish_contest(contest_id):
|
||||
@@ -5018,5 +5212,40 @@ def admin_export_posts():
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
# ========== 数据备份(管理员) ==========
|
||||
UPLOAD_FOLDER_BACKUP = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
|
||||
|
||||
@app.route('/admin/backup')
|
||||
@admin_required
|
||||
def admin_backup_page():
|
||||
"""数据备份页面"""
|
||||
return render_template('admin_backup.html')
|
||||
|
||||
@app.route('/admin/backup/download')
|
||||
@admin_required
|
||||
def admin_backup_download():
|
||||
"""下载完整数据备份(ZIP:数据 JSON + 上传文件)"""
|
||||
full = request.args.get('full', '1') == '1'
|
||||
if full:
|
||||
zip_buf = create_backup_zip(include_uploads=True, upload_folder=UPLOAD_FOLDER_BACKUP)
|
||||
filename = f"智联青云_完整备份_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||||
return send_file(
|
||||
zip_buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
else:
|
||||
data = export_all_data()
|
||||
buf = BytesIO(json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8'))
|
||||
buf.seek(0)
|
||||
filename = f"智联青云_数据备份_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/json; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
socketio.run(app, debug=True,host='0.0.0.0', port=5080)
|
||||
Reference in New Issue
Block a user