Compare commits
12 Commits
8258167f7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd9609165c | |||
| 33763d279e | |||
| 6b668ce313 | |||
| 751cc8dc24 | |||
| 6ccfe9b107 | |||
| 7dec929e29 | |||
| aa012ea3aa | |||
| 86a6c7dd03 | |||
| 2d3163a1a0 | |||
| c325007897 | |||
| b5b9f7db06 | |||
| 8660bbc348 |
@@ -23,7 +23,14 @@
|
||||
"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 pull:*)",
|
||||
"Bash(iconv:*)"
|
||||
"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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
@@ -7,4 +7,5 @@ venv/
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
model/
|
||||
model/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
添加 exam.visibility 字段的数据库迁移脚本
|
||||
"""
|
||||
|
||||
from app import app, db
|
||||
from models import Exam
|
||||
|
||||
def add_visibility_column():
|
||||
"""为 Exam 表添加 visibility 字段"""
|
||||
with app.app_context():
|
||||
try:
|
||||
# 检查字段是否已存在
|
||||
inspector = db.inspect(db.engine)
|
||||
columns = [col['name'] for col in inspector.get_columns('exam')]
|
||||
|
||||
if 'visibility' in columns:
|
||||
print("visibility 字段已存在,无需添加")
|
||||
return
|
||||
|
||||
# 添加字段
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(db.text("ALTER TABLE exam ADD COLUMN visibility VARCHAR(20) DEFAULT 'private'"))
|
||||
conn.commit()
|
||||
print("成功添加 visibility 字段")
|
||||
|
||||
# 将所有现有考试设置为公开(保持向后兼容)
|
||||
conn.execute(db.text("UPDATE exam SET visibility = 'public' WHERE visibility IS NULL"))
|
||||
conn.commit()
|
||||
print("已将现有考试设置为公开")
|
||||
|
||||
except Exception as e:
|
||||
print(f"添加字段时出错: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("开始添加 visibility 字段...")
|
||||
add_visibility_column()
|
||||
print("完成!")
|
||||
116
backup_utils.py
Normal 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
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
检查教师申请数据的完整性
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
db_path = 'instance/database.db'
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("检查教师申请数据完整性...\n")
|
||||
|
||||
# 获取所有待审批的申请
|
||||
cursor.execute("""
|
||||
SELECT id, user_id, contest_id, name, email, status
|
||||
FROM teacher_application
|
||||
WHERE status = 'pending'
|
||||
""")
|
||||
apps = cursor.fetchall()
|
||||
|
||||
if not apps:
|
||||
print("没有待审批的教师申请")
|
||||
else:
|
||||
print(f"找到 {len(apps)} 个待审批的申请\n")
|
||||
|
||||
for app in apps:
|
||||
app_id, user_id, contest_id, name, email, status = app
|
||||
print(f"申请 ID: {app_id}")
|
||||
print(f" 用户ID: {user_id}, 杯赛ID: {contest_id}")
|
||||
print(f" 姓名: {name}, 邮箱: {email}")
|
||||
|
||||
# 检查用户是否存在
|
||||
cursor.execute("SELECT id, name FROM user WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
print(f" [OK] 用户存在: {user[1]}")
|
||||
else:
|
||||
print(f" [ERROR] 用户不存在!")
|
||||
|
||||
# 检查杯赛是否存在
|
||||
cursor.execute("SELECT id, name FROM contest WHERE id = ?", (contest_id,))
|
||||
contest = cursor.fetchone()
|
||||
if contest:
|
||||
print(f" [OK] 杯赛存在: {contest[1]}")
|
||||
else:
|
||||
print(f" [ERROR] 杯赛不存在!")
|
||||
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
print("检查完成")
|
||||
@@ -1,49 +0,0 @@
|
||||
import os
|
||||
from app import app, db
|
||||
from models import (
|
||||
Contest, ContestMembership, ContestApplication, ContestRegistration,
|
||||
Exam, Submission, Draft, ExamBookmark,
|
||||
Post, Reply, Poll, Reaction, Bookmark, Report,
|
||||
QuestionBankItem, Notification, SystemNotification,
|
||||
TeacherApplication, InviteCode, ChatRoom, ChatRoomMember, Message
|
||||
)
|
||||
|
||||
def clean_test_data():
|
||||
with app.app_context():
|
||||
# 删除所有跟杯赛相关的记录
|
||||
ContestMembership.query.delete()
|
||||
ContestApplication.query.delete()
|
||||
ContestRegistration.query.delete()
|
||||
QuestionBankItem.query.delete()
|
||||
TeacherApplication.query.delete()
|
||||
InviteCode.query.delete()
|
||||
Contest.query.delete()
|
||||
|
||||
# 删除所有跟考试相关的记录
|
||||
Submission.query.delete()
|
||||
Draft.query.delete()
|
||||
ExamBookmark.query.delete()
|
||||
Exam.query.delete()
|
||||
|
||||
# 删除所有跟帖子相关的记录
|
||||
Reply.query.delete()
|
||||
Poll.query.delete()
|
||||
Reaction.query.delete()
|
||||
Bookmark.query.delete()
|
||||
Report.query.delete()
|
||||
Post.query.delete()
|
||||
|
||||
# 删除聊天系统相关的记录(因为有自动为杯赛建群的功能,顺便清一下比较干净)
|
||||
Message.query.delete()
|
||||
ChatRoomMember.query.delete()
|
||||
ChatRoom.query.delete()
|
||||
|
||||
# 也可以清理系统通知、普通通知
|
||||
Notification.query.delete()
|
||||
SystemNotification.query.delete()
|
||||
|
||||
db.session.commit()
|
||||
print("所有测试的杯赛、考试、帖子及相关数据已成功删除!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
clean_test_data()
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""创建管理员账号脚本"""
|
||||
|
||||
from app import app, db
|
||||
from models import User
|
||||
|
||||
def create_admin():
|
||||
with app.app_context():
|
||||
# 检查邮箱是否已存在
|
||||
existing_user = User.query.filter_by(email='1512344589@qq.com').first()
|
||||
if existing_user:
|
||||
print(f'邮箱 1512344589@qq.com 已存在')
|
||||
print(f'用户名: {existing_user.name}')
|
||||
print(f'角色: {existing_user.role}')
|
||||
|
||||
# 更新为管理员
|
||||
if existing_user.role != 'admin':
|
||||
existing_user.role = 'admin'
|
||||
existing_user.password = '1'
|
||||
db.session.commit()
|
||||
print('已将该用户更新为管理员')
|
||||
else:
|
||||
print('该用户已经是管理员')
|
||||
return
|
||||
|
||||
# 创建新管理员
|
||||
admin = User(
|
||||
name='管理员',
|
||||
email='1512344589@qq.com',
|
||||
password='1',
|
||||
role='admin'
|
||||
)
|
||||
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
print('管理员账号创建成功!')
|
||||
print(f'邮箱: {admin.email}')
|
||||
print(f'密码: 1')
|
||||
print(f'角色: {admin.role}')
|
||||
print(f'用户ID: {admin.id}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_admin()
|
||||
78
excel_export.py
Normal 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)
|
||||
67
models.py
@@ -1,6 +1,11 @@
|
||||
# models.py
|
||||
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
|
||||
|
||||
db = SQLAlchemy()
|
||||
@@ -17,7 +22,7 @@ class User(db.Model):
|
||||
is_banned = db.Column(db.Boolean, default=False)
|
||||
completed_exams = db.Column(db.Integer, default=0) # 已完成并批改的考试次数
|
||||
name_changed_at = db.Column(db.DateTime, nullable=True) # 上次修改用户名时间
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 关系
|
||||
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)
|
||||
contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False) # 'owner' 或 'teacher'
|
||||
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
joined_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', back_populates='contest_memberships')
|
||||
contest = db.relationship('Contest', back_populates='members')
|
||||
@@ -55,7 +60,7 @@ class ContestApplication(db.Model):
|
||||
description = db.Column(db.Text)
|
||||
contact = db.Column(db.String(100))
|
||||
status = db.Column(db.String(20), default='pending') # pending, approved, rejected
|
||||
applied_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
applied_at = db.Column(db.DateTime, default=beijing_now)
|
||||
reviewed_at = db.Column(db.DateTime)
|
||||
total_score = db.Column(db.Integer, default=150) # 杯赛默认满分
|
||||
start_date = db.Column(db.String(20)) # 申请时填写的开始日期
|
||||
@@ -78,7 +83,7 @@ class ContestRegistration(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=False)
|
||||
registered_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
registered_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'contest_id', name='uq_user_contest_reg'),)
|
||||
|
||||
@@ -93,7 +98,7 @@ class Contest(db.Model):
|
||||
status = db.Column(db.String(20), default='upcoming')
|
||||
participants = db.Column(db.Integer, default=0)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
past_papers = db.Column(db.Text) # JSON 存储历年真题 [{year, title, file}]
|
||||
total_score = db.Column(db.Integer, default=150) # 杯赛默认考试满分
|
||||
visible = db.Column(db.Boolean, default=True) # 是否对普通用户可见
|
||||
@@ -125,7 +130,7 @@ class QuestionBankItem(db.Model):
|
||||
options = db.Column(db.Text) # JSON,选择题选项
|
||||
answer = db.Column(db.Text) # 答案
|
||||
score = db.Column(db.Integer, default=10) # 建议分值
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
contest = db.relationship('Contest', backref='question_bank_items')
|
||||
contributor = db.relationship('User', backref='contributed_questions')
|
||||
@@ -146,7 +151,7 @@ class Exam(db.Model):
|
||||
scheduled_start = db.Column(db.DateTime, nullable=True) # 预定开始时间
|
||||
scheduled_end = db.Column(db.DateTime, nullable=True) # 预定结束时间
|
||||
score_release_time = db.Column(db.DateTime, nullable=True) # 成绩公布时间
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
status = db.Column(db.String(20), default='available')
|
||||
visibility = db.Column(db.String(20), default='private') # 'private' 或 'public'
|
||||
|
||||
@@ -170,7 +175,7 @@ class Submission(db.Model):
|
||||
question_scores = db.Column(db.Text) # JSON
|
||||
graded = db.Column(db.Boolean, default=False)
|
||||
graded_by = db.Column(db.String(80))
|
||||
submitted_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
submitted_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
exam = db.relationship('Exam', back_populates='submissions')
|
||||
user = db.relationship('User', back_populates='submissions')
|
||||
@@ -193,7 +198,7 @@ class Draft(db.Model):
|
||||
exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'))
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
answers = db.Column(db.Text) # JSON
|
||||
saved_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
saved_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
exam = db.relationship('Exam', back_populates='drafts')
|
||||
user = db.relationship('User', back_populates='drafts')
|
||||
@@ -219,8 +224,8 @@ class Post(db.Model):
|
||||
has_poll = db.Column(db.Boolean, default=False)
|
||||
images = db.Column(db.Text) # JSON
|
||||
contest_id = db.Column(db.Integer, db.ForeignKey('contest.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
author = db.relationship('User', back_populates='posts')
|
||||
replies = db.relationship('Reply', back_populates='post', lazy=True, cascade='all, delete-orphan')
|
||||
@@ -243,8 +248,8 @@ class Reply(db.Model):
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
likes = db.Column(db.Integer, default=0)
|
||||
reply_to = db.Column(db.String(80))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
post = db.relationship('Post', back_populates='replies')
|
||||
author = db.relationship('User', back_populates='replies')
|
||||
@@ -259,7 +264,7 @@ class Poll(db.Model):
|
||||
voters = db.Column(db.Text) # JSON
|
||||
multi = db.Column(db.Boolean, default=False)
|
||||
total_votes = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
post = db.relationship('Post', back_populates='poll')
|
||||
|
||||
@@ -282,7 +287,7 @@ class Reaction(db.Model):
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=True)
|
||||
reply_id = db.Column(db.Integer, db.ForeignKey('reply.id'), nullable=True)
|
||||
reaction = db.Column(db.String(20))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', back_populates='reactions')
|
||||
post = db.relationship('Post', back_populates='reactions')
|
||||
@@ -298,7 +303,7 @@ class Bookmark(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', back_populates='bookmarks')
|
||||
post = db.relationship('Post', back_populates='bookmarks')
|
||||
@@ -314,7 +319,7 @@ class Report(db.Model):
|
||||
reason = db.Column(db.String(100))
|
||||
detail = db.Column(db.Text)
|
||||
status = db.Column(db.String(20), default='pending')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# reporter 已在 User 中通过 backref 定义
|
||||
|
||||
@@ -327,7 +332,7 @@ class Notification(db.Model):
|
||||
from_user = db.Column(db.String(80))
|
||||
post_id = db.Column(db.Integer, nullable=True)
|
||||
read = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# user 已在 User 中通过 backref 定义
|
||||
|
||||
@@ -338,8 +343,8 @@ class SystemNotification(db.Model):
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
pinned = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
author = db.relationship('User', backref='system_notifications')
|
||||
|
||||
@@ -350,7 +355,7 @@ class EditHistory(db.Model):
|
||||
title = db.Column(db.String(200))
|
||||
content = db.Column(db.Text)
|
||||
edited_by = db.Column(db.String(80))
|
||||
edited_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
edited_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
post = db.relationship('Post')
|
||||
|
||||
@@ -364,7 +369,7 @@ class TeacherApplication(db.Model):
|
||||
email = db.Column(db.String(120), nullable=False) # 申请人邮箱
|
||||
reason = db.Column(db.Text, nullable=False) # 申请理由
|
||||
status = db.Column(db.String(20), default='pending') # pending, approved, rejected
|
||||
applied_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
applied_at = db.Column(db.DateTime, default=beijing_now)
|
||||
reviewed_at = db.Column(db.DateTime)
|
||||
reviewed_by = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
# 拒绝次数控制
|
||||
@@ -384,7 +389,7 @@ class Friend(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
friend_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
status = db.Column(db.String(20), default='pending') # pending, accepted
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
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'))
|
||||
@@ -394,7 +399,7 @@ class ExamBookmark(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
exam_id = db.Column(db.Integer, db.ForeignKey('exam.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
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'))
|
||||
@@ -412,7 +417,7 @@ class ChatRoom(db.Model):
|
||||
announcement = db.Column(db.Text, default='')
|
||||
announcement_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
announcement_at = db.Column(db.DateTime, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
creator = db.relationship('User', foreign_keys=[creator_id], backref='chatrooms_created')
|
||||
announcement_user = db.relationship('User', foreign_keys=[announcement_by])
|
||||
@@ -428,8 +433,8 @@ class ChatRoomMember(db.Model):
|
||||
role = db.Column(db.String(20), default='member') # 'admin' | 'member'
|
||||
nickname = db.Column(db.String(50), default='')
|
||||
muted = db.Column(db.Boolean, default=False)
|
||||
last_read_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_read_at = db.Column(db.DateTime, default=beijing_now)
|
||||
joined_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', backref='chat_memberships')
|
||||
|
||||
@@ -447,7 +452,7 @@ class Message(db.Model):
|
||||
recalled = db.Column(db.Boolean, default=False)
|
||||
reply_to_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=True)
|
||||
mentions = db.Column(db.Text, default='') # JSON array of user IDs, e.g. "[1,2,3]" or "all"
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
sender = db.relationship('User', backref='messages_sent')
|
||||
reply_to = db.relationship('Message', remote_side='Message.id', backref='replies')
|
||||
@@ -459,7 +464,7 @@ class MessageReaction(db.Model):
|
||||
message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
emoji = db.Column(db.String(10), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', backref='message_reactions')
|
||||
|
||||
@@ -472,7 +477,7 @@ class InviteCode(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
application_id = db.Column(db.Integer, db.ForeignKey('teacher_application.id'), nullable=False)
|
||||
used = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
used_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
user = db.relationship('User', backref='invite_codes')
|
||||
|
||||
@@ -7,3 +7,5 @@ flask-socketio>=5.3.0
|
||||
PyMuPDF>=1.24.0
|
||||
openai>=1.14.0
|
||||
Flask-Caching>=2.3.0
|
||||
openpyxl>=3.1.0
|
||||
Flask-SQLAlchemy>=3.0.0
|
||||
|
||||
@@ -463,7 +463,85 @@ button[onclick^="quickScore"]:active {
|
||||
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;
|
||||
@@ -472,4 +550,174 @@ button[onclick^="quickScore"]:active {
|
||||
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));
|
||||
}
|
||||
|
Before Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 41 KiB |
BIN
static/uploads/avatar_5_1772689796.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
static/uploads/avatar_5_1772689897.jpg
Normal file
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 122 KiB |
61
templates/admin_backup.html
Normal 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 %}
|
||||
@@ -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/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/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 %}
|
||||
@@ -51,9 +52,11 @@
|
||||
|
||||
<!-- 主体内容区 -->
|
||||
<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>
|
||||
{% block admin_content %}{% endblock %}
|
||||
<div class="admin-content-wrapper">
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
<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="futuristic-card-dark overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-futuristic">
|
||||
<div class="table-responsive">
|
||||
<table class="table-futuristic" style="min-width: 900px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>主办方</th>
|
||||
<th>状态</th>
|
||||
<th>开始日期</th>
|
||||
<th>操作</th>
|
||||
<th style="min-width: 60px;">ID</th>
|
||||
<th style="min-width: 200px;">名称</th>
|
||||
<th style="min-width: 150px;">主办方</th>
|
||||
<th style="min-width: 120px;">状态</th>
|
||||
<th style="min-width: 150px;">开始日期</th>
|
||||
<th style="min-width: 180px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contests-tbody">
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const isAdmin = {{ 'true' if user and user.role == 'admin' else 'false' }};
|
||||
const statusMap = {
|
||||
'upcoming': ['即将开始', 'bg-blue-500/20 text-blue-400 border-blue-500/30'],
|
||||
'registering': ['正在报名', 'bg-green-500/20 text-green-400 border-green-500/30'],
|
||||
@@ -53,16 +54,20 @@ async function loadContests() {
|
||||
const [statusText, statusClass] = statusMap[c.status] || ['未知', 'bg-slate-500/20 text-slate-400 border-slate-500/30'];
|
||||
const abolishBtn = c.status !== 'abolished'
|
||||
? `<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>
|
||||
<td>${c.id}</td>
|
||||
<td class="font-medium text-slate-200">${c.name}</td>
|
||||
<td class="text-slate-400">${c.organizer || '-'}</td>
|
||||
<td class="font-medium text-slate-200" style="max-width: 200px; white-space: normal; word-wrap: break-word;">${c.name}</td>
|
||||
<td class="text-slate-400" style="max-width: 150px; white-space: normal; word-wrap: break-word;">${c.organizer || '-'}</td>
|
||||
<td><span class="badge-futuristic ${statusClass}">${statusText}</span></td>
|
||||
<td class="text-slate-400">${c.start_date || '-'}</td>
|
||||
<td class="space-x-2">
|
||||
<a href="/contests/${c.id}" class="text-xs text-cyan-400 hover:text-cyan-300 hover:underline">查看</a>
|
||||
${abolishBtn}
|
||||
${deleteBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -73,7 +78,7 @@ async function loadContests() {
|
||||
}
|
||||
|
||||
async function abolishContest(id, name) {
|
||||
if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留但杯赛不可恢复`)) return;
|
||||
if (!confirm(`确定要废止杯赛「${name}」吗?\n\n废止后:\n- 该杯赛下所有考试将被关闭\n- 无法再报名或参加考试\n- 数据将保留,可再执行删除彻底移除`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/contests/${id}/abolish`, {method: 'POST'});
|
||||
const data = await res.json();
|
||||
@@ -88,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);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
</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="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-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="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>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-slate-300">总用户数</div>
|
||||
<div class="text-xs sm:text-sm font-bold text-slate-300">总用户数</div>
|
||||
</div>
|
||||
<div id="stat-users" class="text-3xl font-black text-white relative z-10">-</div>
|
||||
<div id="stat-users" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
|
||||
</div>
|
||||
|
||||
<div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
|
||||
@@ -33,9 +33,9 @@
|
||||
<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 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-slate-300">赛事总数</div>
|
||||
<div class="text-xs sm:text-sm font-bold text-slate-300">赛事总数</div>
|
||||
</div>
|
||||
<div id="stat-contests" class="text-3xl font-black text-white relative z-10">-</div>
|
||||
<div id="stat-contests" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
|
||||
</div>
|
||||
|
||||
<div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
|
||||
@@ -44,9 +44,9 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-slate-300">考试总数</div>
|
||||
<div class="text-xs sm:text-sm font-bold text-slate-300">考试总数</div>
|
||||
</div>
|
||||
<div id="stat-exams" class="text-3xl font-black text-white relative z-10">-</div>
|
||||
<div id="stat-exams" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
|
||||
</div>
|
||||
|
||||
<div class="futuristic-card-dark relative overflow-hidden group hover:-translate-y-1 transition-transform">
|
||||
@@ -55,9 +55,9 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-sm font-bold text-slate-300">社区帖子</div>
|
||||
<div class="text-xs sm:text-sm font-bold text-slate-300">社区帖子</div>
|
||||
</div>
|
||||
<div id="stat-posts" class="text-3xl font-black text-white relative z-10">-</div>
|
||||
<div id="stat-posts" class="text-2xl sm:text-3xl font-black text-white relative z-10">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,34 +105,38 @@
|
||||
</div>
|
||||
<h2 class="text-lg font-bold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent">快捷操作</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-indigo-500/30">🏆</div>
|
||||
<div class="text-sm font-bold text-slate-300">杯赛管理</div>
|
||||
<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="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-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-xs sm:text-sm font-bold text-slate-300">杯赛管理</div>
|
||||
</a>
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-orange-500/30">📋</div>
|
||||
<div class="text-sm font-bold text-slate-300">杯赛申请</div>
|
||||
<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-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-xs sm:text-sm font-bold text-slate-300">杯赛申请</div>
|
||||
</a>
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-purple-500/30">👨🏫</div>
|
||||
<div class="text-sm font-bold text-slate-300">教师申请</div>
|
||||
<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-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-xs sm:text-sm font-bold text-slate-300">教师申请</div>
|
||||
</a>
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-emerald-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-emerald-500/30">📝</div>
|
||||
<div class="text-sm font-bold text-slate-300">考试管理</div>
|
||||
<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-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 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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-blue-500/30">👥</div>
|
||||
<div class="text-sm font-bold text-slate-300">用户管理</div>
|
||||
<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-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-xs sm:text-sm font-bold text-slate-300">用户管理</div>
|
||||
</a>
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-amber-500/30">💬</div>
|
||||
<div class="text-sm font-bold text-slate-300">帖子管理</div>
|
||||
<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-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-xs sm:text-sm font-bold text-slate-300">帖子管理</div>
|
||||
</a>
|
||||
<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-5">
|
||||
<div class="w-12 h-12 rounded-xl bg-rose-500/20 flex items-center justify-center text-2xl mb-3 group-hover:scale-110 transition-transform border border-rose-500/30">📢</div>
|
||||
<div class="text-sm font-bold text-slate-300">通知管理</div>
|
||||
<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-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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
<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="futuristic-card-dark overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-futuristic">
|
||||
<div class="table-responsive">
|
||||
<table class="table-futuristic" style="min-width: 1000px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>科目</th>
|
||||
<th>出题人</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
<th style="min-width: 60px;">ID</th>
|
||||
<th style="min-width: 200px;">标题</th>
|
||||
<th style="min-width: 100px;">科目</th>
|
||||
<th style="min-width: 120px;">出题人</th>
|
||||
<th style="min-width: 100px;">状态</th>
|
||||
<th style="min-width: 150px;">创建时间</th>
|
||||
<th style="min-width: 180px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exams-tbody"></tbody>
|
||||
@@ -68,7 +68,7 @@ async function loadExams() {
|
||||
const isAvailable = e.status === 'available';
|
||||
html += `<tr>
|
||||
<td>${e.id}</td>
|
||||
<td class="font-medium text-slate-200 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="text-slate-400">${e.subject || '-'}</td>
|
||||
<td class="text-slate-400">${e.creator_name || '-'}</td>
|
||||
<td><span class="badge-futuristic ${statusClass}">${statusText}</span></td>
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
</div>
|
||||
|
||||
<div class="futuristic-card-dark overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-futuristic">
|
||||
<div class="table-responsive">
|
||||
<table class="table-futuristic" style="min-width: 1000px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>置顶</th>
|
||||
<th>发布时间</th>
|
||||
<th>操作</th>
|
||||
<th style="min-width: 60px;">ID</th>
|
||||
<th style="min-width: 250px;">标题</th>
|
||||
<th style="min-width: 120px;">作者</th>
|
||||
<th style="min-width: 100px;">标签</th>
|
||||
<th style="min-width: 100px;">置顶</th>
|
||||
<th style="min-width: 150px;">发布时间</th>
|
||||
<th style="min-width: 200px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="posts-tbody"></tbody>
|
||||
@@ -84,7 +84,7 @@ async function loadPosts() {
|
||||
const tagClass = tagClassMap[p.tag] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
html += `<tr>
|
||||
<td>${p.id}</td>
|
||||
<td class="font-medium text-slate-200 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="text-slate-400">${p.author}</td>
|
||||
<td><span class="badge-futuristic ${tagClass}">${p.tag || '-'}</span></td>
|
||||
<td>
|
||||
|
||||
@@ -31,18 +31,18 @@
|
||||
|
||||
<!-- 桌面端表格视图 -->
|
||||
<div class="hidden md:block bg-white shadow-sm rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<div class="table-responsive">
|
||||
<table class="min-w-full divide-y divide-slate-200" style="min-width: 1100px;">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请人</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">杯赛</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">姓名</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">邮箱</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请理由</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">申请时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">操作</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 60px;">ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 100px;">申请人</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">杯赛</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 100px;">姓名</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">邮箱</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 200px;">申请理由</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">申请时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider" style="min-width: 150px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="applications-table-body" class="bg-white divide-y divide-slate-200">
|
||||
@@ -50,10 +50,10 @@
|
||||
<tr data-id="{{ app.id }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.id }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-900">{{ app.user.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.contest.name }}</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>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.name }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-slate-500">{{ app.email }}</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500 max-w-xs truncate">{{ app.reason }}</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500" style="max-width: 150px; word-wrap: break-word;">{{ app.email }}</td>
|
||||
<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>
|
||||
<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;">
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
</div>
|
||||
|
||||
<div class="futuristic-card-dark overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-futuristic">
|
||||
<div class="table-responsive">
|
||||
<table class="table-futuristic" style="min-width: 900px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
<th style="min-width: 60px;">ID</th>
|
||||
<th style="min-width: 120px;">用户名</th>
|
||||
<th style="min-width: 180px;">邮箱</th>
|
||||
<th style="min-width: 100px;">角色</th>
|
||||
<th style="min-width: 100px;">状态</th>
|
||||
<th style="min-width: 150px;">注册时间</th>
|
||||
<th style="min-width: 180px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
|
||||
@@ -196,6 +196,26 @@
|
||||
<!-- 通知面板脚本 -->
|
||||
<script>
|
||||
(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() {
|
||||
const panel = document.getElementById('notifPanel');
|
||||
panel.classList.toggle('hidden');
|
||||
@@ -217,9 +237,14 @@
|
||||
if (d.count > 0) {
|
||||
badge.textContent = d.count > 99 ? '99+' : d.count;
|
||||
badge.classList.remove('hidden');
|
||||
// 有新通知时播放声音
|
||||
if (lastNotifCount >= 0 && d.count > lastNotifCount) {
|
||||
playNotifSound();
|
||||
}
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
lastNotifCount = d.count;
|
||||
}).catch(()=>{});
|
||||
}
|
||||
updateNotifBadge();
|
||||
@@ -274,11 +299,12 @@
|
||||
}
|
||||
|
||||
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');
|
||||
const dot = el.querySelector('.bg-blue-500');
|
||||
if (dot) dot.remove();
|
||||
updateNotifBadge();
|
||||
}
|
||||
window.markRead = markRead;
|
||||
|
||||
@@ -326,7 +352,7 @@
|
||||
</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">
|
||||
<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>
|
||||
@@ -334,7 +360,7 @@
|
||||
</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">
|
||||
<span class="font-medium text-sm" id="miniTitle">消息</span>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -359,7 +385,14 @@
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<script>
|
||||
(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();
|
||||
let miniRoomId = null;
|
||||
let miniRooms = [];
|
||||
@@ -396,6 +429,11 @@
|
||||
});
|
||||
|
||||
window.toggleMiniChat = function() {
|
||||
// 移动端直接跳转到完整聊天页面
|
||||
if (window.innerWidth < 640) {
|
||||
window.location.href = '/chat';
|
||||
return;
|
||||
}
|
||||
const mc = document.getElementById('miniChat');
|
||||
mc.classList.toggle('hidden');
|
||||
if (!mc.classList.contains('hidden')) {
|
||||
|
||||
@@ -80,9 +80,9 @@
|
||||
</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-white/10 flex items-center justify-between bg-slate-800/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">
|
||||
<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>
|
||||
@@ -95,6 +95,7 @@
|
||||
<span id="chatName" class="text-lg font-bold text-white tracking-tight"></span>
|
||||
<span id="chatMemberCount" class="badge-futuristic text-xs font-bold px-2 py-0.5 rounded-full"></span>
|
||||
</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>
|
||||
@@ -114,9 +115,9 @@
|
||||
</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-cyan-500/10 backdrop-blur-md border-t border-cyan-500/30 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="w-1 h-4 bg-cyan-400 rounded-full"></div>
|
||||
<div class="text-sm font-medium text-cyan-300 truncate"><span id="replyToText"></span></div>
|
||||
@@ -126,7 +127,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- 输入区域 -->
|
||||
<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)]">
|
||||
<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 面板 -->
|
||||
<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>
|
||||
@@ -162,7 +163,7 @@
|
||||
</div>
|
||||
|
||||
<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 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';"></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>
|
||||
|
||||
<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">
|
||||
@@ -187,7 +188,7 @@
|
||||
<div id="friendListForGroup" class="max-h-48 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-white/10">
|
||||
<button onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
|
||||
<button id="groupActionBtn" onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,8 +204,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="membersList" class="p-4 overflow-y-auto max-h-96 space-y-2"></div>
|
||||
<div id="inviteSection" class="px-4 py-3 border-t border-white/10 hidden">
|
||||
<div id="inviteSection" class="px-4 py-3 border-t border-white/10 hidden space-y-2">
|
||||
<button onclick="showInvite()" class="btn-futuristic w-full px-4 py-2 text-sm">邀请好友</button>
|
||||
<button id="dissolveBtn" onclick="dissolveGroup()" class="w-full px-4 py-2 text-sm rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30 transition-colors hidden">解散群聊</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')">
|
||||
<img id="previewImg" class="max-w-[90vw] max-h-[90vh] rounded-lg">
|
||||
</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -296,6 +300,7 @@ let recordingStartTime = null;
|
||||
let recordingInterval = null;
|
||||
let searchTimer = null;
|
||||
let mentionMembers = [];
|
||||
let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}}
|
||||
|
||||
function isMobile() { return window.innerWidth < 640; }
|
||||
|
||||
@@ -370,13 +375,20 @@ function switchTab(tab) {
|
||||
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';
|
||||
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');
|
||||
tabNotif.appendChild(badge);
|
||||
if (badge) {
|
||||
tabNotif.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
async function loadChatNotifications() {
|
||||
const res = await fetch('/api/notifications');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -401,6 +413,7 @@ function renderNotifications() {
|
||||
const isResult = n.type === 'contest_result' || n.type === 'teacher_result';
|
||||
const isNewExam = n.type === 'contest_new_exam';
|
||||
const isGraded = n.type === 'exam_graded';
|
||||
const isSubmission = n.type === 'exam_submission';
|
||||
const isSystem = n.type === 'system_announcement';
|
||||
const isPendingContest = isContestApp && n.application_status === 'pending';
|
||||
const isPendingTeacher = isTeacherApp && n.application_status === 'pending';
|
||||
@@ -409,26 +422,26 @@ function renderNotifications() {
|
||||
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 (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 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 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' : isSubmission ? 'bg-amber-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100';
|
||||
const clickAction = `showNotifDetail(${n.id})`;
|
||||
let actionsHtml = '';
|
||||
if (isPendingContest && currentUser.role === 'admin') {
|
||||
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();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();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();chatRejectContest(${n.post_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
|
||||
</div>`;
|
||||
}
|
||||
if (isPendingTeacher && currentUser.role === 'admin' && n.application_id) {
|
||||
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();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();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();chatRejectTeacher(${n.application_id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
|
||||
</div>`;
|
||||
}
|
||||
if (isPendingFriend && n.friend_request_id) {
|
||||
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();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();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();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>`;
|
||||
}
|
||||
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}">
|
||||
@@ -457,65 +470,65 @@ async function markNotifRead(nid, el) {
|
||||
}
|
||||
}
|
||||
|
||||
async function approveContest(appId) {
|
||||
async function chatApproveContest(appId) {
|
||||
if (!confirm('确定批准该杯赛申请?')) return;
|
||||
const res = await fetch(`/api/contest-applications/${appId}/approve`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectContest(appId) {
|
||||
async function chatRejectContest(appId) {
|
||||
if (!confirm('确定拒绝该杯赛申请?')) return;
|
||||
const res = await fetch(`/api/contest-applications/${appId}/reject`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function approveTeacher(appId) {
|
||||
async function chatApproveTeacher(appId) {
|
||||
if (!confirm('确定批准该教师申请?')) return;
|
||||
const res = await fetch(`/api/teacher-applications/${appId}/approve`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectTeacher(appId) {
|
||||
async function chatRejectTeacher(appId) {
|
||||
if (!confirm('确定拒绝该教师申请?')) return;
|
||||
const res = await fetch(`/api/teacher-applications/${appId}/reject`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptFriendReq(reqId, notifId) {
|
||||
async function chatAcceptFriendReq(reqId, notifId) {
|
||||
const res = await fetch(`/api/friend/accept/${reqId}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectFriendReq(reqId, notifId) {
|
||||
async function chatRejectFriendReq(reqId, notifId) {
|
||||
const res = await fetch(`/api/friend/reject/${reqId}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadNotifications();
|
||||
loadChatNotifications();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
@@ -526,11 +539,24 @@ async function checkUnreadNotifs() {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const badge = document.getElementById('chatNotifBadge');
|
||||
if (data.count > 0) {
|
||||
badge.textContent = data.count > 99 ? '99+' : data.count;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
if (badge) {
|
||||
if (data.count > 0) {
|
||||
badge.textContent = data.count > 99 ? '99+' : data.count;
|
||||
badge.classList.remove('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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,13 +585,15 @@ function showNotifDetail(nid) {
|
||||
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
|
||||
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
|
||||
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
|
||||
'system_announcement': '系统通知', 'friend_request': '好友申请'
|
||||
'exam_submission': '待批改', 'system_announcement': '系统通知',
|
||||
'friend_request': '好友申请'
|
||||
};
|
||||
const typeIcons = {
|
||||
'teacher_application': '👨🏫', 'teacher_result': '🎓',
|
||||
'contest_application': '📋', 'contest_result': '🏅',
|
||||
'contest_new_exam': '📝', 'exam_graded': '✅',
|
||||
'system_announcement': '📢', 'friend_request': '👤'
|
||||
'exam_submission': '📝', 'system_announcement': '📢',
|
||||
'friend_request': '👤'
|
||||
};
|
||||
|
||||
document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔';
|
||||
@@ -578,20 +606,25 @@ function showNotifDetail(nid) {
|
||||
let actHtml = '';
|
||||
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') {
|
||||
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="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="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="chatRejectContest(${n.post_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
|
||||
</div>`;
|
||||
} else if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') {
|
||||
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="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="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="chatRejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
|
||||
</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') {
|
||||
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) {
|
||||
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="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="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="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>`;
|
||||
} else if (n.type === 'friend_request' && n.application_status === 'accepted') {
|
||||
actHtml = '<span class="text-sm text-green-600 font-medium">✅ 已同意</span>';
|
||||
@@ -638,15 +671,18 @@ function renderRooms() {
|
||||
const search = document.getElementById('roomSearch').value.toLowerCase();
|
||||
const list = document.getElementById('roomList');
|
||||
const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search));
|
||||
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})">
|
||||
<div class="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
list.innerHTML = filtered.map(r => {
|
||||
const peerInExam = r.type === 'private' && r.peer_id && peerExamStatus[r.peer_id]?.in_exam;
|
||||
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">` :
|
||||
`<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 class="flex-1 min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-0.5">
|
||||
@@ -655,7 +691,7 @@ function renderRooms() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`}).join('');
|
||||
}
|
||||
|
||||
function getPreview(r) {
|
||||
@@ -674,6 +710,7 @@ function filterRooms() { renderRooms(); }
|
||||
async function selectRoom(roomId) {
|
||||
currentRoomId = roomId;
|
||||
oldestMsgId = null;
|
||||
markChatRead(roomId); // 立即标记已读,使聊天气泡消失
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
// 隐藏通知详情
|
||||
const nd = document.getElementById('notifDetailView');
|
||||
@@ -687,10 +724,32 @@ async function selectRoom(roomId) {
|
||||
if (room) {
|
||||
document.getElementById('chatName').textContent = room.name || '聊天';
|
||||
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();
|
||||
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) {
|
||||
@@ -703,7 +762,10 @@ async function loadMessages(roomId, beforeId) {
|
||||
if (!beforeId) {
|
||||
area.innerHTML = '';
|
||||
data.messages.forEach(m => area.appendChild(createMsgEl(m)));
|
||||
area.scrollTop = area.scrollHeight;
|
||||
// 确保滚动到底部
|
||||
setTimeout(() => {
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}, 100);
|
||||
} else {
|
||||
const oldH = area.scrollHeight;
|
||||
data.messages.forEach((m, i) => area.insertBefore(createMsgEl(m), area.children[i]));
|
||||
@@ -813,6 +875,7 @@ function sendMessage() {
|
||||
reply_to_id: replyToId, mentions: mentionsStr
|
||||
});
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
mentionMembers = [];
|
||||
cancelReply();
|
||||
document.getElementById('emojiPanel').classList.add('hidden');
|
||||
@@ -925,7 +988,7 @@ function previewImage(url) {
|
||||
}
|
||||
|
||||
// ========== 标记已读 ==========
|
||||
function markRead(roomId) {
|
||||
function markChatRead(roomId) {
|
||||
fetch(`/api/chat/rooms/${roomId}/read`, { method: 'POST' });
|
||||
socket.emit('mark_read', { room_id: roomId });
|
||||
const room = rooms.find(r => r.id === roomId);
|
||||
@@ -939,7 +1002,7 @@ async function showCreateGroup() {
|
||||
modal.classList.add('flex');
|
||||
// 重置弹窗标题和按钮(防止被邀请好友功能覆盖)
|
||||
document.querySelector('#createGroupModal h3').textContent = '创建群聊';
|
||||
const createBtn = document.querySelector('#createGroupModal .bg-primary');
|
||||
const createBtn = document.getElementById('groupActionBtn');
|
||||
createBtn.textContent = '创建';
|
||||
createBtn.onclick = createGroup;
|
||||
document.getElementById('groupName').value = '';
|
||||
@@ -1032,6 +1095,7 @@ async function showMembers() {
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('inviteSection').classList.toggle('hidden', !isGroup);
|
||||
document.getElementById('dissolveBtn').classList.toggle('hidden', !isCreator);
|
||||
}
|
||||
|
||||
function hideMembers() {
|
||||
@@ -1040,11 +1104,27 @@ function hideMembers() {
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
|
||||
async function dissolveGroup() {
|
||||
if (!confirm('确定要解散该群聊?所有聊天记录将被删除,此操作不可撤销。')) return;
|
||||
const res = await fetch(`/api/chat/rooms/${currentRoomId}/dissolve`, {method:'POST'});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
hideMembers();
|
||||
currentRoomId = null;
|
||||
document.getElementById('chat-header-info').innerHTML = '';
|
||||
document.getElementById('messages').innerHTML = '';
|
||||
document.getElementById('chat-input-area').classList.add('hidden');
|
||||
await loadRooms();
|
||||
} else {
|
||||
alert(data.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function showInvite() {
|
||||
hideMembers();
|
||||
showCreateGroup();
|
||||
document.querySelector('#createGroupModal h3').textContent = '邀请好友';
|
||||
const btn = document.querySelector('#createGroupModal .bg-primary');
|
||||
const btn = document.getElementById('groupActionBtn');
|
||||
btn.textContent = '邀请';
|
||||
btn.onclick = async () => {
|
||||
const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value));
|
||||
@@ -1337,9 +1417,15 @@ async function stopRecording() {
|
||||
socket.on('new_message', (msg) => {
|
||||
if (msg.room_id === currentRoomId) {
|
||||
const area = document.getElementById('messageArea');
|
||||
const wasAtBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 100;
|
||||
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);
|
||||
@@ -1411,6 +1497,33 @@ socket.on('error', (data) => {
|
||||
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) {
|
||||
if (!s) return '';
|
||||
|
||||
@@ -15,35 +15,35 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="futuristic-card p-6 p-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent mb-2">
|
||||
<div class="flex flex-col lg:flex-row justify-between items-start gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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 }}
|
||||
{% if contest.status == 'abolished' %}
|
||||
<span class="ml-2 badge-futuristic text-xs rounded-full">已废止</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div class="flex items-center space-x-4 text-sm text-slate-400">
|
||||
<span class="flex items-center">
|
||||
<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 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>
|
||||
{{ contest.start_date }}
|
||||
</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>
|
||||
<span id="participants-value">{{ contest.participants }}</span>人已报名
|
||||
</span>
|
||||
</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 user %}
|
||||
<button id="register-btn"
|
||||
onclick="toggleRegistration({{ contest.id }})"
|
||||
class="{% if registered %}btn-outline-futuristic{% else %}btn-futuristic{% endif %} px-6 py-2 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 %}
|
||||
</button>
|
||||
{% if not is_member %}
|
||||
<a href="{{ url_for('apply_teacher', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
|
||||
<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>
|
||||
{% endif %}
|
||||
@@ -51,11 +51,11 @@
|
||||
<a href="{{ url_for('contest_question_bank', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
|
||||
题库管理
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<a href="{{ url_for('exam_create', contest_id=contest.id) }}" class="btn-outline-futuristic px-6 py-2 font-medium">
|
||||
创建考试
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<a href="{{ url_for('admin_teacher_applications') }}" class="btn-outline-futuristic px-6 py-2 font-medium">
|
||||
审批老师申请
|
||||
</a>
|
||||
@@ -239,6 +239,7 @@
|
||||
const CONTEST_ID = {{ contest.id }};
|
||||
let canPost = {{ (can_post is defined and can_post) | 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) {
|
||||
@@ -282,7 +283,7 @@ async function loadPosts() {
|
||||
let html = '';
|
||||
data.data.forEach(p => {
|
||||
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 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-300 mb-2">${escapeHtml(p.content)}</p>
|
||||
<div class="flex items-center text-xs text-slate-400 space-x-3">
|
||||
@@ -378,6 +379,7 @@ async function loadExams() {
|
||||
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 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">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -387,9 +389,10 @@ async function loadExams() {
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-1">${e.subject ? e.subject + ' · ' : ''}满分${e.total_score}分${e.duration ? ' · ' + e.duration + '分钟' : ''}</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-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>`;
|
||||
});
|
||||
@@ -413,6 +416,20 @@ async function removeExam(examId, title) {
|
||||
} 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() {
|
||||
document.getElementById('import-exam-modal').classList.remove('hidden');
|
||||
|
||||
@@ -149,7 +149,7 @@ async function loadQuestions() {
|
||||
<p class="text-sm text-slate-800 mb-2">${escapeHtml(q.content)}</p>`;
|
||||
if (q.type === 'choice' && q.options && q.options.length) {
|
||||
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>';
|
||||
}
|
||||
if (q.answer) {
|
||||
@@ -162,6 +162,13 @@ async function loadQuestions() {
|
||||
html += `</div></div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
// 渲染数学公式
|
||||
if (typeof renderMathInElement === 'function') {
|
||||
renderMathInElement(container, {
|
||||
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
|
||||
throwOnError: false
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('question-list').innerHTML = '<div class="text-center py-8 text-red-500">加载失败</div>';
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
<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>
|
||||
<option value="public">公开(所有人可见)</option>
|
||||
{% if not (is_cup_teacher|default(false)) %}<option value="public">公开(所有人可见)</option>{% endif %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-400">私有考试只有您自己可以看到和管理</p>
|
||||
<p class="mt-1 text-xs text-slate-400">{% if is_cup_teacher|default(false) %}杯赛老师仅可创建自己可见的杯赛考试{% else %}私有考试只有您自己可以看到和管理{% endif %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">考试密码 <span class="text-slate-400 font-normal">(可选)</span></label>
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VIP 弹窗 -->
|
||||
<!-- 智能识别功能开发中弹窗 -->
|
||||
<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]">
|
||||
@@ -121,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>
|
||||
<!-- 关闭按钮 -->
|
||||
<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">×</button>
|
||||
<!-- 皇冠图标 -->
|
||||
<div class="relative flex justify-center mb-4">
|
||||
<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">
|
||||
<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="relative flex justify-center mb-6">
|
||||
<div class="dev-character relative">
|
||||
<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"></></text>
|
||||
</svg>
|
||||
</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>
|
||||
<p class="text-center text-purple-300/60 text-xs mb-6 tracking-widest">SUPREME VIP MEMBERSHIP</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">∞</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>
|
||||
<!-- 主文案 -->
|
||||
<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-200/90 text-lg font-medium mb-1">敬请期待</p>
|
||||
<p class="text-center text-purple-300/50 text-sm">渔鱼余正在努力敲代码中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,6 +190,20 @@
|
||||
0%, 100% { transform: translateY(0); }
|
||||
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 {
|
||||
animation: glow 2s ease-in-out infinite alternate;
|
||||
}
|
||||
@@ -229,6 +229,8 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 从杯赛页面进入时传入的 contest_id,用于创建杯赛考试
|
||||
const PAGE_CONTEST_ID = {{ contest_id|default(none)|tojson }};
|
||||
// VIP 弹窗
|
||||
function showVipModal() {
|
||||
const modal = document.getElementById('vipModal');
|
||||
@@ -585,12 +587,16 @@ function submitExam() {
|
||||
}
|
||||
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', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ title, subject, duration, questions, scheduled_start, scheduled_end, score_release_time, access_password, visibility })
|
||||
body: JSON.stringify(payload)
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) { alert('试卷创建成功!'); window.location.href = '/exams'; }
|
||||
else alert(data.message);
|
||||
if (data.success) {
|
||||
alert('试卷创建成功!');
|
||||
window.location.href = PAGE_CONTEST_ID ? '/contests/' + PAGE_CONTEST_ID : '/exams';
|
||||
} else alert(data.message);
|
||||
}).catch(() => alert('创建失败'));
|
||||
}
|
||||
|
||||
|
||||
@@ -86,26 +86,31 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- 顶部信息栏 -->
|
||||
<div class="futuristic-card p-4 sticky top-0 z-20">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-lg font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">{{ exam.title }}</h1>
|
||||
<div class="mt-1 text-sm text-slate-500">
|
||||
{{ exam.subject }} · {{ exam.duration }}分钟 · 满分{{ exam.total_score }}分
|
||||
<div class="futuristic-card p-4 sticky top-0 z-20 shadow-lg">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<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-xs sm:text-sm text-slate-500 flex flex-wrap gap-2">
|
||||
<span>{{ exam.subject }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ exam.duration }}分钟</span>
|
||||
<span>·</span>
|
||||
<span>满分{{ exam.total_score }}分</span>
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-slate-500">
|
||||
<div class="flex items-center gap-3 sm:gap-4 flex-wrap">
|
||||
<div class="text-xs sm:text-sm text-slate-500 whitespace-nowrap">
|
||||
<span id="progress-text">0</span>/{{ questions|length }} 已答
|
||||
</div>
|
||||
<div class="flex items-center text-red-600 font-medium">
|
||||
<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>
|
||||
<div class="flex items-center text-red-600 font-medium text-sm sm:text-base whitespace-nowrap">
|
||||
<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>
|
||||
</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> 次
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +185,7 @@
|
||||
{% 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 }}">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -237,6 +242,7 @@ const SCHEDULED_END = null;
|
||||
let currentIndex = 0;
|
||||
let answers = {};
|
||||
let tabSwitchCount = 0;
|
||||
let examSubmitted = false; // 提交成功后允许离开
|
||||
|
||||
// ===== 图片上传 =====
|
||||
function examUpload(qid) {
|
||||
@@ -282,7 +288,7 @@ async function examUploadFiles(files, qid) {
|
||||
function initAnswers() {
|
||||
// 优先从服务器草稿恢复
|
||||
{% if draft %}
|
||||
const serverDraft = {{ draft.answers | tojson }};
|
||||
const serverDraft = {{ draft.get_answers() | tojson }};
|
||||
if (serverDraft && typeof serverDraft === 'object' && Object.keys(serverDraft).length > 0) {
|
||||
answers = serverDraft;
|
||||
restoreAnswersToForm();
|
||||
@@ -501,6 +507,9 @@ function doSubmit() {
|
||||
body: JSON.stringify({answers})
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) {
|
||||
examSubmitted = true;
|
||||
// 退出考试状态,通知好友
|
||||
fetch(`/api/exams/${EXAM_ID}/exit`, { method: 'POST' }).catch(() => {});
|
||||
// 清除本地存储
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(TIMER_KEY);
|
||||
@@ -518,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();
|
||||
initTimer();
|
||||
initTabDetection();
|
||||
showQuestion(0);
|
||||
|
||||
// 离开页面提醒
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -60,7 +60,7 @@
|
||||
{% if is_answer %}border-green-300 bg-green-50
|
||||
{% elif is_selected and not is_answer %}border-red-300 bg-red-50
|
||||
{% 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_answer %}<span class="text-xs text-green-600 font-medium">✓ 正确</span>{% endif %}
|
||||
</div>
|
||||
@@ -71,6 +71,18 @@
|
||||
{% else %}
|
||||
<span class="text-red-500 font-medium">0分</span>
|
||||
{% 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>
|
||||
{% else %}
|
||||
@@ -105,6 +117,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</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="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() {
|
||||
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 => {
|
||||
total += parseInt(input.value) || 0;
|
||||
});
|
||||
@@ -155,23 +156,8 @@ recalcTotal();
|
||||
function submitGrade() {
|
||||
const scores = {};
|
||||
{% for q in questions %}
|
||||
{% if q.type == 'choice' %}
|
||||
{% if answers.get(q.id|string,'') == q.get('answer','') %}
|
||||
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 %}
|
||||
const inp{{ q.id }} = document.getElementById('score-{{ q.id }}');
|
||||
scores['{{ q.id }}'] = inp{{ q.id }} ? (parseInt(inp{{ q.id }}.value) || 0) : 0;
|
||||
{% endfor %}
|
||||
fetch('/api/exams/{{ exam.id }}/grade/{{ submission.id }}', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
|
||||
{% if exam.status == 'closed' %}
|
||||
<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 %}
|
||||
<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-cyan-500 mr-1.5 animate-pulse"></span>进行中
|
||||
@@ -193,12 +195,12 @@
|
||||
{% endif %}
|
||||
</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-cyan-500/20 flex flex-wrap gap-2 justify-end">
|
||||
<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-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' %}
|
||||
<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 %}
|
||||
|
||||
@@ -25,7 +25,14 @@
|
||||
.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: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>
|
||||
<!-- 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>
|
||||
<body>
|
||||
<button class="print-btn no-print" onclick="window.print()">打印 / 导出PDF</button>
|
||||
@@ -61,10 +68,17 @@
|
||||
<div class="question">
|
||||
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
|
||||
<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 %}
|
||||
<div class="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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -78,6 +92,13 @@
|
||||
<div class="question">
|
||||
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
|
||||
<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-line"></div>
|
||||
</div>
|
||||
@@ -91,6 +112,13 @@
|
||||
<div class="question">
|
||||
<span class="q-num">{{ loop.index }}.</span> {{ q.content }}
|
||||
<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">
|
||||
{% for i in range(8) %}
|
||||
<div class="answer-line"></div>
|
||||
|
||||
@@ -2,43 +2,43 @@
|
||||
{% block title %}考试结果 - 智联青云{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">考试结果</h1>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<div class="futuristic-card p-6">
|
||||
<h2 class="text-xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">{{ exam.title }}</h2>
|
||||
<div class="mt-2 flex items-center text-sm text-slate-500 space-x-4">
|
||||
<div class="futuristic-card p-4 sm:p-6">
|
||||
<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 flex-wrap items-center text-xs sm:text-sm text-slate-500 gap-2 sm:gap-4">
|
||||
<span>{{ exam.subject }}</span>
|
||||
<span>满分{{ exam.total_score }}分</span>
|
||||
<span>提交时间:{{ submission.submitted_at }}</span>
|
||||
<span class="break-all">提交时间:{{ submission.submitted_at }}</span>
|
||||
</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 %}">
|
||||
{% if score_hidden %}
|
||||
<div class="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-base sm:text-lg font-medium text-blue-700">成绩尚未公布</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 %}
|
||||
<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-sm text-green-600 mt-1">批改人:{{ submission.graded_by }}</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-xs sm:text-sm text-green-600 mt-1">批改人:{{ submission.graded_by }}</div>
|
||||
{% else %}
|
||||
<div class="text-lg font-medium text-yellow-700">待批改</div>
|
||||
<div class="text-sm text-yellow-600">选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改</div>
|
||||
<div class="text-base sm:text-lg font-medium text-yellow-700">待批改</div>
|
||||
<div class="text-xs sm:text-sm text-yellow-600 break-words">选择题已自动批改得分:{{ submission.score }}分,主观题等待老师批改</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% for q in questions %}
|
||||
<div class="futuristic-card p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<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-3">
|
||||
<div class="flex justify-between">
|
||||
<p class="text-lg text-slate-900">{{ q.content }}</p>
|
||||
<span class="text-sm text-slate-400 whitespace-nowrap ml-4">({{ q.score }}分)</span>
|
||||
<div class="futuristic-card p-4 sm:p-6">
|
||||
<div class="flex items-start space-x-3 sm:space-x-4">
|
||||
<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 min-w-0">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between gap-2">
|
||||
<p class="text-base sm:text-lg text-slate-900 break-words">{{ q.content }}</p>
|
||||
<span class="text-xs sm:text-sm text-slate-400 whitespace-nowrap">({{ q.score }}分)</span>
|
||||
</div>
|
||||
{% if q.get('images') %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -57,7 +57,7 @@
|
||||
{% else %}
|
||||
<div class="w-5 h-5"></div>
|
||||
{% 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="badge-futuristic text-xs ml-2">✓ 正确答案</span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -85,4 +85,15 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 渲染页面中的数学公式
|
||||
if (typeof renderMathInElement === 'function') {
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],
|
||||
throwOnError: false
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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-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}}
|
||||
.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-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)}
|
||||
@@ -307,7 +304,7 @@
|
||||
<script>
|
||||
const CU = {{ user | tojson if user else 'null' }};
|
||||
let curTag = '全部';
|
||||
const REACTIONS = {like:'👍',love:'❤️',haha:'😂',wow:'😮',sad:'😢',angry:'😡'};
|
||||
const REACTIONS = {};
|
||||
const TAG_ICONS = {'官方公告':'📢','题目讨论':'📐','经验分享':'💡','求助答疑':'🙋','闲聊灌水':'☕'};
|
||||
const TAG_COLORS = {'官方公告':'red','题目讨论':'blue','经验分享':'green','求助答疑':'yellow','闲聊灌水':'purple'};
|
||||
|
||||
@@ -350,6 +347,9 @@ function insEmoji(em) {
|
||||
// 初始化富文本编辑器
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new RichEditor('new-content');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const postId = params.get('post');
|
||||
if (postId) openPost(parseInt(postId));
|
||||
});
|
||||
|
||||
// ===== 图片上传 =====
|
||||
@@ -443,12 +443,7 @@ function renderPosts(posts) {
|
||||
posts.forEach(p => {
|
||||
const tc = TAG_COLORS[p.tag] || 'slate';
|
||||
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, '[图片]');
|
||||
|
||||
@@ -459,8 +454,11 @@ function renderPosts(posts) {
|
||||
|
||||
<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">
|
||||
<span class="text-xl">${esc(p.author.charAt(0))}</span>
|
||||
<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">
|
||||
${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 ?
|
||||
'<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.' + authorLevel + '</div>'}
|
||||
@@ -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="flex items-center gap-3">
|
||||
<span class="text-sm font-bold text-slate-800 hover:text-indigo-600 transition-colors cursor-pointer" onclick="event.stopPropagation();showProfile('${p.author_id}')">${esc(p.author)}</span>
|
||||
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${randomLv}</span>` : ''}
|
||||
${!p.is_official ? `<span class="text-[10px] text-slate-400 flex items-center gap-1 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-100"><svg class="w-3 h-3 text-amber-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>Lv.${p.author_level || 1}</span>` : ''}
|
||||
<span class="text-xs text-slate-400 bg-slate-50 px-2 py-1 rounded-md">${p.created_at}</span>
|
||||
</div>
|
||||
|
||||
@@ -510,7 +508,6 @@ function renderPosts(posts) {
|
||||
</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>`;
|
||||
});
|
||||
@@ -566,12 +563,6 @@ async function openPost(pid) {
|
||||
const isTeacher = CU && (CU.role === 'teacher' || CU.role === 'admin');
|
||||
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 = '';
|
||||
if (p.has_poll) {
|
||||
try {
|
||||
@@ -612,7 +603,13 @@ async function openPost(pid) {
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -626,7 +623,6 @@ async function openPost(pid) {
|
||||
</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}
|
||||
<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">
|
||||
<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>
|
||||
@@ -635,7 +631,12 @@ async function openPost(pid) {
|
||||
<h3 class="text-sm font-bold text-slate-700 mb-4">💬 回复 (${replies.length})</h3>`;
|
||||
|
||||
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 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>`;
|
||||
@@ -648,7 +649,10 @@ async function openPost(pid) {
|
||||
const canDel = CU && (CU.id === r.author_id || CU.role === 'teacher');
|
||||
const canEdit = CU && CU.id === r.author_id;
|
||||
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 items-center justify-between">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@@ -715,10 +719,7 @@ async function toggleBmModal(pid) {
|
||||
}
|
||||
|
||||
async function reactPost(pid, reaction, btn) {
|
||||
if (!CU) { toast('请先登录','error'); return; }
|
||||
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);
|
||||
// removed
|
||||
}
|
||||
|
||||
async function votePoll(pid) {
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
{% block title %}登录 - 智联青云{% endblock %}
|
||||
{% block navbar %}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<canvas id="meteor-canvas" style="position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none;"></canvas>
|
||||
<div class="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative" style="z-index:1;">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent animate-pulse">登录您的账户</h2>
|
||||
<p class="mt-2 text-center text-sm text-slate-600">
|
||||
<p class="mt-2 text-center text-sm text-slate-300">
|
||||
或者 <a href="/register" class="font-medium text-primary hover:text-blue-500 transition-all duration-300 hover:scale-110 inline-block">注册新账户</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="futuristic-card py-8 px-4 sm:px-10">
|
||||
<div class="futuristic-card py-8 px-4 sm:px-10" style="background:rgba(255,255,255,0.92);backdrop-filter:blur(12px);">
|
||||
<div class="flex border-b border-slate-200 mb-6">
|
||||
<button id="tab-phone" onclick="switchTab('phone')" class="flex-1 pb-4 text-sm font-medium text-center text-primary border-b-2 border-primary transition-all duration-300 hover:scale-105">手机验证码登录</button>
|
||||
<button id="tab-email" onclick="switchTab('email')" class="flex-1 pb-4 text-sm font-medium text-center text-slate-500 hover:text-slate-700 transition-all duration-300 hover:scale-105">邮箱密码登录</button>
|
||||
@@ -143,5 +144,109 @@ async function handleEmailLogin(e) {
|
||||
}
|
||||
|
||||
fetchCaptcha('phone');
|
||||
|
||||
// 流星粒子背景
|
||||
(function() {
|
||||
const canvas = document.getElementById('meteor-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W, H;
|
||||
window._mouseX = -9999;
|
||||
window._mouseY = -9999;
|
||||
document.addEventListener('mousemove', function(e) { window._mouseX = e.clientX; window._mouseY = e.clientY; });
|
||||
document.addEventListener('mouseleave', function() { window._mouseX = -9999; window._mouseY = -9999; });
|
||||
|
||||
function resize() {
|
||||
W = canvas.width = window.innerWidth;
|
||||
H = canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const COUNT = 120;
|
||||
const MOUSE_R = 150;
|
||||
const REPEL = 8;
|
||||
const particles = [];
|
||||
|
||||
function rand(a, b) { return Math.random() * (b - a) + a; }
|
||||
|
||||
function spawn(warm) {
|
||||
const angle = rand(Math.PI * 0.6, Math.PI * 0.8);
|
||||
const speed = rand(2, 5);
|
||||
const p = {
|
||||
x: rand(0, W * 1.2),
|
||||
y: warm ? rand(-H * 0.3, H) : rand(-H * 0.3, 0),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
len: rand(15, 40),
|
||||
size: rand(0.8, 2),
|
||||
alpha: rand(0.3, 0.9),
|
||||
hue: rand(190, 260),
|
||||
life: 0,
|
||||
maxLife: rand(120, 300)
|
||||
};
|
||||
if (warm) p.life = rand(0, p.maxLife);
|
||||
return p;
|
||||
}
|
||||
|
||||
for (let i = 0; i < COUNT; i++) particles.push(spawn(true));
|
||||
|
||||
function loop() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7);
|
||||
bg.addColorStop(0, '#0f172a');
|
||||
bg.addColorStop(1, '#020617');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const mx = window._mouseX, my = window._mouseY;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
p.life++;
|
||||
|
||||
const dx = p.x - mx, dy = p.y - my;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < MOUSE_R && dist > 0) {
|
||||
const f = (1 - dist / MOUSE_R) * REPEL;
|
||||
p.x += (dx / dist) * f;
|
||||
p.y += (dy / dist) * f;
|
||||
}
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
let a = p.alpha;
|
||||
if (p.life < 20) a *= p.life / 20;
|
||||
if (p.life > p.maxLife - 30) a *= (p.maxLife - p.life) / 30;
|
||||
|
||||
const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
const tx = p.x - (p.vx / spd) * p.len * 0.6;
|
||||
const ty = p.y - (p.vy / spd) * p.len * 0.6;
|
||||
|
||||
const g = ctx.createLinearGradient(tx, ty, p.x, p.y);
|
||||
g.addColorStop(0, `hsla(${p.hue},80%,70%,0)`);
|
||||
g.addColorStop(1, `hsla(${p.hue},80%,70%,${a})`);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, ty);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.strokeStyle = g;
|
||||
ctx.lineWidth = p.size;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `hsla(${p.hue},90%,85%,${a})`;
|
||||
ctx.fill();
|
||||
|
||||
if (p.life >= p.maxLife || p.y > H + 50 || p.x < -100) {
|
||||
particles[i] = spawn(false);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -148,36 +148,6 @@
|
||||
|
||||
<!-- 右侧:主要内容 -->
|
||||
<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="futuristic-card-dark text-center group transition-all duration-300 hover:scale-105">
|
||||
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-2xl flex items-center justify-center text-cyan-400 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3 border border-cyan-500/30">
|
||||
<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-300 group-hover:text-cyan-400 transition-colors">通知中心</div>
|
||||
</a>
|
||||
<a href="/chat" class="futuristic-card-dark text-center group transition-all duration-300 hover:scale-105">
|
||||
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-emerald-500/20 to-teal-500/20 rounded-2xl flex items-center justify-center text-emerald-400 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3 border border-emerald-500/30">
|
||||
<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-300 group-hover:text-emerald-400 transition-colors">我的消息</div>
|
||||
</a>
|
||||
<div class="futuristic-card-dark text-center group cursor-pointer transition-all duration-300 hover:scale-105" onclick="document.getElementById('exam-history-tab').scrollIntoView({behavior:'smooth'})">
|
||||
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-purple-500/20 to-fuchsia-500/20 rounded-2xl flex items-center justify-center text-purple-400 shadow-inner group-hover:scale-110 group-hover:rotate-6 transition-transform mb-3 border border-purple-500/30">
|
||||
<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-300 group-hover:text-purple-400 transition-colors">考试记录</div>
|
||||
</div>
|
||||
<div class="futuristic-card-dark text-center group cursor-pointer transition-all duration-300 hover:scale-105" onclick="document.getElementById('bookmarks-tab').scrollIntoView({behavior:'smooth'})">
|
||||
<div class="w-12 h-12 mx-auto bg-gradient-to-br from-amber-500/20 to-orange-500/20 rounded-2xl flex items-center justify-center text-amber-400 shadow-inner group-hover:scale-110 group-hover:-rotate-6 transition-transform mb-3 border border-amber-500/30">
|
||||
<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-300 group-hover:text-amber-400 transition-colors">我的收藏</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 高级选项卡内容区 -->
|
||||
<div class="futuristic-card-dark overflow-hidden">
|
||||
<!-- 科幻未来标签栏 -->
|
||||
@@ -277,23 +247,23 @@ async function loadFriends() {
|
||||
if (!data.success) throw new Error(data.message);
|
||||
if (data.friends.length === 0) {
|
||||
container.innerHTML = '<div class="col-span-2 text-center py-4 text-slate-400">暂无好友</div>';
|
||||
return;
|
||||
} else {
|
||||
let html = '';
|
||||
data.friends.forEach(f => {
|
||||
html += `
|
||||
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
|
||||
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
|
||||
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
|
||||
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
|
||||
</div>
|
||||
<a href="/chat?dm=${f.id}" class="btn-futuristic px-3 py-1.5 text-xs rounded-lg transition-colors font-medium">私聊</a>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
let html = '';
|
||||
data.friends.forEach(f => {
|
||||
html += `
|
||||
<div class="flex items-center space-x-3 p-3 border border-slate-100 rounded-xl bg-white">
|
||||
<div class="w-10 h-10 bg-slate-200 rounded-full flex items-center justify-center text-slate-600 text-sm font-bold overflow-hidden">
|
||||
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : f.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-slate-900 truncate">${f.name}</div>
|
||||
<div class="text-xs text-slate-400">好友 · ${f.created_at}</div>
|
||||
</div>
|
||||
<a href="/chat?dm=${f.id}" class="btn-futuristic px-3 py-1.5 text-xs rounded-lg transition-colors font-medium">私聊</a>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="col-span-2 text-center py-4 text-red-500">加载失败</div>';
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
{% block title %}注册 - 智联青云{% endblock %}
|
||||
{% block navbar %}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<!-- 覆盖全局背景,确保全屏填充 -->
|
||||
<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">
|
||||
<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">
|
||||
已有账户? <a href="/login" class="font-medium text-primary hover:text-blue-500 transition-all duration-300 hover:scale-110 inline-block">立即登录</a>
|
||||
<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-sky-800 font-medium">
|
||||
已有账户? <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>
|
||||
</div>
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="futuristic-card py-8 px-4 sm:px-10">
|
||||
<div class="relative z-10 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<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">
|
||||
<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>
|
||||
@@ -259,5 +264,153 @@ async function handleEmailRegister(e) {
|
||||
|
||||
// 初始化图形验证码(默认手机选项卡)
|
||||
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>
|
||||
{% endblock %}
|
||||