Compare commits

..

24 Commits

Author SHA1 Message Date
dd9609165c mobile . themes 2026-03-16 14:00:50 +08:00
33763d279e mobile app themes 2026-03-10 15:45:51 +08:00
6b668ce313 mobile app register themes 2026-03-10 14:01:29 +08:00
751cc8dc24 mobile chat themes 2026-03-10 13:19:48 +08:00
6ccfe9b107 mobile app chat contest_detail exam_detail exam_list themes 2026-03-09 18:50:22 +08:00
7dec929e29 mobile base exam_grade forum themes 2026-03-09 13:57:01 +08:00
aa012ea3aa mobile chat themes 2026-03-07 21:43:12 +08:00
86a6c7dd03 mobile app admin_base admin_contests admin_dashboard chat contest_detail exam_create exam_detail exam_grade exam_list profile themes 2026-03-07 19:07:01 +08:00
2d3163a1a0 mobile . themes 2026-03-05 22:03:30 +08:00
c325007897 mobile . themes 2026-03-05 12:57:55 +08:00
b5b9f7db06 fdasfae 2026-03-03 17:10:39 +08:00
8660bbc348 Add .claude/settings.local.json and instance/database.db to .gitignore 2026-03-03 16:17:40 +08:00
8258167f7f Merge remote changes and resolve database conflict 2026-03-03 16:11:38 +08:00
9a08140623 mobile app database models admin_contests admin_dashboard admin_exams admin_posts admin_teacher_applications admin_users apply_contest apply_teacher base chat contest_detail contest_list exam_result forum notifications profile themes 2026-03-03 16:02:23 +08:00
35c29f1442 keep database 2026-03-03 05:16:13 +00:00
0764566742 mobile app themes 2026-02-28 18:24:33 +08:00
6086f6588f mobile database migrate_message themes 2026-02-28 15:11:47 +08:00
2604d8460d mobile app themes 2026-02-28 14:42:18 +08:00
ae6e67ed85 mobile app forum themes 2026-02-28 14:34:14 +08:00
695c84b4cf mobile database admin_teacher_applications check_applications migrate test_owner_access themes 2026-02-28 13:31:32 +08:00
13057c8757 mobile app models chat login notifications themes 2026-02-28 11:30:31 +08:00
1d7d451c60 mobile app chat notifications theme 2026-02-27 22:09:52 +08:00
4cd4b6ef52 mobile app apply_contest contest_detail contest_edit notifications theme 2026-02-27 20:36:34 +08:00
17a4415cd2 mpbile .gitigore app chat login register theme 2026-02-27 20:15:07 +08:00
74 changed files with 3870 additions and 1334 deletions

View File

@@ -22,7 +22,15 @@
"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 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)", "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)",
"Bash(git stash:*)", "Bash(git stash:*)",
"Bash(git pull:*)" "Bash(git pull:*)",
"Bash(iconv:*)",
"Bash(git add:*)",
"Bash(git push)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(sqlite3:*)",
"Bash(source venv/Scripts/activate)",
"Bash(venv/Scripts/python.exe:*)"
] ]
} }
} }

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ venv/
dist/ dist/
build/ build/
node_modules/ node_modules/
model/
.claude/settings.local.json

973
app.py

File diff suppressed because it is too large Load Diff

116
backup_utils.py Normal file
View File

@@ -0,0 +1,116 @@
# backup_utils.py - 数据备份工具
"""管理员数据备份:导出所有表数据为 JSON支持打包上传文件"""
import json
import os
import zipfile
from datetime import datetime
from models import beijing_now
from io import BytesIO
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
)
def _serialize_value(val):
"""序列化单个值为 JSON 可存储格式"""
if val is None:
return None
if isinstance(val, datetime):
return val.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(val, bool):
return val
if isinstance(val, (int, float, str)):
return val
if isinstance(val, (list, dict)):
return val
return str(val)
def _model_to_dict(obj):
"""将 SQLAlchemy 模型实例转为可序列化字典"""
if obj is None:
return None
d = {}
for col in obj.__table__.columns:
val = getattr(obj, col.name)
d[col.name] = _serialize_value(val)
return d
# 按依赖顺序排列的表(父表在前)
BACKUP_MODELS = [
('user', User),
('contest', Contest),
('contest_membership', ContestMembership),
('contest_application', ContestApplication),
('contest_registration', ContestRegistration),
('question_bank_item', QuestionBankItem),
('exam', Exam),
('submission', Submission),
('draft', Draft),
('post', Post),
('reply', Reply),
('poll', Poll),
('reaction', Reaction),
('bookmark', Bookmark),
('report', Report),
('notification', Notification),
('edit_history', EditHistory),
('teacher_application', TeacherApplication),
('friend', Friend),
('exam_bookmark', ExamBookmark),
('chat_room', ChatRoom),
('chat_room_member', ChatRoomMember),
('message', Message),
('message_reaction', MessageReaction),
('invite_code', InviteCode),
('system_notification', SystemNotification),
]
def export_all_data():
"""导出所有表数据为 JSON 字典"""
data = {
'meta': {
'exported_at': beijing_now().strftime('%Y-%m-%d %H:%M:%S'),
'version': '1.0',
},
'tables': {}
}
for table_name, model in BACKUP_MODELS:
try:
rows = model.query.order_by(model.id).all()
data['tables'][table_name] = [_model_to_dict(r) for r in rows]
except Exception as e:
data['tables'][table_name] = {'_error': str(e)}
return data
def create_backup_zip(include_uploads=True, upload_folder=None):
"""
创建完整备份 ZIP 包
:param include_uploads: 是否包含上传文件
:param upload_folder: 上传目录路径,默认 static/uploads
:return: BytesIO 中的 ZIP 文件
"""
buf = BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# 1. 导出数据 JSON
data = export_all_data()
zf.writestr('data.json', json.dumps(data, ensure_ascii=False, indent=2))
# 2. 可选:上传文件
if include_uploads and upload_folder and os.path.isdir(upload_folder):
for root, dirs, files in os.walk(upload_folder):
for f in files:
fp = os.path.join(root, f)
arcname = 'uploads/' + os.path.relpath(fp, upload_folder).replace('\\', '/')
zf.write(fp, arcname, compress_type=zipfile.ZIP_DEFLATED)
buf.seek(0)
return buf

View File

@@ -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()

0
database.db Normal file
View File

78
excel_export.py Normal file
View File

@@ -0,0 +1,78 @@
# excel_export.py
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):
headers = ["ID", "用户名", "邮箱", "手机号", "角色", "状态", "完成考试数", "注册时间"]
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)
def export_exams_to_excel(exams):
headers = ["ID", "考试名称", "创建者ID", "总分", "时长(分钟)", "状态", "提交数", "创建时间"]
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)
def export_submissions_to_excel(submissions):
headers = ["ID", "考试ID", "考试名称", "用户ID", "用户名", "得分", "总分", "状态", "提交时间"]
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)
def export_contests_to_excel(contests):
headers = ["ID", "杯赛名称", "主办方", "开始日期", "结束日期", "状态", "参与人数", "创建时间"]
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)
def export_posts_to_excel(posts):
headers = ["ID", "标题", "作者", "分类", "浏览数", "回复数", "点赞数", "创建时间"]
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)

66
fix_question_ids.py Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
修复考试题目ID不连续的问题
重新为所有考试的题目分配连续的ID
"""
from app import app, db
from models import Exam
import json
def fix_question_ids():
"""修复所有考试的题目ID使其连续"""
with app.app_context():
exams = Exam.query.all()
fixed_count = 0
for exam in exams:
try:
# 获取题目列表
if exam.is_encrypted and exam.encrypted_questions:
from app import decrypt_questions
questions = json.loads(decrypt_questions(exam.encrypted_questions))
else:
questions = exam.get_questions()
if not questions:
continue
# 检查ID是否连续
needs_fix = False
for idx, q in enumerate(questions, 1):
if q.get('id') != idx:
needs_fix = True
break
if needs_fix:
# 重新分配连续的ID
for idx, q in enumerate(questions, 1):
q['id'] = idx
# 保存修复后的题目
questions_json = json.dumps(questions)
exam.set_questions(questions)
if exam.is_encrypted:
from app import encrypt_questions
exam.encrypted_questions = encrypt_questions(questions_json)
fixed_count += 1
print(f"修复考试 #{exam.id}: {exam.title}")
except Exception as e:
print(f"处理考试 #{exam.id} 时出错: {e}")
continue
if fixed_count > 0:
db.session.commit()
print(f"\n总共修复了 {fixed_count} 份考试")
else:
print("\n没有需要修复的考试")
if __name__ == '__main__':
print("开始修复考试题目ID...")
fix_question_ids()
print("修复完成!")

Binary file not shown.

62
migrate_db.py Normal file
View File

@@ -0,0 +1,62 @@
"""
数据库迁移脚本:为申请表添加拒绝次数控制字段
"""
import sqlite3
import os
db_path = 'instance/database.db'
if not os.path.exists(db_path):
print(f"错误:找不到数据库文件 {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("开始数据库迁移...")
# 检查并更新 teacher_application 表
print("\n检查 teacher_application 表...")
cursor.execute("PRAGMA table_info(teacher_application)")
columns = [row[1] for row in cursor.fetchall()]
print(f"现有列: {columns}")
if 'rejection_count' not in columns:
print("添加 rejection_count 字段...")
cursor.execute("ALTER TABLE teacher_application ADD COLUMN rejection_count INTEGER DEFAULT 0")
print("[OK] 已添加 rejection_count 字段")
else:
print("[OK] rejection_count 字段已存在")
if 'last_rejected_at' not in columns:
print("添加 last_rejected_at 字段...")
cursor.execute("ALTER TABLE teacher_application ADD COLUMN last_rejected_at DATETIME")
print("[OK] 已添加 last_rejected_at 字段")
else:
print("[OK] last_rejected_at 字段已存在")
# 检查并更新 contest_application 表
print("\n检查 contest_application 表...")
cursor.execute("PRAGMA table_info(contest_application)")
columns = [row[1] for row in cursor.fetchall()]
print(f"现有列: {columns}")
if 'rejection_count' not in columns:
print("添加 rejection_count 字段...")
cursor.execute("ALTER TABLE contest_application ADD COLUMN rejection_count INTEGER DEFAULT 0")
print("[OK] 已添加 rejection_count 字段")
else:
print("[OK] rejection_count 字段已存在")
if 'last_rejected_at' not in columns:
print("添加 last_rejected_at 字段...")
cursor.execute("ALTER TABLE contest_application ADD COLUMN last_rejected_at DATETIME")
print("[OK] 已添加 last_rejected_at 字段")
else:
print("[OK] last_rejected_at 字段已存在")
conn.commit()
conn.close()
print("\n[SUCCESS] 数据库迁移完成!")
print("现在可以正常使用申请功能了。")

35
migrate_message.py Normal file
View File

@@ -0,0 +1,35 @@
"""
数据库迁移脚本:为 message 表添加 mentions 字段
"""
import sqlite3
import os
db_path = 'instance/database.db'
if not os.path.exists(db_path):
print(f"错误:找不到数据库文件 {db_path}")
exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("开始数据库迁移...\n")
# 检查 message 表
print("检查 message 表...")
cursor.execute("PRAGMA table_info(message)")
columns = [row[1] for row in cursor.fetchall()]
print(f"现有列: {columns}")
if 'mentions' not in columns:
print("添加 mentions 字段...")
cursor.execute("ALTER TABLE message ADD COLUMN mentions TEXT")
print("[OK] 已添加 mentions 字段")
else:
print("[OK] mentions 字段已存在")
conn.commit()
conn.close()
print("\n[SUCCESS] 数据库迁移完成!")
print("现在可以正常使用聊天功能了。")

View File

@@ -1,6 +1,11 @@
# models.py # models.py
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from datetime import datetime from datetime import datetime, timezone, timedelta
_beijing_tz = timezone(timedelta(hours=8))
def beijing_now():
return datetime.now(_beijing_tz).replace(tzinfo=None)
import json import json
db = SQLAlchemy() db = SQLAlchemy()
@@ -17,7 +22,7 @@ class User(db.Model):
is_banned = db.Column(db.Boolean, default=False) is_banned = db.Column(db.Boolean, default=False)
completed_exams = db.Column(db.Integer, default=0) # 已完成并批改的考试次数 completed_exams = db.Column(db.Integer, default=0) # 已完成并批改的考试次数
name_changed_at = db.Column(db.DateTime, nullable=True) # 上次修改用户名时间 name_changed_at = db.Column(db.DateTime, nullable=True) # 上次修改用户名时间
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
# 关系 # 关系
exams_created = db.relationship('Exam', back_populates='creator', lazy=True) exams_created = db.relationship('Exam', back_populates='creator', lazy=True)
@@ -39,7 +44,7 @@ class ContestMembership(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
contest_id = db.Column(db.Integer, db.ForeignKey('contest.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' role = db.Column(db.String(20), nullable=False) # 'owner' 或 'teacher'
joined_at = db.Column(db.DateTime, default=datetime.utcnow) joined_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', back_populates='contest_memberships') user = db.relationship('User', back_populates='contest_memberships')
contest = db.relationship('Contest', back_populates='members') contest = db.relationship('Contest', back_populates='members')
@@ -55,7 +60,7 @@ class ContestApplication(db.Model):
description = db.Column(db.Text) description = db.Column(db.Text)
contact = db.Column(db.String(100)) contact = db.Column(db.String(100))
status = db.Column(db.String(20), default='pending') # pending, approved, rejected status = db.Column(db.String(20), default='pending') # pending, approved, rejected
applied_at = db.Column(db.DateTime, default=datetime.utcnow) applied_at = db.Column(db.DateTime, default=beijing_now)
reviewed_at = db.Column(db.DateTime) reviewed_at = db.Column(db.DateTime)
total_score = db.Column(db.Integer, default=150) # 杯赛默认满分 total_score = db.Column(db.Integer, default=150) # 杯赛默认满分
start_date = db.Column(db.String(20)) # 申请时填写的开始日期 start_date = db.Column(db.String(20)) # 申请时填写的开始日期
@@ -65,6 +70,11 @@ class ContestApplication(db.Model):
responsible_phone = db.Column(db.String(20)) # 责任人电话 responsible_phone = db.Column(db.String(20)) # 责任人电话
responsible_email = db.Column(db.String(120)) # 责任人邮箱 responsible_email = db.Column(db.String(120)) # 责任人邮箱
organization = db.Column(db.String(100)) # 所属机构/学校 organization = db.Column(db.String(100)) # 所属机构/学校
# 拒绝次数控制
rejection_count = db.Column(db.Integer, default=0) # 被拒绝次数
last_rejected_at = db.Column(db.DateTime) # 最后一次被拒绝的时间
# 杯赛图片
image = db.Column(db.String(255)) # 杯赛图片URL
user = db.relationship('User', backref='contest_applications') user = db.relationship('User', backref='contest_applications')
@@ -73,7 +83,7 @@ class ContestRegistration(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False) contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False)
registered_at = db.Column(db.DateTime, default=datetime.utcnow) registered_at = db.Column(db.DateTime, default=beijing_now)
__table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest_reg'),) __table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest_reg'),)
@@ -88,7 +98,7 @@ class Contest(db.Model):
status = db.Column(db.String(20), default='upcoming') status = db.Column(db.String(20), default='upcoming')
participants = db.Column(db.Integer, default=0) participants = db.Column(db.Integer, default=0)
created_by = db.Column(db.Integer, db.ForeignKey('user.id')) created_by = db.Column(db.Integer, db.ForeignKey('user.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
past_papers = db.Column(db.Text) # JSON 存储历年真题 [{year, title, file}] past_papers = db.Column(db.Text) # JSON 存储历年真题 [{year, title, file}]
total_score = db.Column(db.Integer, default=150) # 杯赛默认考试满分 total_score = db.Column(db.Integer, default=150) # 杯赛默认考试满分
visible = db.Column(db.Boolean, default=True) # 是否对普通用户可见 visible = db.Column(db.Boolean, default=True) # 是否对普通用户可见
@@ -120,7 +130,7 @@ class QuestionBankItem(db.Model):
options = db.Column(db.Text) # JSON选择题选项 options = db.Column(db.Text) # JSON选择题选项
answer = db.Column(db.Text) # 答案 answer = db.Column(db.Text) # 答案
score = db.Column(db.Integer, default=10) # 建议分值 score = db.Column(db.Integer, default=10) # 建议分值
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
contest = db.relationship('Contest', backref='question_bank_items') contest = db.relationship('Contest', backref='question_bank_items')
contributor = db.relationship('User', backref='contributed_questions') contributor = db.relationship('User', backref='contributed_questions')
@@ -141,8 +151,9 @@ class Exam(db.Model):
scheduled_start = db.Column(db.DateTime, nullable=True) # 预定开始时间 scheduled_start = db.Column(db.DateTime, nullable=True) # 预定开始时间
scheduled_end = db.Column(db.DateTime, nullable=True) # 预定结束时间 scheduled_end = db.Column(db.DateTime, nullable=True) # 预定结束时间
score_release_time = db.Column(db.DateTime, nullable=True) # 成绩公布时间 score_release_time = db.Column(db.DateTime, nullable=True) # 成绩公布时间
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
status = db.Column(db.String(20), default='available') status = db.Column(db.String(20), default='available')
visibility = db.Column(db.String(20), default='private') # 'private' 或 'public'
creator = db.relationship('User', back_populates='exams_created') creator = db.relationship('User', back_populates='exams_created')
submissions = db.relationship('Submission', back_populates='exam', lazy=True, cascade='all, delete-orphan') submissions = db.relationship('Submission', back_populates='exam', lazy=True, cascade='all, delete-orphan')
@@ -164,7 +175,7 @@ class Submission(db.Model):
question_scores = db.Column(db.Text) # JSON question_scores = db.Column(db.Text) # JSON
graded = db.Column(db.Boolean, default=False) graded = db.Column(db.Boolean, default=False)
graded_by = db.Column(db.String(80)) graded_by = db.Column(db.String(80))
submitted_at = db.Column(db.DateTime, default=datetime.utcnow) submitted_at = db.Column(db.DateTime, default=beijing_now)
exam = db.relationship('Exam', back_populates='submissions') exam = db.relationship('Exam', back_populates='submissions')
user = db.relationship('User', back_populates='submissions') user = db.relationship('User', back_populates='submissions')
@@ -187,7 +198,7 @@ class Draft(db.Model):
exam_id = db.Column(db.Integer, db.ForeignKey('exam.id')) exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'))
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
answers = db.Column(db.Text) # JSON answers = db.Column(db.Text) # JSON
saved_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) saved_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
exam = db.relationship('Exam', back_populates='drafts') exam = db.relationship('Exam', back_populates='drafts')
user = db.relationship('User', back_populates='drafts') user = db.relationship('User', back_populates='drafts')
@@ -213,8 +224,8 @@ class Post(db.Model):
has_poll = db.Column(db.Boolean, default=False) has_poll = db.Column(db.Boolean, default=False)
images = db.Column(db.Text) # JSON images = db.Column(db.Text) # JSON
contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True) contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
author = db.relationship('User', back_populates='posts') author = db.relationship('User', back_populates='posts')
replies = db.relationship('Reply', back_populates='post', lazy=True, cascade='all, delete-orphan') replies = db.relationship('Reply', back_populates='post', lazy=True, cascade='all, delete-orphan')
@@ -237,8 +248,8 @@ class Reply(db.Model):
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
likes = db.Column(db.Integer, default=0) likes = db.Column(db.Integer, default=0)
reply_to = db.Column(db.String(80)) reply_to = db.Column(db.String(80))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
post = db.relationship('Post', back_populates='replies') post = db.relationship('Post', back_populates='replies')
author = db.relationship('User', back_populates='replies') author = db.relationship('User', back_populates='replies')
@@ -253,7 +264,7 @@ class Poll(db.Model):
voters = db.Column(db.Text) # JSON voters = db.Column(db.Text) # JSON
multi = db.Column(db.Boolean, default=False) multi = db.Column(db.Boolean, default=False)
total_votes = db.Column(db.Integer, default=0) total_votes = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
post = db.relationship('Post', back_populates='poll') post = db.relationship('Post', back_populates='poll')
@@ -276,7 +287,7 @@ class Reaction(db.Model):
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=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) reply_id = db.Column(db.Integer, db.ForeignKey('reply.id'), nullable=True)
reaction = db.Column(db.String(20)) reaction = db.Column(db.String(20))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', back_populates='reactions') user = db.relationship('User', back_populates='reactions')
post = db.relationship('Post', back_populates='reactions') post = db.relationship('Post', back_populates='reactions')
@@ -292,7 +303,7 @@ class Bookmark(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
post_id = db.Column(db.Integer, db.ForeignKey('post.id')) post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', back_populates='bookmarks') user = db.relationship('User', back_populates='bookmarks')
post = db.relationship('Post', back_populates='bookmarks') post = db.relationship('Post', back_populates='bookmarks')
@@ -308,7 +319,7 @@ class Report(db.Model):
reason = db.Column(db.String(100)) reason = db.Column(db.String(100))
detail = db.Column(db.Text) detail = db.Column(db.Text)
status = db.Column(db.String(20), default='pending') status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
# reporter 已在 User 中通过 backref 定义 # reporter 已在 User 中通过 backref 定义
@@ -321,7 +332,7 @@ class Notification(db.Model):
from_user = db.Column(db.String(80)) from_user = db.Column(db.String(80))
post_id = db.Column(db.Integer, nullable=True) post_id = db.Column(db.Integer, nullable=True)
read = db.Column(db.Boolean, default=False) read = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
# user 已在 User 中通过 backref 定义 # user 已在 User 中通过 backref 定义
@@ -332,8 +343,8 @@ class SystemNotification(db.Model):
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
pinned = db.Column(db.Boolean, default=False) pinned = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
author = db.relationship('User', backref='system_notifications') author = db.relationship('User', backref='system_notifications')
@@ -344,7 +355,7 @@ class EditHistory(db.Model):
title = db.Column(db.String(200)) title = db.Column(db.String(200))
content = db.Column(db.Text) content = db.Column(db.Text)
edited_by = db.Column(db.String(80)) edited_by = db.Column(db.String(80))
edited_at = db.Column(db.DateTime, default=datetime.utcnow) edited_at = db.Column(db.DateTime, default=beijing_now)
post = db.relationship('Post') post = db.relationship('Post')
@@ -358,9 +369,12 @@ class TeacherApplication(db.Model):
email = db.Column(db.String(120), nullable=False) # 申请人邮箱 email = db.Column(db.String(120), nullable=False) # 申请人邮箱
reason = db.Column(db.Text, nullable=False) # 申请理由 reason = db.Column(db.Text, nullable=False) # 申请理由
status = db.Column(db.String(20), default='pending') # pending, approved, rejected status = db.Column(db.String(20), default='pending') # pending, approved, rejected
applied_at = db.Column(db.DateTime, default=datetime.utcnow) applied_at = db.Column(db.DateTime, default=beijing_now)
reviewed_at = db.Column(db.DateTime) reviewed_at = db.Column(db.DateTime)
reviewed_by = db.Column(db.Integer, db.ForeignKey('user.id')) reviewed_by = db.Column(db.Integer, db.ForeignKey('user.id'))
# 拒绝次数控制
rejection_count = db.Column(db.Integer, default=0) # 被拒绝次数
last_rejected_at = db.Column(db.DateTime) # 最后一次被拒绝的时间
# 关系 # 关系
user = db.relationship('User', foreign_keys=[user_id], backref='teacher_applications') user = db.relationship('User', foreign_keys=[user_id], backref='teacher_applications')
@@ -375,7 +389,7 @@ class Friend(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
friend_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 status = db.Column(db.String(20), default='pending') # pending, accepted
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('friends_initiated', lazy='dynamic')) 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')) friend = db.relationship('User', foreign_keys=[friend_id], backref=db.backref('friends_received', lazy='dynamic'))
@@ -385,7 +399,7 @@ class ExamBookmark(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False) exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', backref=db.backref('exam_bookmarks', lazy='dynamic', cascade='all, delete-orphan')) 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')) exam = db.relationship('Exam', backref=db.backref('bookmarked_by', lazy='dynamic', cascade='all, delete-orphan'))
@@ -403,7 +417,7 @@ class ChatRoom(db.Model):
announcement = db.Column(db.Text, default='') announcement = db.Column(db.Text, default='')
announcement_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) announcement_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
announcement_at = db.Column(db.DateTime, nullable=True) announcement_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
creator = db.relationship('User', foreign_keys=[creator_id], backref='chatrooms_created') creator = db.relationship('User', foreign_keys=[creator_id], backref='chatrooms_created')
announcement_user = db.relationship('User', foreign_keys=[announcement_by]) announcement_user = db.relationship('User', foreign_keys=[announcement_by])
@@ -419,8 +433,8 @@ class ChatRoomMember(db.Model):
role = db.Column(db.String(20), default='member') # 'admin' | 'member' role = db.Column(db.String(20), default='member') # 'admin' | 'member'
nickname = db.Column(db.String(50), default='') nickname = db.Column(db.String(50), default='')
muted = db.Column(db.Boolean, default=False) muted = db.Column(db.Boolean, default=False)
last_read_at = db.Column(db.DateTime, default=datetime.utcnow) last_read_at = db.Column(db.DateTime, default=beijing_now)
joined_at = db.Column(db.DateTime, default=datetime.utcnow) joined_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', backref='chat_memberships') user = db.relationship('User', backref='chat_memberships')
@@ -438,7 +452,7 @@ class Message(db.Model):
recalled = db.Column(db.Boolean, default=False) recalled = db.Column(db.Boolean, default=False)
reply_to_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=True) 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" 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) created_at = db.Column(db.DateTime, default=beijing_now)
sender = db.relationship('User', backref='messages_sent') sender = db.relationship('User', backref='messages_sent')
reply_to = db.relationship('Message', remote_side='Message.id', backref='replies') reply_to = db.relationship('Message', remote_side='Message.id', backref='replies')
@@ -450,7 +464,7 @@ class MessageReaction(db.Model):
message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False) message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
emoji = db.Column(db.String(10), nullable=False) emoji = db.Column(db.String(10), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
user = db.relationship('User', backref='message_reactions') user = db.relationship('User', backref='message_reactions')
@@ -463,7 +477,7 @@ class InviteCode(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), 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) application_id = db.Column(db.Integer, db.ForeignKey('teacher_application.id'), nullable=False)
used = db.Column(db.Boolean, default=False) used = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=beijing_now)
used_at = db.Column(db.DateTime, nullable=True) used_at = db.Column(db.DateTime, nullable=True)
user = db.relationship('User', backref='invite_codes') user = db.relationship('User', backref='invite_codes')

View File

@@ -7,3 +7,5 @@ flask-socketio>=5.3.0
PyMuPDF>=1.24.0 PyMuPDF>=1.24.0
openai>=1.14.0 openai>=1.14.0
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
openpyxl>=3.1.0
Flask-SQLAlchemy>=3.0.0

View File

@@ -1,6 +1,54 @@
/* 自定义样式 */ /* 自定义样式 */
body { body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-attachment: fixed;
}
/* 科幻背景动画 */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); }
50% { box-shadow: 0 0 40px rgba(59, 130, 246, 0.8); }
}
@keyframes shimmer {
0% { background-position: -1000px 0; }
100% { background-position: 1000px 0; }
}
/* 高级卡片样式 */
.futuristic-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.futuristic-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
.futuristic-card-dark {
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.futuristic-card-dark:hover {
transform: translateY(-8px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border-color: rgba(59, 130, 246, 0.5);
} }
/* 自定义滚动条 */ /* 自定义滚动条 */
@@ -182,3 +230,494 @@ button[onclick^="quickScore"]:active {
overflow-y: auto; overflow-y: auto;
} }
} }
/* 高级按钮样式 */
.btn-futuristic {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 16px;
padding: 14px 32px;
color: white;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-futuristic:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6);
}
.btn-futuristic::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s;
}
.btn-futuristic:hover::before {
left: 100%;
}
.btn-outline-futuristic {
background: transparent;
border: 2px solid rgba(102, 126, 234, 0.5);
color: #667eea;
border-radius: 16px;
padding: 12px 30px;
font-weight: 600;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.btn-outline-futuristic:hover {
background: rgba(102, 126, 234, 0.1);
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
/* 输入框样式 */
.input-futuristic {
background: rgba(255, 255, 255, 0.9);
border: 2px solid rgba(102, 126, 234, 0.2);
border-radius: 12px;
padding: 12px 16px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.input-futuristic:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
background: rgba(255, 255, 255, 1);
}
/* 导航栏样式 */
.navbar-futuristic {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(102, 126, 234, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
/* 标签样式 */
.badge-futuristic {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.badge-outline-futuristic {
background: transparent;
border: 2px solid rgba(102, 126, 234, 0.5);
color: #667eea;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 600;
}
/* 分隔线 */
.divider-futuristic {
height: 2px;
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.5), transparent);
margin: 2rem 0;
}
/* 加载动画 */
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(102, 126, 234, 0.2);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 通知提示 */
.notification-futuristic {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-left: 4px solid #667eea;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 表格样式 */
.table-futuristic {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.table-futuristic thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.table-futuristic tbody tr {
border-bottom: 1px solid rgba(102, 126, 234, 0.1);
transition: all 0.2s ease;
}
.table-futuristic tbody tr:hover {
background: rgba(102, 126, 234, 0.05);
}
/* 模态框样式 */
.modal-futuristic {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(30px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
/* 进度条 */
.progress-futuristic {
height: 8px;
background: rgba(102, 126, 234, 0.2);
border-radius: 10px;
overflow: hidden;
}
.progress-bar-futuristic {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* 工具提示 */
.tooltip-futuristic {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
color: white;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.875rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
/* 头像样式 */
.avatar-futuristic {
border: 3px solid rgba(102, 126, 234, 0.5);
border-radius: 50%;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
}
.avatar-futuristic:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
/* 侧边栏 */
.sidebar-futuristic {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-right: 1px solid rgba(102, 126, 234, 0.1);
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05);
}
/* 页脚 */
.footer-futuristic {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
/* 聊天界面优化 */
#messageArea {
overflow-y: auto !important;
overflow-x: hidden;
scroll-behavior: smooth;
display: flex;
flex-direction: column;
}
#messageArea::-webkit-scrollbar {
width: 8px;
}
#messageArea::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
border-radius: 4px;
}
#messageArea::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.5);
border-radius: 4px;
}
#messageArea::-webkit-scrollbar-thumb:hover {
background: rgba(59, 130, 246, 0.7);
}
/* 聊天视图布局修复 */
#chatView {
height: 100%;
max-height: 100%;
}
#chatView.flex {
display: flex !important;
flex-direction: column !important;
}
#chatView.hidden {
display: none !important;
}
/* 确保输入框始终在底部 */
#chatView > div:last-child {
margin-top: auto;
}
/* 左侧面板滚动优化 */
#roomList::-webkit-scrollbar,
#chatNotifPanel::-webkit-scrollbar {
width: 6px;
}
#roomList::-webkit-scrollbar-track,
#chatNotifPanel::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.2);
}
#roomList::-webkit-scrollbar-thumb,
#chatNotifPanel::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.4);
border-radius: 3px;
}
/* 移除hide-scrollbar在聊天区域的影响 */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* 但是消息区域要显示滚动条 */
#messageArea.hide-scrollbar::-webkit-scrollbar {
display: block !important;
width: 8px;
}
.particles-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
}
/* 响应式表格容器 - 解决表格内容显示不全 */
.table-responsive {
width: 100%;
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
}
.table-responsive::-webkit-scrollbar {
height: 10px;
}
.table-responsive::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
border-radius: 5px;
}
.table-responsive::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.5);
border-radius: 5px;
}
.table-responsive::-webkit-scrollbar-thumb:hover {
background: rgba(59, 130, 246, 0.7);
}
/* 表格优化 - 确保内容完整显示 */
.table-futuristic {
min-width: 100%;
white-space: nowrap;
}
.table-futuristic td,
.table-futuristic th {
padding: 12px 16px;
vertical-align: middle;
max-width: none;
}
/* 文字换行优化 */
.text-wrap {
white-space: normal;
word-wrap: break-word;
word-break: break-word;
}
.text-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.text-ellipsis-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* 管理后台内容区域优化 */
.admin-content-wrapper {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
/* 卡片内容区域优化 */
.card-content-scrollable {
max-height: 600px;
overflow-y: auto;
overflow-x: hidden;
}
.card-content-scrollable::-webkit-scrollbar {
width: 8px;
}
.card-content-scrollable::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.2);
border-radius: 4px;
}
.card-content-scrollable::-webkit-scrollbar-thumb {
background: rgba(59, 130, 246, 0.4);
border-radius: 4px;
}
/* 移动端优化 */
@media (max-width: 768px) {
.futuristic-card,
.futuristic-card-dark {
border-radius: 16px;
padding: 16px !important;
}
.table-futuristic td,
.table-futuristic th {
padding: 8px 12px;
font-size: 0.875rem;
}
.btn-futuristic,
.btn-outline-futuristic {
padding: 8px 16px;
font-size: 0.875rem;
}
}
/* 图片容器优化 */
.img-container {
width: 100%;
max-width: 100%;
overflow: hidden;
position: relative;
}
.img-container img {
width: 100%;
height: auto;
object-fit: contain;
max-height: 500px;
}
/* 长文本内容优化 */
.content-text {
line-height: 1.8;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 统计卡片响应式优化 */
@media (max-width: 640px) {
.grid-cols-2 {
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
}
}
/* 侧边栏响应式优化 */
@media (max-width: 768px) {
.sidebar-futuristic {
position: relative;
width: 100%;
border-right: none;
border-bottom: 1px solid rgba(102, 126, 234, 0.1);
}
}
/* 防止内容溢出 */
.overflow-safe {
overflow: hidden;
text-overflow: ellipsis;
}
/* 弹性容器优化 */
.flex-container-safe {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
/* 网格容器优化 */
.grid-container-safe {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,61 @@
{% extends "admin_base.html" %}
{% block title %}数据备份 - 管理后台 - 智联青云{% endblock %}
{% block admin_content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-extrabold bg-gradient-to-r from-emerald-400 via-teal-400 to-cyan-400 bg-clip-text text-transparent tracking-tight">数据备份</h1>
<p class="text-sm text-slate-400 mt-1 font-medium">将服务器端所有数据(用户、杯赛、考试、帖子、聊天等)导出备份</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 完整备份 -->
<div class="futuristic-card-dark border-2 border-emerald-500/30 hover:border-emerald-500/50 transition-all overflow-hidden group">
<div class="absolute -right-8 -bottom-8 w-32 h-32 bg-emerald-500/20 rounded-full blur-2xl group-hover:bg-emerald-500/30"></div>
<div class="relative z-10 p-6">
<div class="w-14 h-14 rounded-2xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-2xl mb-4 border border-emerald-500/30">
📦
</div>
<h2 class="text-xl font-bold text-white mb-2">完整备份</h2>
<p class="text-sm text-slate-400 mb-4">包含所有数据库表数据JSON及上传文件头像、图片、附件等</p>
<a href="/admin/backup/download?full=1" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
下载完整备份 (ZIP)
</a>
</div>
</div>
<!-- 仅数据备份 -->
<div class="futuristic-card-dark border-2 border-cyan-500/30 hover:border-cyan-500/50 transition-all overflow-hidden group">
<div class="absolute -right-8 -bottom-8 w-32 h-32 bg-cyan-500/20 rounded-full blur-2xl group-hover:bg-cyan-500/30"></div>
<div class="relative z-10 p-6">
<div class="w-14 h-14 rounded-2xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center text-2xl mb-4 border border-cyan-500/30">
📄
</div>
<h2 class="text-xl font-bold text-white mb-2">仅数据备份</h2>
<p class="text-sm text-slate-400 mb-4">仅导出所有表数据为 JSON 文件,不含上传文件,体积更小</p>
<a href="/admin/backup/download?full=0" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/30">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
下载数据备份 (JSON)
</a>
</div>
</div>
</div>
<div class="futuristic-card-dark p-6 border border-amber-500/20 bg-amber-500/5">
<div class="flex gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-500/20 text-amber-400 flex items-center justify-center flex-shrink-0"></div>
<div>
<h3 class="font-bold text-amber-400 mb-1">备份说明</h3>
<ul class="text-sm text-slate-400 space-y-1 list-disc list-inside">
<li>备份包含:用户、杯赛、考试、提交记录、帖子、回复、通知、聊天记录、好友关系等全部数据</li>
<li>完整备份中的 <code class="text-cyan-400">uploads/</code> 目录包含用户上传的头像、考试图片、聊天文件等</li>
<li>建议定期备份,重要操作前请先备份</li>
<li>备份文件请妥善保管,内含敏感信息(如密码哈希)</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -24,7 +24,8 @@
('/admin/exams', '考试管理', 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'), ('/admin/exams', '考试管理', 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'),
('/admin/users', '用户管理', 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'), ('/admin/users', '用户管理', 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'),
('/admin/posts', '帖子管理', 'M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z'), ('/admin/posts', '帖子管理', 'M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z'),
('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9') ('/admin/notifications', '通知管理', 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9'),
('/admin/backup', '数据备份', 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4')
] %} ] %}
{% for path, name, icon in nav_items %} {% for path, name, icon in nav_items %}
@@ -51,9 +52,11 @@
<!-- 主体内容区 --> <!-- 主体内容区 -->
<main class="flex-1 min-w-0"> <main class="flex-1 min-w-0">
<div class="bg-white/80 backdrop-blur-xl rounded-3xl shadow-sm border border-slate-100 p-6 sm:p-8 relative"> <div class="bg-white/80 backdrop-blur-xl rounded-3xl shadow-sm border border-slate-100 p-6 sm:p-8 relative overflow-hidden">
<div class="absolute top-0 right-0 w-64 h-64 bg-indigo-50/30 rounded-bl-full -z-10"></div> <div class="absolute top-0 right-0 w-64 h-64 bg-indigo-50/30 rounded-bl-full -z-10"></div>
{% block admin_content %}{% endblock %} <div class="admin-content-wrapper">
{% block admin_content %}{% endblock %}
</div>
</div> </div>
</main> </main>
</div> </div>

View File

@@ -4,36 +4,39 @@
{% block admin_content %} {% block admin_content %}
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">杯赛管理</h1> <h1 class="text-3xl font-extrabold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">杯赛管理</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden"> <div class="futuristic-card-dark overflow-hidden">
<table class="min-w-full divide-y divide-slate-200"> <div class="table-responsive">
<thead class="bg-slate-50"> <table class="table-futuristic" style="min-width: 900px;">
<tr> <thead>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">名称</th> <th style="min-width: 60px;">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">主办方</th> <th style="min-width: 200px;">名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th> <th style="min-width: 150px;">主办方</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">开始日期</th> <th style="min-width: 120px;">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th> <th style="min-width: 150px;">开始日期</th>
</tr> <th style="min-width: 180px;">操作</th>
</thead> </tr>
<tbody id="contests-tbody" class="bg-white divide-y divide-slate-200"> </thead>
</tbody> <tbody id="contests-tbody">
</table> </tbody>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无杯赛</div> </table>
</div>
<div id="empty-msg" class="text-center py-12 text-slate-500 hidden">暂无杯赛</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
const isAdmin = {{ 'true' if user and user.role == 'admin' else 'false' }};
const statusMap = { const statusMap = {
'upcoming': ['即将开始', 'bg-blue-100 text-blue-800'], 'upcoming': ['即将开始', 'bg-blue-500/20 text-blue-400 border-blue-500/30'],
'registering': ['正在报名', 'bg-green-100 text-green-800'], 'registering': ['正在报名', 'bg-green-500/20 text-green-400 border-green-500/30'],
'ongoing': ['进行中', 'bg-yellow-100 text-yellow-800'], 'ongoing': ['进行中', 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'],
'ended': ['已结束', 'bg-slate-100 text-slate-800'], 'ended': ['已结束', 'bg-slate-500/20 text-slate-400 border-slate-500/30'],
'abolished': ['已废止', 'bg-red-100 text-red-800'] 'abolished': ['已废止', 'bg-red-500/20 text-red-400 border-red-500/30']
}; };
async function loadContests() { async function loadContests() {
@@ -48,19 +51,23 @@ async function loadContests() {
} }
let html = ''; let html = '';
data.contests.forEach(c => { data.contests.forEach(c => {
const [statusText, statusClass] = statusMap[c.status] || ['未知', 'bg-slate-100 text-slate-800']; const [statusText, statusClass] = statusMap[c.status] || ['未知', 'bg-slate-500/20 text-slate-400 border-slate-500/30'];
const abolishBtn = c.status !== 'abolished' const abolishBtn = c.status !== 'abolished'
? `<button onclick="abolishContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">废止</button>` ? `<button onclick="abolishContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-red-500/30 text-red-400 hover:bg-red-500/20 hover:border-red-500/50">废止</button>`
: '<span class="text-xs text-red-500">已废止</span>'; : '';
const deleteBtn = isAdmin && c.status === 'abolished'
? `<button onclick="deleteContest(${c.id}, '${c.name.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-orange-500/30 text-orange-400 hover:bg-orange-500/20 hover:border-orange-500/50">删除</button>`
: '';
html += `<tr> html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${c.id}</td> <td>${c.id}</td>
<td class="px-6 py-4 text-sm text-slate-900">${c.name}</td> <td class="font-medium text-slate-200" style="max-width: 200px; white-space: normal; word-wrap: break-word;">${c.name}</td>
<td class="px-6 py-4 text-sm text-slate-500">${c.organizer || '-'}</td> <td class="text-slate-400" style="max-width: 150px; white-space: normal; word-wrap: break-word;">${c.organizer || '-'}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span></td> <td><span class="badge-futuristic ${statusClass}">${statusText}</span></td>
<td class="px-6 py-4 text-sm text-slate-500">${c.start_date || '-'}</td> <td class="text-slate-400">${c.start_date || '-'}</td>
<td class="px-6 py-4 space-x-2"> <td class="space-x-2">
<a href="/contests/${c.id}" class="text-xs text-primary hover:underline">查看</a> <a href="/contests/${c.id}" class="text-xs text-cyan-400 hover:text-cyan-300 hover:underline">查看</a>
${abolishBtn} ${abolishBtn}
${deleteBtn}
</td> </td>
</tr>`; </tr>`;
}); });
@@ -71,7 +78,7 @@ async function loadContests() {
} }
async function abolishContest(id, name) { async function abolishContest(id, name) {
if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留但杯赛不可恢复`)) return; if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留,可再执行删除彻底移除`)) return;
try { try {
const res = await fetch(`/api/admin/contests/${id}/abolish`, {method: 'POST'}); const res = await fetch(`/api/admin/contests/${id}/abolish`, {method: 'POST'});
const data = await res.json(); const data = await res.json();
@@ -86,6 +93,22 @@ async function abolishContest(id, name) {
} }
} }
async function deleteContest(id, name) {
if (!confirm(`确定要彻底删除杯赛「${name}」吗?\n\n删除后:\n- 杯赛将从系统中完全移除\n- 不再显示在首页杯赛数量中\n- 用户无法再看到该杯赛\n- 此操作不可恢复!`)) return;
try {
const res = await fetch(`/api/admin/contests/${id}/delete`, {method: 'POST'});
const data = await res.json();
if (data.success) {
alert('杯赛已彻底删除');
loadContests();
} else {
alert(data.message || '操作失败');
}
} catch(e) {
alert('网络错误');
}
}
document.addEventListener('DOMContentLoaded', loadContests); document.addEventListener('DOMContentLoaded', loadContests);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -6,92 +6,92 @@
<div class="space-y-8"> <div class="space-y-8">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-extrabold text-slate-900 tracking-tight">数据概览</h1> <h1 class="text-3xl font-extrabold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent tracking-tight">数据概览</h1>
<p class="text-sm text-slate-500 mt-1 font-medium">查看平台运行状态与核心数据</p> <p class="text-sm text-slate-400 mt-1 font-medium">查看平台运行状态与核心数据</p>
</div> </div>
<button onclick="loadDashboard()" class="w-10 h-10 rounded-xl bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-indigo-600 hover:bg-indigo-50 transition-all flex items-center justify-center group" title="刷新数据"> <button onclick="loadDashboard()" class="w-10 h-10 rounded-xl bg-slate-800/50 text-slate-400 border border-slate-700/50 hover:text-cyan-400 hover:border-cyan-500/50 hover:shadow-lg hover:shadow-cyan-500/20 transition-all flex items-center justify-center group backdrop-blur-sm" title="刷新数据">
<svg class="w-5 h-5 group-hover:rotate-180 transition-transform duration-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="w-5 h-5 group-hover:rotate-180 transition-transform duration-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button> </button>
</div> </div>
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
<div class="bg-gradient-to-br from-indigo-50 to-white rounded-2xl p-6 shadow-sm border border-indigo-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform"> <div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl group-hover:bg-indigo-500/10 transition-colors"></div> <div class="absolute -right-4 -bottom-4 w-24 h-24 bg-cyan-500/20 rounded-full blur-2xl group-hover:bg-cyan-500/30 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10"> <div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-indigo-100 text-indigo-600 flex items-center justify-center shadow-inner"> <div class="w-10 h-10 rounded-xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center shadow-lg shadow-cyan-500/20 border border-cyan-500/30">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</div> </div>
<div class="text-sm font-bold text-slate-600">总用户数</div> <div class="text-xs sm:text-sm font-bold text-slate-300">总用户数</div>
</div> </div>
<div id="stat-users" class="text-3xl font-black text-slate-900 relative z-10">-</div> <div id="stat-users" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
</div> </div>
<div class="bg-gradient-to-br from-purple-50 to-white rounded-2xl p-6 shadow-sm border border-purple-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform"> <div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-purple-500/5 rounded-full blur-2xl group-hover:bg-purple-500/10 transition-colors"></div> <div class="absolute -right-4 -bottom-4 w-24 h-24 bg-purple-500/20 rounded-full blur-2xl group-hover:bg-purple-500/30 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10"> <div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center shadow-inner"> <div class="w-10 h-10 rounded-xl bg-purple-500/20 text-purple-400 flex items-center justify-center shadow-lg shadow-purple-500/20 border border-purple-500/30">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div> </div>
<div class="text-sm font-bold text-slate-600">赛事总数</div> <div class="text-xs sm:text-sm font-bold text-slate-300">赛事总数</div>
</div> </div>
<div id="stat-contests" class="text-3xl font-black text-slate-900 relative z-10">-</div> <div id="stat-contests" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
</div> </div>
<div class="bg-gradient-to-br from-emerald-50 to-white rounded-2xl p-6 shadow-sm border border-emerald-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform"> <div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div> <div class="absolute -right-4 -bottom-4 w-24 h-24 bg-emerald-500/20 rounded-full blur-2xl group-hover:bg-emerald-500/30 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10"> <div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-emerald-100 text-emerald-600 flex items-center justify-center shadow-inner"> <div class="w-10 h-10 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center shadow-lg shadow-emerald-500/20 border border-emerald-500/30">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</div> </div>
<div class="text-sm font-bold text-slate-600">考试总数</div> <div class="text-xs sm:text-sm font-bold text-slate-300">考试总数</div>
</div> </div>
<div id="stat-exams" class="text-3xl font-black text-slate-900 relative z-10">-</div> <div id="stat-exams" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
</div> </div>
<div class="bg-gradient-to-br from-amber-50 to-white rounded-2xl p-6 shadow-sm border border-amber-100/50 relative overflow-hidden group hover:-translate-y-1 transition-transform"> <div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
<div class="absolute -right-4 -bottom-4 w-24 h-24 bg-amber-500/5 rounded-full blur-2xl group-hover:bg-amber-500/10 transition-colors"></div> <div class="absolute -right-4 -bottom-4 w-24 h-24 bg-pink-500/20 rounded-full blur-2xl group-hover:bg-pink-500/30 transition-colors"></div>
<div class="flex items-center gap-3 mb-3 relative z-10"> <div class="flex items-center gap-3 mb-3 relative z-10">
<div class="w-10 h-10 rounded-xl bg-amber-100 text-amber-600 flex items-center justify-center shadow-inner"> <div class="w-10 h-10 rounded-xl bg-pink-500/20 text-pink-400 flex items-center justify-center shadow-lg shadow-pink-500/20 border border-pink-500/30">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/></svg>
</div> </div>
<div class="text-sm font-bold text-slate-600">社区帖子</div> <div class="text-xs sm:text-sm font-bold text-slate-300">社区帖子</div>
</div> </div>
<div id="stat-posts" class="text-3xl font-black text-slate-900 relative z-10">-</div> <div id="stat-posts" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
</div> </div>
</div> </div>
<!-- 待处理 + 最近活动 --> <!-- 待处理 + 最近活动 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 待处理事项 --> <div>
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 flex flex-col h-full">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-rose-100 text-rose-500 flex items-center justify-center shadow-inner"> <div class="w-8 h-8 rounded-lg bg-orange-500/20 text-orange-400 flex items-center justify-center shadow-lg shadow-orange-500/20 border border-orange-500/30">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div> </div>
<h2 class="text-lg font-bold text-slate-800">待处理事项</h2> <h2 class="text-lg font-bold bg-gradient-to-r from-orange-400 to-amber-400 bg-clip-text text-transparent">待处理事项</h2>
</div> </div>
<div id="pending-items" class="space-y-3 flex-1"> <div id="pending-items" class="futuristic-card-dark">
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl"> <div class="flex flex-col items-center justify-center h-48 text-slate-500">
<svg class="animate-spin h-6 w-6 text-indigo-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> <svg class="animate-spin h-6 w-6 text-cyan-400 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span class="text-sm font-medium">加载中...</span> <span class="text-sm font-medium">加载中...</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 最近活动 --> <div>
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 flex flex-col h-full">
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-blue-100 text-blue-500 flex items-center justify-center shadow-inner"> <div class="w-8 h-8 rounded-lg bg-blue-500/20 text-blue-400 flex items-center justify-center shadow-lg shadow-blue-500/20 border border-blue-500/30">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div> </div>
<h2 class="text-lg font-bold text-slate-800">最近活动日志</h2> <h2 class="text-lg font-bold bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">最近活动</h2>
</div> </div>
<div id="recent-activities" class="space-y-4 flex-1 relative pl-3"> <div class="futuristic-card-dark relative">
<div class="absolute left-[19px] top-2 bottom-2 w-0.5 bg-slate-100"></div> <div class="absolute left-[19px] top-2 bottom-2 w-0.5 bg-slate-700/50"></div>
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl ml-4"> <div id="recent-activities">
<svg class="animate-spin h-6 w-6 text-indigo-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> <div class="flex flex-col items-center justify-center h-48 text-slate-500 ml-4">
<span class="text-sm font-medium">加载中...</span> <svg class="animate-spin h-6 w-6 text-cyan-400 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
<span class="text-sm font-medium">加载中...</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -100,39 +100,43 @@
<!-- 快捷导航 --> <!-- 快捷导航 -->
<div> <div>
<div class="flex items-center gap-2 mb-6"> <div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-teal-100 text-teal-600 flex items-center justify-center shadow-inner"> <div class="w-8 h-8 rounded-lg bg-teal-500/20 text-teal-400 flex items-center justify-center shadow-lg shadow-teal-500/20 border border-teal-500/30">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div> </div>
<h2 class="text-lg font-bold text-slate-800">快捷操作</h2> <h2 class="text-lg font-bold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent">快捷操作</h2>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4">
<a href="/admin/contests" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-indigo-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/contests" class="futuristic-card-dark hover:border-indigo-500/50 hover:shadow-lg hover:shadow-indigo-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-indigo-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">🏆</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-indigo-500/30">🏆</div>
<div class="text-sm font-bold text-slate-700">杯赛管理</div> <div class="text-xs sm:text-sm font-bold text-slate-300">杯赛管理</div>
</a> </a>
<a href="/admin/contest-applications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-orange-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/contest-applications" class="futuristic-card-dark hover:border-orange-500/50 hover:shadow-lg hover:shadow-orange-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-orange-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📋</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-orange-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-orange-500/30">📋</div>
<div class="text-sm font-bold text-slate-700">杯赛申请</div> <div class="text-xs sm:text-sm font-bold text-slate-300">杯赛申请</div>
</a> </a>
<a href="/admin/teacher-applications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-purple-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/teacher-applications" class="futuristic-card-dark hover:border-purple-500/50 hover:shadow-lg hover:shadow-purple-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-purple-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">👨‍🏫</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-purple-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-purple-500/30">👨‍🏫</div>
<div class="text-sm font-bold text-slate-700">教师申请</div> <div class="text-xs sm:text-sm font-bold text-slate-300">教师申请</div>
</a> </a>
<a href="/admin/exams" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-emerald-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/exams" class="futuristic-card-dark hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-emerald-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📝</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-emerald-500/30">📝</div>
<div class="text-sm font-bold text-slate-700">考试管理</div> <div class="text-xs sm:text-sm font-bold text-slate-300">考试管理</div>
</a> </a>
<a href="/admin/users" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-blue-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/users" class="futuristic-card-dark hover:border-blue-500/50 hover:shadow-lg hover:shadow-blue-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">👥</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-blue-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-blue-500/30">👥</div>
<div class="text-sm font-bold text-slate-700">用户管理</div> <div class="text-xs sm:text-sm font-bold text-slate-300">用户管理</div>
</a> </a>
<a href="/admin/posts" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-amber-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/posts" class="futuristic-card-dark hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-amber-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">💬</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-amber-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-amber-500/30">💬</div>
<div class="text-sm font-bold text-slate-700">帖子管理</div> <div class="text-xs sm:text-sm font-bold text-slate-300">帖子管理</div>
</a> </a>
<a href="/admin/notifications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:border-rose-200 hover:shadow-md hover:-translate-y-1 transition-all text-center group flex flex-col items-center"> <a href="/admin/notifications" class="futuristic-card-dark hover:border-rose-500/50 hover:shadow-lg hover:shadow-rose-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-12 h-12 rounded-xl bg-rose-50 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform">📢</div> <div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-rose-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-rose-500/30">📢</div>
<div class="text-sm font-bold text-slate-700">通知管理</div> <div class="text-xs sm:text-sm font-bold text-slate-300">通知管理</div>
</a>
<a href="/admin/backup" class="futuristic-card-dark hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/20 hover:-translate-y-1 transition-all text-center group flex flex-col items-center p-4 sm:p-5">
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center text-xl sm:text-2xl mb-2 sm:mb-3 group-hover:scale-110 transition-transform border border-emerald-500/30">📦</div>
<div class="text-xs sm:text-sm font-bold text-slate-300">数据备份</div>
</a> </a>
</div> </div>
</div> </div>
@@ -157,34 +161,38 @@ async function loadDashboard() {
const contestApps = data.stats.pending_contest_apps || 0; const contestApps = data.stats.pending_contest_apps || 0;
if (teacherApps === 0 && contestApps === 0) { if (teacherApps === 0 && contestApps === 0) {
pending.innerHTML = ` pending.innerHTML = `
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl bg-slate-50/50"> <div class="flex flex-col items-center justify-center h-48 text-slate-500 border-2 border-dashed border-slate-700/50 rounded-2xl bg-slate-800/30">
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center text-2xl shadow-sm mb-3">☕</div> <div class="w-12 h-12 bg-slate-800/50 rounded-full flex items-center justify-center text-2xl shadow-lg mb-3 border border-slate-700/50">☕</div>
<span class="text-sm font-bold">太棒了,所有事项都已处理完毕!</span> <span class="text-sm font-bold text-slate-400">太棒了,所有事项都已处理完毕!</span>
</div>`; </div>`;
} else { } else {
let html = ''; let html = '';
if (teacherApps > 0) { if (teacherApps > 0) {
html += `<a href="/admin/teacher-applications" class="flex items-center justify-between p-4 bg-gradient-to-r from-orange-50 to-white border border-orange-200/60 rounded-xl hover:shadow-md hover:border-orange-300 transition-all group"> html += `<a href="/admin/teacher-applications" class="block p-4 bg-slate-800/30 border border-purple-500/30 rounded-xl hover:border-purple-500/50 hover:shadow-lg hover:shadow-purple-500/20 transition-all group">
<div class="flex items-center gap-3"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-full bg-orange-100 flex items-center justify-center text-orange-600 group-hover:scale-110 transition-transform">👨‍🏫</div> <div class="flex items-center gap-3">
<div> <div class="w-10 h-10 rounded-lg bg-purple-500/20 text-purple-400 flex items-center justify-center text-lg border border-purple-500/30">👨‍🏫</div>
<div class="text-sm font-bold text-slate-800">待审核教师申请</div> <div>
<div class="text-xs text-slate-500 mt-0.5">需要您的审批决定</div> <div class="text-sm font-bold text-slate-300">教师申请待审核</div>
<div class="text-xs text-slate-500 mt-0.5">需要您的审批</div>
</div>
</div> </div>
<span class="badge-futuristic">${teacherApps}</span>
</div> </div>
<span class="px-3 py-1 text-xs font-bold bg-orange-500 text-white rounded-full shadow-sm shadow-orange-200 animate-pulse">${teacherApps} 项</span>
</a>`; </a>`;
} }
if (contestApps > 0) { if (contestApps > 0) {
html += `<a href="/admin/contest-applications" class="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-white border border-indigo-200/60 rounded-xl hover:shadow-md hover:border-indigo-300 transition-all group mt-3"> html += `<a href="/admin/contest-applications" class="block p-4 bg-slate-800/30 border border-orange-500/30 rounded-xl hover:border-orange-500/50 hover:shadow-lg hover:shadow-orange-500/20 transition-all group mt-3">
<div class="flex items-center gap-3"> <div class="flex items-center justify-between">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 group-hover:scale-110 transition-transform">🏆</div> <div class="flex items-center gap-3">
<div> <div class="w-10 h-10 rounded-lg bg-orange-500/20 text-orange-400 flex items-center justify-center text-lg border border-orange-500/30">📋</div>
<div class="text-sm font-bold text-slate-800">待审核杯赛申请</div> <div>
<div class="text-xs text-slate-500 mt-0.5">有新的杯赛创建请求</div> <div class="text-sm font-bold text-slate-300">杯赛申请待审核</div>
<div class="text-xs text-slate-500 mt-0.5">需要您的审批</div>
</div>
</div> </div>
<span class="badge-futuristic">${contestApps}</span>
</div> </div>
<span class="px-3 py-1 text-xs font-bold bg-indigo-500 text-white rounded-full shadow-sm shadow-indigo-200 animate-pulse">${contestApps} 项</span>
</a>`; </a>`;
} }
pending.innerHTML = html; pending.innerHTML = html;
@@ -197,16 +205,17 @@ async function loadDashboard() {
const res = await fetch('/api/admin/recent-activities'); const res = await fetch('/api/admin/recent-activities');
const data = await res.json(); const data = await res.json();
const container = document.getElementById('recent-activities'); const container = document.getElementById('recent-activities');
if (data.success && data.activities.length > 0) { if (data.success && data.activities && data.activities.length > 0) {
container.innerHTML = data.activities.map(a => container.innerHTML = data.activities.map(a =>
`<div class="flex items-start gap-4 relative z-10 group"> `<div class="flex gap-4 mb-4 relative z-10">
<div class="w-3 h-3 rounded-full bg-white border-2 border-indigo-400 mt-1.5 flex-shrink-0 group-hover:scale-125 transition-transform group-hover:bg-indigo-400 group-hover:border-indigo-100"></div> <div class="w-10 h-10 rounded-full bg-gradient-to-br ${a.color || 'from-cyan-500 to-blue-500'} flex items-center justify-center text-white text-sm font-bold shadow-lg flex-shrink-0">${a.icon || '📌'}</div>
<div class="bg-slate-50 border border-slate-100 rounded-xl p-3 flex-1 group-hover:bg-white group-hover:shadow-sm transition-all group-hover:border-indigo-100"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-4 mb-1"> <div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="text-xs font-bold text-slate-400 flex items-center gap-1.5"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>${a.time}</span> <span class="text-xs font-bold text-slate-400">${a.type}</span>
<span class="text-xs text-slate-600 flex items-center gap-1"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>${a.time}</span>
</div> </div>
<div class="text-sm text-slate-700 leading-relaxed"> <div class="text-sm text-slate-300 leading-relaxed">
<span class="font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-md border border-indigo-100 mr-1">${a.user}</span> <span class="font-bold text-cyan-400 bg-cyan-500/10 px-2 py-0.5 rounded-md border border-cyan-500/30 mr-1">${a.user}</span>
${a.action} ${a.action}
</div> </div>
</div> </div>
@@ -214,11 +223,10 @@ async function loadDashboard() {
).join(''); ).join('');
} else { } else {
container.innerHTML = ` container.innerHTML = `
<div class="flex flex-col items-center justify-center h-48 text-slate-400 border-2 border-dashed border-slate-100 rounded-2xl bg-slate-50/50 relative z-10 ml-4"> <div class="flex flex-col items-center justify-center h-48 text-slate-500 border-2 border-dashed border-slate-700/50 rounded-2xl bg-slate-800/30 relative z-10 ml-4">
<div class="w-12 h-12 bg-white rounded-full flex items-center justify-center text-2xl shadow-sm mb-3">📭</div> <div class="w-12 h-12 bg-slate-800/50 rounded-full flex items-center justify-center text-2xl shadow-lg mb-3 border border-slate-700/50">📭</div>
<span class="text-sm font-bold">暂无近期活动记录</span> <span class="text-sm font-bold text-slate-400">暂无近期活动记录</span>
</div>`; </div>`;
// Hide the timeline line if no activities
const line = container.previousElementSibling?.classList.contains('absolute') ? container.previousElementSibling : null; const line = container.previousElementSibling?.classList.contains('absolute') ? container.previousElementSibling : null;
if(line) line.style.display = 'none'; if(line) line.style.display = 'none';
} }
@@ -228,3 +236,4 @@ async function loadDashboard() {
document.addEventListener('DOMContentLoaded', loadDashboard); document.addEventListener('DOMContentLoaded', loadDashboard);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -4,41 +4,43 @@
{% block admin_content %} {% block admin_content %}
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">考试管理</h1> <h1 class="text-3xl font-extrabold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">考试管理</h1>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden"> <div class="futuristic-card-dark overflow-hidden">
<table class="min-w-full divide-y divide-slate-200"> <div class="table-responsive">
<thead class="bg-slate-50"> <table class="table-futuristic" style="min-width: 1000px;">
<tr> <thead>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标题</th> <th style="min-width: 60px;">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">科目</th> <th style="min-width: 200px;">标题</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">出题人</th> <th style="min-width: 100px;">科目</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th> <th style="min-width: 120px;">出题人</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">创建时间</th> <th style="min-width: 100px;">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th> <th style="min-width: 150px;">创建时间</th>
</tr> <th style="min-width: 180px;">操作</th>
</thead> </tr>
<tbody id="exams-tbody" class="bg-white divide-y divide-slate-200"></tbody> </thead>
</table> <tbody id="exams-tbody"></tbody>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden"> </table>
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> </div>
<div id="loading-spinner" class="text-center py-8 text-slate-500 hidden">
<svg class="animate-spin h-6 w-6 mx-auto text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
<span class="text-sm">加载中...</span> <span class="text-sm">加载中...</span>
</div> </div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无考试</div> <div id="empty-msg" class="text-center py-12 text-slate-500 hidden">暂无考试</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
const statusMap = { const statusMap = {
'available': ['可用', 'bg-green-100 text-green-800'], 'available': ['可用', 'bg-green-500/20 text-green-400 border-green-500/30'],
'closed': ['已关闭', 'bg-red-100 text-red-800'], 'closed': ['已关闭', 'bg-red-500/20 text-red-400 border-red-500/30'],
'draft': ['草稿', 'bg-yellow-100 text-yellow-800'], 'draft': ['草稿', 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'],
'scheduled': ['已排期', 'bg-blue-100 text-blue-800'] 'scheduled': ['已排期', 'bg-blue-500/20 text-blue-400 border-blue-500/30']
}; };
async function loadExams() { async function loadExams() {
@@ -62,18 +64,18 @@ async function loadExams() {
let html = ''; let html = '';
data.exams.forEach(e => { data.exams.forEach(e => {
const [statusText, statusClass] = statusMap[e.status] || [e.status, 'bg-slate-100 text-slate-800']; const [statusText, statusClass] = statusMap[e.status] || [e.status, 'bg-slate-500/20 text-slate-400 border-slate-500/30'];
const isAvailable = e.status === 'available'; const isAvailable = e.status === 'available';
html += `<tr> html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${e.id}</td> <td>${e.id}</td>
<td class="px-6 py-4 text-sm text-slate-900 max-w-xs truncate">${e.title}</td> <td class="font-medium text-slate-200" style="max-width: 200px; white-space: normal; word-wrap: break-word;">${e.title}</td>
<td class="px-6 py-4 text-sm text-slate-500">${e.subject || '-'}</td> <td class="text-slate-400">${e.subject || '-'}</td>
<td class="px-6 py-4 text-sm text-slate-500">${e.creator_name || '-'}</td> <td class="text-slate-400">${e.creator_name || '-'}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span></td> <td><span class="badge-futuristic ${statusClass}">${statusText}</span></td>
<td class="px-6 py-4 text-sm text-slate-500">${e.created_at}</td> <td class="text-slate-400">${e.created_at}</td>
<td class="px-6 py-4 space-x-2"> <td class="space-x-2">
<button onclick="toggleStatus(${e.id}, '${e.status}')" class="px-2 py-1 text-xs ${isAvailable ? 'bg-yellow-100 text-yellow-700 border-yellow-300' : 'bg-green-100 text-green-700 border-green-300'} border rounded hover:opacity-80">${isAvailable ? '停止' : '恢复'}</button> <button onclick="toggleStatus(${e.id}, '${e.status}')" class="${isAvailable ? 'btn-outline-futuristic border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/20 hover:border-yellow-500/50' : 'btn-futuristic'} px-3 py-1 text-xs">${isAvailable ? '停止' : '恢复'}</button>
<button onclick="deleteExam(${e.id}, '${e.title.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button> <button onclick="deleteExam(${e.id}, '${e.title.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-red-500/30 text-red-400 hover:bg-red-500/20 hover:border-red-500/50">删除</button>
</td> </td>
</tr>`; </tr>`;
}); });

View File

@@ -4,53 +4,55 @@
{% block admin_content %} {% block admin_content %}
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">帖子管理</h1> <h1 class="text-3xl font-extrabold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">帖子管理</h1>
<div class="flex gap-4"> <div class="flex gap-4">
<input id="search-input" type="text" placeholder="搜索标题/内容..." class="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> <input id="search-input" type="text" placeholder="搜索标题/内容..." class="input-futuristic flex-1">
<select id="tag-filter" class="px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> <select id="tag-filter" class="input-futuristic">
<option value="">全部标签</option> <option value="">全部标签</option>
<option value="讨论">讨论</option> <option value="讨论">讨论</option>
<option value="求助">求助</option> <option value="求助">求助</option>
<option value="分享">分享</option> <option value="分享">分享</option>
<option value="公告">公告</option> <option value="公告">公告</option>
</select> </select>
<button onclick="loadPosts()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">搜索</button> <button onclick="loadPosts()" class="btn-futuristic px-6">搜索</button>
</div> </div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden"> <div class="futuristic-card-dark overflow-hidden">
<table class="min-w-full divide-y divide-slate-200"> <div class="table-responsive">
<thead class="bg-slate-50"> <table class="table-futuristic" style="min-width: 1000px;">
<tr> <thead>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标题</th> <th style="min-width: 60px;">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">作者</th> <th style="min-width: 250px;">标题</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">标签</th> <th style="min-width: 120px;">作者</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">置顶</th> <th style="min-width: 100px;">标签</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">发布时间</th> <th style="min-width: 100px;">置顶</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th> <th style="min-width: 150px;">发布时间</th>
</tr> <th style="min-width: 200px;">操作</th>
</thead> </tr>
<tbody id="posts-tbody" class="bg-white divide-y divide-slate-200"></tbody> </thead>
</table> <tbody id="posts-tbody"></tbody>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden"> </table>
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> </div>
<div id="loading-spinner" class="text-center py-8 text-slate-500 hidden">
<svg class="animate-spin h-6 w-6 mx-auto text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
<span class="text-sm">加载中...</span> <span class="text-sm">加载中...</span>
</div> </div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无帖子</div> <div id="empty-msg" class="text-center py-12 text-slate-500 hidden">暂无帖子</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
const tagClassMap = { const tagClassMap = {
'讨论': 'bg-blue-100 text-blue-800', '讨论': 'bg-blue-500/20 text-blue-400 border-blue-500/30',
'求助': 'bg-yellow-100 text-yellow-800', '求助': 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
'分享': 'bg-green-100 text-green-800', '分享': 'bg-green-500/20 text-green-400 border-green-500/30',
'公告': 'bg-red-100 text-red-800' '公告': 'bg-red-500/20 text-red-400 border-red-500/30'
}; };
async function loadPosts() { async function loadPosts() {
@@ -79,19 +81,19 @@ async function loadPosts() {
let html = ''; let html = '';
data.posts.forEach(p => { data.posts.forEach(p => {
const tagClass = tagClassMap[p.tag] || 'bg-slate-100 text-slate-800'; const tagClass = tagClassMap[p.tag] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
html += `<tr> html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${p.id}</td> <td>${p.id}</td>
<td class="px-6 py-4 text-sm text-slate-900 max-w-xs truncate">${p.title}</td> <td class="font-medium text-slate-200" style="max-width: 250px; white-space: normal; word-wrap: break-word;">${p.title}</td>
<td class="px-6 py-4 text-sm text-slate-500">${p.author}</td> <td class="text-slate-400">${p.author}</td>
<td class="px-6 py-4"><span class="px-2 py-1 text-xs rounded-full ${tagClass}">${p.tag || '-'}</span></td> <td><span class="badge-futuristic ${tagClass}">${p.tag || '-'}</span></td>
<td class="px-6 py-4"> <td>
<span class="px-2 py-1 text-xs rounded-full ${p.pinned ? 'bg-orange-100 text-orange-800' : 'bg-slate-100 text-slate-500'}">${p.pinned ? '已置顶' : '未置顶'}</span> <span class="badge-futuristic ${p.pinned ? 'bg-orange-500/20 text-orange-400 border-orange-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}">${p.pinned ? '已置顶' : '未置顶'}</span>
</td> </td>
<td class="px-6 py-4 text-sm text-slate-500">${p.created_at}</td> <td class="text-slate-400">${p.created_at}</td>
<td class="px-6 py-4 space-x-2"> <td class="space-x-2">
<button onclick="togglePin(${p.id}, ${p.pinned})" class="px-2 py-1 text-xs ${p.pinned ? 'bg-slate-100 text-slate-700 border-slate-300' : 'bg-orange-100 text-orange-700 border-orange-300'} border rounded hover:opacity-80">${p.pinned ? '取消置顶' : '置顶'}</button> <button onclick="togglePin(${p.id}, ${p.pinned})" class="${p.pinned ? 'btn-outline-futuristic' : 'btn-futuristic'} px-3 py-1 text-xs">${p.pinned ? '取消置顶' : '置顶'}</button>
<button onclick="deletePost(${p.id}, '${p.title.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button> <button onclick="deletePost(${p.id}, '${p.title.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-red-500/30 text-red-400 hover:bg-red-500/20 hover:border-red-500/50">删除</button>
</td> </td>
</tr>`; </tr>`;
}); });

View File

@@ -4,48 +4,115 @@
{% block admin_content %} {% block admin_content %}
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">教师申请审核</h1> <div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-slate-900">教师申请审核</h1>
<div class="text-sm text-slate-500">
{% if session.user.role == 'admin' %}
<span class="px-3 py-1 bg-indigo-100 text-indigo-700 rounded-full font-medium">管理员视图</span>
{% else %}
<span class="px-3 py-1 bg-purple-100 text-purple-700 rounded-full font-medium">杯赛负责人视图</span>
{% endif %}
</div>
</div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden"> {% if apps|length == 0 %}
<table class="min-w-full divide-y divide-slate-200"> <div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<thead class="bg-slate-50"> <div class="flex items-start gap-3">
<tr> <svg class="w-5 h-5 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">ID</th> <div>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请</th> <p class="font-medium mb-1">暂无待审核的教师申请</p>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">杯赛</th> {% if session.user.role != 'admin' %}
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">姓名</th> <p class="text-blue-700">您只能看到自己负责的杯赛的教师申请。如果有新的申请提交,会在此处显示。</p>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">邮箱</th> {% endif %}
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请理由</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请时间</th> </div>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">操作</th> </div>
</tr> {% endif %}
</thead>
<tbody id="applications-table-body" class="bg-white divide-y divide-slate-200"> <!-- 桌面端表格视图 -->
{% for app in apps %} <div class="hidden md:block bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
<tr data-id="{{ app.id }}"> <div class="table-responsive">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.id }}</td> <table class="min-w-full divide-y divide-slate-200" style="min-width: 1100px;">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.user.name }}</td> <thead class="bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.contest.name }}</td> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.name }}</td> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 60px;">ID</th>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.email }}</td> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 100px;">申请人</th>
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">{{ app.reason }}</td> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">杯赛</th>
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.applied_at.strftime('%Y-%m-%d %H:%M') }}</td> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 100px;">姓名</th>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">邮箱</th>
<form action="{{ url_for('approve_teacher_application', app_id=app.id) }}" method="post" style="display:inline;"> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 200px;">申请理由</th>
<button type="submit" class="text-green-600 hover:text-green-900 mr-2">批准</button> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">申请时间</th>
</form> <th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">操作</th>
<form action="{{ url_for('reject_teacher_application', app_id=app.id) }}" method="post" style="display:inline;"> </tr>
<button type="submit" class="text-red-600 hover:text-red-900">拒绝</button> </thead>
</form> <tbody id="applications-table-body" class="bg-white divide-y divide-slate-200">
</td> {% for app in apps %}
</tr> <tr data-id="{{ app.id }}">
{% else %} <td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.id }}</td>
<tr> <td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.user.name }}</td>
<td colspan="8" class="px-6 py-12 text-center text-slate-500">暂无待审核的教师申请</td> <td class="px-6 py-4 text-sm text-slate-500" style="max-width: 150px; white-space: normal; word-wrap: break-word;">{{ app.contest.name }}</td>
</tr> <td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.name }}</td>
{% endfor %} <td class="px-6 py-4 text-sm text-slate-500" style="max-width: 150px; word-wrap: break-word;">{{ app.email }}</td>
</tbody> <td class="px-6 py-4 text-sm text-slate-500" style="max-width: 200px; white-space: normal; word-wrap: break-word;">{{ app.reason }}</td>
</table> <td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.applied_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<form action="{{ url_for('approve_teacher_application', app_id=app.id) }}" method="post" style="display:inline;">
<button type="submit" class="text-green-600 hover:text-green-900 mr-2">批准</button>
</form>
<form action="{{ url_for('reject_teacher_application', app_id=app.id) }}" method="post" style="display:inline;">
<button type="submit" class="text-red-600 hover:text-red-900">拒绝</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="px-6 py-12 text-center text-slate-500">暂无待审核的教师申请</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 移动端卡片视图 -->
<div class="md:hidden space-y-4">
{% for app in apps %}
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-4" data-id="{{ app.id }}">
<div class="space-y-3">
<div class="flex items-start justify-between">
<div>
<div class="text-xs text-slate-400 mb-1">ID: {{ app.id }}</div>
<div class="font-semibold text-slate-900">{{ app.user.name }}</div>
<div class="text-sm text-slate-600 mt-1">{{ app.contest.name }}</div>
</div>
</div>
<div class="border-t border-slate-100 pt-3 space-y-2 text-sm">
<div><span class="text-slate-500">姓名:</span><span class="text-slate-900">{{ app.name }}</span></div>
<div><span class="text-slate-500">邮箱:</span><span class="text-slate-900 break-all">{{ app.email }}</span></div>
<div><span class="text-slate-500">申请理由:</span><span class="text-slate-900">{{ app.reason }}</span></div>
<div><span class="text-slate-500">申请时间:</span><span class="text-slate-900">{{ app.applied_at.strftime('%Y-%m-%d %H:%M') }}</span></div>
</div>
<div class="border-t border-slate-100 pt-3 flex gap-3">
<form action="{{ url_for('approve_teacher_application', app_id=app.id) }}" method="post" class="flex-1">
<button type="submit" class="w-full px-4 py-2.5 bg-green-500 hover:bg-green-600 active:bg-green-700 text-white font-medium rounded-lg transition-colors touch-manipulation">
批准
</button>
</form>
<form action="{{ url_for('reject_teacher_application', app_id=app.id) }}" method="post" class="flex-1">
<button type="submit" class="w-full px-4 py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white font-medium rounded-lg transition-colors touch-manipulation">
拒绝
</button>
</form>
</div>
</div>
</div>
{% else %}
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-8 text-center text-slate-500">
暂无待审核的教师申请
</div>
{% endfor %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,51 +4,53 @@
{% block admin_content %} {% block admin_content %}
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-900">用户管理</h1> <h1 class="text-3xl font-extrabold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">用户管理</h1>
<div class="flex gap-4"> <div class="flex gap-4">
<input id="search-input" type="text" placeholder="搜索用户名/邮箱..." class="flex-1 px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> <input id="search-input" type="text" placeholder="搜索用户名/邮箱..." class="input-futuristic flex-1">
<select id="role-filter" class="px-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"> <select id="role-filter" class="input-futuristic">
<option value="">全部角色</option> <option value="">全部角色</option>
<option value="admin">管理员</option> <option value="admin">管理员</option>
<option value="teacher">教师</option> <option value="teacher">教师</option>
<option value="student">学生</option> <option value="student">学生</option>
</select> </select>
<button onclick="loadUsers()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">搜索</button> <button onclick="loadUsers()" class="btn-futuristic px-6">搜索</button>
</div> </div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden"> <div class="futuristic-card-dark overflow-hidden">
<table class="min-w-full divide-y divide-slate-200"> <div class="table-responsive">
<thead class="bg-slate-50"> <table class="table-futuristic" style="min-width: 900px;">
<tr> <thead>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户名</th> <th style="min-width: 60px;">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">邮箱</th> <th style="min-width: 120px;">用户名</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">角色</th> <th style="min-width: 180px;">邮箱</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th> <th style="min-width: 100px;">角色</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">注册时间</th> <th style="min-width: 100px;">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th> <th style="min-width: 150px;">注册时间</th>
</tr> <th style="min-width: 180px;">操作</th>
</thead> </tr>
<tbody id="users-tbody" class="bg-white divide-y divide-slate-200"></tbody> </thead>
</table> <tbody id="users-tbody"></tbody>
<div id="loading-spinner" class="text-center py-8 text-slate-400 hidden"> </table>
<svg class="animate-spin h-6 w-6 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> </div>
<div id="loading-spinner" class="text-center py-8 text-slate-500 hidden">
<svg class="animate-spin h-6 w-6 mx-auto text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
<span class="text-sm">加载中...</span> <span class="text-sm">加载中...</span>
</div> </div>
<div id="empty-msg" class="text-center py-12 text-slate-400 hidden">暂无用户</div> <div id="empty-msg" class="text-center py-12 text-slate-500 hidden">暂无用户</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
const roleMap = { const roleMap = {
'admin': ['管理员', 'bg-red-100 text-red-800'], 'admin': ['管理员', 'bg-red-500/20 text-red-400 border-red-500/30'],
'teacher': ['教师', 'bg-blue-100 text-blue-800'], 'teacher': ['教师', 'bg-blue-500/20 text-blue-400 border-blue-500/30'],
'student': ['学生', 'bg-green-100 text-green-800'] 'student': ['学生', 'bg-green-500/20 text-green-400 border-green-500/30']
}; };
async function loadUsers() { async function loadUsers() {
@@ -77,22 +79,22 @@ async function loadUsers() {
let html = ''; let html = '';
data.users.forEach(u => { data.users.forEach(u => {
const [roleText, roleClass] = roleMap[u.role] || ['未知', 'bg-slate-100 text-slate-800']; const [roleText, roleClass] = roleMap[u.role] || ['未知', 'bg-slate-500/20 text-slate-400 border-slate-500/30'];
const banned = u.is_banned; const banned = u.is_banned;
html += `<tr> html += `<tr>
<td class="px-6 py-4 text-sm text-slate-900">${u.id}</td> <td>${u.id}</td>
<td class="px-6 py-4 text-sm text-slate-900">${u.name}</td> <td class="font-medium text-slate-200">${u.name}</td>
<td class="px-6 py-4 text-sm text-slate-500">${u.email || '-'}</td> <td class="text-slate-400">${u.email || '-'}</td>
<td class="px-6 py-4"> <td>
<span class="px-2 py-1 text-xs rounded-full ${roleClass}">${roleText}</span> <span class="badge-futuristic ${roleClass}">${roleText}</span>
</td> </td>
<td class="px-6 py-4"> <td>
<span class="px-2 py-1 text-xs rounded-full ${banned ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'}">${banned ? '已封禁' : '正常'}</span> <span class="badge-futuristic ${banned ? 'bg-red-500/20 text-red-400 border-red-500/30' : 'bg-green-500/20 text-green-400 border-green-500/30'}">${banned ? '已封禁' : '正常'}</span>
</td> </td>
<td class="px-6 py-4 text-sm text-slate-500">${u.created_at}</td> <td class="text-slate-400">${u.created_at}</td>
<td class="px-6 py-4 space-x-2"> <td class="space-x-2">
<button onclick="toggleBan(${u.id}, ${banned})" class="px-2 py-1 text-xs ${banned ? 'bg-green-100 text-green-700 border-green-300' : 'bg-yellow-100 text-yellow-700 border-yellow-300'} border rounded hover:opacity-80">${banned ? '解封' : '封禁'}</button> <button onclick="toggleBan(${u.id}, ${banned})" class="${banned ? 'btn-futuristic' : 'btn-outline-futuristic'} px-3 py-1 text-xs">${banned ? '解封' : '封禁'}</button>
<button onclick="deleteUser(${u.id}, '${u.name.replace(/'/g, "\\'")}')" class="px-2 py-1 text-xs bg-red-100 text-red-700 border border-red-300 rounded hover:bg-red-200">删除</button> <button onclick="deleteUser(${u.id}, '${u.name.replace(/'/g, "\\'")}')" class="btn-outline-futuristic px-3 py-1 text-xs border-red-500/30 text-red-400 hover:bg-red-500/20 hover:border-red-500/50">删除</button>
</td> </td>
</tr>`; </tr>`;
}); });

View File

@@ -4,92 +4,101 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto py-8"> <div class="max-w-3xl mx-auto py-8">
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-6"> <div class="futuristic-card p-6">
<h1 class="text-2xl font-bold text-slate-900 mb-6">申请举办杯赛</h1> <h1 class="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-6">申请举办杯赛</h1>
<form method="POST" action="{{ url_for('apply_contest') }}" class="space-y-6"> <form method="POST" action="{{ url_for('apply_contest') }}" enctype="multipart/form-data" class="space-y-6">
<div> <div>
<label for="name" class="block text-sm font-medium text-slate-700 mb-1">杯赛名称 <span class="text-red-500">*</span></label> <label for="name" class="block text-sm font-medium text-slate-300 mb-1">杯赛名称 <span class="text-red-400">*</span></label>
<input type="text" id="name" name="name" required <input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="例如2026年星火杯"> placeholder="例如2026年星火杯">
</div> </div>
<div> <div>
<label for="organizer" class="block text-sm font-medium text-slate-700 mb-1">主办方 <span class="text-red-500">*</span></label> <label for="organizer" class="block text-sm font-medium text-slate-300 mb-1">主办方 <span class="text-red-400">*</span></label>
<input type="text" id="organizer" name="organizer" required <input type="text" id="organizer" name="organizer" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="例如:星火杯组委会"> placeholder="例如:星火杯组委会">
</div> </div>
<div> <div>
<label for="description" class="block text-sm font-medium text-slate-700 mb-1">描述 <span class="text-red-500">*</span></label> <label for="image" class="block text-sm font-medium text-slate-300 mb-1">杯赛图片</label>
<input type="file" id="image" name="image" accept="image/*"
class="input-futuristic w-full px-3 py-2 sm:text-sm">
<p class="mt-1 text-xs text-slate-400">上传杯赛封面图片(可选,支持 JPG、PNG、GIF 格式,最大 5MB</p>
<div id="image-preview" class="mt-3 hidden">
<img id="preview-img" src="" alt="预览" class="max-w-xs rounded-lg border border-white/10 shadow-sm">
</div>
</div>
<div>
<label for="description" class="block text-sm font-medium text-slate-300 mb-1">描述 <span class="text-red-400">*</span></label>
<textarea id="description" name="description" rows="4" required <textarea id="description" name="description" rows="4" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="简要介绍杯赛的目的、规则等"></textarea> placeholder="简要介绍杯赛的目的、规则等"></textarea>
</div> </div>
<div> <div>
<label for="contact" class="block text-sm font-medium text-slate-700 mb-1">联系方式 <span class="text-red-500">*</span></label> <label for="contact" class="block text-sm font-medium text-slate-300 mb-1">联系方式 <span class="text-red-400">*</span></label>
<input type="text" id="contact" name="contact" required <input type="text" id="contact" name="contact" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="邮箱或手机号"> placeholder="邮箱或手机号">
<p class="mt-1 text-xs text-slate-500">用于管理员与您联系,不会公开显示</p> <p class="mt-1 text-xs text-slate-400">用于管理员与您联系,不会公开显示</p>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label for="start_date" class="block text-sm font-medium text-slate-700 mb-1">开始日期 <span class="text-red-500">*</span></label> <label for="start_date" class="block text-sm font-medium text-slate-300 mb-1">开始日期 <span class="text-red-400">*</span></label>
<input type="date" id="start_date" name="start_date" required <input type="date" id="start_date" name="start_date" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> class="input-futuristic w-full px-3 py-2 sm:text-sm">
</div> </div>
<div> <div>
<label for="end_date" class="block text-sm font-medium text-slate-700 mb-1">结束日期 <span class="text-red-500">*</span></label> <label for="end_date" class="block text-sm font-medium text-slate-300 mb-1">结束日期 <span class="text-red-400">*</span></label>
<input type="date" id="end_date" name="end_date" required <input type="date" id="end_date" name="end_date" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> class="input-futuristic w-full px-3 py-2 sm:text-sm">
</div> </div>
</div> </div>
<div> <div>
<label for="total_score" class="block text-sm font-medium text-slate-700 mb-1">杯赛满分 <span class="text-red-500">*</span></label> <label for="total_score" class="block text-sm font-medium text-slate-300 mb-1">杯赛满分 <span class="text-red-400">*</span></label>
<input type="number" id="total_score" name="total_score" required min="1" value="150" <input type="number" id="total_score" name="total_score" required min="1" value="150"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="例如150"> placeholder="例如150">
<p class="mt-1 text-xs text-slate-500">杯赛考试的默认满分分数</p> <p class="mt-1 text-xs text-slate-400">杯赛考试的默认满分分数</p>
</div> </div>
<!-- 报备信息 --> <!-- 报备信息 -->
<div class="border-t border-slate-200 pt-6 mt-2"> <div class="border-t border-white/10 pt-6 mt-2">
<h2 class="text-lg font-semibold text-slate-900 mb-4">报备信息</h2> <h2 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-4">报备信息</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="responsible_person" class="block text-sm font-medium text-slate-700 mb-1">责任人姓名 <span class="text-red-500">*</span></label> <label for="responsible_person" class="block text-sm font-medium text-slate-300 mb-1">责任人姓名 <span class="text-red-400">*</span></label>
<input type="text" id="responsible_person" name="responsible_person" required <input type="text" id="responsible_person" name="responsible_person" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="赛事责任人真实姓名"> placeholder="赛事责任人真实姓名">
</div> </div>
<div> <div>
<label for="responsible_phone" class="block text-sm font-medium text-slate-700 mb-1">责任人电话 <span class="text-red-500">*</span></label> <label for="responsible_phone" class="block text-sm font-medium text-slate-300 mb-1">责任人电话 <span class="text-red-400">*</span></label>
<input type="tel" id="responsible_phone" name="responsible_phone" required <input type="tel" id="responsible_phone" name="responsible_phone" required
pattern="1[3-9]\d{9}" pattern="1[3-9]\d{9}"
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="11位手机号码"> placeholder="11位手机号码">
<p class="mt-1 text-xs text-slate-500">请填写有效的手机号码,审核通过后将公开展示</p> <p class="mt-1 text-xs text-slate-400">请填写有效的手机号码,审核通过后将公开展示</p>
</div> </div>
<div> <div>
<label for="responsible_email" class="block text-sm font-medium text-slate-700 mb-1">责任人邮箱 <span class="text-red-500">*</span></label> <label for="responsible_email" class="block text-sm font-medium text-slate-300 mb-1">责任人邮箱 <span class="text-red-400">*</span></label>
<input type="email" id="responsible_email" name="responsible_email" required <input type="email" id="responsible_email" name="responsible_email" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="example@email.com"> placeholder="example@email.com">
</div> </div>
<div> <div>
<label for="organization" class="block text-sm font-medium text-slate-700 mb-1">所属机构/学校 <span class="text-red-500">*</span></label> <label for="organization" class="block text-sm font-medium text-slate-300 mb-1">所属机构/学校 <span class="text-red-400">*</span></label>
<input type="text" id="organization" name="organization" required <input type="text" id="organization" name="organization" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="例如XX大学、XX教育机构"> placeholder="例如XX大学、XX教育机构">
</div> </div>
</div> </div>
<p class="mt-3 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md p-3">以上报备信息将在杯赛详情页公开展示,请确保信息真实有效。</p> <p class="mt-3 text-xs text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded-md p-3">以上报备信息将在杯赛详情页公开展示,请确保信息真实有效。</p>
</div> </div>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<a href="{{ url_for('contest_list') }}" class="px-5 py-2.5 border border-slate-300 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"> <a href="{{ url_for('contest_list') }}" class="btn-outline-futuristic px-5 py-2.5 text-sm font-medium">
取消 取消
</a> </a>
<button type="submit" <button type="submit" id="submit-btn"
class="px-5 py-2.5 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"> class="btn-futuristic px-5 py-2.5 text-sm font-medium">
提交申请 提交申请
</button> </button>
</div> </div>
@@ -97,3 +106,38 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
// 图片预览功能
document.getElementById('image').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
// 检查文件大小5MB
if (file.size > 5 * 1024 * 1024) {
alert('图片大小不能超过 5MB');
e.target.value = '';
document.getElementById('image-preview').classList.add('hidden');
return;
}
// 显示预览
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('preview-img').src = e.target.result;
document.getElementById('image-preview').classList.remove('hidden');
};
reader.readAsDataURL(file);
} else {
document.getElementById('image-preview').classList.add('hidden');
}
});
document.querySelector('form').addEventListener('submit', function() {
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.textContent = '提交中...';
btn.classList.add('opacity-50', 'cursor-not-allowed');
});
</script>
{% endblock %}

View File

@@ -5,29 +5,29 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto py-8"> <div class="max-w-3xl mx-auto py-8">
<!-- 邀请码激活区域 --> <!-- 邀请码激活区域 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6"> <div class="futuristic-card border-l-4 border-cyan-500 p-6 mb-6">
<h2 class="text-lg font-semibold text-blue-800 mb-2">🎫 已有邀请码?在此激活</h2> <h2 class="text-lg font-semibold text-cyan-400 mb-2">🎫 已有邀请码?在此激活</h2>
<p class="text-sm text-blue-600 mb-4">审核通过后,您会在私聊消息中收到邀请码。输入邀请码即可正式成为杯赛老师。</p> <p class="text-sm text-slate-300 mb-4">审核通过后,您会在私聊消息中收到邀请码。输入邀请码即可正式成为杯赛老师。</p>
<div class="flex gap-3"> <div class="flex gap-3">
<input type="text" id="invite_code" placeholder="请输入邀请码,如 TC-A3K9M2X7" <input type="text" id="invite_code" placeholder="请输入邀请码,如 TC-A3K9M2X7"
class="flex-1 px-3 py-2 border border-blue-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm uppercase" class="input-futuristic flex-1 px-3 py-2 sm:text-sm uppercase"
maxlength="11"> maxlength="11">
<button type="button" id="activate_btn" onclick="activateInviteCode()" <button type="button" id="activate_btn" onclick="activateInviteCode()"
class="px-5 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="btn-futuristic px-5 py-2 text-sm font-medium">
激活 激活
</button> </button>
</div> </div>
<p id="invite_result" class="text-sm mt-2 hidden"></p> <p id="invite_result" class="text-sm mt-2 hidden"></p>
</div> </div>
<div class="bg-white shadow-sm rounded-lg border border-slate-200 p-6"> <div class="futuristic-card p-6">
<h1 class="text-2xl font-bold text-slate-900 mb-6">申请成为杯赛老师</h1> <h1 class="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-6">申请成为杯赛老师</h1>
<p class="text-sm text-slate-500 mb-6">请选择您希望担任老师的杯赛,并填写申请理由。</p> <p class="text-sm text-slate-400 mb-6">请选择您希望担任老师的杯赛,并填写申请理由。</p>
<form method="POST" action="{{ url_for('apply_teacher') }}" class="space-y-6"> <form method="POST" action="{{ url_for('apply_teacher') }}" class="space-y-6">
<div> <div>
<label for="contest_id" class="block text-sm font-medium text-slate-700 mb-1">选择杯赛 <span class="text-red-500">*</span></label> <label for="contest_id" class="block text-sm font-medium text-slate-300 mb-1">选择杯赛 <span class="text-red-400">*</span></label>
<select id="contest_id" name="contest_id" required <select id="contest_id" name="contest_id" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> class="input-futuristic w-full px-3 py-2 sm:text-sm">
<option value="">请选择杯赛</option> <option value="">请选择杯赛</option>
{% for contest in contests %} {% for contest in contests %}
<option value="{{ contest.id }}" {% if selected_contest and selected_contest.id == contest.id %}selected{% endif %}>{{ contest.name }}</option> <option value="{{ contest.id }}" {% if selected_contest and selected_contest.id == contest.id %}selected{% endif %}>{{ contest.name }}</option>
@@ -35,29 +35,29 @@
</select> </select>
</div> </div>
<div> <div>
<label for="name" class="block text-sm font-medium text-slate-700 mb-1">姓名 <span class="text-red-500">*</span></label> <label for="name" class="block text-sm font-medium text-slate-300 mb-1">姓名 <span class="text-red-400">*</span></label>
<input type="text" id="name" name="name" required <input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="您的真实姓名"> placeholder="您的真实姓名">
</div> </div>
<div> <div>
<label for="email" class="block text-sm font-medium text-slate-700 mb-1">邮箱 <span class="text-red-500">*</span></label> <label for="email" class="block text-sm font-medium text-slate-300 mb-1">邮箱 <span class="text-red-400">*</span></label>
<input type="email" id="email" name="email" required <input type="email" id="email" name="email" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="用于接收审核结果通知"> placeholder="用于接收审核结果通知">
</div> </div>
<div> <div>
<label for="reason" class="block text-sm font-medium text-slate-700 mb-1">申请理由 <span class="text-red-500">*</span></label> <label for="reason" class="block text-sm font-medium text-slate-300 mb-1">申请理由 <span class="text-red-400">*</span></label>
<textarea id="reason" name="reason" rows="5" required <textarea id="reason" name="reason" rows="5" required
class="w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="input-futuristic w-full px-3 py-2 sm:text-sm"
placeholder="请简要说明您的教学经历或申请原因"></textarea> placeholder="请简要说明您的教学经历或申请原因"></textarea>
</div> </div>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<a href="{{ url_for('home') }}" class="px-5 py-2.5 border border-slate-300 rounded-md text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"> <a href="{{ url_for('home') }}" class="btn-outline-futuristic px-5 py-2.5 text-sm font-medium">
取消 取消
</a> </a>
<button type="submit" <button type="submit"
class="px-5 py-2.5 bg-primary text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"> class="btn-futuristic px-5 py-2.5 text-sm font-medium">
提交申请 提交申请
</button> </button>
</div> </div>

View File

@@ -40,38 +40,42 @@
onload="document.addEventListener('DOMContentLoaded',function(){renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});})"></script> onload="document.addEventListener('DOMContentLoaded',function(){renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});})"></script>
<script src="/static/js/rich-editor.js"></script> <script src="/static/js/rich-editor.js"></script>
</head> </head>
<body class="min-h-screen bg-slate-50 font-sans text-slate-800 antialiased selection:bg-primary/30 selection:text-slate-900"> <body class="min-h-screen font-sans text-slate-800 antialiased selection:bg-primary/30 selection:text-slate-900">
<!-- 动态背景 -->
<div class="particles-container">
<canvas id="particles-bg" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1;"></canvas>
</div>
{% block navbar %} {% block navbar %}
<nav class="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200/80 shadow-sm transition-all duration-300"> <nav class="navbar-futuristic sticky top-0 z-40 transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex"> <div class="flex">
<a href="/" class="flex-shrink-0 flex items-center"> <a href="/" class="flex-shrink-0 flex items-center">
<span class="text-xl font-bold text-primary">智联青云</span> <span class="text-2xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">智联青云</span>
</a> </a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8"> <div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/contests" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/contests" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/></svg>
杯赛专栏 杯赛专栏
</a> </a>
<a href="/exams" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/exams" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/></svg>
考试系统 考试系统
</a> </a>
<a href="/forum" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/forum" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
社区论坛 社区论坛
</a> </a>
<a href="/chat" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/chat" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
消息 消息
</a> </a>
<a href="/profile" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/profile" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
个人 个人
</a> </a>
{% if user and user.role == 'student' %} {% if user and user.role == 'student' %}
<a href="/apply-teacher" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <a href="/apply-teacher" class="text-slate-600 hover:text-blue-600 inline-flex items-center px-4 py-2 rounded-xl text-sm font-medium transition-all hover:bg-blue-50">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
申请老师 申请老师
</a> </a>
@@ -102,7 +106,7 @@
</div> </div>
</div> </div>
{% if user.role == 'admin' or user.role == 'teacher' %} {% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="text-slate-500 hover:text-slate-700" title="管理后台"> <a href="/admin" class="text-slate-600 hover:text-blue-600" title="管理后台">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -141,6 +145,10 @@
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
社区论坛 社区论坛
</a> </a>
<a href="/notifications" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-slate-700 hover:bg-slate-100">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
通知
</a>
<a href="/chat" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-slate-700 hover:bg-slate-100"> <a href="/chat" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-slate-700 hover:bg-slate-100">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
消息 消息
@@ -188,6 +196,26 @@
<!-- 通知面板脚本 --> <!-- 通知面板脚本 -->
<script> <script>
(function(){ (function(){
let lastNotifCount = -1;
const notifSound = new Audio('data:audio/wav;base64,UklGRl9vT19teleQBAAAAAABAAEARKwAAIhYAQACABAAZGF0YU' + 'tvT19t');
// 创建通知提示音(简短的叮咚声)
function playNotifSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.3);
} catch(e) {}
}
function toggleNotifPanel() { function toggleNotifPanel() {
const panel = document.getElementById('notifPanel'); const panel = document.getElementById('notifPanel');
panel.classList.toggle('hidden'); panel.classList.toggle('hidden');
@@ -209,9 +237,14 @@
if (d.count > 0) { if (d.count > 0) {
badge.textContent = d.count > 99 ? '99+' : d.count; badge.textContent = d.count > 99 ? '99+' : d.count;
badge.classList.remove('hidden'); badge.classList.remove('hidden');
// 有新通知时播放声音
if (lastNotifCount >= 0 && d.count > lastNotifCount) {
playNotifSound();
}
} else { } else {
badge.classList.add('hidden'); badge.classList.add('hidden');
} }
lastNotifCount = d.count;
}).catch(()=>{}); }).catch(()=>{});
} }
updateNotifBadge(); updateNotifBadge();
@@ -266,11 +299,12 @@
} }
function markRead(nid, el) { function markRead(nid, el) {
fetch(`/api/notifications/${nid}/read`, {method:'POST'}); fetch(`/api/notifications/${nid}/read`, {method:'POST'}).then(()=>{
updateNotifBadge();
});
el.classList.remove('bg-blue-50/50'); el.classList.remove('bg-blue-50/50');
const dot = el.querySelector('.bg-blue-500'); const dot = el.querySelector('.bg-blue-500');
if (dot) dot.remove(); if (dot) dot.remove();
updateNotifBadge();
} }
window.markRead = markRead; window.markRead = markRead;
@@ -318,7 +352,7 @@
</script> </script>
<!-- 浮动聊天气泡 --> <!-- 浮动聊天气泡 -->
<div id="chatBubble" class="fixed bottom-6 right-6 z-40"> <div id="chatBubble" class="fixed bottom-6 right-6 z-30">
<button onclick="toggleMiniChat()" class="w-14 h-14 bg-gradient-to-tr from-primary to-blue-400 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-blue-600 flex items-center justify-center relative transition-all duration-300 hover:scale-110"> <button onclick="toggleMiniChat()" class="w-14 h-14 bg-gradient-to-tr from-primary to-blue-400 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-blue-600 flex items-center justify-center relative transition-all duration-300 hover:scale-110">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span id="bubbleBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center hidden">0</span> <span id="bubbleBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center hidden">0</span>
@@ -326,7 +360,7 @@
</div> </div>
<!-- 迷你聊天窗口 --> <!-- 迷你聊天窗口 -->
<div id="miniChat" class="fixed bottom-24 right-6 w-[380px] h-[500px] bg-white rounded-xl shadow-2xl border border-slate-200 z-40 hidden flex flex-col overflow-hidden"> <div id="miniChat" class="fixed bottom-24 right-6 w-[380px] h-[500px] bg-white rounded-xl shadow-2xl border border-slate-200 z-30 hidden flex flex-col overflow-hidden">
<div class="px-4 py-3 bg-primary text-white flex justify-between items-center rounded-t-xl"> <div class="px-4 py-3 bg-primary text-white flex justify-between items-center rounded-t-xl">
<span class="font-medium text-sm" id="miniTitle">消息</span> <span class="font-medium text-sm" id="miniTitle">消息</span>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -351,7 +385,14 @@
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script> <script>
(function(){ (function(){
if (document.getElementById('chatApp')) return; // chat.html 已有自己的socket // 在聊天页面隐藏浮动气泡(已有完整聊天界面)
if (document.getElementById('chatApp')) {
var bubble = document.getElementById('chatBubble');
if (bubble) bubble.style.display = 'none';
var miniC = document.getElementById('miniChat');
if (miniC) miniC.style.display = 'none';
return;
}
const bubbleSocket = io(); const bubbleSocket = io();
let miniRoomId = null; let miniRoomId = null;
let miniRooms = []; let miniRooms = [];
@@ -388,6 +429,11 @@
}); });
window.toggleMiniChat = function() { window.toggleMiniChat = function() {
// 移动端直接跳转到完整聊天页面
if (window.innerWidth < 640) {
window.location.href = '/chat';
return;
}
const mc = document.getElementById('miniChat'); const mc = document.getElementById('miniChat');
mc.classList.toggle('hidden'); mc.classList.toggle('hidden');
if (!mc.classList.contains('hidden')) { if (!mc.classList.contains('hidden')) {
@@ -490,5 +536,81 @@
})(); })();
</script> </script>
{% endif %} {% endif %}
<script>
// 粒子背景动画
(function() {
const canvas = document.getElementById('particles-bg');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const particles = [];
const particleCount = 80;
for (let i = 0; i < particleCount; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
radius: Math.random() * 2 + 1
});
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
gradient.addColorStop(0, '#667eea');
gradient.addColorStop(1, '#764ba2');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 绘制粒子
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fill();
});
// 绘制连线
particles.forEach((p1, i) => {
particles.slice(i + 1).forEach(p2 => {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 150) {
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${0.2 * (1 - dist / 150)})`;
ctx.lineWidth = 1;
ctx.stroke();
}
});
});
requestAnimationFrame(draw);
}
draw();
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
})();
</script>
</body> </body>
</html> </html>

View File

@@ -2,29 +2,29 @@
{% block title %}消息 - 智联青云{% endblock %} {% block title %}消息 - 智联青云{% endblock %}
{% block content %} {% block content %}
<div id="chatApp" class="flex bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/50 overflow-hidden" style="height:calc(100vh - 120px);min-height:500px;"> <div id="chatApp" class="flex futuristic-card-dark overflow-hidden" style="height:calc(100vh - 120px);min-height:500px;">
<!-- 左侧面板 --> <!-- 左侧面板 -->
<div id="leftPanel" class="w-full sm:w-80 border-r border-slate-200/60 bg-white/50 flex flex-col flex-shrink-0 relative z-10"> <div id="leftPanel" class="w-full sm:w-80 border-r border-white/10 bg-slate-900/50 flex flex-col flex-shrink-0 relative z-10">
<!-- Tab 切换:聊天 / 通知 --> <!-- Tab 切换:聊天 / 通知 -->
<div class="flex p-3 gap-2 bg-white/40 border-b border-slate-200/50 backdrop-blur-md sticky top-0 z-20"> <div class="flex p-3 gap-2 bg-slate-900/60 border-b border-white/10 backdrop-blur-md sticky top-0 z-20">
<button id="tabChat" onclick="switchTab('chat')" class="flex-1 px-4 py-2 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl shadow-md transition-all duration-300">💬 聊天</button> <button id="tabChat" onclick="switchTab('chat')" class="flex-1 px-4 py-2 text-sm font-bold text-white bg-gradient-to-r from-blue-500 to-cyan-500 rounded-xl shadow-md transition-all duration-300 hover:shadow-blue-500/50">💬 聊天</button>
<button id="tabNotif" onclick="switchTab('notif')" class="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:text-indigo-600 hover:bg-white bg-white/50 rounded-xl transition-all duration-300 relative border border-white/50"> <button id="tabNotif" onclick="switchTab('notif')" class="flex-1 px-4 py-2 text-sm font-medium text-slate-300 hover:text-cyan-400 hover:bg-slate-800/50 bg-slate-800/30 rounded-xl transition-all duration-300 relative border border-white/10">
🔔 通知 🔔 通知
<span id="chatNotifBadge" class="hidden absolute -top-1.5 -right-1.5 bg-rose-500 text-white text-[10px] font-bold rounded-full h-5 min-w-[20px] px-1.5 flex items-center justify-center shadow-sm border-2 border-white transform animate-bounce">0</span> <span id="chatNotifBadge" class="hidden absolute -top-1.5 -right-1.5 badge-futuristic text-[10px] font-bold rounded-full h-5 min-w-[20px] px-1.5 flex items-center justify-center shadow-sm border-2 border-slate-900 transform animate-bounce">0</span>
</button> </button>
</div> </div>
<!-- 聊天面板 --> <!-- 聊天面板 -->
<div id="chatPanel" class="flex-1 flex flex-col overflow-hidden"> <div id="chatPanel" class="flex-1 flex flex-col overflow-hidden">
<!-- 搜索 + 创建 --> <!-- 搜索 + 创建 -->
<div class="p-4 space-y-3 bg-white/30 backdrop-blur-sm border-b border-slate-200/50"> <div class="p-4 space-y-3 bg-slate-900/40 backdrop-blur-sm border-b border-white/10">
<div class="relative group"> <div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="w-4 h-4 text-slate-400 group-focus-within:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg class="w-4 h-4 text-slate-500 group-focus-within:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div> </div>
<input id="roomSearch" type="text" placeholder="搜索聊天..." class="w-full pl-10 pr-4 py-2.5 text-sm bg-white border border-slate-200/80 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all shadow-sm" oninput="filterRooms()"> <input id="roomSearch" type="text" placeholder="搜索聊天..." class="input-futuristic w-full pl-10 pr-4 py-2.5 text-sm" oninput="filterRooms()">
</div> </div>
<button onclick="showCreateGroup()" class="w-full px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 border border-indigo-100 rounded-xl hover:bg-indigo-50 hover:shadow-md transition-all flex items-center justify-center gap-2 group"> <button onclick="showCreateGroup()" class="btn-futuristic w-full px-4 py-2.5 text-sm font-bold flex items-center justify-center gap-2 group">
<div class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center group-hover:bg-indigo-600 group-hover:text-white transition-colors"> <div class="w-6 h-6 rounded-full bg-cyan-500/20 text-cyan-400 flex items-center justify-center group-hover:bg-cyan-500 group-hover:text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div> </div>
发起群聊 发起群聊
@@ -37,135 +37,136 @@
<div id="chatNotifPanel" class="flex-1 overflow-y-auto hide-scrollbar hidden p-2 space-y-2 bg-transparent"> <div id="chatNotifPanel" class="flex-1 overflow-y-auto hide-scrollbar hidden p-2 space-y-2 bg-transparent">
<div id="chatNotifList" class="space-y-2"></div> <div id="chatNotifList" class="space-y-2"></div>
<div id="chatNotifEmpty" class="flex flex-col items-center justify-center h-64 text-slate-400"> <div id="chatNotifEmpty" class="flex flex-col items-center justify-center h-64 text-slate-400">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-3">📭</div> <div class="w-16 h-16 bg-slate-800/50 rounded-full flex items-center justify-center mb-3 border border-white/10">📭</div>
<div class="text-sm font-medium">暂无新通知</div> <div class="text-sm font-medium">暂无新通知</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 右侧聊天区 --> <!-- 右侧聊天区 -->
<div id="rightPanel" class="flex-1 flex flex-col hidden sm:flex bg-[url('data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' viewBox=\'0 0 60 60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'none\' fill-rule=\'evenodd\'%3E%3Cg fill=\'%236366f1\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] relative"> <div id="rightPanel" class="flex-1 flex flex-col hidden sm:flex bg-[url('data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' viewBox=\'0 0 60 60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'none\' fill-rule=\'evenodd\'%3E%3Cg fill=\'%2306b6d4\' fill-opacity=\'0.05\'%3E%3Cpath d=\'M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] relative">
<div class="absolute inset-0 bg-gradient-to-br from-white/90 to-white/50 backdrop-blur-[2px] z-0"></div> <div class="absolute inset-0 bg-gradient-to-br from-slate-900/95 to-slate-800/90 backdrop-blur-[2px] z-0"></div>
<!-- 未选中状态 --> <!-- 未选中状态 -->
<div id="emptyState" class="flex-1 flex items-center justify-center z-10"> <div id="emptyState" class="flex-1 flex items-center justify-center z-10">
<div class="text-center transform transition-all hover:scale-105 duration-500"> <div class="text-center transform transition-all hover:scale-105 duration-500">
<div class="w-32 h-32 mx-auto bg-gradient-to-tr from-indigo-100 to-purple-100 rounded-full flex items-center justify-center mb-6 shadow-inner relative"> <div class="w-32 h-32 mx-auto bg-gradient-to-tr from-cyan-500/20 to-blue-500/20 rounded-full flex items-center justify-center mb-6 shadow-inner relative border border-cyan-500/30">
<div class="absolute inset-0 bg-indigo-400 blur-2xl opacity-20 rounded-full animate-pulse"></div> <div class="absolute inset-0 bg-cyan-400 blur-2xl opacity-20 rounded-full animate-pulse"></div>
<span class="text-6xl animate-bounce">💬</span> <span class="text-6xl animate-bounce">💬</span>
</div> </div>
<h3 class="text-2xl font-extrabold text-slate-800 tracking-tight mb-2">欢迎来到联考消息中心</h3> <h3 class="text-2xl font-extrabold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent tracking-tight mb-2">欢迎来到联考消息中心</h3>
<p class="text-slate-500 font-medium">在左侧选择一个聊天,或发起新的对话</p> <p class="text-slate-400 font-medium">在左侧选择一个聊天,或发起新的对话</p>
</div> </div>
</div> </div>
<!-- 通知详情视图 --> <!-- 通知详情视图 -->
<div id="notifDetailView" class="flex-1 flex-col hidden z-10 bg-white/80 backdrop-blur-md"> <div id="notifDetailView" class="flex-1 flex-col hidden z-10 bg-slate-900/80 backdrop-blur-md">
<div class="px-6 py-4 border-b border-slate-200/60 flex items-center gap-4 bg-white/50 sticky top-0 backdrop-blur-xl"> <div class="px-6 py-4 border-b border-white/10 flex items-center gap-4 bg-slate-800/50 sticky top-0 backdrop-blur-xl">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center flex-shrink-0" aria-label="返回"> <button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-700/50 text-slate-300 flex items-center justify-center flex-shrink-0 border border-white/10" aria-label="返回">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button> </button>
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-2xl shadow-sm border border-white" id="notifDetailIconContainer"> <div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 flex items-center justify-center text-2xl shadow-sm border border-cyan-500/30" id="notifDetailIconContainer">
<span id="notifDetailIcon"></span> <span id="notifDetailIcon"></span>
</div> </div>
<div> <div>
<div id="notifDetailType" class="text-lg font-extrabold text-slate-900"></div> <div id="notifDetailType" class="text-lg font-extrabold text-white"></div>
<div id="notifDetailTime" class="text-xs font-medium text-slate-500 mt-1"></div> <div id="notifDetailTime" class="text-xs font-medium text-slate-400 mt-1"></div>
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto p-8 hide-scrollbar"> <div class="flex-1 overflow-y-auto p-8 hide-scrollbar">
<div class="bg-white rounded-3xl p-8 shadow-sm border border-slate-100 relative overflow-hidden"> <div class="futuristic-card-dark rounded-3xl p-8 shadow-sm relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50/50 rounded-bl-full -z-10"></div> <div class="absolute top-0 right-0 w-32 h-32 bg-cyan-500/10 rounded-bl-full -z-10"></div>
<div id="notifDetailContent" class="text-base text-slate-700 leading-relaxed font-medium"></div> <div id="notifDetailContent" class="text-base text-slate-300 leading-relaxed font-medium"></div>
<div id="notifDetailFrom" class="text-sm text-slate-400 mt-6 pt-6 border-t border-slate-100 flex items-center gap-2"></div> <div id="notifDetailFrom" class="text-sm text-slate-500 mt-6 pt-6 border-t border-white/10 flex items-center gap-2"></div>
<div id="notifDetailActions" class="mt-6"></div> <div id="notifDetailActions" class="mt-6"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- 聊天视图 --> <!-- 聊天视图 -->
<div id="chatView" class="flex-1 flex-col hidden z-10 relative"> <div id="chatView" class="flex-1 hidden flex-col z-10 relative h-full">
<!-- 顶栏 --> <!-- 顶栏 -->
<div id="chatHeader" class="px-6 py-4 border-b border-slate-200/60 flex items-center justify-between bg-white/60 backdrop-blur-xl sticky top-0 z-20"> <div id="chatHeader" class="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-slate-800/60 backdrop-blur-xl flex-shrink-0 z-20">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center mr-1" aria-label="返回"> <button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-700/50 text-slate-300 flex items-center justify-center mr-1 border border-white/10" aria-label="返回">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button> </button>
<div id="chatAvatarContainer" class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-bold shadow-sm overflow-hidden"> <div id="chatAvatarContainer" class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 flex items-center justify-center text-cyan-400 font-bold shadow-sm overflow-hidden border border-cyan-500/30">
<!-- 头像动态插入 --> <!-- 头像动态插入 -->
</div> </div>
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span id="chatName" class="text-lg font-bold text-slate-900 tracking-tight"></span> <span id="chatName" class="text-lg font-bold text-white tracking-tight"></span>
<span id="chatMemberCount" class="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-100"></span> <span id="chatMemberCount" class="badge-futuristic text-xs font-bold px-2 py-0.5 rounded-full"></span>
</div> </div>
<div id="typingIndicator" class="text-[11px] font-medium text-indigo-500 hidden animate-pulse mt-0.5"></div> <div id="peerExamBanner" class="text-xs font-medium text-amber-400 bg-amber-500/20 px-2 py-1 rounded mt-1 hidden">对方正在考试中,请勿打扰</div>
<div id="typingIndicator" class="text-[11px] font-medium text-cyan-400 hidden animate-pulse mt-0.5"></div>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button onclick="showAnnouncement()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-amber-600 hover:bg-amber-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="群公告" id="btnAnnouncement"> <button onclick="showAnnouncement()" class="w-10 h-10 rounded-xl bg-slate-700/50 text-slate-400 hover:text-amber-400 hover:bg-amber-500/20 hover:shadow-sm border border-white/10 transition-all flex items-center justify-center group" title="群公告" id="btnAnnouncement">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg> <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
</button> </button>
<button onclick="showSearchPanel()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="搜索聊天记录"> <button onclick="showSearchPanel()" class="w-10 h-10 rounded-xl bg-slate-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 hover:shadow-sm border border-white/10 transition-all flex items-center justify-center group" title="搜索聊天记录">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button> </button>
<button onclick="showFileList()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-emerald-600 hover:bg-emerald-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="群文件"> <button onclick="showFileList()" class="w-10 h-10 rounded-xl bg-slate-700/50 text-slate-400 hover:text-emerald-400 hover:bg-emerald-500/20 hover:shadow-sm border border-white/10 transition-all flex items-center justify-center group" title="群文件">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg> <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
</button> </button>
<button onclick="showMembers()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="成员列表"> <button onclick="showMembers()" class="w-10 h-10 rounded-xl bg-slate-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 hover:shadow-sm border border-white/10 transition-all flex items-center justify-center group" title="成员列表">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg> <svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button> </button>
</div> </div>
</div> </div>
<!-- 消息区域 --> <!-- 消息区域 -->
<div id="messageArea" class="flex-1 overflow-y-auto hide-scrollbar px-6 py-4 space-y-5" onscroll="handleScroll()"></div> <div id="messageArea" class="flex-1 overflow-y-auto px-6 py-4 space-y-5" style="overflow-anchor: none;" onscroll="handleScroll()"></div>
<!-- 引用回复预览 --> <!-- 引用回复预览 -->
<div id="replyPreview" class="px-4 py-3 bg-indigo-50/80 backdrop-blur-md border-t border-indigo-100 hidden flex items-center justify-between shadow-inner"> <div id="replyPreview" class="px-4 py-3 bg-cyan-500/10 backdrop-blur-md border-t border-cyan-500/30 hidden flex items-center justify-between shadow-inner flex-shrink-0">
<div class="flex items-center gap-2 overflow-hidden"> <div class="flex items-center gap-2 overflow-hidden">
<div class="w-1 h-4 bg-indigo-500 rounded-full"></div> <div class="w-1 h-4 bg-cyan-400 rounded-full"></div>
<div class="text-sm font-medium text-indigo-900 truncate"><span id="replyToText"></span></div> <div class="text-sm font-medium text-cyan-300 truncate"><span id="replyToText"></span></div>
</div> </div>
<button onclick="cancelReply()" class="w-6 h-6 rounded-full bg-white/50 text-indigo-400 hover:text-indigo-600 hover:bg-white flex items-center justify-center transition-colors shadow-sm ml-2 flex-shrink-0"> <button onclick="cancelReply()" class="w-6 h-6 rounded-full bg-slate-700/50 text-cyan-400 hover:text-cyan-300 hover:bg-slate-700 flex items-center justify-center transition-colors shadow-sm ml-2 flex-shrink-0 border border-white/10">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button> </button>
</div> </div>
<!-- 输入区域 --> <!-- 输入区域 -->
<div class="p-4 bg-white/80 backdrop-blur-xl border-t border-slate-200/60 shadow-[0_-4px_20px_-15px_rgba(0,0,0,0.1)]"> <div class="p-4 bg-slate-800/80 backdrop-blur-xl border-t border-white/10 shadow-[0_-4px_20px_-15px_rgba(0,0,0,0.3)] flex-shrink-0 relative">
<!-- Emoji 面板 --> <!-- Emoji 面板 -->
<div id="emojiPanel" class="hidden absolute bottom-[calc(100%+10px)] left-4 p-3 bg-white/95 backdrop-blur-xl border border-slate-200/60 rounded-2xl shadow-xl max-h-48 overflow-y-auto w-64 z-50"> <div id="emojiPanel" class="hidden absolute bottom-[calc(100%+10px)] left-4 p-3 futuristic-card-dark backdrop-blur-xl rounded-2xl shadow-xl max-h-48 overflow-y-auto w-64 z-50">
<div class="flex flex-wrap gap-2 justify-center" id="emojiGrid"></div> <div class="flex flex-wrap gap-2 justify-center" id="emojiGrid"></div>
</div> </div>
<!-- @提及自动补全 --> <!-- @提及自动补全 -->
<div id="mentionPanel" class="hidden absolute bottom-[calc(100%+10px)] left-20 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto w-56 z-50"> <div id="mentionPanel" class="hidden absolute bottom-[calc(100%+10px)] left-20 futuristic-card-dark rounded-xl shadow-xl max-h-48 overflow-y-auto w-56 z-50">
<div id="mentionList" class="py-1"></div> <div id="mentionList" class="py-1"></div>
</div> </div>
<!-- 录音指示器 --> <!-- 录音指示器 -->
<div id="recordingIndicator" class="hidden absolute bottom-[calc(100%+10px)] left-1/2 -translate-x-1/2 bg-rose-500 text-white px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-50"> <div id="recordingIndicator" class="hidden absolute bottom-[calc(100%+10px)] left-1/2 -translate-x-1/2 badge-futuristic px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-50">
<div class="w-3 h-3 bg-white rounded-full animate-pulse"></div> <div class="w-3 h-3 bg-white rounded-full animate-pulse"></div>
<span class="text-sm font-medium">录音中... <span id="recordingTime">0s</span></span> <span class="text-sm font-medium">录音中... <span id="recordingTime">0s</span></span>
</div> </div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<div class="flex flex-col gap-2 pb-1.5"> <div class="flex flex-col gap-2 pb-1.5">
<button onclick="toggleEmoji()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-amber-500 hover:bg-amber-50 transition-colors flex items-center justify-center shadow-sm" title="表情"> <button onclick="toggleEmoji()" class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-amber-400 hover:bg-amber-500/20 transition-colors flex items-center justify-center shadow-sm border border-white/10" title="表情">
😀 😀
</button> </button>
<label class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-emerald-500 hover:bg-emerald-50 transition-colors flex items-center justify-center shadow-sm cursor-pointer" title="发送图片"> <label class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-emerald-400 hover:bg-emerald-500/20 transition-colors flex items-center justify-center shadow-sm cursor-pointer border border-white/10" title="发送图片">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<input type="file" accept="image/*" class="hidden" onchange="uploadFile(this,'image')"> <input type="file" accept="image/*" class="hidden" onchange="uploadFile(this,'image')">
</label> </label>
<label class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center shadow-sm cursor-pointer" title="发送文件"> <label class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-blue-400 hover:bg-blue-500/20 transition-colors flex items-center justify-center shadow-sm cursor-pointer border border-white/10" title="发送文件">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
<input type="file" class="hidden" onchange="uploadFile(this,'file')"> <input type="file" class="hidden" onchange="uploadFile(this,'file')">
</label> </label>
<button onclick="insertMention()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-indigo-500 hover:bg-indigo-50 transition-colors flex items-center justify-center shadow-sm font-bold text-sm" title="@提及"> <button onclick="insertMention()" class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 transition-colors flex items-center justify-center shadow-sm font-bold text-sm border border-white/10" title="@提及">
@ @
</button> </button>
<button id="voiceBtn" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-rose-500 hover:bg-rose-50 transition-colors flex items-center justify-center shadow-sm" title="按住录音"> <button id="voiceBtn" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()" class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-rose-400 hover:bg-rose-500/20 transition-colors flex items-center justify-center shadow-sm border border-white/10" title="按住录音">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
</button> </button>
</div> </div>
<div class="flex-1 bg-white rounded-2xl border border-slate-200/80 shadow-sm focus-within:ring-2 focus-within:ring-indigo-500/30 focus-within:border-indigo-400 transition-all overflow-hidden"> <div class="flex-1 futuristic-card-dark rounded-2xl shadow-sm focus-within:ring-2 focus-within:ring-cyan-500/30 focus-within:border-cyan-400/50 transition-all overflow-hidden">
<textarea id="msgInput" rows="1" placeholder="输入消息... (Enter发送, Shift+Enter换行)" class="flex-1 w-full px-4 py-3.5 text-sm bg-transparent border-none focus:outline-none focus:ring-0 resize-none max-h-32 min-h-[48px] hide-scrollbar" oninput="handleTyping(); this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px';"></textarea> <textarea id="msgInput" rows="1" placeholder="输入消息... (Enter发送, Shift+Enter换行)" class="flex-1 w-full px-4 py-3.5 text-sm bg-transparent text-white placeholder-slate-500 border-none focus:outline-none focus:ring-0 resize-none max-h-32 min-h-[48px] hide-scrollbar" oninput="handleTyping(); this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px';" onkeydown="handleInputKey(event)"></textarea>
</div> </div>
<button onclick="sendMessage()" class="w-12 h-12 rounded-2xl bg-gradient-to-r from-indigo-500 to-purple-600 text-white flex items-center justify-center hover:shadow-lg hover:shadow-indigo-500/30 transform hover:-translate-y-0.5 transition-all flex-shrink-0 group"> <button onclick="sendMessage()" class="btn-futuristic w-12 h-12 rounded-2xl flex items-center justify-center hover:shadow-lg hover:shadow-cyan-500/30 transform hover:-translate-y-0.5 transition-all flex-shrink-0 group">
<svg class="w-5 h-5 transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg> <svg class="w-5 h-5 transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
</button> </button>
</div> </div>
@@ -175,42 +176,43 @@
</div> </div>
<!-- 创建群聊弹窗 --> <!-- 创建群聊弹窗 -->
<div id="createGroupModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center"> <div id="createGroupModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-96 max-h-[80vh] flex flex-col"> <div class="futuristic-card-dark rounded-xl shadow-xl w-96 max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-white/10 flex justify-between items-center">
<h3 class="font-semibold">创建群聊</h3> <h3 class="font-semibold text-white">创建群聊</h3>
<button onclick="hideCreateGroup()" class="text-slate-400 hover:text-slate-600">&times;</button> <button onclick="hideCreateGroup()" class="text-slate-400 hover:text-slate-300">&times;</button>
</div> </div>
<div class="p-4 space-y-3"> <div class="p-4 space-y-3">
<input id="groupName" type="text" placeholder="群聊名称" class="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50"> <input id="groupName" type="text" placeholder="群聊名称" class="input-futuristic w-full px-3 py-2 text-sm">
<p class="text-sm text-slate-500">选择好友加入群聊:</p> <p class="text-sm text-slate-400">选择好友加入群聊:</p>
<div id="friendListForGroup" class="max-h-48 overflow-y-auto space-y-1"></div> <div id="friendListForGroup" class="max-h-48 overflow-y-auto space-y-1"></div>
</div> </div>
<div class="px-4 py-3 border-t border-slate-200"> <div class="px-4 py-3 border-t border-white/10">
<button onclick="createGroup()" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 text-sm">创建</button> <button id="groupActionBtn" onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 成员列表弹窗 --> <!-- 成员列表弹窗 -->
<div id="membersModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center"> <div id="membersModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[420px] max-h-[80vh] flex flex-col"> <div class="futuristic-card-dark rounded-xl shadow-xl w-[420px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-white/10 flex justify-between items-center">
<h3 class="font-semibold">群成员</h3> <h3 class="font-semibold text-white">群成员</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="showNicknameModal()" class="text-xs text-indigo-500 hover:text-indigo-700" title="设置群昵称">我的昵称</button> <button onclick="showNicknameModal()" class="text-xs text-cyan-400 hover:text-cyan-300" title="设置群昵称">我的昵称</button>
<button onclick="hideMembers()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button> <button onclick="hideMembers()" class="text-slate-400 hover:text-slate-300 text-lg">&times;</button>
</div> </div>
</div> </div>
<div id="membersList" class="p-4 overflow-y-auto max-h-96 space-y-2"></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-slate-200 hidden"> <div id="inviteSection" class="px-4 py-3 border-t border-white/10 hidden space-y-2">
<button onclick="showInvite()" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 text-sm">邀请好友</button> <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> </div>
</div> </div>
<!-- 群公告弹窗 --> <!-- 群公告弹窗 -->
<div id="announcementModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center"> <div id="announcementModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-96 flex flex-col"> <div class="bg-white rounded-xl shadow-xl w-96 flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">群公告</h3> <h3 class="font-semibold">群公告</h3>
@@ -232,7 +234,7 @@
</div> </div>
<!-- 搜索聊天记录弹窗 --> <!-- 搜索聊天记录弹窗 -->
<div id="searchModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center"> <div id="searchModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col"> <div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">搜索聊天记录</h3> <h3 class="font-semibold">搜索聊天记录</h3>
@@ -246,7 +248,7 @@
</div> </div>
<!-- 群文件弹窗 --> <!-- 群文件弹窗 -->
<div id="fileListModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center"> <div id="fileListModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col"> <div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">群文件</h3> <h3 class="font-semibold">群文件</h3>
@@ -257,7 +259,7 @@
</div> </div>
<!-- 群昵称弹窗 --> <!-- 群昵称弹窗 -->
<div id="nicknameModal" class="fixed inset-0 bg-black/50 z-[9991] hidden flex items-center justify-center"> <div id="nicknameModal" class="fixed inset-0 bg-black/50 z-[9991] hidden items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-80 flex flex-col"> <div class="bg-white rounded-xl shadow-xl w-80 flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center"> <div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">设置群昵称</h3> <h3 class="font-semibold">设置群昵称</h3>
@@ -274,6 +276,8 @@
<div id="imagePreview" class="fixed inset-0 bg-black/80 z-[9990] hidden flex items-center justify-center cursor-pointer" onclick="this.classList.add('hidden')"> <div id="imagePreview" class="fixed inset-0 bg-black/80 z-[9990] hidden flex items-center justify-center cursor-pointer" onclick="this.classList.add('hidden')">
<img id="previewImg" class="max-w-[90vw] max-h-[90vh] rounded-lg"> <img id="previewImg" class="max-w-[90vw] max-h-[90vh] rounded-lg">
</div> </div>
<!-- 轻提示 -->
<div id="chatToast" class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-slate-800 text-amber-400 rounded-lg shadow-lg z-[9995] hidden text-sm font-medium"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -296,6 +300,7 @@ let recordingStartTime = null;
let recordingInterval = null; let recordingInterval = null;
let searchTimer = null; let searchTimer = null;
let mentionMembers = []; let mentionMembers = [];
let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}}
function isMobile() { return window.innerWidth < 640; } function isMobile() { return window.innerWidth < 640; }
@@ -332,6 +337,19 @@ document.addEventListener('DOMContentLoaded', () => {
if (params.get('room_id')) { if (params.get('room_id')) {
setTimeout(() => selectRoom(parseInt(params.get('room_id'))), 500); setTimeout(() => selectRoom(parseInt(params.get('room_id'))), 500);
} }
if (params.get('dm')) {
const dmUserId = parseInt(params.get('dm'));
setTimeout(async () => {
try {
const res = await fetch(`/api/chat/private/${dmUserId}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
await loadRooms();
selectRoom(data.room_id);
}
} catch(e) { console.error('打开私聊失败', e); }
}, 500);
}
if (params.get('tab') === 'notif') { if (params.get('tab') === 'notif') {
switchTab('notif'); switchTab('notif');
} }
@@ -357,13 +375,20 @@ function switchTab(tab) {
notifPanel.classList.remove('hidden'); notifPanel.classList.remove('hidden');
tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600'; tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600';
tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary relative'; tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary relative';
loadNotifications(); loadChatNotifications();
// Mark all notifications as read when switching to the notification tab
fetch('/api/notifications/read-all', { method: 'POST' }).then(() => {
checkUnreadNotifs();
});
} }
const badge = document.getElementById('chatNotifBadge'); const badge = document.getElementById('chatNotifBadge');
tabNotif.appendChild(badge); if (badge) {
tabNotif.appendChild(badge);
}
} }
async function loadNotifications() { async function loadChatNotifications() {
const res = await fetch('/api/notifications'); const res = await fetch('/api/notifications');
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -388,6 +413,7 @@ function renderNotifications() {
const isResult = n.type === 'contest_result' || n.type === 'teacher_result'; const isResult = n.type === 'contest_result' || n.type === 'teacher_result';
const isNewExam = n.type === 'contest_new_exam'; const isNewExam = n.type === 'contest_new_exam';
const isGraded = n.type === 'exam_graded'; const isGraded = n.type === 'exam_graded';
const isSubmission = n.type === 'exam_submission';
const isSystem = n.type === 'system_announcement'; const isSystem = n.type === 'system_announcement';
const isPendingContest = isContestApp && n.application_status === 'pending'; const isPendingContest = isContestApp && n.application_status === 'pending';
const isPendingTeacher = isTeacherApp && n.application_status === 'pending'; const isPendingTeacher = isTeacherApp && n.application_status === 'pending';
@@ -396,26 +422,26 @@ function renderNotifications() {
if ((isContestApp || isTeacherApp) && n.application_status === 'approved') statusHtml = '<span class="text-xs text-green-600 font-medium">已批准</span>'; if ((isContestApp || isTeacherApp) && n.application_status === 'approved') statusHtml = '<span class="text-xs text-green-600 font-medium">已批准</span>';
else if ((isContestApp || isTeacherApp) && n.application_status === 'rejected') statusHtml = '<span class="text-xs text-red-600 font-medium">已拒绝</span>'; else if ((isContestApp || isTeacherApp) && n.application_status === 'rejected') statusHtml = '<span class="text-xs text-red-600 font-medium">已拒绝</span>';
else if (isFriendReq && n.application_status === 'accepted') statusHtml = '<span class="text-xs text-green-600 font-medium">已同意</span>'; else if (isFriendReq && n.application_status === 'accepted') statusHtml = '<span class="text-xs text-green-600 font-medium">已同意</span>';
const icon = isContestApp ? '📋' : isTeacherApp ? '👨‍🏫' : isFriendReq ? '👤' : isResult ? '📢' : isNewExam ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔'; const icon = isContestApp ? '📋' : isTeacherApp ? '👨‍🏫' : isFriendReq ? '👤' : isResult ? '📢' : isNewExam ? '📝' : isSubmission ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔';
const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-100' : isFriendReq ? 'bg-blue-100' : isResult ? 'bg-green-100' : isNewExam ? 'bg-indigo-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100'; const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-100' : isFriendReq ? 'bg-blue-100' : isResult ? 'bg-green-100' : isNewExam ? 'bg-indigo-100' : isSubmission ? 'bg-amber-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100';
const clickAction = `showNotifDetail(${n.id})`; const clickAction = `showNotifDetail(${n.id})`;
let actionsHtml = ''; let actionsHtml = '';
if (isPendingContest && currentUser.role === 'admin') { if (isPendingContest && currentUser.role === 'admin') {
actionsHtml = `<div class="flex gap-2 mt-2"> actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveContest(${n.post_id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">批准</button> <button onclick="event.stopPropagation();chatApproveContest(${n.post_id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">批准</button>
<button onclick="event.stopPropagation();rejectContest(${n.post_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button> <button onclick="event.stopPropagation();chatRejectContest(${n.post_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} }
if (isPendingTeacher && currentUser.role === 'admin' && n.application_id) { if (isPendingTeacher && currentUser.role === 'admin' && n.application_id) {
actionsHtml = `<div class="flex gap-2 mt-2"> actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveTeacher(${n.application_id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">批准</button> <button onclick="event.stopPropagation();chatApproveTeacher(${n.application_id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">批准</button>
<button onclick="event.stopPropagation();rejectTeacher(${n.application_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button> <button onclick="event.stopPropagation();chatRejectTeacher(${n.application_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} }
if (isPendingFriend && n.friend_request_id) { if (isPendingFriend && n.friend_request_id) {
actionsHtml = `<div class="flex gap-2 mt-2"> actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();acceptFriendReq(${n.friend_request_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button> <button onclick="event.stopPropagation();chatAcceptFriendReq(${n.friend_request_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button>
<button onclick="event.stopPropagation();rejectFriendReq(${n.friend_request_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button> <button onclick="event.stopPropagation();chatRejectFriendReq(${n.friend_request_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} }
return `<div class="px-3 py-3 ${n.read ? '' : 'bg-blue-50'} hover:bg-slate-50 border-b border-slate-100 transition cursor-pointer" onclick="${clickAction}"> return `<div class="px-3 py-3 ${n.read ? '' : 'bg-blue-50'} hover:bg-slate-50 border-b border-slate-100 transition cursor-pointer" onclick="${clickAction}">
@@ -444,65 +470,65 @@ async function markNotifRead(nid, el) {
} }
} }
async function approveContest(appId) { async function chatApproveContest(appId) {
if (!confirm('确定批准该杯赛申请?')) return; if (!confirm('确定批准该杯赛申请?')) return;
const res = await fetch(`/api/contest-applications/${appId}/approve`, { method: 'POST' }); const res = await fetch(`/api/contest-applications/${appId}/approve`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
} }
async function rejectContest(appId) { async function chatRejectContest(appId) {
if (!confirm('确定拒绝该杯赛申请?')) return; if (!confirm('确定拒绝该杯赛申请?')) return;
const res = await fetch(`/api/contest-applications/${appId}/reject`, { method: 'POST' }); const res = await fetch(`/api/contest-applications/${appId}/reject`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
} }
async function approveTeacher(appId) { async function chatApproveTeacher(appId) {
if (!confirm('确定批准该教师申请?')) return; if (!confirm('确定批准该教师申请?')) return;
const res = await fetch(`/api/teacher-applications/${appId}/approve`, { method: 'POST' }); const res = await fetch(`/api/teacher-applications/${appId}/approve`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
} }
async function rejectTeacher(appId) { async function chatRejectTeacher(appId) {
if (!confirm('确定拒绝该教师申请?')) return; if (!confirm('确定拒绝该教师申请?')) return;
const res = await fetch(`/api/teacher-applications/${appId}/reject`, { method: 'POST' }); const res = await fetch(`/api/teacher-applications/${appId}/reject`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
} }
async function acceptFriendReq(reqId, notifId) { async function chatAcceptFriendReq(reqId, notifId) {
const res = await fetch(`/api/friend/accept/${reqId}`, { method: 'POST' }); const res = await fetch(`/api/friend/accept/${reqId}`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
} }
async function rejectFriendReq(reqId, notifId) { async function chatRejectFriendReq(reqId, notifId) {
const res = await fetch(`/api/friend/reject/${reqId}`, { method: 'POST' }); const res = await fetch(`/api/friend/reject/${reqId}`, { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadNotifications(); loadChatNotifications();
} else { } else {
alert(data.message || '操作失败'); alert(data.message || '操作失败');
} }
@@ -513,11 +539,24 @@ async function checkUnreadNotifs() {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
const badge = document.getElementById('chatNotifBadge'); const badge = document.getElementById('chatNotifBadge');
if (data.count > 0) { if (badge) {
badge.textContent = data.count > 99 ? '99+' : data.count; if (data.count > 0) {
badge.classList.remove('hidden'); badge.textContent = data.count > 99 ? '99+' : data.count;
} else { badge.classList.remove('hidden');
badge.classList.add('hidden'); } else {
badge.classList.add('hidden');
}
}
// Also update the global notification badge if it exists
const globalBadge = document.getElementById('notifBadge');
if (globalBadge) {
if (data.count > 0) {
globalBadge.textContent = data.count > 99 ? '99+' : data.count;
globalBadge.classList.remove('hidden');
} else {
globalBadge.classList.add('hidden');
}
} }
} }
} }
@@ -546,13 +585,15 @@ function showNotifDetail(nid) {
'teacher_application': '教师申请', 'teacher_result': '教师审核结果', 'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
'contest_application': '杯赛申请', 'contest_result': '杯赛通知', 'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
'contest_new_exam': '新考试', 'exam_graded': '成绩通知', 'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
'system_announcement': '系统通知', 'friend_request': '好友申请' 'exam_submission': '待批改', 'system_announcement': '系统通知',
'friend_request': '好友申请'
}; };
const typeIcons = { const typeIcons = {
'teacher_application': '👨‍🏫', 'teacher_result': '🎓', 'teacher_application': '👨‍🏫', 'teacher_result': '🎓',
'contest_application': '📋', 'contest_result': '🏅', 'contest_application': '📋', 'contest_result': '🏅',
'contest_new_exam': '📝', 'exam_graded': '✅', 'contest_new_exam': '📝', 'exam_graded': '✅',
'system_announcement': '📢', 'friend_request': '👤' 'exam_submission': '📝', 'system_announcement': '📢',
'friend_request': '👤'
}; };
document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔'; document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔';
@@ -565,20 +606,25 @@ function showNotifDetail(nid) {
let actHtml = ''; let actHtml = '';
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') { if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') {
actHtml = `<div class="flex gap-3"> actHtml = `<div class="flex gap-3">
<button onclick="approveContest(${n.post_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button> <button onclick="chatApproveContest(${n.post_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
<button onclick="rejectContest(${n.post_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button> <button onclick="chatRejectContest(${n.post_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} else if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') { } else if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') {
actHtml = `<div class="flex gap-3"> actHtml = `<div class="flex gap-3">
<button onclick="approveTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button> <button onclick="chatApproveTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
<button onclick="rejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button> <button onclick="chatRejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} else if (n.type === 'exam_submission' && n.exam_id && n.submission_id) {
actHtml = `<a href="/exams/${n.exam_id}/grade/${n.submission_id}" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
去批改
</a>`;
} else if (n.type === 'contest_new_exam' || n.type === 'exam_graded') { } else if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
actHtml = `<a href="/exams/${n.post_id}" class="inline-block px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-blue-700">查看考试</a>`; actHtml = `<a href="/exams/${n.post_id}" class="btn-futuristic inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium">查看考试</a>`;
} else if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) { } else if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) {
actHtml = `<div class="flex gap-3"> actHtml = `<div class="flex gap-3">
<button onclick="acceptFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">同意</button> <button onclick="chatAcceptFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">同意</button>
<button onclick="rejectFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button> <button onclick="chatRejectFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`; </div>`;
} else if (n.type === 'friend_request' && n.application_status === 'accepted') { } else if (n.type === 'friend_request' && n.application_status === 'accepted') {
actHtml = '<span class="text-sm text-green-600 font-medium">✅ 已同意</span>'; actHtml = '<span class="text-sm text-green-600 font-medium">✅ 已同意</span>';
@@ -625,15 +671,18 @@ function renderRooms() {
const search = document.getElementById('roomSearch').value.toLowerCase(); const search = document.getElementById('roomSearch').value.toLowerCase();
const list = document.getElementById('roomList'); const list = document.getElementById('roomList');
const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search)); const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search));
list.innerHTML = filtered.map(r => ` list.innerHTML = filtered.map(r => {
<div class="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-slate-50 transition ${currentRoomId === r.id ? 'bg-blue-50 border-r-2 border-primary' : ''}" onclick="selectRoom(${r.id})"> const peerInExam = r.type === 'private' && r.peer_id && peerExamStatus[r.peer_id]?.in_exam;
<div class="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 overflow-hidden"> return `
<div class="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-white/10 transition rounded-xl ${currentRoomId === r.id ? 'bg-cyan-500/15 border-r-2 border-cyan-400' : ''}" onclick="selectRoom(${r.id})">
<div class="w-10 h-10 rounded-full bg-slate-700/50 flex items-center justify-center flex-shrink-0 overflow-hidden border border-white/10">
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` : ${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` :
`<span class="text-sm font-medium text-slate-500">${(r.name || '?')[0]}</span>`} `<span class="text-sm font-medium text-cyan-400">${(r.name || '?')[0]}</span>`}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-sm font-medium text-slate-800 truncate">${escHtml(r.name || '未命名')}</span> <span class="text-sm font-medium text-white truncate">${escHtml(r.name || '未命名')}</span>
${peerInExam ? '<span class="text-[10px] text-amber-400 bg-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">考试中</span>' : ''}
<span class="text-xs text-slate-400 flex-shrink-0">${r.last_message ? formatTime(r.last_message.created_at) : ''}</span> <span class="text-xs text-slate-400 flex-shrink-0">${r.last_message ? formatTime(r.last_message.created_at) : ''}</span>
</div> </div>
<div class="flex justify-between items-center mt-0.5"> <div class="flex justify-between items-center mt-0.5">
@@ -642,7 +691,7 @@ function renderRooms() {
</div> </div>
</div> </div>
</div> </div>
`).join(''); `}).join('');
} }
function getPreview(r) { function getPreview(r) {
@@ -661,6 +710,7 @@ function filterRooms() { renderRooms(); }
async function selectRoom(roomId) { async function selectRoom(roomId) {
currentRoomId = roomId; currentRoomId = roomId;
oldestMsgId = null; oldestMsgId = null;
markChatRead(roomId); // 立即标记已读,使聊天气泡消失
document.getElementById('emptyState').classList.add('hidden'); document.getElementById('emptyState').classList.add('hidden');
// 隐藏通知详情 // 隐藏通知详情
const nd = document.getElementById('notifDetailView'); const nd = document.getElementById('notifDetailView');
@@ -674,10 +724,32 @@ async function selectRoom(roomId) {
if (room) { if (room) {
document.getElementById('chatName').textContent = room.name || '聊天'; document.getElementById('chatName').textContent = room.name || '聊天';
document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : ''; document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : '';
// 私聊:获取对方考试状态
if (room.type === 'private' && room.peer_id) {
try {
const res = await fetch(`/api/user/${room.peer_id}/exam-status`);
const data = await res.json();
if (data.success) {
peerExamStatus[room.peer_id] = { in_exam: data.in_exam, exam_title: data.exam_title || '', end_time: data.end_time };
}
} catch (e) { peerExamStatus[room.peer_id] = { in_exam: false }; }
}
updateChatHeaderExamStatus(room);
} }
renderRooms(); renderRooms();
await loadMessages(roomId); await loadMessages(roomId);
markRead(roomId); }
function updateChatHeaderExamStatus(room) {
const examBanner = document.getElementById('peerExamBanner');
if (!examBanner) return;
if (room && room.type === 'private' && room.peer_id && peerExamStatus[room.peer_id]?.in_exam) {
const info = peerExamStatus[room.peer_id];
examBanner.textContent = `${room.name || '对方'} 正在考试中(${info.exam_title || '考试'}),请勿打扰`;
examBanner.classList.remove('hidden');
} else {
examBanner.classList.add('hidden');
}
} }
// ========== 消息加载与渲染 ========== // ========== 消息加载与渲染 ==========
async function loadMessages(roomId, beforeId) { async function loadMessages(roomId, beforeId) {
@@ -690,7 +762,10 @@ async function loadMessages(roomId, beforeId) {
if (!beforeId) { if (!beforeId) {
area.innerHTML = ''; area.innerHTML = '';
data.messages.forEach(m => area.appendChild(createMsgEl(m))); data.messages.forEach(m => area.appendChild(createMsgEl(m)));
area.scrollTop = area.scrollHeight; // 确保滚动到底部
setTimeout(() => {
area.scrollTop = area.scrollHeight;
}, 100);
} else { } else {
const oldH = area.scrollHeight; const oldH = area.scrollHeight;
data.messages.forEach((m, i) => area.insertBefore(createMsgEl(m), area.children[i])); data.messages.forEach((m, i) => area.insertBefore(createMsgEl(m), area.children[i]));
@@ -800,6 +875,7 @@ function sendMessage() {
reply_to_id: replyToId, mentions: mentionsStr reply_to_id: replyToId, mentions: mentionsStr
}); });
input.value = ''; input.value = '';
input.style.height = 'auto';
mentionMembers = []; mentionMembers = [];
cancelReply(); cancelReply();
document.getElementById('emojiPanel').classList.add('hidden'); document.getElementById('emojiPanel').classList.add('hidden');
@@ -912,7 +988,7 @@ function previewImage(url) {
} }
// ========== 标记已读 ========== // ========== 标记已读 ==========
function markRead(roomId) { function markChatRead(roomId) {
fetch(`/api/chat/rooms/${roomId}/read`, { method: 'POST' }); fetch(`/api/chat/rooms/${roomId}/read`, { method: 'POST' });
socket.emit('mark_read', { room_id: roomId }); socket.emit('mark_read', { room_id: roomId });
const room = rooms.find(r => r.id === roomId); const room = rooms.find(r => r.id === roomId);
@@ -921,7 +997,15 @@ function markRead(roomId) {
// ========== 创建群聊 ========== // ========== 创建群聊 ==========
async function showCreateGroup() { async function showCreateGroup() {
document.getElementById('createGroupModal').classList.remove('hidden'); const modal = document.getElementById('createGroupModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
// 重置弹窗标题和按钮(防止被邀请好友功能覆盖)
document.querySelector('#createGroupModal h3').textContent = '创建群聊';
const createBtn = document.getElementById('groupActionBtn');
createBtn.textContent = '创建';
createBtn.onclick = createGroup;
document.getElementById('groupName').value = '';
const res = await fetch('/api/user/friends'); const res = await fetch('/api/user/friends');
const data = await res.json(); const data = await res.json();
const list = document.getElementById('friendListForGroup'); const list = document.getElementById('friendListForGroup');
@@ -940,7 +1024,11 @@ async function showCreateGroup() {
} }
} }
function hideCreateGroup() { document.getElementById('createGroupModal').classList.add('hidden'); } function hideCreateGroup() {
const modal = document.getElementById('createGroupModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function createGroup() { async function createGroup() {
const name = document.getElementById('groupName').value.trim(); const name = document.getElementById('groupName').value.trim();
@@ -964,7 +1052,9 @@ async function createGroup() {
// ========== 成员列表 ========== // ========== 成员列表 ==========
async function showMembers() { async function showMembers() {
if (!currentRoomId) return; if (!currentRoomId) return;
document.getElementById('membersModal').classList.remove('hidden'); const modal = document.getElementById('membersModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/members`); const res = await fetch(`/api/chat/rooms/${currentRoomId}/members`);
const data = await res.json(); const data = await res.json();
if (!data.success) return; if (!data.success) return;
@@ -1005,15 +1095,36 @@ async function showMembers() {
</div>`; </div>`;
}).join(''); }).join('');
document.getElementById('inviteSection').classList.toggle('hidden', !isGroup); document.getElementById('inviteSection').classList.toggle('hidden', !isGroup);
document.getElementById('dissolveBtn').classList.toggle('hidden', !isCreator);
} }
function hideMembers() { document.getElementById('membersModal').classList.add('hidden'); } function hideMembers() {
const modal = document.getElementById('membersModal');
modal.classList.add('hidden');
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() { async function showInvite() {
hideMembers(); hideMembers();
showCreateGroup(); showCreateGroup();
document.querySelector('#createGroupModal h3').textContent = '邀请好友'; document.querySelector('#createGroupModal h3').textContent = '邀请好友';
const btn = document.querySelector('#createGroupModal .bg-primary'); const btn = document.getElementById('groupActionBtn');
btn.textContent = '邀请'; btn.textContent = '邀请';
btn.onclick = async () => { btn.onclick = async () => {
const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value)); const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value));
@@ -1063,7 +1174,9 @@ async function transferOwner(uid, name) {
// ========== 群公告 ========== // ========== 群公告 ==========
async function showAnnouncement() { async function showAnnouncement() {
if (!currentRoomId) return; if (!currentRoomId) return;
document.getElementById('announcementModal').classList.remove('hidden'); const modal = document.getElementById('announcementModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/announcement`); const res = await fetch(`/api/chat/rooms/${currentRoomId}/announcement`);
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -1084,7 +1197,11 @@ async function showAnnouncement() {
} }
document.getElementById('btnEditAnnouncement').classList.toggle('hidden', myRoomRole !== 'admin'); document.getElementById('btnEditAnnouncement').classList.toggle('hidden', myRoomRole !== 'admin');
} }
function hideAnnouncement() { document.getElementById('announcementModal').classList.add('hidden'); } function hideAnnouncement() {
const modal = document.getElementById('announcementModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function editAnnouncement() { function editAnnouncement() {
document.getElementById('announcementEditSection').classList.remove('hidden'); document.getElementById('announcementEditSection').classList.remove('hidden');
document.getElementById('btnEditAnnouncement').classList.add('hidden'); document.getElementById('btnEditAnnouncement').classList.add('hidden');
@@ -1106,8 +1223,20 @@ async function saveAnnouncement() {
} }
// ========== 搜索聊天记录 ========== // ========== 搜索聊天记录 ==========
function showSearchPanel() { if (!currentRoomId) return; document.getElementById('searchModal').classList.remove('hidden'); document.getElementById('msgSearchInput').value = ''; document.getElementById('searchResults').innerHTML = ''; document.getElementById('msgSearchInput').focus(); } function showSearchPanel() {
function hideSearchPanel() { document.getElementById('searchModal').classList.add('hidden'); } if (!currentRoomId) return;
const modal = document.getElementById('searchModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('msgSearchInput').value = '';
document.getElementById('searchResults').innerHTML = '';
document.getElementById('msgSearchInput').focus();
}
function hideSearchPanel() {
const modal = document.getElementById('searchModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(doSearch, 300); } function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(doSearch, 300); }
async function doSearch() { async function doSearch() {
const q = document.getElementById('msgSearchInput').value.trim(); const q = document.getElementById('msgSearchInput').value.trim();
@@ -1135,7 +1264,9 @@ function scrollToMsg(msgId) {
// ========== 群文件 ========== // ========== 群文件 ==========
async function showFileList() { async function showFileList() {
if (!currentRoomId) return; if (!currentRoomId) return;
document.getElementById('fileListModal').classList.remove('hidden'); const modal = document.getElementById('fileListModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/files`); const res = await fetch(`/api/chat/rooms/${currentRoomId}/files`);
const data = await res.json(); const data = await res.json();
const container = document.getElementById('fileListContent'); const container = document.getElementById('fileListContent');
@@ -1154,11 +1285,25 @@ async function showFileList() {
</div>`; </div>`;
}).join(''); }).join('');
} }
function hideFileList() { document.getElementById('fileListModal').classList.add('hidden'); } function hideFileList() {
const modal = document.getElementById('fileListModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
// ========== 群昵称 ========== // ========== 群昵称 ==========
function showNicknameModal() { document.getElementById('nicknameModal').classList.remove('hidden'); const me = currentRoomMembers.find(m => m.id === currentUser.id); document.getElementById('nicknameInput').value = me?.nickname || ''; } function showNicknameModal() {
function hideNicknameModal() { document.getElementById('nicknameModal').classList.add('hidden'); } const modal = document.getElementById('nicknameModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
const me = currentRoomMembers.find(m => m.id === currentUser.id);
document.getElementById('nicknameInput').value = me?.nickname || '';
}
function hideNicknameModal() {
const modal = document.getElementById('nicknameModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function saveNickname() { async function saveNickname() {
const nickname = document.getElementById('nicknameInput').value.trim(); const nickname = document.getElementById('nicknameInput').value.trim();
const res = await fetch(`/api/chat/rooms/${currentRoomId}/nickname`, { const res = await fetch(`/api/chat/rooms/${currentRoomId}/nickname`, {
@@ -1272,9 +1417,15 @@ async function stopRecording() {
socket.on('new_message', (msg) => { socket.on('new_message', (msg) => {
if (msg.room_id === currentRoomId) { if (msg.room_id === currentRoomId) {
const area = document.getElementById('messageArea'); const area = document.getElementById('messageArea');
const wasAtBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 100;
area.appendChild(createMsgEl(msg)); area.appendChild(createMsgEl(msg));
area.scrollTop = area.scrollHeight; // 如果用户在底部或者是自己发的消息,自动滚动到底部
markRead(currentRoomId); if (wasAtBottom || msg.sender_id === currentUser.id) {
setTimeout(() => {
area.scrollTop = area.scrollHeight;
}, 50);
}
markChatRead(currentRoomId);
} }
// 更新会话列表 // 更新会话列表
const room = rooms.find(r => r.id === msg.room_id); const room = rooms.find(r => r.id === msg.room_id);
@@ -1346,6 +1497,33 @@ socket.on('error', (data) => {
if (data.message) alert(data.message); if (data.message) alert(data.message);
}); });
socket.on('friend_exam_status', (data) => {
const uid = data.user_id;
peerExamStatus[uid] = {
in_exam: data.in_exam,
exam_title: data.exam_title || '',
end_time: data.end_time
};
const room = rooms.find(r => r.id === currentRoomId);
if (room && room.type === 'private' && room.peer_id === uid) {
updateChatHeaderExamStatus(room);
}
renderRooms();
});
socket.on('peer_in_exam', (data) => {
showToast(data.message || '对方正在考试中,消息将在考试结束后送达');
});
function showToast(msg) {
const el = document.getElementById('chatToast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(el._toastTimer);
el._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
}
// ========== 工具函数 ========== // ========== 工具函数 ==========
function escHtml(s) { function escHtml(s) {
if (!s) return ''; if (!s) return '';

View File

@@ -3,69 +3,72 @@
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-8">
{% if contest.status == 'abolished' %} {% if contest.status == 'abolished' %}
<div class="bg-red-50 border border-red-300 rounded-lg p-4 text-red-700 font-medium"> <div class="futuristic-card p-6 border-l-4 border-red-500 p-4 text-red-300">
⚠️ 该杯赛已被废止,所有考试已关闭,无法报名或参加考试。 ⚠️ 该杯赛已被废止,所有考试已关闭,无法报名或参加考试。
</div> </div>
{% endif %} {% endif %}
{% if not contest.visible and is_owner %} {% if not contest.visible and is_owner %}
<div class="bg-yellow-50 border border-yellow-300 rounded-lg p-4 flex justify-between items-center"> <div class="futuristic-card p-6 border-l-4 border-yellow-500 p-4 flex justify-between items-center">
<span class="text-yellow-800 font-medium">该杯赛尚未发布,仅负责人和管理员可见。完善资料后请点击发布。</span> <span class="text-yellow-300 font-medium">该杯赛尚未发布,仅负责人和管理员可见。完善资料后请点击发布。</span>
<button onclick="publishContest()" id="publish-btn" class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700">发布杯赛</button> <button onclick="publishContest()" id="publish-btn" class="btn-futuristic px-4 py-2 text-sm font-medium">发布杯赛</button>
</div> </div>
{% endif %} {% endif %}
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6 p-6">
<div class="flex justify-between items-start"> <div class="flex flex-col lg:flex-row justify-between items-start gap-4">
<div> <div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-slate-900 mb-2"> <h1 class="text-xl sm:text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-2 break-words">
{{ contest.name }} {{ contest.name }}
{% if contest.status == 'abolished' %} {% if contest.status == 'abolished' %}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-800">已废止</span> <span class="ml-2 badge-futuristic text-xs rounded-full">已废止</span>
{% endif %} {% endif %}
</h1> </h1>
<div class="flex items-center space-x-4 text-sm text-slate-500"> <div class="flex flex-wrap items-center gap-3 sm:gap-4 text-xs sm:text-sm text-slate-400">
<span class="flex items-center"> <span class="flex items-center whitespace-nowrap">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ contest.start_date }} {{ contest.start_date }}
</span> </span>
<span class="flex items-center" id="participants-count"> <span class="flex items-center whitespace-nowrap" id="participants-count">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
<span id="participants-value">{{ contest.participants }}</span>人已报名 <span id="participants-value">{{ contest.participants }}</span>人已报名
</span> </span>
</div> </div>
</div> </div>
<div class="flex space-x-3"> <div class="flex flex-wrap gap-3 w-full lg:w-auto">
{% if contest.status != 'abolished' %} {% if contest.status != 'abolished' %}
{% if user %} {% if user %}
<button id="register-btn" <button id="register-btn"
onclick="toggleRegistration({{ contest.id }})" onclick="toggleRegistration({{ contest.id }})"
class="px-6 py-2 {% if registered %}bg-slate-100 text-slate-700 border border-slate-300 hover:bg-slate-200{% else %}bg-primary text-white hover:bg-blue-700{% endif %} rounded-md font-medium"> class="{% if registered %}btn-outline-futuristic{% else %}btn-futuristic{% endif %} px-4 sm:px-6 py-2 font-medium text-sm whitespace-nowrap">
{% if registered %}已报名{% else %}立即报名{% endif %} {% if registered %}已报名{% else %}立即报名{% endif %}
</button> </button>
{% if not is_member %} {% if not is_member %}
<a href="{{ url_for('apply_teacher', contest_id=contest.id) }}" class="px-6 py-2 bg-green-100 text-green-700 border border-green-300 rounded-md font-medium hover:bg-green-200"> <a href="{{ url_for('apply_teacher', contest_id=contest.id) }}" class="btn-outline-futuristic px-4 sm:px-6 py-2 font-medium text-sm whitespace-nowrap">
申请成为本杯赛老师 申请成为本杯赛老师
</a> </a>
{% endif %} {% endif %}
{% if is_member %} {% if is_member %}
<a href="{{ url_for('contest_question_bank', contest_id=contest.id) }}" class="px-6 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-md font-medium hover:bg-purple-200"> <a href="{{ url_for('contest_question_bank', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
题库管理 题库管理
</a> </a>
<a href="{{ url_for('exam_create', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
创建考试
</a>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}
<a href="{{ url_for('exam_create', contest_id=contest.id) }}" class="px-6 py-2 bg-blue-100 text-blue-700 border border-blue-300 rounded-md font-medium hover:bg-blue-200"> <a href="{{ url_for('admin_teacher_applications') }}" class="btn-outline-futuristic px-6 py-2 font-medium">
创建考试
</a>
<a href="{{ url_for('admin_teacher_applications') }}" class="px-6 py-2 bg-orange-100 text-orange-700 border border-orange-300 rounded-md font-medium hover:bg-orange-200">
审批老师申请 审批老师申请
</a> </a>
<a href="{{ url_for('contest_edit', contest_id=contest.id) }}" class="px-6 py-2 bg-yellow-100 text-yellow-700 border border-yellow-300 rounded-md font-medium hover:bg-yellow-200"> <a href="{{ url_for('contest_edit', contest_id=contest.id) }}#papers" class="btn-outline-futuristic px-6 py-2 font-medium">
上传历年真题
</a>
<a href="{{ url_for('contest_edit', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
编辑主页 编辑主页
</a> </a>
{% endif %} {% endif %}
{% else %} {% else %}
<a href="/login?next={{ url_for('contest_detail', contest_id=contest.id) }}" <a href="/login?next={{ url_for('contest_detail', contest_id=contest.id) }}"
class="px-6 py-2 bg-primary text-white rounded-md hover:bg-blue-700 font-medium"> class="btn-futuristic px-6 py-2 font-medium">
登录后报名 登录后报名
</a> </a>
{% endif %} {% endif %}
@@ -81,9 +84,9 @@
<!-- 比赛详情 --> <!-- 比赛详情 -->
<!-- 历年真题 --> <!-- 历年真题 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6 p-6">
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center"> <h2 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
</svg> </svg>
历年真题 历年真题
@@ -92,12 +95,12 @@
{% if papers %} {% if papers %}
<div class="space-y-3"> <div class="space-y-3">
{% for paper in papers %} {% for paper in papers %}
<div class="flex items-center justify-between py-2 border-b border-slate-100 last:border-0"> <div class="flex items-center justify-between py-2 border-b border-white/10 last:border-0">
<div class="flex items-center"> <div class="flex items-center">
<span class="text-sm font-medium text-slate-700 w-16">{{ paper.year }}</span> <span class="badge-futuristic text-sm font-medium w-16">{{ paper.year }}</span>
<span class="text-sm text-slate-600">{{ paper.title }}</span> <span class="text-sm text-slate-300 ml-3">{{ paper.title }}</span>
</div> </div>
<a href="{{ paper.file }}" target="_blank" class="inline-flex items-center px-3 py-1 text-xs font-medium text-primary border border-primary rounded hover:bg-blue-50"> <a href="{{ paper.file }}" target="_blank" class="btn-outline-futuristic inline-flex items-center px-3 py-1 text-xs font-medium">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg> </svg>
@@ -107,19 +110,19 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center py-8 text-slate-500">暂无历年真题,敬请期待!</div> <div class="text-center py-8 text-slate-400">暂无历年真题,敬请期待!</div>
{% endif %} {% endif %}
</div> </div>
<!-- 考试列表 --> <!-- 考试列表 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6 p-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-slate-900 flex items-center"> <h2 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg> <svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
考试列表 考试列表
</h2> </h2>
{% if is_owner %} {% if is_owner %}
<button onclick="showImportModal()" class="px-3 py-1.5 bg-green-600 text-white rounded-md text-sm hover:bg-green-700">导入考试</button> <button onclick="showImportModal()" class="btn-futuristic px-3 py-1.5 text-sm">导入考试</button>
{% endif %} {% endif %}
</div> </div>
<div id="exam-list" class="space-y-3"> <div id="exam-list" class="space-y-3">
@@ -130,48 +133,48 @@
<!-- 右侧一列(主办方信息) --> <!-- 右侧一列(主办方信息) -->
<div class="space-y-6"> <div class="space-y-6">
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6">
<h3 class="text-lg font-semibold text-slate-900 mb-4">主办方信息</h3> <h3 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-4">主办方信息</h3>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<div class="font-medium text-slate-900">{{ contest.organizer }}</div> <div class="font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">{{ contest.organizer }}</div>
<p class="text-sm text-slate-500 mt-1">{{ contest.description[:100] + '...' if contest.description|length > 100 else contest.description }}</p> <p class="text-sm text-slate-400 mt-1">{{ contest.description[:100] + '...' if contest.description|length > 100 else contest.description }}</p>
</div> </div>
{% if contest.responsible_person %} {% if contest.responsible_person %}
<div class="pt-4 border-t border-slate-100"> <div class="pt-4 border-t border-white/10">
<div class="text-sm text-slate-500 mb-1">报备信息</div> <div class="text-sm text-slate-400 mb-1">报备信息</div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">责任人</span> <span class="text-slate-400 w-16 flex-shrink-0">责任人</span>
<span class="font-medium text-slate-900">{{ contest.responsible_person }}</span> <span class="font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">{{ contest.responsible_person }}</span>
</div> </div>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">电话</span> <span class="text-slate-400 w-16 flex-shrink-0">电话</span>
<span class="font-medium text-slate-900">{{ contest.responsible_phone }}</span> <span class="font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">{{ contest.responsible_phone }}</span>
</div> </div>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">邮箱</span> <span class="text-slate-400 w-16 flex-shrink-0">邮箱</span>
<span class="font-medium text-primary">{{ contest.responsible_email }}</span> <span class="font-medium text-cyan-400">{{ contest.responsible_email }}</span>
</div> </div>
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<span class="text-slate-500 w-16 flex-shrink-0">机构</span> <span class="text-slate-400 w-16 flex-shrink-0">机构</span>
<span class="font-medium text-slate-900">{{ contest.organization }}</span> <span class="font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">{{ contest.organization }}</span>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if contest.contact %} {% if contest.contact %}
<div class="pt-4 border-t border-slate-100"> <div class="pt-4 border-t border-white/10">
<div class="text-sm text-slate-500">联系方式</div> <div class="text-sm text-slate-400">联系方式</div>
<div class="text-sm font-medium text-primary">{{ contest.contact }}</div> <div class="text-sm font-medium text-cyan-400">{{ contest.contact }}</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- 排行榜 --> <!-- 排行榜 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6">
<h3 class="text-lg font-semibold text-slate-900 mb-4 flex items-center"> <h3 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg> <svg class="w-5 h-5 mr-2 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/></svg>
成绩排行榜 成绩排行榜
</h3> </h3>
@@ -183,9 +186,9 @@
</div> </div>
<!-- 讨论区(动态区域) --> <!-- 讨论区(动态区域) -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-6">
<h2 class="text-lg font-semibold text-slate-900 mb-4 flex items-center"> <h2 class="text-lg font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
讨论区 讨论区
</h2> </h2>
@@ -208,7 +211,7 @@
<!-- 帖子列表容器 --> <!-- 帖子列表容器 -->
<div id="post-list" class="space-y-4"> <div id="post-list" class="space-y-4">
<div class="text-center py-8 text-slate-500">加载中...</div> <div class="text-center py-8 text-slate-400">加载中...</div>
</div> </div>
</div> </div>
</div> </div>
@@ -219,9 +222,9 @@
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">导入已有考试</h3> <h3 class="text-lg font-semibold">导入已有考试</h3>
<button onclick="hideImportModal()" class="text-slate-400 hover:text-slate-600 text-xl">&times;</button> <button onclick="hideImportModal()" class="text-slate-400 hover:text-slate-300 text-xl">&times;</button>
</div> </div>
<p class="text-sm text-slate-500 mb-4">选择您创建的未关联杯赛的考试,导入到当前杯赛。</p> <p class="text-sm text-slate-400 mb-4">选择您创建的未关联杯赛的考试,导入到当前杯赛。</p>
<div id="available-exams-list" class="space-y-2"> <div id="available-exams-list" class="space-y-2">
<div class="text-center py-4 text-slate-400 text-sm">加载中...</div> <div class="text-center py-4 text-slate-400 text-sm">加载中...</div>
</div> </div>
@@ -236,6 +239,7 @@
const CONTEST_ID = {{ contest.id }}; const CONTEST_ID = {{ contest.id }};
let canPost = {{ (can_post is defined and can_post) | lower }}; let canPost = {{ (can_post is defined and can_post) | lower }};
const isOwner = {{ (is_owner is defined and is_owner) | lower }}; const isOwner = {{ (is_owner is defined and is_owner) | lower }};
const isMember = {{ (is_member is defined and is_member) | lower }};
// 报名切换 // 报名切换
async function toggleRegistration(contestId) { async function toggleRegistration(contestId) {
@@ -273,15 +277,15 @@ async function loadPosts() {
const data = await res.json(); const data = await res.json();
if (!data.success) throw new Error(data.message); if (!data.success) throw new Error(data.message);
if (data.data.length === 0) { if (data.data.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-slate-500">暂无讨论,来抢沙发吧!</div>'; container.innerHTML = '<div class="text-center py-8 text-slate-400">暂无讨论,来抢沙发吧!</div>';
return; return;
} }
let html = ''; let html = '';
data.data.forEach(p => { data.data.forEach(p => {
html += ` html += `
<div class="border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow"> <div class="border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow cursor-pointer" onclick="window.location.href='/forum?post=${p.id}'">
<h3 class="text-base font-semibold text-slate-900 mb-1">${escapeHtml(p.title)}</h3> <h3 class="text-base font-semibold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-1">${escapeHtml(p.title)}</h3>
<p class="text-sm text-slate-600 mb-2">${escapeHtml(p.content)}</p> <p class="text-sm text-slate-300 mb-2">${escapeHtml(p.content)}</p>
<div class="flex items-center text-xs text-slate-400 space-x-3"> <div class="flex items-center text-xs text-slate-400 space-x-3">
<span>${escapeHtml(p.author)}</span> <span>${escapeHtml(p.author)}</span>
<span>${p.created_at}</span> <span>${p.created_at}</span>
@@ -375,18 +379,20 @@ async function loadExams() {
const statusText = e.status === 'available' ? '进行中' : '已关闭'; const statusText = e.status === 'available' ? '进行中' : '已关闭';
const subCount = e.submission_count !== null ? `<span class="text-xs text-slate-400 ml-2">${e.submission_count}人提交</span>` : ''; const subCount = e.submission_count !== null ? `<span class="text-xs text-slate-400 ml-2">${e.submission_count}人提交</span>` : '';
const removeBtn = isOwner ? `<button onclick="removeExam(${e.id}, '${escapeHtml(e.title)}')" class="ml-2 px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">移除</button>` : ''; const removeBtn = isOwner ? `<button onclick="removeExam(${e.id}, '${escapeHtml(e.title)}')" class="ml-2 px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">移除</button>` : '';
const toggleBtn = isOwner ? (e.status === 'available' ? `<button onclick="toggleExamStatus(${e.id}, 'closed')" class="ml-2 px-2 py-1 text-xs text-orange-600 border border-orange-300 rounded hover:bg-orange-50">关闭考试</button>` : `<button onclick="toggleExamStatus(${e.id}, 'available')" class="ml-2 px-2 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50">开放考试</button>`) : '';
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50"> html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/exams/${e.id}" class="text-sm font-medium text-slate-900 hover:text-primary truncate">${escapeHtml(e.title)}</a> <a href="/exams/${e.id}" class="text-sm font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent hover:text-cyan-400 truncate">${escapeHtml(e.title)}</a>
<span class="px-2 py-0.5 text-xs rounded-full ${statusClass}">${statusText}</span> <span class="px-2 py-0.5 text-xs rounded-full ${statusClass}">${statusText}</span>
${subCount} ${subCount}
</div> </div>
<div class="text-xs text-slate-500 mt-1">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}${e.duration ? ' · ' + e.duration + '分钟' : ''}</div> <div class="text-xs text-slate-400 mt-1">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}${e.duration ? ' · ' + e.duration + '分钟' : ''}</div>
</div> </div>
<div class="flex items-center ml-3 shrink-0"> <div class="flex items-center gap-2 ml-3 shrink-0">
<a href="/exams/${e.id}" class="px-3 py-1 text-xs font-medium text-primary border border-primary rounded hover:bg-blue-50">进入考试</a> <a href="/exams/${e.id}" class="px-3 py-1 text-xs font-medium text-cyan-400 border border-primary rounded hover:bg-blue-50">进入考试</a>
${removeBtn} ${isMember ? `<a href="/exams/${e.id}/submissions" class="px-3 py-1 text-xs font-medium text-amber-500 border border-amber-400 rounded hover:bg-amber-50">批改</a>` : ''}
${toggleBtn}${removeBtn}
</div> </div>
</div>`; </div>`;
}); });
@@ -410,6 +416,20 @@ async function removeExam(examId, title) {
} catch(e) { alert('网络错误'); } } catch(e) { alert('网络错误'); }
} }
// 切换考试状态(关闭/开放)
function toggleExamStatus(examId, status) {
const label = status === 'closed' ? '关闭' : '开放';
if (!confirm(`确定${label}该考试?`)) return;
fetch(`/api/exams/${examId}/status`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status: status })
}).then(r => r.json()).then(data => {
if (data.success) { loadExams(); }
else { alert(data.message); }
}).catch(() => alert('操作失败'));
}
// 导入考试弹窗 // 导入考试弹窗
function showImportModal() { function showImportModal() {
document.getElementById('import-exam-modal').classList.remove('hidden'); document.getElementById('import-exam-modal').classList.remove('hidden');
@@ -433,8 +453,8 @@ async function loadAvailableExams() {
data.exams.forEach(e => { data.exams.forEach(e => {
html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg"> html += `<div class="flex items-center justify-between p-3 border border-slate-200 rounded-lg">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-900">${escapeHtml(e.title)}</div> <div class="text-sm font-medium bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">${escapeHtml(e.title)}</div>
<div class="text-xs text-slate-500">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}分 · ${e.created_at}</div> <div class="text-xs text-slate-400">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}分 · ${e.created_at}</div>
</div> </div>
<button onclick="importExam(${e.id})" class="ml-3 px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700 shrink-0">导入</button> <button onclick="importExam(${e.id})" class="ml-3 px-3 py-1 text-xs font-medium text-white bg-green-600 rounded hover:bg-green-700 shrink-0">导入</button>
</div>`; </div>`;
@@ -472,12 +492,12 @@ async function loadLeaderboard() {
} }
let html = ''; let html = '';
data.leaderboard.forEach(item => { data.leaderboard.forEach(item => {
const rankClass = item.rank <= 3 ? 'font-bold text-yellow-600' : 'text-slate-500'; const rankClass = item.rank <= 3 ? 'font-bold text-yellow-600' : 'text-slate-400';
const medal = item.rank === 1 ? '🥇' : (item.rank === 2 ? '🥈' : (item.rank === 3 ? '🥉' : item.rank)); const medal = item.rank === 1 ? '🥇' : (item.rank === 2 ? '🥈' : (item.rank === 3 ? '🥉' : item.rank));
html += `<div class="flex items-center justify-between py-2 ${item.rank <= 3 ? '' : 'border-t border-slate-100'}"> html += `<div class="flex items-center justify-between py-2 ${item.rank <= 3 ? '' : 'border-t border-white/10'}">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="w-8 text-center ${rankClass}">${medal}</span> <span class="w-8 text-center ${rankClass}">${medal}</span>
<span class="text-sm text-slate-900">${escapeHtml(item.user_name)}</span> <span class="text-sm bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">${escapeHtml(item.user_name)}</span>
</div> </div>
<div class="text-sm font-medium text-slate-700">${item.total_score}分 <span class="text-xs text-slate-400">(${item.exam_count}科)</span></div> <div class="text-sm font-medium text-slate-700">${item.total_score}分 <span class="text-xs text-slate-400">(${item.exam_count}科)</span></div>
</div>`; </div>`;

View File

@@ -44,7 +44,7 @@
</div> </div>
<!-- 往届真题管理 --> <!-- 往届真题管理 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div id="papers" class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<h2 class="text-lg font-semibold text-slate-900 mb-4">往届真题管理</h2> <h2 class="text-lg font-semibold text-slate-900 mb-4">往届真题管理</h2>
<div id="papers-list" class="space-y-2 mb-6"> <div id="papers-list" class="space-y-2 mb-6">
{% for paper in contest.get_past_papers() %} {% for paper in contest.get_past_papers() %}

View File

@@ -3,21 +3,21 @@
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-8">
<!-- 头部区域 --> <!-- 头部区域 -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-6 rounded-2xl shadow-sm border border-slate-100"> <div class="futuristic-card flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-6">
<div> <div>
<h1 class="text-2xl font-bold text-slate-900 flex items-center"> <h1 class="text-2xl font-bold flex items-center bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
<span class="w-10 h-10 bg-blue-50 text-blue-600 rounded-xl flex items-center justify-center mr-3 shadow-sm border border-blue-100">🏆</span> <span class="w-10 h-10 bg-gradient-to-br from-cyan-400 to-blue-600 text-white rounded-xl flex items-center justify-center mr-3 shadow-lg border border-cyan-300/30 animate-pulse-glow">🏆</span>
杯赛专栏 杯赛专栏
</h1> </h1>
<p class="text-slate-500 text-sm mt-1 ml-13">参与官方联考,检验学习成果,赢取荣誉与奖励。</p> <p class="text-slate-400 text-sm mt-1 ml-13">参与官方联考,检验学习成果,赢取荣誉与奖励。</p>
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<a href="{{ url_for('apply_contest') }}" class="inline-flex items-center px-4 py-2.5 bg-green-50 text-green-600 rounded-xl hover:bg-green-100 shadow-sm border border-green-200 text-sm font-medium transition-all transform hover:-translate-y-0.5"> <a href="{{ url_for('apply_contest') }}" class="btn-futuristic inline-flex items-center px-4 py-2.5 text-sm font-medium">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
申请举办杯赛 申请举办杯赛
</a> </a>
{% if user and (user.role == 'admin' or user.role == 'teacher') %} {% if user and (user.role == 'admin' or user.role == 'teacher') %}
<a href="/admin/contests/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white rounded-xl hover:bg-blue-600 shadow-sm text-sm font-medium transition-all transform hover:-translate-y-0.5"> <a href="/admin/contests/create" class="btn-futuristic inline-flex items-center px-4 py-2.5 text-sm font-medium">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
发布新杯赛 发布新杯赛
</a> </a>
@@ -26,29 +26,29 @@
</div> </div>
<!-- 筛选和搜索 --> <!-- 筛选和搜索 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10 glass-panel"> <div class="futuristic-card p-5 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10 backdrop-blur-lg">
<div class="flex flex-wrap gap-3 items-center w-full sm:w-auto"> <div class="flex flex-wrap gap-3 items-center w-full sm:w-auto">
<div class="relative w-full sm:w-auto"> <div class="relative w-full sm:w-auto">
<select id="statusFilter" class="w-full sm:w-40 appearance-none px-4 py-2.5 pl-10 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer" onchange="searchContests()"> <select id="statusFilter" class="input-futuristic w-full sm:w-40 appearance-none px-4 py-2.5 pl-10 border border-cyan-500/30 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 bg-slate-900/50 hover:bg-slate-800/50 transition-all cursor-pointer text-slate-200" onchange="searchContests()">
<option value="">所有状态</option> <option value="">所有状态</option>
<option value="registering">🟢 报名中</option> <option value="registering">🟢 报名中</option>
<option value="upcoming">🔵 进行中</option> <option value="upcoming">🔵 进行中</option>
<option value="ended">⚪ 已结束</option> <option value="ended">⚪ 已结束</option>
</select> </select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg> <svg class="h-4 w-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/></svg>
</div> </div>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg> <svg class="h-4 w-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div> </div>
</div> </div>
</div> </div>
<div class="flex gap-2 w-full sm:w-auto relative group"> <div class="flex gap-2 w-full sm:w-auto relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg class="h-4 w-4 text-cyan-400 group-focus-within:text-cyan-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div> </div>
<input type="text" id="search-contest" placeholder="搜索杯赛名称..." class="flex-1 sm:w-72 pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all" onkeydown="if(event.key==='Enter') searchContests()"> <input type="text" id="search-contest" placeholder="搜索杯赛名称..." class="input-futuristic flex-1 sm:w-72 pl-10 pr-4 py-2.5 border border-cyan-500/30 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 bg-slate-900/50 transition-all text-slate-200 placeholder-slate-500" onkeydown="if(event.key==='Enter') searchContests()">
<button onclick="searchContests()" class="px-5 py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm"> <button onclick="searchContests()" class="btn-futuristic px-5 py-2.5 text-sm font-medium">
搜索 搜索
</button> </button>
</div> </div>
@@ -71,48 +71,48 @@ let allContests = [];
function renderContests(contests) { function renderContests(contests) {
const container = document.getElementById('contest-list'); const container = document.getElementById('contest-list');
if (contests.length === 0) { if (contests.length === 0) {
container.innerHTML = '<div class="col-span-full text-center py-12 text-slate-500">暂无杯赛</div>'; container.innerHTML = '<div class="col-span-full text-center py-12 text-cyan-400">暂无杯赛</div>';
return; return;
} }
let html = ''; let html = '';
contests.forEach(c => { contests.forEach(c => {
let statusHtml = ''; let statusHtml = '';
if (c.status === 'registering') { if (c.status === 'registering') {
statusHtml = '<span class="absolute top-3 right-3 bg-blue-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-blue-400/50"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>报名中</span>'; statusHtml = '<span class="badge-futuristic absolute top-3 right-3 bg-gradient-to-r from-blue-500 to-cyan-500 text-white text-xs font-bold px-3 py-1.5 shadow-lg flex items-center"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>报名中</span>';
} else if (c.status === 'upcoming') { } else if (c.status === 'upcoming') {
statusHtml = '<span class="absolute top-3 right-3 bg-green-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-green-400/50"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>即将开始</span>'; statusHtml = '<span class="badge-futuristic absolute top-3 right-3 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs font-bold px-3 py-1.5 shadow-lg flex items-center"><span class="w-1.5 h-1.5 rounded-full bg-white mr-1.5 animate-pulse"></span>即将开始</span>';
} else if (c.status === 'abolished') { } else if (c.status === 'abolished') {
statusHtml = '<span class="absolute top-3 right-3 bg-red-500/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-red-400/50">已废止</span>'; statusHtml = '<span class="badge-futuristic absolute top-3 right-3 bg-gradient-to-r from-red-500 to-pink-500 text-white text-xs font-bold px-3 py-1.5 shadow-lg flex items-center">已废止</span>';
} else { } else {
statusHtml = '<span class="absolute top-3 right-3 bg-slate-600/90 backdrop-blur text-white text-xs font-medium px-2.5 py-1 rounded-lg shadow-sm flex items-center border border-slate-500/50">已结束</span>'; statusHtml = '<span class="badge-futuristic absolute top-3 right-3 bg-gradient-to-r from-slate-500 to-slate-600 text-white text-xs font-bold px-3 py-1.5 shadow-lg flex items-center">已结束</span>';
} }
html += ` html += `
<div class="group bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-soft hover:border-blue-200 transition-all duration-300 transform hover:-translate-y-1 cursor-pointer flex flex-col" onclick="location.href='/contests/${c.id}'"> <div class="futuristic-card-dark group overflow-hidden transition-all duration-300 transform hover:scale-105 hover:shadow-2xl hover:shadow-cyan-500/30 cursor-pointer flex flex-col" onclick="location.href='/contests/${c.id}'">
<div class="h-40 bg-slate-100 relative overflow-hidden"> <div class="h-40 bg-slate-900 relative overflow-hidden">
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-indigo-100 to-purple-100 text-indigo-400 group-hover:scale-105 transition-transform duration-500"> <div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-cyan-500/20 via-blue-500/20 to-purple-500/20 text-cyan-400 group-hover:scale-110 transition-transform duration-500">
<svg class="w-16 h-16 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg> <svg class="w-16 h-16 opacity-60 drop-shadow-glow" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg>
</div> </div>
${statusHtml} ${statusHtml}
<div class="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div> <div class="absolute inset-0 bg-gradient-to-t from-cyan-500/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div> </div>
<div class="p-5 flex-1 flex flex-col relative"> <div class="p-5 flex-1 flex flex-col relative">
<h3 class="text-lg font-bold text-slate-900 mb-2 line-clamp-2 group-hover:text-primary transition-colors">${c.name}</h3> <h3 class="text-lg font-bold text-slate-100 mb-2 line-clamp-2 group-hover:text-cyan-400 transition-colors bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text group-hover:text-transparent">${c.name}</h3>
<p class="text-xs text-indigo-600 font-medium mb-2 bg-indigo-50 inline-block px-2 py-1 rounded-md w-max border border-indigo-100">主办方:${c.organizer || '未知'}</p> <p class="badge-futuristic text-xs font-bold mb-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white inline-block px-3 py-1.5 w-max shadow-md">主办方:${c.organizer || '未知'}</p>
<div class="text-sm text-slate-500 mb-5 flex-1 line-clamp-2 leading-relaxed">${c.description || '暂无简介'}</div> <div class="text-sm text-slate-400 mb-5 flex-1 line-clamp-2 leading-relaxed">${c.description || '暂无简介'}</div>
<div class="mt-auto pt-4 border-t border-slate-100 grid grid-cols-2 gap-4"> <div class="mt-auto pt-4 border-t border-cyan-500/30 grid grid-cols-2 gap-4">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-[10px] font-medium text-slate-400 uppercase tracking-wider mb-1">开始时间</span> <span class="text-[10px] font-medium text-slate-500 uppercase tracking-wider mb-1">开始时间</span>
<span class="text-xs font-medium text-slate-700 flex items-center"> <span class="text-xs font-semibold text-slate-300 flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> <svg class="w-3.5 h-3.5 mr-1 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
${c.start_date ? c.start_date.split(' ')[0] : '待定'} ${c.start_date ? c.start_date.split(' ')[0] : '待定'}
</span> </span>
</div> </div>
<div class="flex flex-col border-l border-slate-100 pl-4"> <div class="flex flex-col border-l border-cyan-500/30 pl-4">
<span class="text-[10px] font-medium text-slate-400 uppercase tracking-wider mb-1">参与人数</span> <span class="text-[10px] font-medium text-slate-500 uppercase tracking-wider mb-1">参与人数</span>
<span class="text-xs font-bold text-primary flex items-center"> <span class="text-xs font-bold text-cyan-400 flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-primary/70" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg> <svg class="w-3.5 h-3.5 mr-1 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
${c.participants} ${c.participants}
</span> </span>
</div> </div>

View File

@@ -149,7 +149,7 @@ async function loadQuestions() {
<p class="text-sm text-slate-800 mb-2">${escapeHtml(q.content)}</p>`; <p class="text-sm text-slate-800 mb-2">${escapeHtml(q.content)}</p>`;
if (q.type === 'choice' && q.options && q.options.length) { if (q.type === 'choice' && q.options && q.options.length) {
html += '<div class="text-sm text-slate-600 space-y-1 ml-4">'; html += '<div class="text-sm text-slate-600 space-y-1 ml-4">';
q.options.forEach(opt => { html += `<div>${escapeHtml(opt)}</div>`; }); q.options.forEach(opt => { html += `<div class="option-text">${opt}</div>`; });
html += '</div>'; html += '</div>';
} }
if (q.answer) { if (q.answer) {
@@ -162,6 +162,13 @@ async function loadQuestions() {
html += `</div></div>`; html += `</div></div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
// 渲染数学公式
if (typeof renderMathInElement === 'function') {
renderMathInElement(container, {
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
throwOnError: false
});
}
} catch(e) { } catch(e) {
document.getElementById('question-list').innerHTML = '<div class="text-center py-8 text-red-500">加载失败</div>'; document.getElementById('question-list').innerHTML = '<div class="text-center py-8 text-red-500">加载失败</div>';
} }

View File

@@ -47,10 +47,18 @@
<!-- 考试密码设置 --> <!-- 考试密码设置 -->
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-700">考试可见性</label>
<select id="exam-visibility" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm">
<option value="private">私有(仅自己可见)</option>
{% if not (is_cup_teacher|default(false)) %}<option value="public">公开(所有人可见)</option>{% endif %}
</select>
<p class="mt-1 text-xs text-slate-400">{% if is_cup_teacher|default(false) %}杯赛老师仅可创建自己可见的杯赛考试{% else %}私有考试只有您自己可以看到和管理{% endif %}</p>
</div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label> <label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
<input id="exam-password" type="text" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="留空则不设密码"> <input id="exam-password" type="text" class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md sm:text-sm" placeholder="留空则不设密码">
<p class="mt-1 text-xs text-slate-400">设置密码后考生需输入密码才能进入考试,试卷内容将加密存储</p> <p class="mt-1 text-xs text-slate-400">设置密码后考生需输入密码才能进入考试</p>
</div> </div>
</div> </div>
</div> </div>
@@ -96,7 +104,7 @@
</div> </div>
</div> </div>
<!-- VIP 弹窗 --> <!-- 智能识别功能开发中弹窗 -->
<div id="vipModal" class="fixed inset-0 z-[999] hidden" onclick="if(event.target===this)closeVipModal()"> <div id="vipModal" class="fixed inset-0 z-[999] hidden" onclick="if(event.target===this)closeVipModal()">
<!-- 背景:深空黑 + 动态星点 --> <!-- 背景:深空黑 + 动态星点 -->
<div class="absolute inset-0 bg-gradient-to-br from-[#0a0015] via-[#0d0d2b] to-[#0a0015]"> <div class="absolute inset-0 bg-gradient-to-br from-[#0a0015] via-[#0d0d2b] to-[#0a0015]">
@@ -113,56 +121,42 @@
<div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div> <div class="absolute -top-10 left-1/2 -translate-x-1/2 w-40 h-40 bg-cyan-400/10 rounded-full blur-2xl"></div>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&times;</button> <button onclick="closeVipModal()" class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 text-white/40 hover:text-white/80 transition text-lg">&times;</button>
<!-- 皇冠图标 --> <!-- 动漫小人辛苦工作插图 -->
<div class="relative flex justify-center mb-4"> <div class="relative flex justify-center mb-6">
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-amber-400 via-yellow-300 to-amber-500 flex items-center justify-center shadow-lg shadow-amber-500/30 vip-crown"> <div class="dev-character relative">
<svg class="w-10 h-10 text-amber-900" fill="currentColor" viewBox="0 0 24 24"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5v-2z"/></svg> <div class="absolute inset-0 flex items-center justify-center">
<div class="w-28 h-28 rounded-full bg-cyan-500/10 blur-2xl"></div>
</div>
<svg class="w-36 h-36 mx-auto relative z-10" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- 身体/工作服 -->
<ellipse cx="60" cy="85" rx="28" ry="18" fill="#6366f1"/>
<rect x="32" y="55" width="56" height="35" rx="8" fill="#4f46e5"/>
<!-- 头部 -->
<circle cx="60" cy="38" r="26" fill="#fde68a"/>
<circle cx="60" cy="38" r="26" stroke="#f59e0b" stroke-width="2"/>
<!-- 大眼睛 -->
<ellipse cx="52" cy="35" rx="6" ry="8" fill="#1e293b"/>
<ellipse cx="68" cy="35" rx="6" ry="8" fill="#1e293b"/>
<circle cx="54" cy="33" r="2" fill="white"/>
<circle cx="70" cy="33" r="2" fill="white"/>
<!-- 腮红 -->
<ellipse cx="42" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<ellipse cx="78" cy="42" rx="4" ry="3" fill="#fda4af" opacity="0.7"/>
<!-- 汗珠(辛苦工作) -->
<ellipse cx="88" cy="28" rx="4" ry="6" fill="#93c5fd" opacity="0.9" class="sweat-drop"/>
<ellipse cx="92" cy="32" rx="3" ry="5" fill="#93c5fd" opacity="0.7" class="sweat-drop"/>
<!-- 锤子/工具(敲代码) -->
<rect x="75" y="45" width="8" height="25" rx="2" fill="#78716c" transform="rotate(-30 79 57)"/>
<rect x="70" y="35" width="18" height="10" rx="2" fill="#a8a29e" transform="rotate(-30 79 40)"/>
<!-- 代码符号 -->
<text x="42" y="72" fill="white" font-size="10" font-family="monospace" font-weight="bold" opacity="0.95">&lt;/&gt;</text>
</svg>
</div> </div>
</div> </div>
<!-- 标题 --> <!-- 主文案 -->
<h2 class="text-center text-2xl font-bold bg-gradient-to-r from-amber-200 via-yellow-100 to-amber-200 bg-clip-text text-transparent mb-1">SVIP 超级会员</h2> <h2 class="text-center text-2xl font-bold bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent mb-2">该功能正在开发中</h2>
<p class="text-center text-purple-300/60 text-xs mb-6 tracking-widest">SUPREME VIP MEMBERSHIP</p> <p class="text-center text-purple-200/90 text-lg font-medium mb-1">敬请期待</p>
<!-- 功能列表 --> <p class="text-center text-purple-300/50 text-sm">渔鱼余正在努力敲代码中...</p>
<div class="space-y-3 mb-8">
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center text-purple-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">AI 智能识别试卷</p><p class="text-white/30 text-xs">PDF 一键导入,公式图形全自动解析</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-cyan-500/20 flex items-center justify-center text-cyan-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">量子级 OCR 引擎</p><p class="text-white/30 text-xs">手写体、印刷体、火星文通通拿下</p></div>
</div>
<div class="flex items-center space-x-3 px-4 py-3 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<span class="flex-shrink-0 w-8 h-8 rounded-lg bg-amber-500/20 flex items-center justify-center text-amber-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</span>
<div><p class="text-white/90 text-sm font-medium">永久有效 · 无限次数</p><p class="text-white/30 text-xs">一次开通,终身尊享(真的吗?)</p></div>
</div>
</div>
<!-- 价格区 -->
<div class="text-center mb-6">
<div class="inline-flex items-baseline space-x-2 mb-1">
<span class="text-white/30 text-sm line-through decoration-red-400/60">原价 ¥998/年</span>
</div>
<div class="flex items-baseline justify-center">
<span class="text-white/50 text-lg mr-1">¥</span>
<span class="text-5xl font-black bg-gradient-to-r from-cyan-300 via-purple-400 to-pink-400 bg-clip-text text-transparent vip-price">&infin;</span>
</div>
<p class="text-white/20 text-xs mt-2">限时优惠 · 仅剩 <span class="text-amber-400/80">0</span> 个名额</p>
</div>
<!-- 无支付按钮区域 -->
<div class="relative">
<div class="w-full py-3 rounded-xl bg-white/[0.04] border border-dashed border-white/10 text-center">
<p class="text-white/20 text-sm">支付通道维护中,预计恢复时间:</p>
<p class="text-purple-300/40 text-xs mt-1 font-mono tracking-wider">2099年12月31日 23:59:59</p>
</div>
</div>
<!-- 底部小字 -->
<p class="text-center text-white/10 text-[10px] mt-4">本页面仅供观赏,不构成任何消费邀约。如有雷同,纯属巧合。</p>
</div> </div>
</div> </div>
</div> </div>
@@ -196,6 +190,20 @@
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); } 50% { transform: translateY(-6px); }
} }
.dev-character {
animation: workBounce 2s ease-in-out infinite;
}
@keyframes workBounce {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-4px) scale(1.02); }
}
.dev-character .sweat-drop {
animation: sweatDrop 1.5s ease-in-out infinite;
}
@keyframes sweatDrop {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.4; }
}
.vip-price { .vip-price {
animation: glow 2s ease-in-out infinite alternate; animation: glow 2s ease-in-out infinite alternate;
} }
@@ -221,6 +229,8 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// 从杯赛页面进入时传入的 contest_id用于创建杯赛考试
const PAGE_CONTEST_ID = {{ contest_id|default(none)|tojson }};
// VIP 弹窗 // VIP 弹窗
function showVipModal() { function showVipModal() {
const modal = document.getElementById('vipModal'); const modal = document.getElementById('vipModal');
@@ -291,7 +301,7 @@ function addQuestion(type, prefill) {
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<span class="drag-handle cursor-grab text-slate-400 hover:text-slate-600">⠿</span> <span class="drag-handle cursor-grab text-slate-400 hover:text-slate-600">⠿</span>
<span class="q-number flex-shrink-0 w-8 h-8 bg-${typeColor}-100 rounded-full flex items-center justify-center text-${typeColor}-600 font-medium text-sm">${qid}</span> <span class="q-number flex-shrink-0 w-8 h-8 bg-${typeColor}-100 rounded-full flex items-center justify-center text-${typeColor}-600 font-medium text-sm">1</span>
<span class="text-xs font-medium px-2 py-1 rounded bg-${typeColor}-50 text-${typeColor}-700">${typeLabel}</span> <span class="text-xs font-medium px-2 py-1 rounded bg-${typeColor}-50 text-${typeColor}-700">${typeLabel}</span>
<input type="number" class="q-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="分值" value="${prefill?.score || defaultScore}" min="1"> <input type="number" class="q-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" placeholder="分值" value="${prefill?.score || defaultScore}" min="1">
</div> </div>
@@ -299,7 +309,7 @@ function addQuestion(type, prefill) {
<button onclick="moveQuestion(${qid}, -1)" class="text-slate-400 hover:text-slate-600 text-sm" title="上移">↑</button> <button onclick="moveQuestion(${qid}, -1)" class="text-slate-400 hover:text-slate-600 text-sm" title="上移">↑</button>
<button onclick="moveQuestion(${qid}, 1)" class="text-slate-400 hover:text-slate-600 text-sm" title="下移">↓</button> <button onclick="moveQuestion(${qid}, 1)" class="text-slate-400 hover:text-slate-600 text-sm" title="下移">↓</button>
<button onclick="duplicateQuestion(${qid})" class="text-blue-400 hover:text-blue-600 text-sm" title="复制">复制</button> <button onclick="duplicateQuestion(${qid})" class="text-blue-400 hover:text-blue-600 text-sm" title="复制">复制</button>
<button onclick="document.getElementById('q-${qid}').remove();updateStats();" class="text-red-400 hover:text-red-600 text-sm">删除</button> <button onclick="document.getElementById('q-${qid}').remove();updateStats();renumberQuestions();" class="text-red-400 hover:text-red-600 text-sm">删除</button>
</div> </div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -357,6 +367,7 @@ function addQuestion(type, prefill) {
// 选择题选项公式预览 // 选择题选项公式预览
if (type === 'choice' && prefill?.options) { setTimeout(function() { previewOptMath(qid); }, 50); } if (type === 'choice' && prefill?.options) { setTimeout(function() { previewOptMath(qid); }, 50); }
updateStats(); updateStats();
renumberQuestions();
} }
// 数学公式实时预览 // 数学公式实时预览
@@ -575,12 +586,17 @@ function submitExam() {
} }
} }
const access_password = document.getElementById('exam-password').value.trim(); const access_password = document.getElementById('exam-password').value.trim();
const visibility = document.getElementById('exam-visibility').value;
const payload = { title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password, visibility };
if (PAGE_CONTEST_ID) payload.contest_id = PAGE_CONTEST_ID;
fetch('/api/exams', { fetch('/api/exams', {
method: 'POST', headers: {'Content-Type':'application/json'}, method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password }) body: JSON.stringify(payload)
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; } if (data.success) {
else alert(data.message); alert('试卷创建成功!');
window.location.href = PAGE_CONTEST_ID ? '/contests/' + PAGE_CONTEST_ID : '/exams';
} else alert(data.message);
}).catch(() => alert('创建失败')); }).catch(() => alert('创建失败'));
} }

View File

@@ -3,18 +3,18 @@
{% block content %} {% block content %}
<div class="max-w-5xl mx-auto"> <div class="max-w-5xl mx-auto">
{% if need_password %} {% if need_password %}
<div class="bg-white shadow-sm rounded-lg p-8 border border-slate-200 max-w-md mx-auto mt-12"> <div class="futuristic-card p-8 max-w-md mx-auto mt-12">
<div class="text-center mb-6"> <div class="text-center mb-6">
<svg class="w-16 h-16 mx-auto text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg> <svg class="w-16 h-16 mx-auto text-amber-500 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
<h2 class="text-xl font-bold text-slate-900">{{ exam.title }}</h2> <h2 class="text-xl font-bold text-slate-900 bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">{{ exam.title }}</h2>
<p class="text-sm text-slate-500 mt-1">该考试需要输入密码才能进入</p> <p class="text-sm text-slate-500 mt-1">该考试需要输入密码才能进入</p>
</div> </div>
<div id="password-form"> <div id="password-form">
<input type="password" id="exam-pwd" placeholder="请输入考试密码" <input type="password" id="exam-pwd" placeholder="请输入考试密码"
class="w-full px-4 py-3 border border-slate-300 rounded-md text-center text-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary" class="input-futuristic w-full text-center text-lg"
onkeydown="if(event.key==='Enter')verifyPassword()"> onkeydown="if(event.key==='Enter')verifyPassword()">
<p id="pwd-error" class="text-red-500 text-sm text-center mt-2 hidden">密码错误,请重试</p> <p id="pwd-error" class="text-red-500 text-sm text-center mt-2 hidden">密码错误,请重试</p>
<button onclick="verifyPassword()" class="w-full mt-4 px-4 py-3 bg-primary text-white rounded-md font-medium hover:bg-blue-700"> <button onclick="verifyPassword()" class="btn-futuristic w-full mt-4">
验证并进入考试 验证并进入考试
</button> </button>
<a href="/exams" class="block text-center mt-3 text-sm text-slate-500 hover:text-slate-700">返回列表</a> <a href="/exams" class="block text-center mt-3 text-sm text-slate-500 hover:text-slate-700">返回列表</a>
@@ -36,27 +36,27 @@
} }
</script> </script>
{% elif existing_submission %} {% elif existing_submission %}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center"> <div class="futuristic-card p-6 text-center">
<p class="text-yellow-800 font-medium">您已提交过该试卷</p> <p class="text-yellow-800 font-medium">您已提交过该试卷</p>
<a href="/exams/{{ exam.id }}/result" class="mt-3 inline-block px-4 py-2 bg-primary text-white rounded-md text-sm">查看结果</a> <a href="/exams/{{ exam.id }}/result" class="mt-3 inline-block btn-futuristic text-sm">查看结果</a>
</div> </div>
{% elif exam.status == 'closed' %} {% elif exam.status == 'closed' %}
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center"> <div class="futuristic-card p-6 text-center">
<p class="text-red-800 font-medium">该考试已关闭</p> <p class="text-red-800 font-medium">该考试已关闭</p>
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a> <a href="/exams" class="mt-3 inline-block btn-outline-futuristic text-sm">返回列表</a>
</div> </div>
{% elif schedule_status == 'not_started' %} {% elif schedule_status == 'not_started' %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 text-center"> <div class="futuristic-card p-6 text-center">
<p class="text-blue-800 font-medium text-xl mb-2">⏰ 考试尚未开始</p> <p class="text-blue-800 font-medium text-xl mb-2 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">⏰ 考试尚未开始</p>
<p class="text-blue-600">预定开始时间:{{ exam.scheduled_start.strftime('%Y-%m-%d %H:%M') }}</p> <p class="text-blue-600">预定开始时间:{{ exam.scheduled_start.strftime('%Y-%m-%d %H:%M') }}</p>
{% if exam.scheduled_end %} {% if exam.scheduled_end %}
<p class="text-blue-600">预定结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p> <p class="text-blue-600">预定结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
{% endif %} {% endif %}
<div class="mt-4"> <div class="mt-4">
<p class="text-sm text-blue-500 mb-2">距离开考还有:</p> <p class="text-sm text-blue-500 mb-2">距离开考还有:</p>
<div id="countdown" class="text-3xl font-bold text-blue-700"></div> <div id="countdown" class="text-3xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"></div>
</div> </div>
<a href="/exams" class="mt-4 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a> <a href="/exams" class="mt-4 inline-block btn-outline-futuristic text-sm">返回列表</a>
</div> </div>
<script> <script>
(function() { (function() {
@@ -79,47 +79,52 @@
})(); })();
</script> </script>
{% elif schedule_status == 'ended' %} {% elif schedule_status == 'ended' %}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 text-center"> <div class="futuristic-card p-6 text-center">
<p class="text-gray-800 font-medium">该考试已结束</p> <p class="text-gray-800 font-medium">该考试已结束</p>
<p class="text-gray-600 text-sm mt-1">结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p> <p class="text-gray-600 text-sm mt-1">结束时间:{{ exam.scheduled_end.strftime('%Y-%m-%d %H:%M') }}</p>
<a href="/exams" class="mt-3 inline-block px-4 py-2 bg-slate-500 text-white rounded-md text-sm">返回列表</a> <a href="/exams" class="mt-3 inline-block btn-outline-futuristic text-sm">返回列表</a>
</div> </div>
{% else %} {% else %}
<!-- 顶部信息栏 --> <!-- 顶部信息栏 -->
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-0 z-20"> <div class="futuristic-card p-4 sticky top-0 z-20 shadow-lg">
<div class="flex justify-between items-center"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div> <div class="flex-1 min-w-0">
<h1 class="text-lg font-bold text-slate-900">{{ exam.title }}</h1> <h1 class="text-lg sm:text-xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent truncate">{{ exam.title }}</h1>
<div class="mt-1 text-sm text-slate-500"> <div class="mt-1 text-xs sm:text-sm text-slate-500 flex flex-wrap gap-2">
{{ exam.subject }} · {{ exam.duration }}分钟 · 满分{{ exam.total_score }}分 <span>{{ exam.subject }}</span>
<span>·</span>
<span>{{ exam.duration }}分钟</span>
<span>·</span>
<span>满分{{ exam.total_score }}分</span>
{% if exam.scheduled_end %} {% if exam.scheduled_end %}
· 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }} <span>·</span>
<span>截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="flex items-center space-x-4"> <div class="flex items-center gap-3 sm:gap-4 flex-wrap">
<div class="text-sm text-slate-500"> <div class="text-xs sm:text-sm text-slate-500 whitespace-nowrap">
<span id="progress-text">0</span>/{{ questions|length }} 已答 <span id="progress-text">0</span>/{{ questions|length }} 已答
</div> </div>
<div class="flex items-center text-red-600 font-medium"> <div class="flex items-center text-red-600 font-medium text-sm sm:text-base whitespace-nowrap">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-4 h-4 sm:w-5 sm:h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span id="timer">--:--:--</span> <span id="timer">--:--:--</span>
</div> </div>
<div id="tab-warning" class="hidden text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded"> <div id="tab-warning" class="hidden text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded whitespace-nowrap">
切屏 <span id="tab-count">0</span> 切屏 <span id="tab-count">0</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 进度条 --> <!-- 进度条 -->
<div class="mt-3 w-full bg-slate-100 rounded-full h-1.5"> <div class="mt-3 progress-futuristic">
<div id="progress-bar" class="bg-primary h-1.5 rounded-full transition-all duration-300" style="width:0%"></div> <div id="progress-bar" class="progress-bar-futuristic transition-all duration-300" style="width:0%"></div>
</div> </div>
</div> </div>
<div class="mt-4 flex gap-4"> <div class="mt-4 flex gap-4">
<!-- 题号导航面板 --> <!-- 题号导航面板 -->
<div class="hidden lg:block w-48 flex-shrink-0"> <div class="hidden lg:block w-48 flex-shrink-0">
<div class="bg-white shadow-sm rounded-lg p-4 border border-slate-200 sticky top-24"> <div class="futuristic-card p-4 sticky top-24">
<div class="text-sm font-medium text-slate-700 mb-3">题目导航</div> <div class="text-sm font-medium text-slate-700 mb-3">题目导航</div>
<div class="grid grid-cols-5 gap-2" id="nav-panel"> <div class="grid grid-cols-5 gap-2" id="nav-panel">
{% for q in questions %} {% for q in questions %}
@@ -141,7 +146,7 @@
<!-- 主答题区 --> <!-- 主答题区 -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<!-- 移动端题号导航 --> <!-- 移动端题号导航 -->
<div class="lg:hidden mb-4 bg-white shadow-sm rounded-lg p-3 border border-slate-200"> <div class="lg:hidden mb-4 futuristic-card p-3">
<div class="flex flex-wrap gap-1.5" id="nav-panel-mobile"> <div class="flex flex-wrap gap-1.5" id="nav-panel-mobile">
{% for q in questions %} {% for q in questions %}
<button onclick="goToQuestion({{ loop.index0 }})" id="nav-m-{{ loop.index0 }}" <button onclick="goToQuestion({{ loop.index0 }})" id="nav-m-{{ loop.index0 }}"
@@ -154,16 +159,14 @@
<form id="exam-form" onsubmit="handleSubmit(event)"> <form id="exam-form" onsubmit="handleSubmit(event)">
{% for q in questions %} {% for q in questions %}
<div class="question-card bg-white shadow-sm rounded-lg p-6 border border-slate-200 mb-4" data-index="{{ loop.index0 }}" style="{% if loop.index0 != 0 %}display:none{% endif %}"> <div class="question-card futuristic-card p-6 mb-4" data-index="{{ loop.index0 }}" style="{% if loop.index0 != 0 %}display:none{% endif %}">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span> <span class="flex-shrink-0 w-8 h-8 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center text-white font-medium">{{ loop.index }}</span>
<div class="flex-1 space-y-4"> <div class="flex-1 space-y-4">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<span class="text-xs font-medium px-2 py-0.5 rounded <span class="badge-futuristic text-xs
{% if q.type == 'choice' %}bg-blue-50 text-blue-700 {% if q.type == 'choice' %}{% elif q.type == 'fill' %}{% else %}{% endif %}">
{% elif q.type == 'fill' %}bg-green-50 text-green-700
{% else %}bg-purple-50 text-purple-700{% endif %}">
{% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %} {% if q.type == 'choice' %}选择题{% elif q.type == 'fill' %}填空题{% else %}解答题{% endif %}
</span> </span>
<p class="mt-2 text-lg text-slate-900">{{ q.content }}</p> <p class="mt-2 text-lg text-slate-900">{{ q.content }}</p>
@@ -182,17 +185,17 @@
{% for opt in q.options %} {% for opt in q.options %}
<label class="flex items-center space-x-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer transition-colors option-label" data-qid="{{ q.id }}"> <label class="flex items-center space-x-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer transition-colors option-label" data-qid="{{ q.id }}">
<input type="radio" name="q-{{ q.id }}" value="{{ ['A','B','C','D'][loop.index0] }}" class="h-4 w-4 text-primary border-slate-300 focus:ring-primary answer-input" data-qid="{{ q.id }}" onchange="onAnswerChange({{ q.id }})"> <input type="radio" name="q-{{ q.id }}" value="{{ ['A','B','C','D'][loop.index0] }}" class="h-4 w-4 text-primary border-slate-300 focus:ring-primary answer-input" data-qid="{{ q.id }}" onchange="onAnswerChange({{ q.id }})">
<span class="text-slate-700">{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}</span> <span class="text-slate-700 option-text">{{ ['A','B','C','D'][loop.index0] }}. {{ opt|safe }}</span>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
{% elif q.type == 'fill' %} {% elif q.type == 'fill' %}
<input type="text" name="q-{{ q.id }}" class="w-full px-3 py-2 border border-slate-300 rounded-md answer-input" placeholder="请输入答案" data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})"> <input type="text" name="q-{{ q.id }}" class="input-futuristic w-full answer-input" placeholder="请输入答案" data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})">
{% else %} {% else %}
<textarea name="q-{{ q.id }}" rows="6" class="w-full rounded-lg border-slate-300 shadow-sm focus:border-primary focus:ring-primary border px-3 py-2 answer-input" placeholder="请输入您的答案..." data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})"></textarea> <textarea name="q-{{ q.id }}" rows="6" class="input-futuristic w-full answer-input" placeholder="请输入您的答案..." data-qid="{{ q.id }}" oninput="onAnswerChange({{ q.id }})"></textarea>
<div class="flex items-center gap-2 mt-2"> <div class="flex items-center gap-2 mt-2">
<button type="button" onclick="examUpload({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📷 上传图片</button> <button type="button" onclick="examUpload({{ q.id }})" class="btn-outline-futuristic inline-flex items-center gap-1 text-xs py-1.5 px-3">📷 上传图片</button>
<button type="button" onclick="examCamera({{ q.id }})" class="inline-flex items-center gap-1 px-3 py-1.5 text-xs border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600">📸 拍照上传</button> <button type="button" onclick="examCamera({{ q.id }})" class="btn-outline-futuristic inline-flex items-center gap-1 text-xs py-1.5 px-3">📸 拍照上传</button>
</div> </div>
<div id="img-preview-{{ q.id }}" class="flex flex-wrap gap-2 mt-2"></div> <div id="img-preview-{{ q.id }}" class="flex flex-wrap gap-2 mt-2"></div>
{% endif %} {% endif %}
@@ -203,16 +206,16 @@
<!-- 分页控制 --> <!-- 分页控制 -->
<div class="flex justify-between items-center mt-4 mb-8"> <div class="flex justify-between items-center mt-4 mb-8">
<button type="button" id="prev-btn" onclick="prevQuestion()" class="px-5 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed" disabled> <button type="button" id="prev-btn" onclick="prevQuestion()" class="btn-outline-futuristic disabled:opacity-40 disabled:cursor-not-allowed" disabled>
← 上一题 ← 上一题
</button> </button>
<span class="text-sm text-slate-500"> <span class="text-sm text-slate-500">
<span id="current-num">1</span> / {{ questions|length }} 题 <span id="current-num">1</span> / {{ questions|length }} 题
</span> </span>
<button type="button" id="next-btn" onclick="nextQuestion()" class="px-5 py-2.5 bg-primary text-white rounded-lg font-medium hover:bg-blue-700"> <button type="button" id="next-btn" onclick="nextQuestion()" class="btn-futuristic">
下一题 → 下一题 →
</button> </button>
<button type="submit" id="submit-btn" class="px-6 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 hidden"> <button type="submit" id="submit-btn" class="btn-futuristic bg-gradient-to-r from-red-500 to-pink-600 hidden">
提交试卷 提交试卷
</button> </button>
</div> </div>
@@ -239,6 +242,7 @@ const SCHEDULED_END = null;
let currentIndex = 0; let currentIndex = 0;
let answers = {}; let answers = {};
let tabSwitchCount = 0; let tabSwitchCount = 0;
let examSubmitted = false; // 提交成功后允许离开
// ===== 图片上传 ===== // ===== 图片上传 =====
function examUpload(qid) { function examUpload(qid) {
@@ -284,7 +288,7 @@ async function examUploadFiles(files, qid) {
function initAnswers() { function initAnswers() {
// 优先从服务器草稿恢复 // 优先从服务器草稿恢复
{% if draft %} {% if draft %}
const serverDraft = {{ draft.answers | tojson }}; const serverDraft = {{ draft.get_answers() | tojson }};
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) { if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
answers = serverDraft; answers = serverDraft;
restoreAnswersToForm(); restoreAnswersToForm();
@@ -503,6 +507,9 @@ function doSubmit() {
body: JSON.stringify({answers}) body: JSON.stringify({answers})
}).then(r => r.json()).then(data => { }).then(r => r.json()).then(data => {
if (data.success) { if (data.success) {
examSubmitted = true;
// 退出考试状态,通知好友
fetch(`/api/exams/${EXAM_ID}/exit`, { method: 'POST' }).catch(() => {});
// 清除本地存储 // 清除本地存储
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TIMER_KEY); localStorage.removeItem(TIMER_KEY);
@@ -520,17 +527,31 @@ function doSubmit() {
}); });
} }
// 进入考试:标记状态并通知好友
fetch(`/api/exams/${EXAM_ID}/enter`, { method: 'POST' }).catch(() => {});
// 禁止中途退出:拦截返回按钮
history.pushState(null, '', location.href);
window.addEventListener('popstate', (e) => {
if (!examSubmitted) {
history.pushState(null, '', location.href);
alert('考试进行中,请勿离开!提交前无法退出。');
}
});
// 离开页面提醒(提交成功后允许离开)
window.addEventListener('beforeunload', (e) => {
if (!examSubmitted) {
e.preventDefault();
e.returnValue = '';
}
});
// 初始化 // 初始化
initAnswers(); initAnswers();
initTimer(); initTimer();
initTabDetection(); initTabDetection();
showQuestion(0); showQuestion(0);
// 离开页面提醒
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = '';
});
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -60,7 +60,7 @@
{% if is_answer %}border-green-300 bg-green-50 {% if is_answer %}border-green-300 bg-green-50
{% elif is_selected and not is_answer %}border-red-300 bg-red-50 {% elif is_selected and not is_answer %}border-red-300 bg-red-50
{% else %}border-slate-100{% endif %}"> {% else %}border-slate-100{% endif %}">
<span class="text-sm text-slate-700">{{ letter }}. {{ opt }}</span> <span class="text-sm text-slate-700 option-text">{{ letter }}. {{ opt|safe }}</span>
{% if is_selected %}<span class="text-xs {% if is_answer %}text-green-600{% else %}text-red-500{% endif %} font-medium">← 考生选择</span>{% endif %} {% if is_selected %}<span class="text-xs {% if is_answer %}text-green-600{% else %}text-red-500{% endif %} font-medium">← 考生选择</span>{% endif %}
{% if is_answer %}<span class="text-xs text-green-600 font-medium">✓ 正确</span>{% endif %} {% if is_answer %}<span class="text-xs text-green-600 font-medium">✓ 正确</span>{% endif %}
</div> </div>
@@ -71,6 +71,18 @@
{% else %} {% else %}
<span class="text-red-500 font-medium">0分</span> <span class="text-red-500 font-medium">0分</span>
{% endif %} {% endif %}
<span class="text-slate-400 ml-2">(可人工修改)</span>
</div>
<div class="flex items-center gap-3 mt-2">
<label class="text-sm text-slate-600">给分:</label>
<input type="number" id="score-{{ q.id }}" min="0" max="{{ q.score }}"
value="{{ question_scores.get(q.id|string, (q.score if answers.get(q.id|string,'') == q.get('answer','') else 0)) }}"
class="grade-score w-20 px-2 py-1 border border-slate-300 rounded text-sm" data-max="{{ q.score }}" data-qid="{{ q.id }}">
<span class="text-sm text-slate-400">/ {{ q.score }}</span>
<div class="flex space-x-1">
<button type="button" onclick="quickScore('{{ q.id }}', 0)" class="px-2 py-0.5 text-xs rounded border border-red-200 text-red-600 hover:bg-red-50">0分</button>
<button type="button" onclick="quickScore('{{ q.id }}', {{ q.score }})" class="px-2 py-0.5 text-xs rounded border border-green-200 text-green-600 hover:bg-green-50">满分</button>
</div>
</div> </div>
</div> </div>
{% else %} {% else %}
@@ -105,6 +117,9 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
{% endfor %}
<div class="flex justify-between items-center bg-white shadow-sm rounded-lg p-6 border border-slate-200 sticky bottom-4"> <div class="flex justify-between items-center bg-white shadow-sm rounded-lg p-6 border border-slate-200 sticky bottom-4">
<div class="text-lg font-medium text-slate-900">总分:<span id="total-score" class="text-primary">0</span> / {{ exam.total_score }}</div> <div class="text-lg font-medium text-slate-900">总分:<span id="total-score" class="text-primary">0</span> / {{ exam.total_score }}</div>
@@ -126,21 +141,7 @@ function quickScore(qid, score) {
function recalcTotal() { function recalcTotal() {
let total = 0; let total = 0;
// 选择题自动得分 // 所有题目得分(客观题预填自动判分,教师可人工修改)
{% for q in questions %}
{% if q.type == 'choice' %}
{% if answers.get(q.id|string,'') == q.get('answer','') %}
total += {{ q.score }};
{% endif %}
{% elif q.type == 'fill' %}
{% set student_ans = answers.get(q.id|string,'').strip() %}
{% set correct_answers = q.get('answer','').split('|') %}
{% if student_ans in correct_answers %}
total += {{ q.score }};
{% endif %}
{% endif %}
{% endfor %}
// 主观题手动得分
document.querySelectorAll('.grade-score').forEach(input => { document.querySelectorAll('.grade-score').forEach(input => {
total += parseInt(input.value) || 0; total += parseInt(input.value) || 0;
}); });
@@ -155,23 +156,8 @@ recalcTotal();
function submitGrade() { function submitGrade() {
const scores = {}; const scores = {};
{% for q in questions %} {% for q in questions %}
{% if q.type == 'choice' %} const inp{{ q.id }} = document.getElementById('score-{{ q.id }}');
{% if answers.get(q.id|string,'') == q.get('answer','') %} scores['{{ q.id }}'] = inp{{ q.id }} ? (parseInt(inp{{ q.id }}.value) || 0) : 0;
scores['{{ q.id }}'] = {{ q.score }};
{% else %}
scores['{{ q.id }}'] = 0;
{% endif %}
{% elif q.type == 'fill' %}
{% set student_ans = answers.get(q.id|string,'').strip() %}
{% set correct_answers = q.get('answer','').split('|') %}
{% if student_ans in correct_answers %}
scores['{{ q.id }}'] = {{ q.score }};
{% else %}
scores['{{ q.id }}'] = 0;
{% endif %}
{% else %}
scores['{{ q.id }}'] = parseInt(document.getElementById('score-{{ q.id }}').value) || 0;
{% endif %}
{% endfor %} {% endfor %}
fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', { fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},

View File

@@ -5,17 +5,17 @@
{% block content %} {% block content %}
<div class="space-y-8"> <div class="space-y-8">
<!-- 头部区域 --> <!-- 头部区域 -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-white p-6 rounded-2xl shadow-sm border border-slate-100"> <div class="futuristic-card flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-6">
<div> <div>
<h1 class="text-2xl font-bold text-slate-900 flex items-center"> <h1 class="text-2xl font-bold flex items-center bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600 bg-clip-text text-transparent">
<span class="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mr-3 shadow-sm border border-indigo-100">📝</span> <span class="w-10 h-10 bg-gradient-to-br from-cyan-500 to-blue-600 text-white rounded-xl flex items-center justify-center mr-3 shadow-lg shadow-cyan-500/50">📝</span>
考试中心 考试中心
</h1> </h1>
<p class="text-slate-500 text-sm mt-1 ml-13">海量真题与模拟卷,随时随地进行练习与自测。</p> <p class="text-slate-400 text-sm mt-1 ml-13">海量真题与模拟卷,随时随地进行练习与自测。</p>
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
{% if user and (user.role == 'admin' or user.role == 'teacher') %} {% if user and (user.role == 'admin' or user.role == 'teacher') %}
<a href="/exams/create" class="inline-flex items-center px-4 py-2.5 bg-primary text-white rounded-xl hover:bg-blue-600 shadow-sm text-sm font-medium transition-all transform hover:-translate-y-0.5"> <a href="/exams/create" class="btn-futuristic inline-flex items-center px-4 py-2.5 text-sm font-medium">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
创建新试卷 创建新试卷
</a> </a>
@@ -24,54 +24,54 @@
</div> </div>
<!-- 搜索区域 --> <!-- 搜索区域 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10 glass-panel"> <div class="futuristic-card p-5 flex flex-wrap gap-4 items-center justify-between sticky top-20 z-10">
<form method="GET" action="/exams" class="flex flex-wrap items-center gap-3 w-full"> <form method="GET" action="/exams" class="flex flex-wrap items-center gap-3 w-full">
<div class="relative w-full sm:w-auto flex-1"> <div class="relative w-full sm:w-auto flex-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg class="h-4 w-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div> </div>
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="搜索试卷名称..." class="w-full pl-10 pr-4 py-2.5 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all"> <input type="text" name="q" value="{{ search_query or '' }}" placeholder="搜索试卷名称..." class="w-full pl-10 pr-4 py-2.5 border border-cyan-500/30 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 bg-slate-900/50 text-slate-200 placeholder-slate-500 transition-all">
</div> </div>
<div class="relative w-full sm:w-auto"> <div class="relative w-full sm:w-auto">
<select name="subject" class="w-full sm:w-32 appearance-none px-4 py-2.5 pl-10 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer"> <select name="subject" class="w-full sm:w-32 appearance-none px-4 py-2.5 pl-10 border border-cyan-500/30 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500 bg-slate-900/50 text-slate-200 hover:bg-slate-800/50 transition-colors cursor-pointer">
<option value="">所有科目</option> <option value="">所有科目</option>
{% for subject in all_subjects %} {% for subject in all_subjects %}
<option value="{{ subject }}" {% if subject_filter == subject %}selected{% endif %}>{{ subject }}</option> <option value="{{ subject }}" {% if subject_filter == subject %}selected{% endif %}>{{ subject }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/></svg> <svg class="h-4 w-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/></svg>
</div> </div>
<div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg> <svg class="h-4 w-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div> </div>
</div> </div>
<button type="submit" class="w-full sm:w-auto px-5 py-2.5 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm"> <button type="submit" class="btn-futuristic w-full sm:w-auto px-5 py-2.5 text-sm font-medium">
搜索 搜索
</button> </button>
{% if search_query or subject_filter %} {% if search_query or subject_filter %}
<a href="/exams" class="w-full sm:w-auto px-5 py-2.5 bg-slate-100 text-slate-600 rounded-xl hover:bg-slate-200 text-sm font-medium transition-colors text-center border border-slate-200"> <a href="/exams" class="w-full sm:w-auto px-5 py-2.5 bg-slate-800/50 text-slate-300 rounded-xl hover:bg-slate-700/50 text-sm font-medium transition-all text-center border border-cyan-500/30 hover:border-cyan-500/50">
重置 重置
</a> </a>
{% endif %} {% endif %}
</form> </form>
{% if search_query or subject_filter %} {% if search_query or subject_filter %}
<div class="mt-3 w-full text-xs text-slate-500 bg-slate-50 px-3 py-2 rounded-lg border border-slate-100 inline-block"> <div class="mt-3 w-full text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg border border-cyan-500/20 inline-block">
<span class="font-medium text-slate-700">筛选结果:</span> <span class="font-medium text-cyan-400">筛选结果:</span>
{% if search_query %}包含 "<span class="text-primary">{{ search_query }}</span>"{% endif %} {% if search_query %}包含 "<span class="text-cyan-300">{{ search_query }}</span>"{% endif %}
{% if subject_filter %}{% if search_query %}{% endif %}科目为 "<span class="text-primary">{{ subject_filter }}</span>"{% endif %} {% if subject_filter %}{% if search_query %}{% endif %}科目为 "<span class="text-cyan-300">{{ subject_filter }}</span>"{% endif %}
<span class="ml-2 text-slate-400">共找到 {{ exams|length }} 份试卷</span> <span class="ml-2 text-slate-500">共找到 {{ exams|length }} 份试卷</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- 试卷列表 --> <!-- 试卷列表 -->
{% if exams|length == 0 %} {% if exams|length == 0 %}
<div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400 bg-white rounded-2xl border border-slate-100 border-dashed"> <div class="col-span-full py-20 flex flex-col items-center justify-center text-slate-400 futuristic-card border-dashed">
<svg class="w-16 h-16 mb-4 text-slate-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg class="w-16 h-16 mb-4 text-cyan-500/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<p>暂无符合条件的试卷</p> <p>暂无符合条件的试卷</p>
{% if user and (user.role == 'admin' or user.role == 'teacher') %} {% if user and (user.role == 'admin' or user.role == 'teacher') %}
<p class="text-sm mt-2">点击上方"创建新试卷"按钮开始命题吧</p> <p class="text-sm mt-2">点击上方"创建新试卷"按钮开始命题吧</p>
@@ -81,130 +81,132 @@
<div class="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{% for exam in exams %} {% for exam in exams %}
{% set subjectColor = 'blue' %} {% set subjectColor = 'blue' %}
{% set subjectGradient = 'from-blue-500 to-cyan-400' %} {% set subjectGradient = 'from-cyan-500 to-blue-600' %}
{% 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' %} {% 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 == '数学' %} {% if exam.subject == '数学' %}
{% set subjectColor = 'indigo' %} {% set subjectColor = 'indigo' %}
{% set subjectGradient = 'from-indigo-500 to-purple-400' %} {% set subjectGradient = 'from-purple-500 to-indigo-600' %}
{% 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' %} {% 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 == '英语' %} {% elif exam.subject == '英语' %}
{% set subjectColor = 'rose' %} {% set subjectColor = 'rose' %}
{% set subjectGradient = 'from-rose-500 to-pink-400' %} {% set subjectGradient = 'from-pink-500 to-rose-600' %}
{% 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' %} {% 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 ['物理', '化学', '生物'] %} {% elif exam.subject in ['物理', '化学', '生物'] %}
{% set subjectColor = 'emerald' %} {% set subjectColor = 'emerald' %}
{% set subjectGradient = 'from-emerald-500 to-teal-400' %} {% set subjectGradient = 'from-emerald-500 to-teal-600' %}
{% 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' %} {% 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 ['历史', '地理', '政治'] %} {% elif exam.subject in ['历史', '地理', '政治'] %}
{% set subjectColor = 'amber' %} {% set subjectColor = 'amber' %}
{% set subjectGradient = 'from-amber-500 to-orange-400' %} {% set subjectGradient = 'from-amber-500 to-orange-600' %}
{% 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' %} {% 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 %} {% endif %}
<div class="group bg-white rounded-3xl shadow-sm border border-slate-100 overflow-hidden hover-card-up flex flex-col relative"> <div class="futuristic-card-dark group hover-lift flex flex-col relative overflow-hidden transition-all duration-300 transform hover:scale-105">
<!-- 卡片封面海报 --> <!-- 卡片封面海报 -->
<div class="relative h-28 bg-gradient-to-r {{ subjectGradient }} overflow-hidden"> <div class="relative h-28 bg-gradient-to-r {{ subjectGradient }} overflow-hidden">
<div class="absolute inset-0 bg-grid-pattern opacity-10"></div> <div class="absolute inset-0 bg-grid-pattern opacity-10"></div>
<div class="absolute -bottom-10 -right-10 w-32 h-32 bg-white/20 blur-2xl rounded-full"></div> <div class="absolute -bottom-10 -right-10 w-32 h-32 bg-white/10 blur-2xl rounded-full"></div>
{% if exam.status == 'closed' %} {% if exam.status == 'closed' %}
<span class="absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-white text-xs font-medium px-3 py-1 rounded-full shadow-sm border border-white/10">已关闭</span> <span class="badge-futuristic absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-slate-300 border-slate-700/50">已关闭</span>
{% elif exam.scheduled_end and now and now > exam.scheduled_end %}
<span class="badge-futuristic absolute top-4 right-4 bg-slate-900/50 backdrop-blur-md text-slate-300 border-slate-700/50">已结束</span>
{% else %} {% else %}
<span class="absolute top-4 right-4 bg-white/90 backdrop-blur-md text-{{subjectColor}}-600 text-xs font-bold px-3 py-1 rounded-full shadow-sm flex items-center"> <span class="badge-futuristic absolute top-4 right-4 bg-white/90 backdrop-blur-md text-cyan-600 border-cyan-500/30">
<span class="w-1.5 h-1.5 rounded-full bg-{{subjectColor}}-500 mr-1.5 animate-pulse"></span>进行中 <span class="w-1.5 h-1.5 rounded-full bg-cyan-500 mr-1.5 animate-pulse"></span>进行中
</span> </span>
{% endif %} {% endif %}
</div> </div>
<!-- 悬浮图标 --> <!-- 悬浮图标 -->
<div class="absolute top-[4.5rem] left-6"> <div class="absolute top-[4.5rem] left-6">
<div class="w-14 h-14 bg-white rounded-2xl flex items-center justify-center text-{{subjectColor}}-500 shadow-md border-4 border-white group-hover:scale-110 transition-transform duration-300"> <div class="w-14 h-14 bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl flex items-center justify-center text-cyan-400 shadow-lg shadow-cyan-500/30 border-2 border-cyan-500/30 group-hover:scale-110 group-hover:shadow-cyan-500/50 transition-all duration-300">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{subjectIcon}}"/></svg> <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{subjectIcon}}"/></svg>
</div> </div>
</div> </div>
<div class="p-6 pt-12 flex-1 flex flex-col"> <div class="p-6 pt-12 flex-1 flex flex-col">
<div class="flex items-start mb-3"> <div class="flex items-start mb-3">
<span class="inline-flex items-center px-2.5 py-1 rounded-lg bg-slate-100 text-slate-600 text-xs font-medium border border-slate-200"> <span class="badge-futuristic">
{{ exam.subject }} {{ exam.subject }}
</span> </span>
</div> </div>
<h3 class="text-lg font-bold text-slate-900 mb-3 line-clamp-2 group-hover:text-{{subjectColor}}-600 transition-colors flex-1">{{ exam.title }}</h3> <h3 class="text-lg font-bold mb-3 line-clamp-2 bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent group-hover:from-blue-300 group-hover:via-purple-300 group-hover:to-pink-300 transition-all flex-1">{{ exam.title }}</h3>
<div class="grid grid-cols-2 gap-3 mb-5 bg-slate-50 p-3 rounded-xl border border-slate-100"> <div class="grid grid-cols-2 gap-3 mb-5 bg-slate-900/30 p-3 rounded-xl border border-cyan-500/20">
<div> <div>
<span class="text-[10px] text-slate-400 block mb-0.5">满分 / 题目</span> <span class="text-[10px] text-slate-500 block mb-0.5">满分 / 题目</span>
<span class="text-xs font-semibold text-slate-700">{{ exam.total_score }}分 / {{ exam.questions|fromjson|length }}题</span> <span class="text-xs font-semibold text-cyan-300">{{ exam.total_score }}分 / {{ exam.questions|fromjson|length }}题</span>
</div> </div>
<div> <div>
<span class="text-[10px] text-slate-400 block mb-0.5">考试时长</span> <span class="text-[10px] text-slate-500 block mb-0.5">考试时长</span>
<span class="text-xs font-semibold text-slate-700">{{ exam.duration }} 分钟</span> <span class="text-xs font-semibold text-cyan-300">{{ exam.duration }} 分钟</span>
</div> </div>
</div> </div>
{% if exam.scheduled_start or exam.scheduled_end %} {% if exam.scheduled_start or exam.scheduled_end %}
<div class="mb-4 text-xs text-slate-500 flex flex-col gap-1 border-l-2 border-{{subjectColor}}-200 pl-2"> <div class="mb-4 text-xs text-slate-400 flex flex-col gap-1 border-l-2 border-cyan-500/50 pl-2">
{% if exam.scheduled_start %} {% if exam.scheduled_start %}
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-3.5 h-3.5 mr-1 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
开始:{{ exam.scheduled_start.strftime('%m-%d %H:%M') }} 开始:{{ exam.scheduled_start.strftime('%m-%d %H:%M') }}
</div> </div>
{% endif %} {% endif %}
{% if exam.scheduled_end %} {% if exam.scheduled_end %}
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-3.5 h-3.5 mr-1 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-3.5 h-3.5 mr-1 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }} 截止:{{ exam.scheduled_end.strftime('%m-%d %H:%M') }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<div class="mt-auto pt-4 border-t border-slate-100 flex items-center justify-between gap-2 flex-wrap"> <div class="mt-auto pt-4 border-t border-cyan-500/20 flex items-center justify-between gap-2 flex-wrap">
{% set sub = user_submissions.get(exam.id) %} {% set sub = user_submissions.get(exam.id) %}
{% if sub %} {% if sub %}
<div class="flex items-center bg-slate-50 px-2.5 py-1.5 rounded-lg border border-slate-200"> <div class="flex items-center bg-slate-900/30 px-2.5 py-1.5 rounded-lg border border-cyan-500/30">
{% if sub.graded %} {% if sub.graded %}
<span class="text-xs font-medium text-slate-600 mr-2 border-r border-slate-200 pr-2">已批改</span> <span class="text-xs font-medium text-slate-400 mr-2 border-r border-slate-600 pr-2">已批改</span>
<span class="text-sm font-bold text-{{subjectColor}}-600">{{ sub.score }} <span class="text-[10px] text-slate-400 font-normal">/ {{ exam.total_score }}</span></span> <span class="text-sm font-bold text-cyan-400">{{ sub.score }} <span class="text-[10px] text-slate-500 font-normal">/ {{ exam.total_score }}</span></span>
{% else %} {% else %}
<span class="text-xs font-medium text-amber-600 flex items-center"> <span class="text-xs font-medium text-amber-400 flex items-center">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
待批改 待批改
</span> </span>
{% endif %} {% endif %}
</div> </div>
<a href="/exams/{{ exam.id }}/result" class="ml-auto inline-flex items-center px-4 py-2 border border-slate-200 text-xs font-medium rounded-xl text-slate-700 bg-white hover:bg-slate-50 hover:border-slate-300 transition-colors shadow-sm"> <a href="/exams/{{ exam.id }}/result" class="btn-outline-futuristic ml-auto inline-flex items-center px-4 py-2 text-xs font-medium">
查看试卷 查看试卷
</a> </a>
{% else %} {% else %}
<div class="text-xs text-slate-400 flex items-center"> <div class="text-xs text-slate-400 flex items-center">
<span class="w-5 h-5 rounded-full bg-slate-100 flex items-center justify-center mr-1.5 text-[10px]">{{ exam.creator.name[0] if exam.creator else '?' }}</span> <span class="w-5 h-5 rounded-full bg-slate-800 flex items-center justify-center mr-1.5 text-[10px] text-cyan-400 border border-cyan-500/30">{{ exam.creator.name[0] if exam.creator else '?' }}</span>
{{ exam.creator.name if exam.creator else '未知出题人' }} {{ exam.creator.name if exam.creator else '未知出题人' }}
</div> </div>
{% if exam.status != 'closed' %} {% if exam.status != 'closed' %}
<a href="/exams/{{ exam.id }}" class="ml-auto inline-flex items-center px-5 py-2 border border-transparent text-sm font-medium rounded-xl shadow-sm text-white bg-{{subjectColor}}-600 hover:bg-{{subjectColor}}-700 transition-colors transform hover:-translate-y-0.5"> <a href="/exams/{{ exam.id }}" class="btn-futuristic ml-auto inline-flex items-center px-5 py-2 text-sm font-medium">
开始考试 开始考试
</a> </a>
{% else %} {% else %}
<span class="ml-auto text-sm text-slate-400 font-medium px-4 py-2 bg-slate-50 rounded-xl">已关闭</span> <span class="ml-auto text-sm text-slate-500 font-medium px-4 py-2 bg-slate-800/50 rounded-xl border border-slate-700/50">已关闭</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if user and (user.role == 'admin' or user.role == 'teacher') %} {% if user and can_grade_exam(user, exam) %}
<div class="mt-4 pt-3 border-t border-slate-100/50 flex flex-wrap gap-2 justify-end"> <div class="mt-4 pt-3 border-t border-cyan-500/20 flex flex-wrap gap-2 justify-end">
<a href="/exams/{{ exam.id }}/submissions" class="px-3 py-1.5 bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 rounded-lg text-xs font-medium transition-colors border border-slate-200">提交情况</a> <a href="/exams/{{ exam.id }}/submissions" class="px-3 py-1.5 bg-slate-800/50 text-slate-300 hover:bg-slate-700/50 hover:text-slate-200 rounded-lg text-xs font-medium transition-colors border border-cyan-500/30">提交情况</a>
<a href="/exams/{{ exam.id }}/print" class="px-3 py-1.5 bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 rounded-lg text-xs font-medium transition-colors border border-slate-200">打印试卷</a> <a href="/exams/{{ exam.id }}/print" class="px-3 py-1.5 bg-slate-800/50 text-slate-300 hover:bg-slate-700/50 hover:text-slate-200 rounded-lg text-xs font-medium transition-colors border border-cyan-500/30">打印试卷</a>
{% if user.role == 'admin' or exam.creator_id == user.id %} {% if user.role == 'admin' or exam.creator_id == user.id or (exam.contest_id and exam.contest_id in cup_owner_contest_ids) %}
{% if exam.status == 'available' %} {% if exam.status == 'available' %}
<button onclick="toggleExamStatus({{ exam.id }}, 'closed')" class="px-3 py-1.5 bg-orange-50 text-orange-600 hover:bg-orange-100 rounded-lg text-xs font-medium transition-colors border border-orange-200">关闭考试</button> <button onclick="toggleExamStatus({{ exam.id }}, 'closed')" class="px-3 py-1.5 bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 rounded-lg text-xs font-medium transition-colors border border-orange-500/30">关闭考试</button>
{% else %} {% else %}
<button onclick="toggleExamStatus({{ exam.id }}, 'available')" class="px-3 py-1.5 bg-green-50 text-green-600 hover:bg-green-100 rounded-lg text-xs font-medium transition-colors border border-green-200">开放考试</button> <button onclick="toggleExamStatus({{ exam.id }}, 'available')" class="px-3 py-1.5 bg-green-500/20 text-green-400 hover:bg-green-500/30 rounded-lg text-xs font-medium transition-colors border border-green-500/30">开放考试</button>
{% endif %} {% endif %}
<button onclick="deleteExam({{ exam.id }})" class="px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-lg text-xs font-medium transition-colors border border-red-200">删除</button> <button onclick="deleteExam({{ exam.id }})" class="px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-xs font-medium transition-colors border border-red-500/30">删除</button>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -25,7 +25,14 @@
.footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; border-top: 1px solid #ccc; padding-top: 10px; } .footer { text-align: center; margin-top: 30px; font-size: 12px; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }
.print-btn { position: fixed; top: 20px; right: 20px; padding: 10px 24px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; z-index: 100; } .print-btn { position: fixed; top: 20px; right: 20px; padding: 10px 24px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; z-index: 100; }
.print-btn:hover { background: #2563eb; } .print-btn:hover { background: #2563eb; }
.q-images { margin: 8px 0 4px 25px; }
.q-images img { max-height: 200px; max-width: 100%; border: 1px solid #ccc; margin: 4px 4px 4px 0; }
</style> </style>
<!-- KaTeX 数学公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
</head> </head>
<body> <body>
<button class="print-btn no-print" onclick="window.print()">打印 / 导出PDF</button> <button class="print-btn no-print" onclick="window.print()">打印 / 导出PDF</button>
@@ -61,10 +68,17 @@
<div class="question"> <div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }} <span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span> <span class="q-score">{{ q.score }}分)</span>
{% if q.get('images') %}
<div class="q-images">
{% for img in q.images %}
<img src="{{ img }}" alt="题目图片">
{% endfor %}
</div>
{% endif %}
{% if q.options %} {% if q.options %}
<div class="options"> <div class="options">
{% for opt in q.options %} {% for opt in q.options %}
<div class="opt">{{ ['A','B','C','D'][loop.index0] }}. {{ opt }}</div> <div class="opt">{{ ['A','B','C','D'][loop.index0] }}. {{ opt|safe }}</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -78,6 +92,13 @@
<div class="question"> <div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }} <span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span> <span class="q-score">{{ q.score }}分)</span>
{% if q.get('images') %}
<div class="q-images">
{% for img in q.images %}
<img src="{{ img }}" alt="题目图片">
{% endfor %}
</div>
{% endif %}
<div class="answer-area"> <div class="answer-area">
<div class="answer-line"></div> <div class="answer-line"></div>
</div> </div>
@@ -91,6 +112,13 @@
<div class="question"> <div class="question">
<span class="q-num">{{ loop.index }}.</span> {{ q.content }} <span class="q-num">{{ loop.index }}.</span> {{ q.content }}
<span class="q-score">{{ q.score }}分)</span> <span class="q-score">{{ q.score }}分)</span>
{% if q.get('images') %}
<div class="q-images">
{% for img in q.images %}
<img src="{{ img }}" alt="题目图片">
{% endfor %}
</div>
{% endif %}
<div class="answer-area"> <div class="answer-area">
{% for i in range(8) %} {% for i in range(8) %}
<div class="answer-line"></div> <div class="answer-line"></div>

View File

@@ -2,43 +2,43 @@
{% block title %}考试结果 - 智联青云{% endblock %} {% block title %}考试结果 - 智联青云{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<div class="flex justify-between items-center"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<h1 class="text-2xl font-bold text-slate-900">考试结果</h1> <h1 class="text-xl sm:text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">考试结果</h1>
<a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a> <a href="/exams" class="text-sm text-slate-500 hover:text-slate-700">← 返回列表</a>
</div> </div>
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-4 sm:p-6">
<h2 class="text-xl font-bold text-slate-900">{{ exam.title }}</h2> <h2 class="text-lg sm:text-xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent break-words">{{ exam.title }}</h2>
<div class="mt-2 flex items-center text-sm text-slate-500 space-x-4"> <div class="mt-2 flex flex-wrap items-center text-xs sm:text-sm text-slate-500 gap-2 sm:gap-4">
<span>{{ exam.subject }}</span> <span>{{ exam.subject }}</span>
<span>满分{{ exam.total_score }}分</span> <span>满分{{ exam.total_score }}分</span>
<span>提交时间:{{ submission.submitted_at }}</span> <span class="break-all">提交时间:{{ submission.submitted_at }}</span>
</div> </div>
<div class="mt-4 p-4 rounded-lg {% if score_hidden %}bg-blue-50 border border-blue-200{% elif submission.graded %}bg-green-50 border border-green-200{% else %}bg-yellow-50 border border-yellow-200{% endif %}"> <div class="mt-4 p-4 rounded-lg {% if score_hidden %}bg-blue-50 border border-blue-200{% elif submission.graded %}bg-green-50 border border-green-200{% else %}bg-yellow-50 border border-yellow-200{% endif %}">
{% if score_hidden %} {% if score_hidden %}
<div class="text-lg font-medium text-blue-700">成绩尚未公布</div> <div class="text-base sm:text-lg font-medium text-blue-700">成绩尚未公布</div>
<div class="text-sm text-blue-600 mt-1">成绩将于 {{ exam.score_release_time.strftime('%Y年%m月%d日 %H:%M') }} 公布</div> <div class="text-xs sm:text-sm text-blue-600 mt-1 break-words">成绩将于 {{ exam.score_release_time.strftime('%Y年%m月%d日 %H:%M') }} 公布</div>
{% elif submission.graded %} {% elif submission.graded %}
<div class="text-3xl font-bold text-green-700">{{ submission.score }} <span class="text-lg font-normal text-green-600">/ {{ exam.total_score }}</span></div> <div class="text-2xl sm:text-3xl font-bold text-green-700">{{ submission.score }} <span class="text-base sm:text-lg font-normal text-green-600">/ {{ exam.total_score }}</span></div>
<div class="text-sm text-green-600 mt-1">批改人:{{ submission.graded_by }}</div> <div class="text-xs sm:text-sm text-green-600 mt-1">批改人:{{ submission.graded_by }}</div>
{% else %} {% else %}
<div class="text-lg font-medium text-yellow-700">待批改</div> <div class="text-base sm:text-lg font-medium text-yellow-700">待批改</div>
<div class="text-sm text-yellow-600">选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改</div> <div class="text-xs sm:text-sm text-yellow-600 break-words">选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% for q in questions %} {% for q in questions %}
<div class="bg-white shadow-sm rounded-lg p-6 border border-slate-200"> <div class="futuristic-card p-4 sm:p-6">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<span class="flex-shrink-0 w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 font-medium">{{ loop.index }}</span> <span class="flex-shrink-0 w-7 h-7 sm:w-8 sm:h-8 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center text-white font-medium text-sm">{{ loop.index }}</span>
<div class="flex-1 space-y-3"> <div class="flex-1 space-y-3 min-w-0">
<div class="flex justify-between"> <div class="flex flex-col sm:flex-row sm:justify-between gap-2">
<p class="text-lg text-slate-900">{{ q.content }}</p> <p class="text-base sm:text-lg text-slate-900 break-words">{{ q.content }}</p>
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">{{ q.score }}分)</span> <span class="text-xs sm:text-sm text-slate-400 whitespace-nowrap">{{ q.score }}分)</span>
</div> </div>
{% if q.get('images') %} {% if q.get('images') %}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% for img in q.images %} {% for img in q.images %}
<img src="{{ img }}" class="max-h-48 rounded border border-slate-200 cursor-pointer" onclick="window.open(this.src)" alt="题目图片"> <img src="{{ img }}" class="max-h-32 sm:max-h-48 rounded border border-slate-200 cursor-pointer max-w-full" onclick="window.open(this.src)" alt="题目图片">
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -57,8 +57,8 @@
{% else %} {% else %}
<div class="w-5 h-5"></div> <div class="w-5 h-5"></div>
{% endif %} {% endif %}
<span class="text-slate-700">{{ letter }}. {{ opt }}</span> <span class="text-slate-700 option-text">{{ letter }}. {{ opt|safe }}</span>
{% if is_answer %}<span class="text-xs text-green-600 font-medium ml-2">✓ 正确答案</span>{% endif %} {% if is_answer %}<span class="badge-futuristic text-xs ml-2">✓ 正确答案</span>{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -86,3 +86,14 @@
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
// 渲染页面中的数学公式
if (typeof renderMathInElement === 'function') {
renderMathInElement(document.body, {
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
throwOnError: false
});
}
</script>
{% endblock %}

View File

@@ -7,9 +7,6 @@
.toast{position:fixed;top:20px;right:20px;z-index:9999;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;animation:slideIn .3s ease;box-shadow:0 4px 12px rgba(0,0,0,.15)} .toast{position:fixed;top:20px;right:20px;z-index:9999;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;animation:slideIn .3s ease;box-shadow:0 4px 12px rgba(0,0,0,.15)}
.toast-success{background:#10b981}.toast-error{background:#ef4444}.toast-info{background:#3b82f6} .toast-success{background:#10b981}.toast-error{background:#ef4444}.toast-info{background:#3b82f6}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}} @keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.reaction-btn{transition:all .2s;cursor:pointer;padding:2px 8px;border-radius:20px;border:1px solid #e2e8f0;font-size:13px;display:inline-flex;align-items:center;gap:3px}
.reaction-btn:hover{transform:scale(1.15);border-color:#93c5fd;background:#eff6ff}
.reaction-btn.active{background:#dbeafe;border-color:#60a5fa}
.poll-bar{height:28px;border-radius:6px;background:#f1f5f9;overflow:hidden;position:relative;margin:4px 0} .poll-bar{height:28px;border-radius:6px;background:#f1f5f9;overflow:hidden;position:relative;margin:4px 0}
.poll-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,#3b82f6,#60a5fa);transition:width .6s ease;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#fff;font-weight:500;min-width:fit-content} .poll-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,#3b82f6,#60a5fa);transition:width .6s ease;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#fff;font-weight:500;min-width:fit-content}
.poll-bar.voted .poll-fill{background:linear-gradient(90deg,#10b981,#34d399)} .poll-bar.voted .poll-fill{background:linear-gradient(90deg,#10b981,#34d399)}
@@ -44,47 +41,47 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- 统计卡片 --> <!-- 统计卡片 -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group"> <div class="futuristic-card p-5 relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div> <div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10"> <div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-posts">0</div> <div class="text-3xl font-bold mb-1 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent" id="s-posts">0</div>
<div class="text-sm text-blue-100 flex items-center"> <div class="text-sm text-slate-600 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
总帖子数 总帖子数
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group"> <div class="futuristic-card p-5 relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div> <div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10"> <div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-replies">0</div> <div class="text-3xl font-bold mb-1 bg-gradient-to-r from-emerald-600 to-green-600 bg-clip-text text-transparent" id="s-replies">0</div>
<div class="text-sm text-emerald-100 flex items-center"> <div class="text-sm text-slate-600 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
总回复数 总回复数
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-orange-500 to-orange-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group"> <div class="futuristic-card p-5 relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div> <div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10"> <div class="relative z-10">
<div class="text-3xl font-bold mb-1" id="s-today">0</div> <div class="text-3xl font-bold mb-1 bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent" id="s-today">0</div>
<div class="text-sm text-orange-100 flex items-center"> <div class="text-sm text-slate-600 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
今日新帖 今日新帖
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gradient-to-br from-violet-500 to-violet-600 rounded-2xl p-5 text-white shadow-sm relative overflow-hidden group"> <div class="futuristic-card p-5 relative overflow-hidden group">
<div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div> <div class="absolute -right-4 -top-4 w-24 h-24 bg-white/10 rounded-full blur-xl group-hover:bg-white/20 transition-colors"></div>
<div class="relative z-10"> <div class="relative z-10">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<span class="relative flex h-3 w-3 mr-2"> <span class="relative flex h-3 w-3 mr-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-500 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-white"></span> <span class="relative inline-flex rounded-full h-3 w-3 bg-purple-600"></span>
</span> </span>
<div class="text-3xl font-bold" id="s-online">0</div> <div class="text-3xl font-bold bg-gradient-to-r from-violet-600 to-purple-600 bg-clip-text text-transparent" id="s-online">0</div>
</div> </div>
<div class="text-sm text-violet-100 flex items-center"> <div class="text-sm text-slate-600 flex items-center">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
当前在线人数 当前在线人数
</div> </div>
@@ -93,37 +90,37 @@
</div> </div>
<!-- 头部操作区: 高级毛玻璃渐变 --> <!-- 头部操作区: 高级毛玻璃渐变 -->
<div class="relative flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-gradient-to-r from-indigo-600 to-purple-600 p-8 rounded-3xl shadow-xl border border-indigo-500 overflow-hidden text-white"> <div class="futuristic-card relative flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 p-8 overflow-hidden">
<div class="absolute top-0 right-0 w-64 h-64 bg-white/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div> <div class="absolute top-0 right-0 w-64 h-64 bg-purple-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
<div class="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-500/20 blur-2xl rounded-full"></div> <div class="absolute -bottom-10 -left-10 w-40 h-40 bg-blue-500/10 blur-2xl rounded-full"></div>
<div class="relative z-10"> <div class="relative z-10">
<h1 class="text-3xl font-extrabold flex items-center drop-shadow-md"> <h1 class="text-3xl font-extrabold flex items-center bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
<span class="w-12 h-12 bg-white/20 backdrop-blur-md text-white rounded-2xl flex items-center justify-center mr-4 shadow-inner border border-white/20">💬</span> <span class="w-12 h-12 bg-gradient-to-br from-purple-500 to-blue-500 text-white rounded-2xl flex items-center justify-center mr-4 shadow-lg">💬</span>
社区论坛 社区论坛
</h1> </h1>
<p class="text-indigo-100 text-base mt-2 ml-16 opacity-90">分享经验、交流问题、结识志同道合的学习伙伴。</p> <p class="text-slate-600 text-base mt-2 ml-16">分享经验、交流问题、结识志同道合的学习伙伴。</p>
</div> </div>
<div class="relative z-10 flex flex-wrap items-center gap-3"> <div class="relative z-10 flex flex-wrap items-center gap-3">
{% if user %} {% if user %}
<button onclick="showLeaderboard()" class="p-3 bg-white/10 hover:bg-white/20 text-white backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-white/40 shadow-lg transform hover:-translate-y-1" title="排行榜"> <button onclick="showLeaderboard()" class="btn-outline-futuristic p-3" title="排行榜">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</button> </button>
<button onclick="showBookmarks()" class="p-3 bg-white/10 hover:bg-white/20 text-yellow-300 backdrop-blur-md rounded-2xl transition-all border border-white/20 hover:border-yellow-300/50 shadow-lg transform hover:-translate-y-1" title="我的收藏"> <button onclick="showBookmarks()" class="btn-outline-futuristic p-3" title="我的收藏">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</button> </button>
<button onclick="openNewPost()" class="flex items-center gap-2 px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105"> <button onclick="openNewPost()" class="btn-futuristic flex items-center gap-2 text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
发布新帖 发布新帖
</button> </button>
{% else %} {% else %}
<a href="/login" class="px-6 py-3 bg-white text-indigo-600 rounded-2xl hover:bg-indigo-50 text-sm font-bold shadow-xl transition-all transform hover:-translate-y-1 hover:scale-105">登录后发帖</a> <a href="/login" class="btn-futuristic text-sm">登录后发帖</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- 搜索和分类 --> <!-- 搜索和分类 -->
<div class="bg-white p-5 rounded-2xl shadow-sm border border-slate-100 sticky top-20 z-10 glass-panel"> <div class="futuristic-card p-5 sticky top-20 z-10">
<div class="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center"> <div class="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center">
<!-- 分类标签 --> <!-- 分类标签 -->
<div class="flex flex-wrap gap-2" id="tab-nav"> <div class="flex flex-wrap gap-2" id="tab-nav">
@@ -141,10 +138,10 @@
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> <svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div> </div>
<input type="text" id="search-input" placeholder="搜索帖子内容..." class="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 transition-all" onkeydown="if(event.key==='Enter')loadPosts()"> <input type="text" id="search-input" placeholder="搜索帖子内容..." class="input-futuristic w-full pl-10 pr-4 py-2 text-sm" onkeydown="if(event.key==='Enter')loadPosts()">
</div> </div>
<div class="relative"> <div class="relative">
<select id="sort-select" onchange="loadPosts()" class="appearance-none pl-9 pr-8 py-2 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-slate-50 hover:bg-slate-100 transition-colors cursor-pointer"> <select id="sort-select" onchange="loadPosts()" class="input-futuristic appearance-none pl-9 pr-8 py-2 text-sm cursor-pointer">
<option value="newest">最新发布</option> <option value="newest">最新发布</option>
<option value="hottest">热度最高</option> <option value="hottest">热度最高</option>
<option value="most_replies">最多回复</option> <option value="most_replies">最多回复</option>
@@ -156,7 +153,7 @@
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg> <svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div> </div>
</div> </div>
<button onclick="loadPosts()" class="px-4 py-2 bg-slate-800 text-white rounded-xl hover:bg-slate-700 text-sm font-medium transition-colors shadow-sm hidden sm:block"> <button onclick="loadPosts()" class="btn-futuristic text-sm hidden sm:block">
搜索 搜索
</button> </button>
</div> </div>
@@ -166,7 +163,7 @@
<!-- 帖子列表 --> <!-- 帖子列表 -->
<div id="post-list" class="space-y-4"> <div id="post-list" class="space-y-4">
<!-- 骨架屏加载状态 --> <!-- 骨架屏加载状态 -->
<div class="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm animate-pulse"> <div class="futuristic-card p-6 animate-pulse">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-10 h-10 bg-slate-200 rounded-full"></div> <div class="w-10 h-10 bg-slate-200 rounded-full"></div>
<div class="flex-1 space-y-3"> <div class="flex-1 space-y-3">
@@ -186,7 +183,7 @@
<!-- 右侧边栏 --> <!-- 右侧边栏 -->
<div class="space-y-6 hidden lg:block"> <div class="space-y-6 hidden lg:block">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="futuristic-card overflow-hidden">
<div class="bg-gradient-to-r from-red-50 to-orange-50 px-5 py-4 border-b border-red-100"> <div class="bg-gradient-to-r from-red-50 to-orange-50 px-5 py-4 border-b border-red-100">
<h3 class="text-sm font-bold text-red-700 flex items-center"> <h3 class="text-sm font-bold text-red-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/></svg> <svg class="w-4 h-4 mr-1.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/></svg>
@@ -198,7 +195,7 @@
</div> </div>
</div> </div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> <div class="futuristic-card overflow-hidden">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-5 py-4 border-b border-blue-100"> <div class="bg-gradient-to-r from-blue-50 to-indigo-50 px-5 py-4 border-b border-blue-100">
<h3 class="text-sm font-bold text-blue-700 flex items-center"> <h3 class="text-sm font-bold text-blue-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg> <svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
@@ -210,7 +207,7 @@
</div> </div>
</div> </div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden sticky top-20"> <div class="futuristic-card overflow-hidden sticky top-20">
<div class="bg-gradient-to-r from-slate-50 to-gray-50 px-5 py-4 border-b border-slate-100"> <div class="bg-gradient-to-r from-slate-50 to-gray-50 px-5 py-4 border-b border-slate-100">
<h3 class="text-sm font-bold text-slate-700 flex items-center"> <h3 class="text-sm font-bold text-slate-700 flex items-center">
<svg class="w-4 h-4 mr-1.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg> <svg class="w-4 h-4 mr-1.5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
@@ -307,7 +304,7 @@
<script> <script>
const CU = {{ user | tojson if user else 'null' }}; const CU = {{ user | tojson if user else 'null' }};
let curTag = '全部'; let curTag = '全部';
const REACTIONS = {like:'👍',love:'❤️',haha:'😂',wow:'😮',sad:'😢',angry:'😡'}; const REACTIONS = {};
const TAG_ICONS = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'}; const TAG_ICONS = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
const TAG_COLORS = {'官方公告':'red','题目讨论':'blue','经验分享':'green','求助答疑':'yellow','闲聊灌水':'purple'}; const TAG_COLORS = {'官方公告':'red','题目讨论':'blue','经验分享':'green','求助答疑':'yellow','闲聊灌水':'purple'};
@@ -350,6 +347,9 @@ function insEmoji(em) {
// 初始化富文本编辑器 // 初始化富文本编辑器
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
new RichEditor('new-content'); new RichEditor('new-content');
const params = new URLSearchParams(window.location.search);
const postId = params.get('post');
if (postId) openPost(parseInt(postId));
}); });
// ===== 图片上传 ===== // ===== 图片上传 =====
@@ -443,27 +443,25 @@ function renderPosts(posts) {
posts.forEach(p => { posts.forEach(p => {
const tc = TAG_COLORS[p.tag] || 'slate'; const tc = TAG_COLORS[p.tag] || 'slate';
const ti = TAG_ICONS[p.tag] || '📋'; const ti = TAG_ICONS[p.tag] || '📋';
const reactions = p.reactions || {};
let reactHtml = '';
for (const [k, v] of Object.entries(reactions)) {
if (v > 0) reactHtml += `<span class="inline-flex items-center gap-1 text-xs bg-slate-50 border border-slate-100 px-2 py-0.5 rounded-md text-slate-600">${REACTIONS[k]||k} ${v}</span>`;
}
// 生成纯文本内容用于预览 // 生成纯文本内容用于预览
let cleanContent = p.content.replace(/\[img:[^\]]+\]/g, '[图片]'); let cleanContent = p.content.replace(/\[img:[^\]]+\]/g, '[图片]');
// 生成等级 // 使用真实等级
const randomLv = (p.id % 5) + 1; const authorLevel = p.author_level || 1;
h += `<div class="bg-white rounded-3xl p-6 border ${p.pinned ? 'border-amber-200 shadow-md bg-gradient-to-br from-amber-50/40 to-white' : 'border-slate-100 shadow-sm'} hover-card-up transition-all duration-300 cursor-pointer group flex flex-col sm:flex-row gap-5" onclick="openPost(${p.id})"> h += `<div class="bg-white rounded-3xl p-6 border ${p.pinned ? 'border-amber-200 shadow-md bg-gradient-to-br from-amber-50/40 to-white' : 'border-slate-100 shadow-sm'} hover-card-up transition-all duration-300 cursor-pointer group flex flex-col sm:flex-row gap-5" onclick="openPost(${p.id})">
<div class="hidden sm:flex flex-col items-center gap-3 flex-shrink-0" onclick="event.stopPropagation()"> <div class="hidden sm:flex flex-col items-center gap-3 flex-shrink-0" onclick="event.stopPropagation()">
<!-- 游戏化头像与等级 --> <!-- 游戏化头像与等级 -->
<div class="relative w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-bold border-2 border-white shadow-md transform group-hover:rotate-6 transition-transform z-10"> <div class="relative w-14 h-14 rounded-2xl ${p.author_avatar ? '' : 'bg-gradient-to-br from-indigo-100 to-purple-100'} flex items-center justify-center text-indigo-600 font-bold border-2 border-white shadow-md transform group-hover:rotate-6 transition-transform z-10 overflow-hidden">
<span class="text-xl">${esc(p.author.charAt(0))}</span> ${p.author_avatar ?
`<img src="${p.author_avatar}" alt="${esc(p.author)}" class="w-full h-full object-cover">` :
`<span class="text-xl">${esc(p.author.charAt(0))}</span>`
}
${p.is_official ? ${p.is_official ?
'<div class="absolute -bottom-2 -right-2 bg-gradient-to-r from-red-500 to-rose-600 text-white text-[9px] font-black px-1.5 py-0.5 rounded-md shadow-sm border border-white">官方</div>' : '<div class="absolute -bottom-2 -right-2 bg-gradient-to-r from-red-500 to-rose-600 text-white text-[9px] font-black px-1.5 py-0.5 rounded-md shadow-sm border border-white">官方</div>' :
'<div class="absolute -bottom-1.5 -right-1.5 bg-slate-800 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full shadow-sm border border-white">Lv.' + randomLv + '</div>'} '<div class="absolute -bottom-1.5 -right-1.5 bg-slate-800 text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full shadow-sm border border-white">Lv.' + authorLevel + '</div>'}
</div> </div>
<button onclick="toggleLike(${p.id},this)" class="group/btn w-12 h-12 flex flex-col items-center justify-center rounded-2xl border ${p.liked?'border-rose-200 bg-rose-50 text-rose-500':'border-slate-100 bg-slate-50 text-slate-400 hover:bg-rose-50 hover:text-rose-500 hover:border-rose-200'} transition-all transform hover:-translate-y-1"> <button onclick="toggleLike(${p.id},this)" class="group/btn w-12 h-12 flex flex-col items-center justify-center rounded-2xl border ${p.liked?'border-rose-200 bg-rose-50 text-rose-500':'border-slate-100 bg-slate-50 text-slate-400 hover:bg-rose-50 hover:text-rose-500 hover:border-rose-200'} transition-all transform hover:-translate-y-1">
@@ -488,7 +486,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="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"> <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> <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> <span class="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded-md">${p.created_at}</span>
</div> </div>
@@ -510,7 +508,6 @@ function renderPosts(posts) {
</div> </div>
</div> </div>
</div> </div>
${reactHtml ? `<div class="mt-4 pt-3 border-t border-slate-100/60 flex flex-wrap gap-2">${reactHtml}</div>` : ''}
</div> </div>
</div>`; </div>`;
}); });
@@ -563,15 +560,9 @@ async function openPost(pid) {
const p = data.data; const p = data.data;
const replies = data.replies || []; const replies = data.replies || [];
const isAuthor = CU && CU.id === p.author_id; const isAuthor = CU && CU.id === p.author_id;
const isTeacher = CU && CU.role === 'teacher'; const isTeacher = CU && (CU.role === 'teacher' || CU.role === 'admin');
const reactions = p.reactions || {}; const reactions = p.reactions || {};
let reactBtns = '';
for (const [k, emoji] of Object.entries(REACTIONS)) {
const cnt = reactions[k] || 0;
reactBtns += `<button onclick="reactPost(${p.id},'${k}',this)" class="reaction-btn${cnt>0?' active':''}">${emoji} <span>${cnt}</span></button>`;
}
let pollHtml = ''; let pollHtml = '';
if (p.has_poll) { if (p.has_poll) {
try { try {
@@ -612,7 +603,13 @@ async function openPost(pid) {
</div> </div>
<h2 class="text-xl font-bold text-slate-900">${esc(p.title)}</h2> <h2 class="text-xl font-bold text-slate-900">${esc(p.title)}</h2>
<div class="mt-2 flex items-center text-sm text-slate-400 gap-3"> <div class="mt-2 flex items-center text-sm text-slate-400 gap-3">
<span class="cursor-pointer hover:text-primary" onclick="showProfile('${p.author_id}')">${esc(p.author)}</span> <div class="flex items-center gap-2">
${p.author_avatar ?
`<img src="${p.author_avatar}" alt="${esc(p.author)}" class="w-6 h-6 rounded-full object-cover border border-slate-200">` :
`<div class="w-6 h-6 rounded-full bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 text-xs font-bold border border-slate-200">${esc(p.author.charAt(0))}</div>`
}
<span class="cursor-pointer hover:text-primary" onclick="showProfile('${p.author_id}')">${esc(p.author)}</span>
</div>
<span>${p.created_at}</span><span>👁 ${p.views||0}</span><span>❤️ ${p.likes}</span><span>💬 ${replies.length}</span> <span>${p.created_at}</span><span>👁 ${p.views||0}</span><span>❤️ ${p.likes}</span><span>💬 ${replies.length}</span>
</div> </div>
</div> </div>
@@ -626,7 +623,6 @@ async function openPost(pid) {
</div> </div>
<div class="prose prose-sm max-w-none text-slate-800 whitespace-pre-wrap border-b border-slate-100 pb-5 mb-4">${renderContent(p.content)}</div> <div class="prose prose-sm max-w-none text-slate-800 whitespace-pre-wrap border-b border-slate-100 pb-5 mb-4">${renderContent(p.content)}</div>
${pollHtml} ${pollHtml}
<div class="flex items-center gap-2 flex-wrap mb-4">${reactBtns}</div>
<div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4"> <div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<button onclick="toggleLikeModal(${p.id})" id="ml-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.liked?'border-red-300 text-red-500 bg-red-50':'border-slate-200 text-slate-500'} hover:bg-red-50 text-sm">❤️ <span id="ml-cnt">${p.likes}</span></button> <button onclick="toggleLikeModal(${p.id})" id="ml-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.liked?'border-red-300 text-red-500 bg-red-50':'border-slate-200 text-slate-500'} hover:bg-red-50 text-sm">❤️ <span id="ml-cnt">${p.likes}</span></button>
<button onclick="toggleBmModal(${p.id})" id="mb-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.bookmarked?'border-yellow-300 text-yellow-500 bg-yellow-50':'border-slate-200 text-slate-500'} hover:bg-yellow-50 text-sm">${p.bookmarked?'⭐ 已收藏':'☆ 收藏'}</button> <button onclick="toggleBmModal(${p.id})" id="mb-btn" class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border ${p.bookmarked?'border-yellow-300 text-yellow-500 bg-yellow-50':'border-slate-200 text-slate-500'} hover:bg-yellow-50 text-sm">${p.bookmarked?'⭐ 已收藏':'☆ 收藏'}</button>
@@ -635,7 +631,12 @@ async function openPost(pid) {
<h3 class="text-sm font-bold text-slate-700 mb-4">💬 回复 (${replies.length})</h3>`; <h3 class="text-sm font-bold text-slate-700 mb-4">💬 回复 (${replies.length})</h3>`;
if (CU) { if (CU) {
h += `<div class="flex gap-3 mb-6"><div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">${esc(CU.name.charAt(0))}</div> const currentUserAvatar = CU.avatar || '';
h += `<div class="flex gap-3 mb-6">
${currentUserAvatar ?
`<img src="${currentUserAvatar}" alt="${esc(CU.name)}" class="w-8 h-8 rounded-full object-cover border border-slate-200 flex-shrink-0">` :
`<div class="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">${esc(CU.name.charAt(0))}</div>`
}
<div class="flex-1"><textarea id="reply-input" rows="2" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="写下你的回复..."></textarea> <div class="flex-1"><textarea id="reply-input" rows="2" class="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="写下你的回复..."></textarea>
<div id="reply-preview" class="flex flex-wrap gap-2 mt-1 hidden"></div> <div id="reply-preview" class="flex flex-wrap gap-2 mt-1 hidden"></div>
<div class="flex justify-between items-center mt-2"><div class="flex gap-1"><button onclick="triggerUpload('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="上传图片">📷</button><button onclick="triggerCamera('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="拍照上传">📸</button></div><button onclick="submitReply(${p.id})" class="px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">回复</button></div></div></div>`; <div class="flex justify-between items-center mt-2"><div class="flex gap-1"><button onclick="triggerUpload('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="上传图片">📷</button><button onclick="triggerCamera('reply-input')" class="p-1 rounded hover:bg-slate-200 text-sm" title="拍照上传">📸</button></div><button onclick="submitReply(${p.id})" class="px-4 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-700">回复</button></div></div></div>`;
@@ -648,7 +649,10 @@ async function openPost(pid) {
const canDel = CU && (CU.id === r.author_id || CU.role === 'teacher'); const canDel = CU && (CU.id === r.author_id || CU.role === 'teacher');
const canEdit = CU && CU.id === r.author_id; const canEdit = CU && CU.id === r.author_id;
h += `<div class="flex gap-3 py-3 ${i>0?'border-t border-slate-100':''}"> h += `<div class="flex gap-3 py-3 ${i>0?'border-t border-slate-100':''}">
<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold flex-shrink-0">${esc(r.author.charAt(0))}</div> ${r.author_avatar ?
`<img src="${r.author_avatar}" alt="${esc(r.author)}" class="w-8 h-8 rounded-full object-cover border border-slate-200 flex-shrink-0">` :
`<div class="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-xs font-bold flex-shrink-0">${esc(r.author.charAt(0))}</div>`
}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
@@ -715,10 +719,7 @@ async function toggleBmModal(pid) {
} }
async function reactPost(pid, reaction, btn) { async function reactPost(pid, reaction, btn) {
if (!CU) { toast('请先登录','error'); return; } // removed
const res = await fetch(`/api/posts/${pid}/react`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reaction})});
const d = await res.json();
if (d.success) openPost(pid);
} }
async function votePoll(pid) { async function votePoll(pid) {

View File

@@ -2,33 +2,34 @@
{% block title %}登录 - 智联青云{% endblock %} {% block title %}登录 - 智联青云{% endblock %}
{% block navbar %}{% endblock %} {% block navbar %}{% endblock %}
{% block content %} {% 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"> <div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-slate-900">登录您的账户</h2> <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">注册新账户</a> 或者 <a href="/register" class="font-medium text-primary hover:text-blue-500 transition-all duration-300 hover:scale-110 inline-block">注册新账户</a>
</p> </p>
</div> </div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg 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"> <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">手机验证码登录</button> <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">邮箱密码登录</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>
</div> </div>
<!-- 手机登录表单 --> <!-- 手机登录表单 -->
<form id="form-phone" class="space-y-6" onsubmit="handlePhoneLogin(event)"> <form id="form-phone" class="space-y-6" onsubmit="handlePhoneLogin(event)">
<div> <div>
<label class="block text-sm font-medium text-slate-700">手机号码</label> <label class="block text-sm font-medium text-slate-700">手机号码</label>
<div class="mt-1 relative rounded-md shadow-sm"> <div class="mt-1 relative rounded-md shadow-sm">
<input id="phone" type="tel" required class="focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入11位手机号"> <input id="phone" type="tel" required class="input-futuristic focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入11位手机号">
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label> <label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2"> <div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-phone" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码"> <input id="captcha-text-phone" type="text" class="input-futuristic focus:ring-primary focus:border-primary block flex-1 sm:text-sm rounded-md py-2 px-3" placeholder="请输入图形验证码">
<img id="captcha-img-phone" class="cursor-pointer h-10" onclick="fetchCaptcha('phone')" alt="验证码"> <img id="captcha-img-phone" class="cursor-pointer h-10 transition-transform duration-300 hover:scale-110" onclick="fetchCaptcha('phone')" alt="验证码">
<button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码"> <button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600 transition-all duration-300 hover:rotate-180" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button> </button>
</div> </div>
@@ -36,23 +37,31 @@
<div> <div>
<label class="block text-sm font-medium text-slate-700">短信验证码</label> <label class="block text-sm font-medium text-slate-700">短信验证码</label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<input id="sms-code" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入短信验证码"> <input id="sms-code" type="text" required class="input-futuristic focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm py-2 px-3" placeholder="请输入短信验证码">
<button type="button" id="send-sms-btn" onclick="handleSendSms()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button> <button type="button" id="send-sms-btn" onclick="handleSendSms()" class="btn-futuristic relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-r-md">获取验证码</button>
</div> </div>
</div> </div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">登录</button> <div class="flex items-center">
<input id="remember-phone" type="checkbox" class="h-4 w-4 text-primary focus:ring-primary border-slate-300 rounded">
<label for="remember-phone" class="ml-2 block text-sm text-slate-700">保持登录10天</label>
</div>
<button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">登录</button>
</form> </form>
<!-- 邮箱登录表单 --> <!-- 邮箱登录表单 -->
<form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailLogin(event)"> <form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailLogin(event)">
<div> <div>
<label class="block text-sm font-medium text-slate-700">邮箱地址</label> <label class="block text-sm font-medium text-slate-700">邮箱地址</label>
<input id="login-email" type="email" required class="mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入邮箱"> <input id="login-email" type="email" required class="input-futuristic mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入邮箱">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">密码</label> <label class="block text-sm font-medium text-slate-700">密码</label>
<input id="login-password" type="password" required class="mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm border-slate-300 rounded-md py-2 border" placeholder="请输入密码"> <input id="login-password" type="password" required class="input-futuristic mt-1 focus:ring-primary focus:border-primary block w-full pl-3 sm:text-sm rounded-md py-2" placeholder="请输入密码">
</div> </div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">登录</button> <div class="flex items-center">
<input id="remember-email" type="checkbox" class="h-4 w-4 text-primary focus:ring-primary border-slate-300 rounded">
<label for="remember-email" class="ml-2 block text-sm text-slate-700">保持登录10天</label>
</div>
<button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">登录</button>
</form> </form>
<div class="mt-6 relative"> <div class="mt-6 relative">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-300"></div></div> <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-300"></div></div>
@@ -102,8 +111,7 @@ async function handleSendSms() {
if (data.success) { if (data.success) {
countdown = 60; countdown = 60;
countdownTimer = setInterval(() => { countdown--; btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码'; if(countdown<=0){clearInterval(countdownTimer);btn.disabled=false;} }, 1000); countdownTimer = setInterval(() => { countdown--; btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码'; if(countdown<=0){clearInterval(countdownTimer);btn.disabled=false;} }, 1000);
if (data.mockCode) alert('验证码已发送: ' + data.mockCode); alert('验证码已发送');
else alert('验证码已发送');
} else { alert(data.message); btn.disabled = false; if(data.refreshCaptcha) fetchCaptcha('phone'); } } else { alert(data.message); btn.disabled = false; if(data.refreshCaptcha) fetchCaptcha('phone'); }
} catch(e) { alert('发送失败,请确保后端已启动'); btn.disabled = false; } } catch(e) { alert('发送失败,请确保后端已启动'); btn.disabled = false; }
fetchCaptcha('phone'); fetchCaptcha('phone');
@@ -113,8 +121,9 @@ async function handlePhoneLogin(e) {
e.preventDefault(); e.preventDefault();
const phone = document.getElementById('phone').value; const phone = document.getElementById('phone').value;
const code = document.getElementById('sms-code').value; const code = document.getElementById('sms-code').value;
const remember = document.getElementById('remember-phone').checked;
try { try {
const res = await fetch('/api/verify-code', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone, code}) }); const res = await fetch('/api/verify-code', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({phone, code, remember}) });
const data = await res.json(); const data = await res.json();
if (data.success) { window.location.href = '/'; } if (data.success) { window.location.href = '/'; }
else alert(data.message); else alert(data.message);
@@ -125,8 +134,9 @@ async function handleEmailLogin(e) {
e.preventDefault(); e.preventDefault();
const email = document.getElementById('login-email').value; const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value; const password = document.getElementById('login-password').value;
const remember = document.getElementById('remember-email').checked;
try { try {
const res = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password}) }); const res = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password, remember}) });
const data = await res.json(); const data = await res.json();
if (data.success) { window.location.href = '/'; } if (data.success) { window.location.href = '/'; }
else alert(data.message); else alert(data.message);
@@ -134,5 +144,109 @@ async function handleEmailLogin(e) {
} }
fetchCaptcha('phone'); 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> </script>
{% endblock %} {% endblock %}

View File

@@ -4,30 +4,31 @@
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto py-8 px-4 sm:px-6"> <div class="max-w-4xl mx-auto py-8 px-4 sm:px-6">
<!-- 头部区域 --> <!-- 头部区域 -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative overflow-hidden group"> <div class="futuristic-card p-6 mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4 relative overflow-hidden group">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50/50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div> <div class="absolute top-0 right-0 w-32 h-32 bg-cyan-500/10 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-indigo-200 transform group-hover:rotate-12 transition-transform duration-300"> <div class="w-12 h-12 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-cyan-500/30 transform group-hover:rotate-12 transition-transform duration-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
</div> </div>
<div> <div>
<h1 class="text-2xl font-extrabold text-slate-900 tracking-tight">通知中心</h1> <h1 class="text-2xl font-extrabold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent tracking-tight">通知中心</h1>
<p class="text-sm text-slate-500 font-medium mt-0.5">查看系统通知、审核结果及最新公告</p> <p class="text-sm text-slate-400 font-medium mt-0.5">查看系统通知、审核结果及最新公告</p>
</div> </div>
</div> </div>
<button onclick="markAllRead()" class="px-5 py-2.5 text-sm font-bold text-slate-600 bg-slate-50 border border-slate-200 rounded-xl hover:bg-white hover:text-indigo-600 hover:border-indigo-200 hover:shadow-sm transition-all flex items-center gap-2 group/btn"> <button onclick="markAllRead()" class="btn-outline-futuristic px-5 py-2.5 text-sm font-bold flex items-center gap-2 group/btn">
<svg class="w-4 h-4 text-slate-400 group-hover/btn:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> <svg class="w-4 h-4 text-slate-400 group-hover/btn:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
全部标为已读 全部标为已读
</button> </button>
</div> </div>
<!-- 标签切换 (玻璃拟物化) --> <!-- 标签切换 (玻璃拟物化) -->
<div class="bg-white/80 backdrop-blur-md rounded-2xl p-1.5 shadow-sm border border-slate-100 mb-6 flex overflow-x-auto hide-scrollbar sticky top-4 z-10"> <div class="futuristic-card-dark backdrop-blur-md rounded-2xl p-1.5 mb-6 flex overflow-x-auto hide-scrollbar sticky top-4 z-10">
<button onclick="switchTab('all')" id="tab-all" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 shadow-sm rounded-xl transition-all duration-300">全部</button> <button onclick="switchTab('all')" id="tab-all" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-gradient-to-r from-cyan-500 to-blue-500 text-white shadow-sm rounded-xl transition-all duration-300">全部</button>
<button onclick="switchTab('system')" id="tab-system" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">📢 系统公告</button> <button onclick="switchTab('system')" id="tab-system" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300">📢 系统公告</button>
<button onclick="switchTab('teacher')" id="tab-teacher" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">👨‍🏫 教师相关</button> <button onclick="switchTab('teacher')" id="tab-teacher" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300">👨‍🏫 教师相关</button>
<button onclick="switchTab('contest')" id="tab-contest" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">🏆 杯赛相关</button> <button onclick="switchTab('contest')" id="tab-contest" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300">🏆 杯赛相关</button>
<button onclick="switchTab('exam')" id="tab-exam" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300">📝 考试相关</button> <button onclick="switchTab('friend')" id="tab-friend" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300">👤 好友</button>
<button onclick="switchTab('exam')" id="tab-exam" class="flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300">📝 考试相关</button>
</div> </div>
<!-- 列表内容区 --> <!-- 列表内容区 -->
@@ -36,7 +37,7 @@
<div id="announcements-section" class="hidden"> <div id="announcements-section" class="hidden">
<div class="flex items-center gap-2 mb-4 px-2"> <div class="flex items-center gap-2 mb-4 px-2">
<span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center font-bold">📢</span> <span class="w-8 h-8 rounded-lg bg-amber-100 text-amber-600 flex items-center justify-center font-bold">📢</span>
<h2 class="text-lg font-bold text-slate-800">系统公告</h2> <h2 class="text-lg font-bold text-slate-300">系统公告</h2>
</div> </div>
<div id="announcements-list" class="space-y-4"></div> <div id="announcements-list" class="space-y-4"></div>
</div> </div>
@@ -69,10 +70,10 @@ function esc(s) { if(!s)return''; const d=document.createElement('div'); d.textC
function switchTab(tab) { function switchTab(tab) {
currentTab = tab; currentTab = tab;
document.querySelectorAll('[id^="tab-"]').forEach(el => { document.querySelectorAll('[id^="tab-"]').forEach(el => {
el.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-slate-50 rounded-xl transition-all duration-300'; el.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 rounded-xl transition-all duration-300';
}); });
const activeBtn = document.getElementById('tab-' + tab); const activeBtn = document.getElementById('tab-' + tab);
activeBtn.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 shadow-sm rounded-xl transition-all duration-300'; activeBtn.className = 'flex-1 min-w-[100px] px-4 py-2.5 text-sm font-bold bg-gradient-to-r from-cyan-500 to-blue-500 text-white shadow-sm rounded-xl transition-all duration-300';
renderNotifications(); renderNotifications();
} }
@@ -81,6 +82,7 @@ function getTypeFilter(tab) {
if (tab === 'system') return ['system_announcement']; if (tab === 'system') return ['system_announcement'];
if (tab === 'teacher') return ['teacher_application', 'teacher_result']; if (tab === 'teacher') return ['teacher_application', 'teacher_result'];
if (tab === 'contest') return ['contest_application', 'contest_result', 'contest_new_exam']; if (tab === 'contest') return ['contest_application', 'contest_result', 'contest_new_exam'];
if (tab === 'friend') return ['friend_request'];
if (tab === 'exam') return ['exam_graded', 'contest_new_exam']; if (tab === 'exam') return ['exam_graded', 'contest_new_exam'];
return null; return null;
} }
@@ -90,7 +92,7 @@ function getTypeIcon(type) {
'teacher_application': '👨‍🏫', 'teacher_result': '🎓', 'teacher_application': '👨‍🏫', 'teacher_result': '🎓',
'contest_application': '🏆', 'contest_result': '🏅', 'contest_application': '🏆', 'contest_result': '🏅',
'contest_new_exam': '📝', 'exam_graded': '✅', 'contest_new_exam': '📝', 'exam_graded': '✅',
'system_announcement': '📢' 'system_announcement': '📢', 'friend_request': '👤'
}; };
return icons[type] || '🔔'; return icons[type] || '🔔';
} }
@@ -100,7 +102,7 @@ function getTypeLabel(type) {
'teacher_application': '教师申请', 'teacher_result': '教师审核结果', 'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
'contest_application': '杯赛申请', 'contest_result': '杯赛通知', 'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
'contest_new_exam': '新考试', 'exam_graded': '成绩通知', 'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
'system_announcement': '系统公告' 'system_announcement': '系统公告', 'friend_request': '好友申请'
}; };
return labels[type] || '通知'; return labels[type] || '通知';
} }
@@ -114,21 +116,21 @@ function renderNotifications() {
if ((currentTab === 'all' || currentTab === 'system') && announcements.length > 0) { if ((currentTab === 'all' || currentTab === 'system') && announcements.length > 0) {
annSection.style.display = ''; annSection.style.display = '';
annList.innerHTML = announcements.map(a => ` annList.innerHTML = announcements.map(a => `
<div class="bg-white rounded-2xl p-5 shadow-sm border ${a.pinned ? 'border-amber-200 shadow-amber-100/50' : 'border-indigo-100 shadow-indigo-100/50'} relative overflow-hidden group hover:shadow-md transition-all duration-300"> <div class="futuristic-card p-5 border ${a.pinned ? 'border-amber-500/30' : 'border-cyan-500/30'} relative overflow-hidden group hover:shadow-lg transition-all duration-300">
<div class="absolute top-0 right-0 w-24 h-24 ${a.pinned ? 'bg-amber-50' : 'bg-indigo-50'} rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div> <div class="absolute top-0 right-0 w-24 h-24 ${a.pinned ? 'bg-amber-500/10' : 'bg-cyan-500/10'} rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
${a.pinned ? '<span class="text-[10px] bg-gradient-to-r from-amber-400 to-orange-500 text-white px-2 py-0.5 rounded-full font-bold shadow-sm flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg> 置顶</span>' : ''} ${a.pinned ? '<span class="badge-futuristic text-[10px] px-2 py-0.5 rounded-full font-bold shadow-sm flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg> 置顶</span>' : ''}
<h3 class="text-lg font-bold text-slate-900 group-hover:text-indigo-600 transition-colors">${esc(a.title)}</h3> <h3 class="text-lg font-bold text-white group-hover:text-cyan-400 transition-colors">${esc(a.title)}</h3>
</div> </div>
<p class="text-sm text-slate-600 mt-3 whitespace-pre-wrap leading-relaxed">${esc(a.content)}</p> <p class="text-sm text-slate-300 mt-3 whitespace-pre-wrap leading-relaxed">${esc(a.content)}</p>
<div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-100/60"> <div class="flex items-center gap-3 mt-4 pt-4 border-t border-slate-900/10">
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-500 bg-slate-50 px-2.5 py-1 rounded-lg"> <span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 bg-slate-800/50 px-2.5 py-1 rounded-lg">
<span class="w-4 h-4 rounded-full bg-slate-200 flex items-center justify-center text-[8px]">${esc(a.author_name)[0]}</span> <span class="w-4 h-4 rounded-full bg-slate-700 flex items-center justify-center text-[8px] text-white">${esc(a.author_name)[0]}</span>
${esc(a.author_name)} ${esc(a.author_name)}
</span> </span>
<span class="text-xs font-medium text-slate-400 flex items-center gap-1"> <span class="text-xs font-medium text-slate-500 flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
${a.created_at} ${a.created_at}
</span> </span>
@@ -154,8 +156,8 @@ function renderNotifications() {
if (currentTab === 'system') { if (currentTab === 'system') {
container.innerHTML = announcements.length === 0 ? ` container.innerHTML = announcements.length === 0 ? `
<div class="flex flex-col items-center justify-center py-16 text-slate-400 bg-white rounded-3xl border border-slate-100 border-dashed"> <div class="flex flex-col items-center justify-center py-16 text-slate-400 futuristic-card border-dashed">
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div> <div class="w-16 h-16 bg-slate-800/50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div>
<div class="font-medium">暂无系统公告</div> <div class="font-medium">暂无系统公告</div>
</div>` : ''; </div>` : '';
return; return;
@@ -163,8 +165,8 @@ function renderNotifications() {
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-slate-400 bg-white rounded-3xl border border-slate-100 border-dashed"> <div class="flex flex-col items-center justify-center py-16 text-slate-400 futuristic-card border-dashed">
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div> <div class="w-16 h-16 bg-slate-800/50 rounded-full flex items-center justify-center text-3xl mb-4">📭</div>
<div class="font-medium">暂无通知</div> <div class="font-medium">暂无通知</div>
</div>`; </div>`;
return; return;
@@ -175,12 +177,12 @@ function renderNotifications() {
const iconBgColor = { const iconBgColor = {
'teacher_application': 'bg-purple-100 text-purple-600', 'teacher_result': 'bg-fuchsia-100 text-fuchsia-600', 'teacher_application': 'bg-purple-100 text-purple-600', 'teacher_result': 'bg-fuchsia-100 text-fuchsia-600',
'contest_application': 'bg-orange-100 text-orange-600', 'contest_result': 'bg-amber-100 text-amber-600', 'contest_application': 'bg-orange-100 text-orange-600', 'contest_result': 'bg-amber-100 text-amber-600',
'contest_new_exam': 'bg-indigo-100 text-indigo-600', 'exam_graded': 'bg-emerald-100 text-emerald-600', 'contest_new_exam': 'bg-indigo-100 text-cyan-400', 'exam_graded': 'bg-emerald-100 text-emerald-600',
'system_announcement': 'bg-blue-100 text-blue-600' 'system_announcement': 'bg-blue-100 text-blue-600', 'friend_request': 'bg-cyan-100 text-cyan-600'
}[n.type] || 'bg-slate-100 text-slate-600'; }[n.type] || 'bg-slate-100 text-slate-600';
return `<div class="bg-white border ${n.read ? 'border-slate-100' : 'border-indigo-200 shadow-md shadow-indigo-100/50 relative'} rounded-2xl p-4 sm:p-5 hover:border-indigo-300 hover:shadow-lg transition-all duration-300 cursor-pointer group" onclick="markSingleRead(${n.id}, this)"> return `<div class="futuristic-card ${n.read ? 'border-slate-900/10' : 'border-cyan-500/30 shadow-md shadow-indigo-100/50 relative'} rounded-2xl p-4 sm:p-5 hover:border-cyan-500/50 hover:shadow-lg transition-all duration-300 cursor-pointer group" onclick="markSingleRead(${n.id}, this, ${JSON.stringify(n).replace(/"/g, '&quot;')})">
${!n.read ? '<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-white animate-pulse"></div>' : ''} ${!n.read ? '<div class="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full border-2 border-slate-900 animate-pulse"></div>' : ''}
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-2xl ${iconBgColor} flex items-center justify-center text-2xl flex-shrink-0 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform"> <div class="w-12 h-12 rounded-2xl ${iconBgColor} flex items-center justify-center text-2xl flex-shrink-0 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform">
${getTypeIcon(n.type)} ${getTypeIcon(n.type)}
@@ -194,7 +196,7 @@ function renderNotifications() {
${n.created_at} ${n.created_at}
</span> </span>
</div> </div>
<div class="text-sm font-medium text-slate-800 leading-relaxed ${!n.read ? 'font-bold' : ''}">${esc(n.content)}</div> <div class="text-sm font-medium text-slate-300 leading-relaxed ${!n.read ? 'font-bold' : ''}">${esc(n.content)}</div>
${actions} ${actions}
</div> </div>
</div> </div>
@@ -203,28 +205,44 @@ function renderNotifications() {
} }
function buildActions(n) { function buildActions(n) {
if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') { if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id) {
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-100"> // 管理员显示快捷操作按钮
<button onclick="event.stopPropagation();approveTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button> if (currentUser.role === 'admin') {
<button onclick="event.stopPropagation();rejectTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button> return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-900/10">
<button onclick="event.stopPropagation();approveTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button>
<button onclick="event.stopPropagation();rejectTeacherN(${n.application_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button>
</div>`;
}
// 杯赛负责人显示查看按钮
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-900/10">
<a href="/admin/teacher-applications" onclick="event.stopPropagation()" class="px-4 py-1.5 text-xs font-bold bg-indigo-50 text-cyan-400 border border-cyan-500/30 rounded-lg hover:bg-indigo-500 hover:text-white hover:border-indigo-500 transition-colors shadow-sm">👁️ 查看申请</a>
</div>`; </div>`;
} }
if (n.type === 'teacher_application' && n.application_status === 'approved') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</div>'; if (n.type === 'teacher_application' && n.application_status === 'approved') return '<div class="mt-3 flex items-center gap-2"><span class="inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</span>' + (currentUser.role === 'admin' ? `<button onclick="event.stopPropagation();deleteNotif(${n.id})" class="px-3 py-1 text-xs font-bold bg-slate-800/50 text-slate-500 border border-slate-200 rounded-lg hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors">删除</button>` : '') + '</div>';
if (n.type === 'teacher_application' && n.application_status === 'rejected') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</div>'; if (n.type === 'teacher_application' && n.application_status === 'rejected') return '<div class="mt-3 flex items-center gap-2"><span class="inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</span>' + (currentUser.role === 'admin' ? `<button onclick="event.stopPropagation();deleteNotif(${n.id})" class="px-3 py-1 text-xs font-bold bg-slate-800/50 text-slate-500 border border-slate-200 rounded-lg hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors">删除</button>` : '') + '</div>';
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') { if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') {
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-100"> return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-900/10">
<button onclick="event.stopPropagation();approveContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button> <button onclick="event.stopPropagation();approveContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意申请</button>
<button onclick="event.stopPropagation();rejectContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button> <button onclick="event.stopPropagation();rejectContestN(${n.post_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝申请</button>
</div>`; </div>`;
} }
if (n.type === 'contest_application' && n.application_status === 'approved') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</div>'; if (n.type === 'contest_application' && n.application_status === 'approved') return '<div class="mt-3 flex items-center gap-2"><span class="inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</span>' + (currentUser.role === 'admin' ? `<button onclick="event.stopPropagation();deleteNotif(${n.id})" class="px-3 py-1 text-xs font-bold bg-slate-800/50 text-slate-500 border border-slate-200 rounded-lg hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors">删除</button>` : '') + '</div>';
if (n.type === 'contest_application' && n.application_status === 'rejected') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</div>'; if (n.type === 'contest_application' && n.application_status === 'rejected') return '<div class="mt-3 flex items-center gap-2"><span class="inline-flex items-center gap-1.5 px-3 py-1 bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 已拒绝</span>' + (currentUser.role === 'admin' ? `<button onclick="event.stopPropagation();deleteNotif(${n.id})" class="px-3 py-1 text-xs font-bold bg-slate-800/50 text-slate-500 border border-slate-200 rounded-lg hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors">删除</button>` : '') + '</div>';
// 好友申请处理
if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) {
return `<div class="flex gap-3 mt-4 pt-3 border-t border-slate-900/10">
<button onclick="event.stopPropagation();acceptFriendN(${n.friend_request_id})" class="px-4 py-1.5 text-xs font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded-lg hover:bg-emerald-500 hover:text-white hover:border-emerald-500 transition-colors shadow-sm">✅ 同意</button>
<button onclick="event.stopPropagation();rejectFriendN(${n.friend_request_id})" class="px-4 py-1.5 text-xs font-bold bg-rose-50 text-rose-600 border border-rose-200 rounded-lg hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-colors shadow-sm">❌ 拒绝</button>
</div>`;
}
if (n.type === 'friend_request' && n.application_status === 'accepted') return '<div class="mt-3 inline-flex items-center gap-1.5 px-3 py-1 bg-emerald-50 border border-emerald-100 text-emerald-600 text-xs font-bold rounded-lg"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 已同意</div>';
// 如果有考试关联的通知,添加快捷入口 // 如果有考试关联的通知,添加快捷入口
if (n.type === 'contest_new_exam' || n.type === 'exam_graded') { if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
if(n.post_id) { if(n.post_id) {
return `<div class="mt-4 pt-3 border-t border-slate-100"><a href="/exams/${n.post_id}" class="inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-bold bg-indigo-50 text-indigo-600 border border-indigo-200 rounded-lg hover:bg-indigo-600 hover:text-white hover:border-indigo-600 transition-colors shadow-sm"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg> 查看考试</a></div>`; return `<div class="mt-4 pt-3 border-t border-slate-900/10"><a href="/exams/${n.post_id}" class="inline-flex items-center gap-1.5 px-4 py-1.5 text-xs font-bold bg-indigo-50 text-cyan-400 border border-cyan-500/30 rounded-lg hover:bg-indigo-600 hover:text-white hover:border-indigo-600 transition-colors shadow-sm"><svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg> 查看考试</a></div>`;
} }
} }
return ''; return '';
@@ -265,15 +283,38 @@ async function rejectContestN(appId) {
} catch(e) { alert('操作失败'); } } catch(e) { alert('操作失败'); }
} }
function markSingleRead(nid, el) { async function acceptFriendN(reqId) {
try {
const res = await fetch(`/api/friend/accept/${reqId}`, {method:'POST'});
const d = await res.json();
if (d.success) { loadAll(); }
else { alert(d.message || '操作失败'); }
} catch(e) { alert('操作失败'); }
}
async function rejectFriendN(reqId) {
if (!confirm('确定拒绝该好友申请?')) return;
try {
const res = await fetch(`/api/friend/reject/${reqId}`, {method:'POST'});
const d = await res.json();
if (d.success) { loadAll(); }
else { alert(d.message || '操作失败'); }
} catch(e) { alert('操作失败'); }
}
function markSingleRead(nid, el, notif) {
fetch(`/api/notifications/${nid}/read`, {method:'POST'}); fetch(`/api/notifications/${nid}/read`, {method:'POST'});
// 移除未读的特定样式 // 移除未读的特定样式
el.classList.remove('border-indigo-200', 'shadow-md', 'shadow-indigo-100/50'); el.classList.remove('border-cyan-500/30', 'shadow-md', 'shadow-indigo-100/50');
el.classList.add('border-slate-100'); el.classList.add('border-slate-900/10');
const content = el.querySelector('.font-bold.leading-relaxed'); const content = el.querySelector('.font-bold.leading-relaxed');
if(content) content.classList.remove('font-bold'); if(content) content.classList.remove('font-bold');
const dot = el.querySelector('.bg-red-500.animate-pulse'); const dot = el.querySelector('.bg-red-500.animate-pulse');
if (dot) dot.remove(); if (dot) dot.remove();
// 根据通知类型跳转
if (notif && notif.type === 'teacher_application' && notif.application_status === 'pending') {
window.location.href = '/admin/teacher-applications';
}
} }
function markAllRead() { function markAllRead() {
@@ -283,6 +324,20 @@ function markAllRead() {
}); });
} }
async function deleteNotif(nid) {
if (!confirm('确定删除该通知?')) return;
try {
const res = await fetch(`/api/notifications/${nid}`, {method:'DELETE'});
const d = await res.json();
if (d.success) {
allNotifs = allNotifs.filter(n => n.id !== nid);
renderNotifications();
} else {
alert(d.message || '删除失败');
}
} catch(e) { alert('删除失败'); }
}
async function loadAll() { async function loadAll() {
const [notifRes, annRes] = await Promise.all([ const [notifRes, annRes] = await Promise.all([
fetch('/api/notifications').then(r => r.json()), fetch('/api/notifications').then(r => r.json()),

View File

@@ -2,8 +2,8 @@
{% block title %}个人中心 - 智联青云{% endblock %} {% block title %}个人中心 - 智联青云{% endblock %}
{% block content %} {% block content %}
<div class="max-w-6xl mx-auto space-y-6"> <div class="max-w-6xl mx-auto space-y-6">
<!-- 顶部横幅与头像:高级游戏化设计 --> <!-- 顶部横幅与头像:科幻未来风格 -->
<div class="bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden relative group"> <div class="futuristic-card-dark overflow-hidden relative group transition-all duration-300 hover:shadow-2xl hover:shadow-cyan-500/20">
<div class="h-48 bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 relative overflow-hidden"> <div class="h-48 bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 relative overflow-hidden">
<!-- 高级光影动画背景 --> <!-- 高级光影动画背景 -->
<div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M20 20.5V18H0v-2h20v-2H0v-2h20v-2H0V8h20V6H0V4h20V2H0V0h22v20h2V0h2v20h2V0h2v20h2V0h2v20h2V0h2v20h2v2H20v-1.5zM0 20h2v20H0V20zm4 0h2v20H4V20zm4 0h2v20H8V20zm4 0h2v20h-2V20zm4 0h2v20h-2V20zm4 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2z\' fill=\'%23ffffff\' fill-opacity=\'0.05\' fill-rule=\'evenodd\'/%3E%3C/svg%3E')] opacity-50"></div> <div class="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M20 20.5V18H0v-2h20v-2H0v-2h20v-2H0V8h20V6H0V4h20V2H0V0h22v20h2V0h2v20h2V0h2v20h2V0h2v20h2V0h2v20h2v2H20v-1.5zM0 20h2v20H0V20zm4 0h2v20H4V20zm4 0h2v20H8V20zm4 0h2v20h-2V20zm4 0h2v20h-2V20zm4 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2zm0 4h20v2H20v-2z\' fill=\'%23ffffff\' fill-opacity=\'0.05\' fill-rule=\'evenodd\'/%3E%3C/svg%3E')] opacity-50"></div>
@@ -13,10 +13,10 @@
<div class="px-6 pb-8 sm:px-12 relative"> <div class="px-6 pb-8 sm:px-12 relative">
<div class="flex flex-col sm:flex-row items-center sm:items-end -mt-20 sm:-mt-24 sm:space-x-8"> <div class="flex flex-col sm:flex-row items-center sm:items-end -mt-20 sm:-mt-24 sm:space-x-8">
<!-- 头像区域(呼吸发光效果 --> <!-- 头像区域(科幻未来风格 -->
<div class="relative group/avatar"> <div class="relative group/avatar">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full blur-md opacity-40 group-hover/avatar:opacity-60 animate-pulse transition-opacity"></div> <div class="absolute inset-0 bg-gradient-to-br from-cyan-500 to-blue-500 rounded-full blur-md opacity-40 group-hover/avatar:opacity-60 animate-pulse transition-opacity"></div>
<div id="avatar-display" class="relative w-36 h-36 rounded-full overflow-hidden border-4 border-white shadow-xl flex items-center justify-center bg-gradient-to-br from-indigo-50 to-blue-50 text-indigo-600 text-5xl font-black cursor-pointer transform group-hover/avatar:scale-105 group-hover/avatar:rotate-3 transition-all duration-300 z-10" onclick="uploadAvatar()"> <div id="avatar-display" class="avatar-futuristic relative w-36 h-36 rounded-full overflow-hidden border-4 border-cyan-400/30 shadow-xl flex items-center justify-center bg-gradient-to-br from-slate-900 to-slate-800 text-cyan-400 text-5xl font-black cursor-pointer transform group-hover/avatar:scale-105 group-hover/avatar:rotate-3 transition-all duration-300 z-10" onclick="uploadAvatar()">
{% if profile_user.avatar %} {% if profile_user.avatar %}
<img src="{{ profile_user.avatar }}" class="w-full h-full object-cover" id="avatar-img"> <img src="{{ profile_user.avatar }}" class="w-full h-full object-cover" id="avatar-img">
{% else %} {% else %}
@@ -24,38 +24,38 @@
{% endif %} {% endif %}
</div> </div>
{% if profile_user.id == user.id %} {% if profile_user.id == user.id %}
<div class="absolute bottom-1 right-1 bg-white p-2.5 rounded-full shadow-lg border-2 border-slate-100 cursor-pointer text-slate-500 hover:text-indigo-600 hover:border-indigo-200 transition-all z-20 transform hover:scale-110" onclick="uploadAvatar()"> <div class="absolute bottom-1 right-1 bg-slate-900/90 p-2.5 rounded-full shadow-lg border-2 border-cyan-400/30 cursor-pointer text-cyan-400 hover:text-cyan-300 hover:border-cyan-300 transition-all z-20 transform hover:scale-110" onclick="uploadAvatar()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div> </div>
{% endif %} {% endif %}
<!-- 浮动等级徽章 --> <!-- 浮动等级徽章 -->
<div class="absolute -top-2 -right-2 bg-slate-900 text-white text-xs font-black px-3 py-1.5 rounded-full shadow-lg border-2 border-white z-20 transform -rotate-12 group-hover/avatar:rotate-0 transition-transform"> <div class="badge-futuristic absolute -top-2 -right-2 text-xs font-black px-3 py-1.5 rounded-full shadow-lg border-2 border-cyan-400/50 z-20 transform -rotate-12 group-hover/avatar:rotate-0 transition-transform">
Lv.{{ level }} Lv.{{ level }}
</div> </div>
</div> </div>
<!-- 个人信息 --> <!-- 个人信息 -->
<div class="mt-5 sm:mt-0 text-center sm:text-left flex-1 pb-2"> <div class="mt-5 sm:mt-0 text-center sm:text-left flex-1 pb-2">
<h1 class="text-4xl font-extrabold text-slate-900 flex items-center justify-center sm:justify-start gap-4 tracking-tight drop-shadow-sm"> <h1 class="text-4xl font-extrabold flex items-center justify-center sm:justify-start gap-4 tracking-tight drop-shadow-sm bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
{{ get_display_name(profile_user.id, profile_user.name) if profile_user.id == user.id else profile_user.name }} {{ get_display_name(profile_user.id, profile_user.name) if profile_user.id == user.id else profile_user.name }}
{% if profile_user.role == 'admin' %} {% if profile_user.role == 'admin' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-200 transform hover:scale-105 transition-transform">管理员</span> <span class="badge-futuristic px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-red-500 to-rose-600 text-white shadow-sm shadow-red-200 transform hover:scale-105 transition-transform">管理员</span>
{% elif profile_user.role == 'teacher' %} {% elif profile_user.role == 'teacher' %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-sm shadow-blue-200 transform hover:scale-105 transition-transform flex items-center gap-1">👨‍🏫 认证教师</span> <span class="badge-futuristic px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-blue-500 to-indigo-600 text-white shadow-sm shadow-blue-200 transform hover:scale-105 transition-transform flex items-center gap-1">👨‍🏫 认证教师</span>
{% else %} {% else %}
<span class="px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-200 transform hover:scale-105 transition-transform flex items-center gap-1">🎓 学生</span> <span class="badge-futuristic px-3 py-1 rounded-xl text-xs font-bold bg-gradient-to-r from-emerald-400 to-teal-500 text-white shadow-sm shadow-emerald-200 transform hover:scale-105 transition-transform flex items-center gap-1">🎓 学生</span>
{% endif %} {% endif %}
</h1> </h1>
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-5 mt-4 text-sm font-medium text-slate-500"> <div class="flex flex-wrap items-center justify-center sm:justify-start gap-5 mt-4 text-sm font-medium text-slate-400">
{% if profile_user.email %} {% if profile_user.email %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>{{ profile_user.email }}</span> <span class="flex items-center gap-1.5 bg-slate-800/50 px-3 py-1.5 rounded-lg border border-cyan-500/30 hover:bg-slate-700/50 transition-colors"><svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>{{ profile_user.email }}</span>
{% endif %} {% endif %}
{% if profile_user.phone %} {% if profile_user.phone %}
<span class="flex items-center gap-1.5 bg-slate-50 px-3 py-1.5 rounded-lg border border-slate-100 hover:bg-slate-100 transition-colors"><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>{{ profile_user.phone }}</span> <span class="flex items-center gap-1.5 bg-slate-800/50 px-3 py-1.5 rounded-lg border border-cyan-500/30 hover:bg-slate-700/50 transition-colors"><svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"/></svg>{{ profile_user.phone }}</span>
{% endif %} {% endif %}
<span class="flex items-center gap-1.5 bg-indigo-50 text-indigo-600 px-3 py-1.5 rounded-lg border border-indigo-100"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>加入于 {{ profile_user.created_at.strftime('%Y-%m-%d') }}</span> <span class="flex items-center gap-1.5 bg-indigo-500/20 text-indigo-400 px-3 py-1.5 rounded-lg border border-indigo-500/30"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>加入于 {{ profile_user.created_at.strftime('%Y-%m-%d') }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -65,21 +65,21 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左侧:统计与信息 --> <!-- 左侧:统计与信息 -->
<div class="space-y-6 lg:col-span-1"> <div class="space-y-6 lg:col-span-1">
<!-- 游戏化数据统计(Bento 风格) --> <!-- 游戏化数据统计(科幻未来风格) -->
<div class="bg-white rounded-3xl p-6 shadow-sm border border-slate-100 relative overflow-hidden group"> <div class="futuristic-card-dark relative overflow-hidden group transition-all duration-300 hover:scale-105">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div> <div class="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-bl-full -z-10 group-hover:scale-110 transition-transform duration-500"></div>
<h3 class="text-xl font-extrabold text-slate-900 mb-5 flex items-center"> <h3 class="text-xl font-extrabold mb-5 flex items-center bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
<span class="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-xl flex items-center justify-center mr-3 shadow-sm">📊</span> <span class="w-8 h-8 bg-indigo-500/20 text-indigo-400 rounded-xl flex items-center justify-center mr-3 shadow-sm border border-indigo-500/30">📊</span>
活跃数据 活跃数据
</h3> </h3>
<!-- 积分进度条 --> <!-- 积分进度条 -->
<div class="mb-6 bg-slate-50 rounded-2xl p-4 border border-slate-100"> <div class="mb-6 bg-slate-900/50 rounded-2xl p-4 border border-cyan-500/30">
<div class="flex justify-between text-xs font-bold text-slate-600 mb-2"> <div class="flex justify-between text-xs font-bold text-slate-400 mb-2">
<span>当前等级进度</span> <span>当前等级进度</span>
<span class="text-indigo-600">{{ points }} / {{ (level * 100) }} XP</span> <span class="text-indigo-400">{{ points }} / {{ (level * 100) }} XP</span>
</div> </div>
<div class="h-3 w-full bg-slate-200 rounded-full overflow-hidden shadow-inner relative"> <div class="h-3 w-full bg-slate-800/50 rounded-full overflow-hidden shadow-inner relative border border-cyan-500/30">
{% set progress = (points / (level * 100) * 100) | int %} {% set progress = (points / (level * 100) * 100) | int %}
{% set p_width = progress if progress <= 100 else 100 %} {% set p_width = progress if progress <= 100 else 100 %}
<div class="absolute top-0 left-0 h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full transition-all duration-1000 ease-out overflow-hidden" style="width: {{ p_width }}%;"> <div class="absolute top-0 left-0 h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full transition-all duration-1000 ease-out overflow-hidden" style="width: {{ p_width }}%;">
@@ -89,56 +89,56 @@
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="bg-gradient-to-br from-indigo-50 to-blue-50 rounded-2xl p-4 text-center border border-indigo-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300"> <div class="bg-gradient-to-br from-indigo-500/20 to-blue-500/20 rounded-2xl p-4 text-center border border-indigo-500/30 hover:shadow-md hover:shadow-indigo-500/20 hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-indigo-600 drop-shadow-sm">{{ points }}</div> <div class="text-3xl font-black text-indigo-400 drop-shadow-sm">{{ points }}</div>
<div class="text-[11px] font-bold text-indigo-400 uppercase tracking-widest mt-1">总积分</div> <div class="text-[11px] font-bold text-indigo-300 uppercase tracking-widest mt-1">总积分</div>
</div> </div>
<div class="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-2xl p-4 text-center border border-emerald-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300"> <div class="bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-2xl p-4 text-center border border-emerald-500/30 hover:shadow-md hover:shadow-emerald-500/20 hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-emerald-600 drop-shadow-sm">{{ post_count }}</div> <div class="text-3xl font-black text-emerald-400 drop-shadow-sm">{{ post_count }}</div>
<div class="text-[11px] font-bold text-emerald-500 uppercase tracking-widest mt-1">发帖数</div> <div class="text-[11px] font-bold text-emerald-300 uppercase tracking-widest mt-1">发帖数</div>
</div> </div>
<div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl p-4 text-center border border-amber-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300"> <div class="bg-gradient-to-br from-amber-500/20 to-orange-500/20 rounded-2xl p-4 text-center border border-amber-500/30 hover:shadow-md hover:shadow-amber-500/20 hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-amber-600 drop-shadow-sm">{{ reply_count }}</div> <div class="text-3xl font-black text-amber-400 drop-shadow-sm">{{ reply_count }}</div>
<div class="text-[11px] font-bold text-amber-500 uppercase tracking-widest mt-1">回复数</div> <div class="text-[11px] font-bold text-amber-300 uppercase tracking-widest mt-1">回复数</div>
</div> </div>
<div class="bg-gradient-to-br from-rose-50 to-pink-50 rounded-2xl p-4 text-center border border-rose-100/50 hover:shadow-md hover:-translate-y-1 transition-all duration-300"> <div class="bg-gradient-to-br from-rose-500/20 to-pink-500/20 rounded-2xl p-4 text-center border border-rose-500/30 hover:shadow-md hover:shadow-rose-500/20 hover:-translate-y-1 transition-all duration-300">
<div class="text-3xl font-black text-rose-500 drop-shadow-sm">{{ likes_received }}</div> <div class="text-3xl font-black text-rose-400 drop-shadow-sm">{{ likes_received }}</div>
<div class="text-[11px] font-bold text-rose-400 uppercase tracking-widest mt-1">获赞数</div> <div class="text-[11px] font-bold text-rose-300 uppercase tracking-widest mt-1">获赞数</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 账号设置 --> <!-- 账号设置 -->
{% if profile_user.id == user.id %} {% if profile_user.id == user.id %}
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100"> <div class="futuristic-card-dark transition-all duration-300 hover:scale-105">
<h3 class="text-lg font-bold text-slate-900 mb-4 flex items-center"> <h3 class="text-lg font-bold mb-4 flex items-center bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent">
<svg class="w-5 h-5 mr-2 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
账号设置 账号设置
</h3> </h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between p-3 bg-slate-50 rounded-xl"> <div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-xl border border-cyan-500/30">
<div> <div>
<div class="text-sm font-medium text-slate-900">用户名</div> <div class="text-sm font-medium text-slate-200">用户名</div>
<div class="text-xs text-slate-500 mt-0.5" id="display-username">{{ profile_user.name }}</div> <div class="text-xs text-slate-400 mt-0.5" id="display-username">{{ profile_user.name }}</div>
</div> </div>
<button onclick="changeName()" class="px-3 py-1.5 text-xs font-medium text-primary bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">修改</button> <button onclick="changeName()" class="btn-futuristic px-3 py-1.5 text-xs font-medium rounded-lg transition-colors">修改</button>
</div> </div>
{% if user.role == 'student' %} {% if user.role == 'student' %}
<a href="/apply-teacher" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group"> <a href="/apply-teacher" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-xl hover:bg-slate-800/50 border border-blue-500/30 hover:border-blue-500/50 transition-all group">
<div> <div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">申请成为老师</div> <div class="text-sm font-medium text-slate-200 group-hover:text-cyan-400 transition-colors">申请成为老师</div>
<div class="text-xs text-slate-500 mt-0.5">获取发布赛事和考试的权限</div> <div class="text-xs text-slate-400 mt-0.5">获取发布赛事和考试的权限</div>
</div> </div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg> <svg class="w-5 h-5 text-slate-400 group-hover:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a> </a>
{% endif %} {% endif %}
{% if user.role == 'admin' or user.role == 'teacher' %} {% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="flex items-center justify-between p-3 bg-slate-50 rounded-xl hover:bg-slate-100 transition-colors group"> <a href="/admin" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-xl hover:bg-slate-800/50 border border-purple-500/30 hover:border-purple-500/50 transition-all group">
<div> <div>
<div class="text-sm font-medium text-slate-900 group-hover:text-primary transition-colors">进入管理后台</div> <div class="text-sm font-medium text-slate-200 group-hover:text-cyan-400 transition-colors">进入管理后台</div>
<div class="text-xs text-slate-500 mt-0.5">管理系统各项数据</div> <div class="text-xs text-slate-400 mt-0.5">管理系统各项数据</div>
</div> </div>
<svg class="w-5 h-5 text-slate-400 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg> <svg class="w-5 h-5 text-slate-400 group-hover:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a> </a>
{% endif %} {% endif %}
</div> </div>
@@ -148,45 +148,15 @@
<!-- 右侧:主要内容 --> <!-- 右侧:主要内容 -->
<div class="space-y-6 lg:col-span-2"> <div class="space-y-6 lg:col-span-2">
<!-- 快捷入口(玻璃拟物化卡片) -->
{% if profile_user.id == user.id %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<a href="/notifications" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-blue-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center text-blue-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-blue-600 transition-colors">通知中心</div>
</a>
<a href="/chat" class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-emerald-200 hover:-translate-y-1 transition-all duration-300 text-center group">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-emerald-100 to-teal-100 rounded-2xl flex items-center justify-center text-emerald-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-emerald-600 transition-colors">我的消息</div>
</a>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-purple-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('exam-history-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-purple-100 to-fuchsia-100 rounded-2xl flex items-center justify-center text-purple-600 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-purple-600 transition-colors">考试记录</div>
</div>
<div class="bg-white rounded-2xl p-5 shadow-sm border border-slate-100 hover:shadow-lg hover:border-amber-200 hover:-translate-y-1 transition-all duration-300 text-center group cursor-pointer" onclick="document.getElementById('bookmarks-tab').scrollIntoView({behavior:'smooth'})">
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-amber-100 to-orange-100 rounded-2xl flex items-center justify-center text-amber-600 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
</div>
<div class="text-sm font-bold text-slate-700 group-hover:text-amber-600 transition-colors">我的收藏</div>
</div>
</div>
{% endif %}
<!-- 高级选项卡内容区 --> <!-- 高级选项卡内容区 -->
<div class="bg-white shadow-sm rounded-3xl border border-slate-100 overflow-hidden"> <div class="futuristic-card-dark overflow-hidden">
<!-- 游戏化标签栏 --> <!-- 科幻未来标签栏 -->
<div class="p-2 bg-slate-50/80 border-b border-slate-100"> <div class="p-2 bg-slate-900/50 border-b border-cyan-500/20">
<div class="flex gap-2 overflow-x-auto hide-scrollbar"> <div class="flex gap-2 overflow-x-auto hide-scrollbar">
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-bold text-indigo-600 bg-white rounded-xl shadow-sm transition-all duration-300 whitespace-nowrap" id="tab-btn-history" onclick="switchTab('history')">📜 考试经历</button> <button class="btn-futuristic flex-1 min-w-[120px] px-6 py-3.5 text-sm font-bold rounded-xl shadow-sm transition-all duration-300 whitespace-nowrap" id="tab-btn-history" onclick="switchTab('history')">📜 考试经历</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-posts" onclick="switchTab('posts')">📝 我的帖子</button> <button class="btn-outline-futuristic flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-posts" onclick="switchTab('posts')">📝 我的帖子</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-bookmarks" onclick="switchTab('bookmarks')">⭐ 收藏试卷</button> <button class="btn-outline-futuristic flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-bookmarks" onclick="switchTab('bookmarks')">⭐ 收藏试卷</button>
<button class="flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium text-slate-500 hover:text-slate-800 hover:bg-white/60 bg-transparent rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-friends" onclick="switchTab('friends')">👥 好友列表</button> <button class="btn-outline-futuristic flex-1 min-w-[120px] px-6 py-3.5 text-sm font-medium rounded-xl transition-all duration-300 whitespace-nowrap" id="tab-btn-friends" onclick="switchTab('friends')">👥 好友列表</button>
</div> </div>
</div> </div>
@@ -219,7 +189,7 @@
<h4 class="text-sm font-bold text-slate-700">搜索用户</h4> <h4 class="text-sm font-bold text-slate-700">搜索用户</h4>
<div class="flex gap-2"> <div class="flex gap-2">
<input id="friend-search-input" type="text" placeholder="输入用户名搜索..." class="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"> <input id="friend-search-input" type="text" placeholder="输入用户名搜索..." class="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button onclick="searchUsers()" class="px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-blue-600 transition-colors">搜索</button> <button onclick="searchUsers()" class="btn-futuristic px-4 py-2 text-sm rounded-lg transition-colors">搜索</button>
</div> </div>
<div id="search-results"></div> <div id="search-results"></div>
</div> </div>
@@ -277,23 +247,23 @@ async function loadFriends() {
if (!data.success) throw new Error(data.message); if (!data.success) throw new Error(data.message);
if (data.friends.length === 0) { if (data.friends.length === 0) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-slate-400">暂无好友</div>'; 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="px-3 py-1.5 text-xs bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors font-medium">私聊</a>
</div>`;
});
container.innerHTML = html;
} catch (e) { } catch (e) {
container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>'; container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>';
} }
@@ -319,7 +289,7 @@ async function searchUsers() {
} else if (u.friend_status === 'pending') { } else if (u.friend_status === 'pending') {
actionBtn = '<span class="text-xs text-amber-600 font-medium">已申请</span>'; actionBtn = '<span class="text-xs text-amber-600 font-medium">已申请</span>';
} else { } else {
actionBtn = `<button onclick="addFriend(${u.id}, this)" class="px-3 py-1 text-xs bg-primary text-white rounded-lg hover:bg-blue-600 transition-colors">加好友</button>`; actionBtn = `<button onclick="addFriend(${u.id}, this)" class="btn-futuristic px-3 py-1 text-xs rounded-lg transition-colors">加好友</button>`;
} }
html += ` html += `
<div class="flex items-center space-x-3 p-2 rounded-lg hover:bg-white transition-colors"> <div class="flex items-center space-x-3 p-2 rounded-lg hover:bg-white transition-colors">
@@ -362,8 +332,8 @@ async function loadFriendRequests() {
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` : r.name.charAt(0).toUpperCase()} ${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` : r.name.charAt(0).toUpperCase()}
</div> </div>
<div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${r.name}</div></div> <div class="flex-1 min-w-0"><div class="text-sm font-medium text-slate-800 truncate">${r.name}</div></div>
<button onclick="acceptFriend(${r.id})" class="px-2.5 py-1 text-xs bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors">同意</button> <button onclick="acceptFriend(${r.id})" class="btn-futuristic px-2.5 py-1 text-xs rounded-lg transition-colors">同意</button>
<button onclick="rejectFriend(${r.id})" class="px-2.5 py-1 text-xs bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">拒绝</button> <button onclick="rejectFriend(${r.id})" class="btn-outline-futuristic px-2.5 py-1 text-xs rounded-lg transition-colors">拒绝</button>
</div>`; </div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -503,8 +473,8 @@ function switchTab(tabName) {
const btn = document.getElementById(`tab-btn-${t}`); const btn = document.getElementById(`tab-btn-${t}`);
if(btn) { if(btn) {
btn.classList.remove('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold'); btn.classList.remove('btn-futuristic');
btn.classList.add('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium'); btn.classList.add('btn-outline-futuristic');
} }
}); });
@@ -519,8 +489,8 @@ function switchTab(tabName) {
// 激活按钮样式 // 激活按钮样式
const activeBtn = document.getElementById(`tab-btn-${tabName}`); const activeBtn = document.getElementById(`tab-btn-${tabName}`);
if(activeBtn) { if(activeBtn) {
activeBtn.classList.remove('text-slate-500', 'bg-transparent', 'hover:bg-white/60', 'font-medium'); activeBtn.classList.remove('btn-outline-futuristic');
activeBtn.classList.add('text-indigo-600', 'bg-white', 'shadow-sm', 'font-bold'); activeBtn.classList.add('btn-futuristic');
} }
} }

View File

@@ -2,37 +2,42 @@
{% block title %}注册 - 智联青云{% endblock %} {% block title %}注册 - 智联青云{% endblock %}
{% block navbar %}{% endblock %} {% block navbar %}{% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <!-- 覆盖全局背景,确保全屏填充 -->
<div class="fixed inset-0 z-[-1] bg-gradient-to-b from-sky-400 via-sky-200 to-white"></div>
<!-- 云团动态背景画布 -->
<canvas id="cloudCanvas" class="fixed inset-0 z-[-1] pointer-events-none w-full h-full"></canvas>
<div class="relative z-10 flex flex-col justify-center min-h-screen py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md"> <div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-slate-900">注册新账户</h2> <h2 class="mt-6 text-center text-4xl font-extrabold text-white drop-shadow-md tracking-tight">注册新账户</h2>
<p class="mt-2 text-center text-sm text-slate-600"> <p class="mt-2 text-center text-sm text-sky-800 font-medium">
已有账户? <a href="/login" class="font-medium text-primary hover:text-blue-500">立即登录</a> 已有账户? <a href="/login" class="font-bold text-blue-600 hover:text-blue-800 transition-all duration-300 hover:scale-110 inline-block drop-shadow-sm">立即登录</a>
</p> </p>
</div> </div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> <div class="relative z-10 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> <div class="bg-white/70 backdrop-blur-xl py-8 px-4 sm:px-10 rounded-3xl shadow-[0_8px_30px_rgb(0,0,0,0.1)] border border-white/60">
<!-- 选项卡 --> <!-- 选项卡 -->
<div class="flex border-b border-slate-200 mb-6"> <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">手机号注册</button> <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">邮箱注册</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>
</div> </div>
<!-- 手机注册表单 --> <!-- 手机注册表单 -->
<form id="form-phone" class="space-y-6" onsubmit="handlePhoneRegister(event)"> <form id="form-phone" class="space-y-6" onsubmit="handlePhoneRegister(event)">
<div> <div>
<label class="block text-sm font-medium text-slate-700">姓名</label> <label class="block text-sm font-medium text-slate-700">姓名</label>
<input id="phone-name" type="text" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="phone-name" type="text" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">手机号码</label> <label class="block text-sm font-medium text-slate-700">手机号码</label>
<input id="phone-number" type="tel" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="phone-number" type="tel" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label> <label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2"> <div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-phone" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码"> <input id="captcha-text-phone" type="text" class="input-futuristic focus:ring-primary focus:border-primary block flex-1 sm:text-sm rounded-md py-2 px-3" placeholder="请输入图形验证码">
<img id="captcha-img-phone" class="cursor-pointer h-10" onclick="fetchCaptcha('phone')" alt="验证码"> <img id="captcha-img-phone" class="cursor-pointer h-10 transition-transform duration-300 hover:scale-110" onclick="fetchCaptcha('phone')" alt="验证码">
<button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码"> <button type="button" onclick="fetchCaptcha('phone')" class="p-2 text-slate-400 hover:text-slate-600 transition-all duration-300 hover:rotate-180" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button> </button>
</div> </div>
@@ -40,41 +45,41 @@
<div> <div>
<label class="block text-sm font-medium text-slate-700">短信验证码</label> <label class="block text-sm font-medium text-slate-700">短信验证码</label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<input id="sms-code-phone" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入短信验证码"> <input id="sms-code-phone" type="text" required class="input-futuristic focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm py-2 px-3" placeholder="请输入短信验证码">
<button type="button" id="send-sms-btn-phone" onclick="handleSendSmsPhone()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button> <button type="button" id="send-sms-btn-phone" onclick="handleSendSmsPhone()" class="btn-futuristic relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-r-md">获取验证码</button>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">密码</label> <label class="block text-sm font-medium text-slate-700">密码</label>
<input id="phone-password" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="phone-password" type="password" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">确认密码</label> <label class="block text-sm font-medium text-slate-700">确认密码</label>
<input id="phone-confirm" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="phone-confirm" type="password" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">注册</button> <button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">注册</button>
</form> </form>
<!-- 邮箱注册表单(已添加手机号绑定) --> <!-- 邮箱注册表单(已添加手机号绑定) -->
<form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailRegister(event)"> <form id="form-email" class="space-y-6 hidden" onsubmit="handleEmailRegister(event)">
<div> <div>
<label class="block text-sm font-medium text-slate-700">姓名</label> <label class="block text-sm font-medium text-slate-700">姓名</label>
<input id="reg-name" type="text" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="reg-name" type="text" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">邮箱地址</label> <label class="block text-sm font-medium text-slate-700">邮箱地址</label>
<input id="reg-email" type="email" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="reg-email" type="email" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">手机号码(用于绑定)</label> <label class="block text-sm font-medium text-slate-700">手机号码(用于绑定)</label>
<input id="reg-phone" type="tel" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="reg-phone" type="tel" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">图形验证码</label> <label class="block text-sm font-medium text-slate-700">图形验证码</label>
<div class="mt-1 flex items-center space-x-2"> <div class="mt-1 flex items-center space-x-2">
<input id="captcha-text-reg" type="text" class="focus:ring-primary focus:border-primary block flex-1 sm:text-sm border-slate-300 rounded-md py-2 border px-3" placeholder="请输入图形验证码"> <input id="captcha-text-reg" type="text" class="input-futuristic focus:ring-primary focus:border-primary block flex-1 sm:text-sm rounded-md py-2 px-3" placeholder="请输入图形验证码">
<img id="captcha-img-reg" class="cursor-pointer h-10" onclick="fetchCaptcha('reg')" alt="验证码"> <img id="captcha-img-reg" class="cursor-pointer h-10 transition-transform duration-300 hover:scale-110" onclick="fetchCaptcha('reg')" alt="验证码">
<button type="button" onclick="fetchCaptcha('reg')" class="p-2 text-slate-400 hover:text-slate-600" title="刷新验证码"> <button type="button" onclick="fetchCaptcha('reg')" class="p-2 text-slate-400 hover:text-slate-600 transition-all duration-300 hover:rotate-180" title="刷新验证码">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button> </button>
</div> </div>
@@ -82,19 +87,19 @@
<div> <div>
<label class="block text-sm font-medium text-slate-700">邮箱验证码</label> <label class="block text-sm font-medium text-slate-700">邮箱验证码</label>
<div class="mt-1 flex rounded-md shadow-sm"> <div class="mt-1 flex rounded-md shadow-sm">
<input id="reg-email-code" type="text" required class="focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm border-slate-300 py-2 border px-3" placeholder="请输入邮箱验证码"> <input id="reg-email-code" type="text" required class="input-futuristic focus:ring-primary focus:border-primary block w-full rounded-none rounded-l-md sm:text-sm py-2 px-3" placeholder="请输入邮箱验证码">
<button type="button" id="send-email-btn" onclick="handleSendEmailCode()" class="relative inline-flex items-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-r-md text-slate-700 bg-slate-50 hover:bg-slate-100">获取验证码</button> <button type="button" id="send-email-btn" onclick="handleSendEmailCode()" class="btn-futuristic relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-r-md">获取验证码</button>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">密码</label> <label class="block text-sm font-medium text-slate-700">密码</label>
<input id="reg-password" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="reg-password" type="password" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">确认密码</label> <label class="block text-sm font-medium text-slate-700">确认密码</label>
<input id="reg-confirm" type="password" required class="mt-1 appearance-none block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"> <input id="reg-confirm" type="password" required class="input-futuristic mt-1 appearance-none block w-full px-3 py-2 rounded-md shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm">
</div> </div>
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-blue-700">注册</button> <button type="submit" class="btn-futuristic w-full flex justify-center py-2 px-4 rounded-md text-sm font-medium text-white">注册</button>
</form> </form>
</div> </div>
</div> </div>
@@ -110,8 +115,8 @@ let countdown = 0;
function switchTab(tab) { function switchTab(tab) {
document.getElementById('form-phone').classList.toggle('hidden', tab !== 'phone'); document.getElementById('form-phone').classList.toggle('hidden', tab !== 'phone');
document.getElementById('form-email').classList.toggle('hidden', tab !== 'email'); document.getElementById('form-email').classList.toggle('hidden', tab !== 'email');
document.getElementById('tab-phone').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'phone' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700'); document.getElementById('tab-phone').className = 'flex-1 pb-4 text-sm font-medium text-center transition-all duration-300 hover:scale-105 ' + (tab === 'phone' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
document.getElementById('tab-email').className = 'flex-1 pb-4 text-sm font-medium text-center ' + (tab === 'email' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700'); document.getElementById('tab-email').className = 'flex-1 pb-4 text-sm font-medium text-center transition-all duration-300 hover:scale-105 ' + (tab === 'email' ? 'text-primary border-b-2 border-primary' : 'text-slate-500 hover:text-slate-700');
} }
// 获取图形验证码 // 获取图形验证码
@@ -148,8 +153,7 @@ async function handleSendSmsPhone() {
btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码'; btn.textContent = countdown > 0 ? countdown+'s后重发' : '获取验证码';
if(countdown<=0){clearInterval(timer);btn.disabled=false;} if(countdown<=0){clearInterval(timer);btn.disabled=false;}
}, 1000); }, 1000);
if (data.mockCode) alert('验证码已发送: ' + data.mockCode); alert('验证码已发送');
else alert('验证码已发送');
} else { } else {
alert(data.message); alert(data.message);
btn.disabled = false; btn.disabled = false;
@@ -260,5 +264,153 @@ async function handleEmailRegister(e) {
// 初始化图形验证码(默认手机选项卡) // 初始化图形验证码(默认手机选项卡)
fetchCaptcha('phone'); fetchCaptcha('phone');
// ========== 云团动态背景动画 ==========
const canvas = document.getElementById('cloudCanvas');
const ctx = canvas.getContext('2d');
let width, height;
let clouds = [];
let mouse = { x: -1000, y: -1000 };
function resize() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
window.addEventListener('mousemove', (e) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
window.addEventListener('mouseleave', () => {
mouse.x = -1000;
mouse.y = -1000;
});
// 预渲染柔和的云朵粒子,提升性能和真实感
const puffCanvas = document.createElement('canvas');
puffCanvas.width = 200;
puffCanvas.height = 200;
const pctx = puffCanvas.getContext('2d');
const grad = pctx.createRadialGradient(100, 100, 0, 100, 100, 100);
grad.addColorStop(0, 'rgba(255, 255, 255, 1)');
grad.addColorStop(0.2, 'rgba(255, 255, 255, 0.8)');
grad.addColorStop(0.5, 'rgba(255, 255, 255, 0.3)');
grad.addColorStop(1, 'rgba(255, 255, 255, 0)');
pctx.fillStyle = grad;
pctx.beginPath();
pctx.arc(100, 100, 100, 0, Math.PI * 2);
pctx.fill();
class Cloud {
constructor() {
this.reset(true);
}
reset(initial = false) {
this.size = Math.random() * 150 + 100; // 更大的云团
this.x = initial ? Math.random() * width : -this.size * 2;
this.y = Math.random() * height * 0.8; // 主要分布在上方
this.speedX = Math.random() * 0.5 + 0.2;
this.speedY = (Math.random() - 0.5) * 0.1;
this.baseOpacity = Math.random() * 0.5 + 0.3;
this.opacity = this.baseOpacity;
this.scattered = false;
// 生成云朵形状(由多个柔和粒子组成)
this.parts = [];
const numParts = Math.floor(Math.random() * 6) + 6;
for (let i = 0; i < numParts; i++) {
// 云朵底部较平,顶部不规则
const nx = (Math.random() - 0.5) * this.size * 1.5;
const ny = (Math.random() * 0.5 - 0.8) * this.size * 0.5;
this.parts.push({
offsetX: nx,
offsetY: ny,
radius: Math.random() * this.size * 0.5 + this.size * 0.4,
vx: 0,
vy: 0
});
}
}
update() {
if (!this.scattered) {
this.x += this.speedX;
this.y += this.speedY;
// 鼠标交互(散开效果)
const dx = this.x - mouse.x;
const dy = this.y - mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// 鼠标靠近时触发散开
if (dist < 250) {
this.scattered = true;
// 给每个粒子一个远离鼠标的速度
for (let part of this.parts) {
const pdx = (this.x + part.offsetX) - mouse.x;
const pdy = (this.y + part.offsetY) - mouse.y;
const pdist = Math.sqrt(pdx * pdx + pdy * pdy) || 1;
const force = Math.max(0, 300 - pdist) / 300;
part.vx = (pdx / pdist) * force * (Math.random() * 6 + 2);
part.vy = (pdy / pdist) * force * (Math.random() * 6 + 2);
}
}
} else {
// 散开状态:粒子移动并淡出
this.opacity -= 0.015;
for (let part of this.parts) {
part.offsetX += part.vx;
part.offsetY += part.vy;
part.radius += 0.5; // 散开时稍微膨胀
// 增加空气阻力
part.vx *= 0.95;
part.vy *= 0.95;
}
}
// 边界检测或完全淡出后重置
if (this.x > width + this.size * 2 || this.opacity <= 0 || this.y < -this.size * 2 || this.y > height + this.size * 2) {
this.reset();
}
}
draw() {
if (this.opacity <= 0) return;
ctx.save();
ctx.globalAlpha = Math.max(0, this.opacity);
for (let part of this.parts) {
const cx = this.x + part.offsetX;
const cy = this.y + part.offsetY;
const r = part.radius;
// 视野外不绘制
if (cx + r < 0 || cx - r > width || cy + r < 0 || cy - r > height) continue;
// 使用预渲染的柔和云朵粒子
ctx.drawImage(puffCanvas, cx - r, cy - r, r * 2, r * 2);
}
ctx.restore();
}
}
// 初始化云朵
for (let i = 0; i < 12; i++) {
clouds.push(new Cloud());
}
function animate() {
ctx.clearRect(0, 0, width, height);
for (let cloud of clouds) {
cloud.update();
cloud.draw();
}
requestAnimationFrame(animate);
}
animate();
</script> </script>
{% endblock %} {% endblock %}

68
test_owner_access.py Normal file
View File

@@ -0,0 +1,68 @@
"""
测试杯赛负责人访问教师申请审批页面
"""
import sqlite3
db_path = 'instance/database.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("模拟杯赛负责人访问教师申请审批页面...\n")
# 获取一个杯赛负责人
cursor.execute("""
SELECT cm.user_id, u.name, u.role, cm.contest_id, c.name as contest_name
FROM contest_membership cm
JOIN user u ON cm.user_id = u.id
JOIN contest c ON cm.contest_id = c.id
WHERE cm.role = 'owner'
LIMIT 1
""")
owner = cursor.fetchone()
if not owner:
print("没有找到杯赛负责人")
conn.close()
exit()
user_id, user_name, user_role, contest_id, contest_name = owner
print(f"杯赛负责人信息:")
print(f" 用户ID: {user_id}")
print(f" 用户名: {user_name}")
print(f" 角色: {user_role}")
print(f" 负责杯赛ID: {contest_id}")
print(f" 杯赛名称: {contest_name}")
# 模拟查询该负责人能看到的申请
cursor.execute("""
SELECT cm.contest_id
FROM contest_membership cm
WHERE cm.user_id = ? AND cm.role = 'owner'
""", (user_id,))
owned_contests = [row[0] for row in cursor.fetchall()]
print(f"\n该用户负责的所有杯赛ID: {owned_contests}")
if owned_contests:
placeholders = ','.join('?' * len(owned_contests))
cursor.execute(f"""
SELECT ta.id, ta.user_id, ta.contest_id, ta.status, u.name, c.name
FROM teacher_application ta
JOIN user u ON ta.user_id = u.id
JOIN contest c ON ta.contest_id = c.id
WHERE ta.status = 'pending' AND ta.contest_id IN ({placeholders})
ORDER BY ta.applied_at DESC
""", owned_contests)
apps = cursor.fetchall()
print(f"\n该负责人能看到的待审批申请数: {len(apps)}")
if apps:
for app in apps:
print(f" 申请ID: {app[0]}, 申请人: {app[4]}, 杯赛: {app[5]}")
else:
print(" 暂无待审核的教师申请")
else:
print("\n该用户不负责任何杯赛")
conn.close()
print("\n测试完成")