mobile . themes
This commit is contained in:
@@ -28,7 +28,9 @@
|
||||
"Bash(git push)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(sqlite3:*)"
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(source venv/Scripts/activate)",
|
||||
"Bash(venv/Scripts/python.exe:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
添加 exam.visibility 字段的数据库迁移脚本
|
||||
"""
|
||||
|
||||
from app import app, db
|
||||
from models import Exam
|
||||
|
||||
def add_visibility_column():
|
||||
"""为 Exam 表添加 visibility 字段"""
|
||||
with app.app_context():
|
||||
try:
|
||||
# 检查字段是否已存在
|
||||
inspector = db.inspect(db.engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('exam')]
|
||||
|
||||
if 'visibility' in columns:
|
||||
print("visibility 字段已存在,无需添加")
|
||||
return
|
||||
|
||||
# 添加字段
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(db.text("ALTER TABLE exam ADD COLUMN visibility VARCHAR(20) DEFAULT 'private'"))
|
||||
conn.commit()
|
||||
print("成功添加 visibility 字段")
|
||||
|
||||
# 将所有现有考试设置为公开(保持向后兼容)
|
||||
conn.execute(db.text("UPDATE exam SET visibility = 'public' WHERE visibility IS NULL"))
|
||||
conn.commit()
|
||||
print("已将现有考试设置为公开")
|
||||
|
||||
except Exception as e:
|
||||
print(f"添加字段时出错: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("开始添加 visibility 字段...")
|
||||
add_visibility_column()
|
||||
print("完成!")
|
||||
38
app.py
38
app.py
@@ -4130,6 +4130,24 @@ def api_remove_chat_member(room_id, uid):
|
||||
socketio.emit('room_updated', {'room_id': room_id}, room=f'room_{room_id}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/chat/rooms/<int:room_id>/dissolve', methods=['POST'])
|
||||
@login_required
|
||||
def api_dissolve_room(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
|
||||
if room.creator_id != user_id:
|
||||
return jsonify({'success': False, 'message': '只有群主才能解散群聊'}), 403
|
||||
# 删除所有成员、消息、群聊
|
||||
ChatRoomMember.query.filter_by(room_id=room_id).delete()
|
||||
Message.query.filter_by(room_id=room_id).delete()
|
||||
db.session.delete(room)
|
||||
db.session.commit()
|
||||
socketio.emit('room_dissolved', {'room_id': room_id}, room=f'room_{room_id}')
|
||||
return jsonify({'success': True, 'message': '群聊已解散'})
|
||||
|
||||
@app.route('/api/chat/rooms/<int:room_id>/read', methods=['POST'])
|
||||
@login_required
|
||||
def api_mark_chat_read(room_id):
|
||||
@@ -4920,10 +4938,10 @@ def admin_export_users():
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
output = export_users_to_excel(users)
|
||||
|
||||
filename = f"用户数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"用户数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -4938,10 +4956,10 @@ def admin_export_exams():
|
||||
exams = Exam.query.order_by(Exam.created_at.desc()).all()
|
||||
output = export_exams_to_excel(exams)
|
||||
|
||||
filename = f"考试数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"考试数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -4956,10 +4974,10 @@ def admin_export_submissions():
|
||||
submissions = Submission.query.order_by(Submission.submitted_at.desc()).all()
|
||||
output = export_submissions_to_excel(submissions)
|
||||
|
||||
filename = f"提交记录_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"提交记录_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -4974,10 +4992,10 @@ def admin_export_contests():
|
||||
contests = Contest.query.order_by(Contest.created_at.desc()).all()
|
||||
output = export_contests_to_excel(contests)
|
||||
|
||||
filename = f"杯赛数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"杯赛数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
@@ -4992,10 +5010,10 @@ def admin_export_posts():
|
||||
posts = Post.query.order_by(Post.created_at.desc()).all()
|
||||
output = export_posts_to_excel(posts)
|
||||
|
||||
filename = f"论坛帖子_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"论坛帖子_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
检查教师申请数据的完整性
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
db_path = 'instance/database.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("检查教师申请数据完整性...\n")
|
||||
|
||||
# 获取所有待审批的申请
|
||||
cursor.execute("""
|
||||
SELECT id, user_id, contest_id, name, email, status
|
||||
FROM teacher_application
|
||||
WHERE status = 'pending'
|
||||
""")
|
||||
apps = cursor.fetchall()
|
||||
|
||||
if not apps:
|
||||
print("没有待审批的教师申请")
|
||||
else:
|
||||
print(f"找到 {len(apps)} 个待审批的申请\n")
|
||||
|
||||
for app in apps:
|
||||
app_id, user_id, contest_id, name, email, status = app
|
||||
print(f"申请 ID: {app_id}")
|
||||
print(f" 用户ID: {user_id}, 杯赛ID: {contest_id}")
|
||||
print(f" 姓名: {name}, 邮箱: {email}")
|
||||
|
||||
# 检查用户是否存在
|
||||
cursor.execute("SELECT id, name FROM user WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
print(f" [OK] 用户存在: {user[1]}")
|
||||
else:
|
||||
print(f" [ERROR] 用户不存在!")
|
||||
|
||||
# 检查杯赛是否存在
|
||||
cursor.execute("SELECT id, name FROM contest WHERE id = ?", (contest_id,))
|
||||
contest = cursor.fetchone()
|
||||
if contest:
|
||||
print(f" [OK] 杯赛存在: {contest[1]}")
|
||||
else:
|
||||
print(f" [ERROR] 杯赛不存在!")
|
||||
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
print("检查完成")
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
# 设置输出编码为UTF-8
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
db_path = 'instance/database.db'
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 检查数据库编码
|
||||
cursor.execute('PRAGMA encoding')
|
||||
encoding = cursor.fetchone()[0]
|
||||
print(f"数据库编码: {encoding}")
|
||||
print("-" * 50)
|
||||
|
||||
# 获取所有表
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
print(f"\n数据表列表 (共{len(tables)}个):")
|
||||
for table in tables:
|
||||
print(f" - {table}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
# 检查每个表的数据
|
||||
for table in tables:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f"\n表 '{table}': {count} 条记录")
|
||||
|
||||
# 显示表结构
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
columns = cursor.fetchall()
|
||||
print(f" 字段: {', '.join([col[1] for col in columns])}")
|
||||
|
||||
# 显示前3条数据示例
|
||||
if count > 0:
|
||||
cursor.execute(f"SELECT * FROM {table} LIMIT 3")
|
||||
rows = cursor.fetchall()
|
||||
print(f" 示例数据 (前{len(rows)}条):")
|
||||
for i, row in enumerate(rows, 1):
|
||||
print(f" 记录{i}: {row[:5]}...") # 只显示前5个字段
|
||||
|
||||
conn.close()
|
||||
print("\n" + "=" * 50)
|
||||
print("数据库检查完成!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,49 +0,0 @@
|
||||
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()
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""创建管理员账号脚本"""
|
||||
|
||||
from app import app, db
|
||||
from models import User
|
||||
|
||||
def create_admin():
|
||||
with app.app_context():
|
||||
# 检查邮箱是否已存在
|
||||
existing_user = User.query.filter_by(email='1512344589@qq.com').first()
|
||||
if existing_user:
|
||||
print(f'邮箱 1512344589@qq.com 已存在')
|
||||
print(f'用户名: {existing_user.name}')
|
||||
print(f'角色: {existing_user.role}')
|
||||
|
||||
# 更新为管理员
|
||||
if existing_user.role != 'admin':
|
||||
existing_user.role = 'admin'
|
||||
existing_user.password = '1'
|
||||
db.session.commit()
|
||||
print('已将该用户更新为管理员')
|
||||
else:
|
||||
print('该用户已经是管理员')
|
||||
return
|
||||
|
||||
# 创建新管理员
|
||||
admin = User(
|
||||
name='管理员',
|
||||
email='1512344589@qq.com',
|
||||
password='1',
|
||||
role='admin'
|
||||
)
|
||||
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
print('管理员账号创建成功!')
|
||||
print(f'邮箱: {admin.email}')
|
||||
print(f'密码: 1')
|
||||
print(f'角色: {admin.role}')
|
||||
print(f'用户ID: {admin.id}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_admin()
|
||||
255
excel_export.py
255
excel_export.py
@@ -1,217 +1,78 @@
|
||||
# excel_export.py
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import csv
|
||||
from io import StringIO, BytesIO
|
||||
|
||||
|
||||
def _to_csv(headers, rows):
|
||||
"""生成 UTF-8 BOM 编码的 CSV,兼容 VSCode 和 Excel"""
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
writer.writerows(rows)
|
||||
|
||||
output = BytesIO()
|
||||
output.write(b'\xef\xbb\xbf') # UTF-8 BOM
|
||||
output.write(buf.getvalue().encode('utf-8'))
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
|
||||
def export_users_to_excel(users):
|
||||
"""导出用户数据到Excel"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "用户数据"
|
||||
|
||||
# 设置表头样式
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
# 表头
|
||||
headers = ["ID", "用户名", "邮箱", "手机号", "角色", "状态", "完成考试数", "注册时间"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
rows = []
|
||||
for u in users:
|
||||
rows.append([
|
||||
u.id, u.name, u.email or "", u.phone or "", u.role,
|
||||
"已封禁" if u.is_banned else "正常", u.completed_exams,
|
||||
u.created_at.strftime("%Y-%m-%d %H:%M:%S") if u.created_at else ""
|
||||
])
|
||||
return _to_csv(headers, rows)
|
||||
|
||||
# 数据行
|
||||
for row, user in enumerate(users, 2):
|
||||
ws.cell(row=row, column=1, value=user.id)
|
||||
ws.cell(row=row, column=2, value=user.name)
|
||||
ws.cell(row=row, column=3, value=user.email or "")
|
||||
ws.cell(row=row, column=4, value=user.phone or "")
|
||||
ws.cell(row=row, column=5, value=user.role)
|
||||
ws.cell(row=row, column=6, value="已封禁" if user.is_banned else "正常")
|
||||
ws.cell(row=row, column=7, value=user.completed_exams)
|
||||
ws.cell(row=row, column=8, value=user.created_at.strftime("%Y-%m-%d %H:%M:%S") if user.created_at else "")
|
||||
|
||||
# 调整列宽
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 20
|
||||
ws.column_dimensions['C'].width = 30
|
||||
ws.column_dimensions['D'].width = 15
|
||||
ws.column_dimensions['E'].width = 12
|
||||
ws.column_dimensions['F'].width = 10
|
||||
ws.column_dimensions['G'].width = 12
|
||||
ws.column_dimensions['H'].width = 20
|
||||
|
||||
# 保存到内存
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def export_exams_to_excel(exams):
|
||||
"""导出考试数据到Excel"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "考试数据"
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
headers = ["ID", "考试名称", "创建者ID", "总分", "时长(分钟)", "状态", "提交数", "创建时间"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
rows = []
|
||||
for e in exams:
|
||||
rows.append([
|
||||
e.id, e.title, e.creator_id, e.total_score, e.duration, e.status,
|
||||
len(e.submissions) if e.submissions else 0,
|
||||
e.created_at.strftime("%Y-%m-%d %H:%M:%S") if e.created_at else ""
|
||||
])
|
||||
return _to_csv(headers, rows)
|
||||
|
||||
for row, exam in enumerate(exams, 2):
|
||||
ws.cell(row=row, column=1, value=exam.id)
|
||||
ws.cell(row=row, column=2, value=exam.title)
|
||||
ws.cell(row=row, column=3, value=exam.creator_id)
|
||||
ws.cell(row=row, column=4, value=exam.total_score)
|
||||
ws.cell(row=row, column=5, value=exam.duration)
|
||||
ws.cell(row=row, column=6, value=exam.status)
|
||||
ws.cell(row=row, column=7, value=len(exam.submissions) if exam.submissions else 0)
|
||||
ws.cell(row=row, column=8, value=exam.created_at.strftime("%Y-%m-%d %H:%M:%S") if exam.created_at else "")
|
||||
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 30
|
||||
ws.column_dimensions['C'].width = 12
|
||||
ws.column_dimensions['D'].width = 10
|
||||
ws.column_dimensions['E'].width = 15
|
||||
ws.column_dimensions['F'].width = 12
|
||||
ws.column_dimensions['G'].width = 10
|
||||
ws.column_dimensions['H'].width = 20
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def export_submissions_to_excel(submissions):
|
||||
"""导出提交记录到Excel"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "提交记录"
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
headers = ["ID", "考试ID", "考试名称", "用户ID", "用户名", "得分", "总分", "状态", "提交时间"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
rows = []
|
||||
for s in submissions:
|
||||
rows.append([
|
||||
s.id, s.exam_id, s.exam.title if s.exam else "", s.user_id,
|
||||
s.user.name if s.user else "", s.score if s.score is not None else "",
|
||||
s.total_score, s.status,
|
||||
s.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if s.submitted_at else ""
|
||||
])
|
||||
return _to_csv(headers, rows)
|
||||
|
||||
for row, sub in enumerate(submissions, 2):
|
||||
ws.cell(row=row, column=1, value=sub.id)
|
||||
ws.cell(row=row, column=2, value=sub.exam_id)
|
||||
ws.cell(row=row, column=3, value=sub.exam.title if sub.exam else "")
|
||||
ws.cell(row=row, column=4, value=sub.user_id)
|
||||
ws.cell(row=row, column=5, value=sub.user.name if sub.user else "")
|
||||
ws.cell(row=row, column=6, value=sub.score if sub.score is not None else "")
|
||||
ws.cell(row=row, column=7, value=sub.total_score)
|
||||
ws.cell(row=row, column=8, value=sub.status)
|
||||
ws.cell(row=row, column=9, value=sub.submitted_at.strftime("%Y-%m-%d %H:%M:%S") if sub.submitted_at else "")
|
||||
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 10
|
||||
ws.column_dimensions['C'].width = 30
|
||||
ws.column_dimensions['D'].width = 10
|
||||
ws.column_dimensions['E'].width = 20
|
||||
ws.column_dimensions['F'].width = 10
|
||||
ws.column_dimensions['G'].width = 10
|
||||
ws.column_dimensions['H'].width = 12
|
||||
ws.column_dimensions['I'].width = 20
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def export_contests_to_excel(contests):
|
||||
"""导出杯赛数据到Excel"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "杯赛数据"
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
headers = ["ID", "杯赛名称", "主办方", "开始日期", "结束日期", "状态", "参与人数", "创建时间"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
rows = []
|
||||
for c in contests:
|
||||
rows.append([
|
||||
c.id, c.name, c.organizer or "", c.start_date or "", c.end_date or "",
|
||||
c.status, c.participants,
|
||||
c.created_at.strftime("%Y-%m-%d %H:%M:%S") if c.created_at else ""
|
||||
])
|
||||
return _to_csv(headers, rows)
|
||||
|
||||
for row, contest in enumerate(contests, 2):
|
||||
ws.cell(row=row, column=1, value=contest.id)
|
||||
ws.cell(row=row, column=2, value=contest.name)
|
||||
ws.cell(row=row, column=3, value=contest.organizer or "")
|
||||
ws.cell(row=row, column=4, value=contest.start_date or "")
|
||||
ws.cell(row=row, column=5, value=contest.end_date or "")
|
||||
ws.cell(row=row, column=6, value=contest.status)
|
||||
ws.cell(row=row, column=7, value=contest.participants)
|
||||
ws.cell(row=row, column=8, value=contest.created_at.strftime("%Y-%m-%d %H:%M:%S") if contest.created_at else "")
|
||||
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 30
|
||||
ws.column_dimensions['C'].width = 20
|
||||
ws.column_dimensions['D'].width = 15
|
||||
ws.column_dimensions['E'].width = 15
|
||||
ws.column_dimensions['F'].width = 12
|
||||
ws.column_dimensions['G'].width = 12
|
||||
ws.column_dimensions['H'].width = 20
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
def export_posts_to_excel(posts):
|
||||
"""导出论坛帖子到Excel"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "论坛帖子"
|
||||
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF", size=12)
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
headers = ["ID", "标题", "作者", "分类", "浏览数", "回复数", "点赞数", "创建时间"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = header_alignment
|
||||
|
||||
for row, post in enumerate(posts, 2):
|
||||
ws.cell(row=row, column=1, value=post.id)
|
||||
ws.cell(row=row, column=2, value=post.title)
|
||||
ws.cell(row=row, column=3, value=post.author.name if post.author else "")
|
||||
ws.cell(row=row, column=4, value=post.category or "")
|
||||
ws.cell(row=row, column=5, value=post.views)
|
||||
ws.cell(row=row, column=6, value=post.reply_count)
|
||||
ws.cell(row=row, column=7, value=post.like_count)
|
||||
ws.cell(row=row, column=8, value=post.created_at.strftime("%Y-%m-%d %H:%M:%S") if post.created_at else "")
|
||||
|
||||
ws.column_dimensions['A'].width = 8
|
||||
ws.column_dimensions['B'].width = 40
|
||||
ws.column_dimensions['C'].width = 20
|
||||
ws.column_dimensions['D'].width = 15
|
||||
ws.column_dimensions['E'].width = 10
|
||||
ws.column_dimensions['F'].width = 10
|
||||
ws.column_dimensions['G'].width = 10
|
||||
ws.column_dimensions['H'].width = 20
|
||||
|
||||
output = BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
rows = []
|
||||
for p in posts:
|
||||
rows.append([
|
||||
p.id, p.title, p.author.name if p.author else "", p.category or "",
|
||||
p.views, p.reply_count, p.like_count,
|
||||
p.created_at.strftime("%Y-%m-%d %H:%M:%S") if p.created_at else ""
|
||||
])
|
||||
return _to_csv(headers, rows)
|
||||
|
||||
Binary file not shown.
BIN
static/uploads/avatar_5_1772689796.jpg
Normal file
BIN
static/uploads/avatar_5_1772689796.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
static/uploads/avatar_5_1772689897.jpg
Normal file
BIN
static/uploads/avatar_5_1772689897.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -187,7 +187,7 @@
|
||||
<div id="friendListForGroup" class="max-h-48 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-white/10">
|
||||
<button onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
|
||||
<button id="groupActionBtn" onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,8 +203,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="membersList" class="p-4 overflow-y-auto max-h-96 space-y-2"></div>
|
||||
<div id="inviteSection" class="px-4 py-3 border-t border-white/10 hidden">
|
||||
<div id="inviteSection" class="px-4 py-3 border-t border-white/10 hidden space-y-2">
|
||||
<button onclick="showInvite()" class="btn-futuristic w-full px-4 py-2 text-sm">邀请好友</button>
|
||||
<button id="dissolveBtn" onclick="dissolveGroup()" class="w-full px-4 py-2 text-sm rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30 transition-colors hidden">解散群聊</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -943,7 +944,7 @@ async function showCreateGroup() {
|
||||
modal.classList.add('flex');
|
||||
// 重置弹窗标题和按钮(防止被邀请好友功能覆盖)
|
||||
document.querySelector('#createGroupModal h3').textContent = '创建群聊';
|
||||
const createBtn = document.querySelector('#createGroupModal .bg-primary');
|
||||
const createBtn = document.getElementById('groupActionBtn');
|
||||
createBtn.textContent = '创建';
|
||||
createBtn.onclick = createGroup;
|
||||
document.getElementById('groupName').value = '';
|
||||
@@ -1036,6 +1037,7 @@ async function showMembers() {
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('inviteSection').classList.toggle('hidden', !isGroup);
|
||||
document.getElementById('dissolveBtn').classList.toggle('hidden', !isCreator);
|
||||
}
|
||||
|
||||
function hideMembers() {
|
||||
@@ -1044,11 +1046,27 @@ function hideMembers() {
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
async function dissolveGroup() {
|
||||
if (!confirm('确定要解散该群聊?所有聊天记录将被删除,此操作不可撤销。')) return;
|
||||
const res = await fetch(`/api/chat/rooms/${currentRoomId}/dissolve`, {method:'POST'});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
hideMembers();
|
||||
currentRoomId = null;
|
||||
document.getElementById('chat-header-info').innerHTML = '';
|
||||
document.getElementById('messages').innerHTML = '';
|
||||
document.getElementById('chat-input-area').classList.add('hidden');
|
||||
await loadRooms();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function showInvite() {
|
||||
hideMembers();
|
||||
showCreateGroup();
|
||||
document.querySelector('#createGroupModal h3').textContent = '邀请好友';
|
||||
const btn = document.querySelector('#createGroupModal .bg-primary');
|
||||
const btn = document.getElementById('groupActionBtn');
|
||||
btn.textContent = '邀请';
|
||||
btn.onclick = async () => {
|
||||
const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value));
|
||||
|
||||
@@ -491,7 +491,7 @@ function renderPosts(posts) {
|
||||
<div class="mt-auto flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-4 border-t border-slate-100/60">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold text-slate-800 hover:text-indigo-600 transition-colors cursor-pointer" onclick="event.stopPropagation();showProfile('${p.author_id}')">${esc(p.author)}</span>
|
||||
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${randomLv}</span>` : ''}
|
||||
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${p.author_level || 1}</span>` : ''}
|
||||
<span class="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded-md">${p.created_at}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
{% block title %}登录 - 智联青云{% endblock %}
|
||||
{% block navbar %}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<canvas id="meteor-canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;"></canvas>
|
||||
<div class="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative" style="z-index:1;">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent animate-pulse">登录您的账户</h2>
|
||||
<p class="mt-2 text-center text-sm text-slate-600">
|
||||
<p class="mt-2 text-center text-sm text-slate-300">
|
||||
或者 <a href="/register" class="font-medium text-primary hover:text-blue-500 transition-all duration-300 hover:scale-110 inline-block">注册新账户</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="futuristic-card py-8 px-4 sm:px-10">
|
||||
<div class="futuristic-card py-8 px-4 sm:px-10" style="background:rgba(255,255,255,0.92);backdrop-filter:blur(12px);">
|
||||
<div class="flex border-b border-slate-200 mb-6">
|
||||
<button id="tab-phone" onclick="switchTab('phone')" class="flex-1 pb-4 text-sm font-medium text-center text-primary border-b-2 border-primary transition-all duration-300 hover:scale-105">手机验证码登录</button>
|
||||
<button id="tab-email" onclick="switchTab('email')" class="flex-1 pb-4 text-sm font-medium text-center text-slate-500 hover:text-slate-700 transition-all duration-300 hover:scale-105">邮箱密码登录</button>
|
||||
@@ -143,5 +144,109 @@ async function handleEmailLogin(e) {
|
||||
}
|
||||
|
||||
fetchCaptcha('phone');
|
||||
|
||||
// 流星粒子背景
|
||||
(function() {
|
||||
const canvas = document.getElementById('meteor-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H;
|
||||
window._mouseX = -9999;
|
||||
window._mouseY = -9999;
|
||||
document.addEventListener('mousemove', function(e) { window._mouseX = e.clientX; window._mouseY = e.clientY; });
|
||||
document.addEventListener('mouseleave', function() { window._mouseX = -9999; window._mouseY = -9999; });
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = window.innerWidth;
|
||||
H = canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const COUNT = 120;
|
||||
const MOUSE_R = 150;
|
||||
const REPEL = 8;
|
||||
const particles = [];
|
||||
|
||||
function rand(a, b) { return Math.random() * (b - a) + a; }
|
||||
|
||||
function spawn(warm) {
|
||||
const angle = rand(Math.PI * 0.6, Math.PI * 0.8);
|
||||
const speed = rand(2, 5);
|
||||
const p = {
|
||||
x: rand(0, W * 1.2),
|
||||
y: warm ? rand(-H * 0.3, H) : rand(-H * 0.3, 0),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
len: rand(15, 40),
|
||||
size: rand(0.8, 2),
|
||||
alpha: rand(0.3, 0.9),
|
||||
hue: rand(190, 260),
|
||||
life: 0,
|
||||
maxLife: rand(120, 300)
|
||||
};
|
||||
if (warm) p.life = rand(0, p.maxLife);
|
||||
return p;
|
||||
}
|
||||
|
||||
for (let i = 0; i < COUNT; i++) particles.push(spawn(true));
|
||||
|
||||
function loop() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7);
|
||||
bg.addColorStop(0, '#0f172a');
|
||||
bg.addColorStop(1, '#020617');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const mx = window._mouseX, my = window._mouseY;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
p.life++;
|
||||
|
||||
const dx = p.x - mx, dy = p.y - my;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < MOUSE_R && dist > 0) {
|
||||
const f = (1 - dist / MOUSE_R) * REPEL;
|
||||
p.x += (dx / dist) * f;
|
||||
p.y += (dy / dist) * f;
|
||||
}
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
let a = p.alpha;
|
||||
if (p.life < 20) a *= p.life / 20;
|
||||
if (p.life > p.maxLife - 30) a *= (p.maxLife - p.life) / 30;
|
||||
|
||||
const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
const tx = p.x - (p.vx / spd) * p.len * 0.6;
|
||||
const ty = p.y - (p.vy / spd) * p.len * 0.6;
|
||||
|
||||
const g = ctx.createLinearGradient(tx, ty, p.x, p.y);
|
||||
g.addColorStop(0, `hsla(${p.hue},80%,70%,0)`);
|
||||
g.addColorStop(1, `hsla(${p.hue},80%,70%,${a})`);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, ty);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = p.size;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${p.hue},90%,85%,${a})`;
|
||||
ctx.fill();
|
||||
|
||||
if (p.life >= p.maxLife || p.y > H + 50 || p.x < -100) {
|
||||
particles[i] = spawn(false);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -277,23 +277,23 @@ async function loadFriends() {
|
||||
if (!data.success) throw new Error(data.message);
|
||||
if (data.friends.length === 0) {
|
||||
container.innerHTML = '<div class="col-span-2 text-center py-4 text-slate-400">暂无好友</div>';
|
||||
return;
|
||||
} else {
|
||||
let html = '';
|
||||
data.friends.forEach(f => {
|
||||
html += `
|
||||
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
|
||||
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
|
||||
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
|
||||
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
|
||||
</div>
|
||||
<a href="/chat?dm=${f.id}" class="btn-futuristic px-3 py-1.5 text-xs rounded-lg transition-colors font-medium">私聊</a>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
let html = '';
|
||||
data.friends.forEach(f => {
|
||||
html += `
|
||||
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
|
||||
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
|
||||
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
|
||||
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
|
||||
</div>
|
||||
<a href="/chat?dm=${f.id}" class="btn-futuristic px-3 py-1.5 text-xs rounded-lg transition-colors font-medium">私聊</a>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user