diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9b4bc3d..ab12c0c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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:*)" ] } } diff --git a/add_visibility_column.py b/add_visibility_column.py deleted file mode 100644 index c3d14ff..0000000 --- a/add_visibility_column.py +++ /dev/null @@ -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("完成!") diff --git a/app.py b/app.py index 9b14632..0936bb0 100644 --- a/app.py +++ b/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//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//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 ) diff --git a/check_applications.py b/check_applications.py deleted file mode 100644 index c786140..0000000 --- a/check_applications.py +++ /dev/null @@ -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("检查完成") diff --git a/check_database.py b/check_database.py deleted file mode 100644 index eddac14..0000000 --- a/check_database.py +++ /dev/null @@ -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() diff --git a/clean_data.py b/clean_data.py deleted file mode 100644 index 392f8be..0000000 --- a/clean_data.py +++ /dev/null @@ -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() diff --git a/create_admin.py b/create_admin.py deleted file mode 100644 index acf42a7..0000000 --- a/create_admin.py +++ /dev/null @@ -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() diff --git a/excel_export.py b/excel_export.py index e81238d..72770c0 100644 --- a/excel_export.py +++ b/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) diff --git a/instance/database.db b/instance/database.db index 1be1d0b..f39170e 100644 Binary files a/instance/database.db and b/instance/database.db differ diff --git a/static/uploads/avatar_5_1772689796.jpg b/static/uploads/avatar_5_1772689796.jpg new file mode 100644 index 0000000..0a03548 Binary files /dev/null and b/static/uploads/avatar_5_1772689796.jpg differ diff --git a/static/uploads/avatar_5_1772689897.jpg b/static/uploads/avatar_5_1772689897.jpg new file mode 100644 index 0000000..915d859 Binary files /dev/null and b/static/uploads/avatar_5_1772689897.jpg differ diff --git a/templates/chat.html b/templates/chat.html index e592312..93d407c 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -187,7 +187,7 @@
- +
@@ -203,8 +203,9 @@
- @@ -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() { `; }).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)); diff --git a/templates/forum.html b/templates/forum.html index 500ab9e..d6f307b 100644 --- a/templates/forum.html +++ b/templates/forum.html @@ -491,7 +491,7 @@ function renderPosts(posts) {
${esc(p.author)} - ${!p.is_official ? `Lv.${randomLv}` : ''} + ${!p.is_official ? `Lv.${p.author_level || 1}` : ''} ${p.created_at}
diff --git a/templates/login.html b/templates/login.html index 9707903..6087f51 100644 --- a/templates/login.html +++ b/templates/login.html @@ -2,15 +2,16 @@ {% block title %}登录 - 智联青云{% endblock %} {% block navbar %}{% endblock %} {% block content %} -
+ +

登录您的账户

-

+

或者 注册新账户

-
+
@@ -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(); +})(); {% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html index 9b76bbc..643b4da 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -277,23 +277,23 @@ async function loadFriends() { if (!data.success) throw new Error(data.message); if (data.friends.length === 0) { container.innerHTML = '
暂无好友
'; - return; + } else { + let html = ''; + data.friends.forEach(f => { + html += ` +
+
+ ${f.avatar ? `` : f.name.charAt(0).toUpperCase()} +
+
+
${f.name}
+
好友 · ${f.created_at}
+
+ 私聊 +
`; + }); + container.innerHTML = html; } - let html = ''; - data.friends.forEach(f => { - html += ` -
-
- ${f.avatar ? `` : f.name.charAt(0).toUpperCase()} -
-
-
${f.name}
-
好友 · ${f.created_at}
-
- 私聊 -
`; - }); - container.innerHTML = html; } catch (e) { container.innerHTML = '
加载失败
'; }