diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 229ba16..57ab03a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(cd \"D:/360MoveData/Users/HEIHAHA/Documents/WeChat Files/wxid_k0iaj5miuryq22/FileStorage/File/2026-02/超级大网站2月23号\" && py -3 -c \"\nimport sys, os\nsys.path.insert\\(0, os.path.dirname\\(os.path.abspath\\('app.py'\\)\\)\\)\nfrom app import app\nfrom models import db, User\nwith app.app_context\\(\\):\n users = User.query.all\\(\\)\n for u in users:\n print\\(f'id={u.id}, name={u.name}, email={u.email}, phone={u.phone}, role={u.role}'\\)\n if not users:\n print\\('No users in database'\\)\n\" 2>&1)", "Bash(cd \"D:/360MoveData/Users/HEIHAHA/Documents/WeChat Files/wxid_k0iaj5miuryq22/FileStorage/File/2026-02/超级大网站2月23号\" && py -3 -c \"\nimport os, glob\ntemplates = glob.glob\\('templates/*.html'\\)\ncount = 0\nfor f in templates:\n with open\\(f, 'r', encoding='utf-8'\\) as fh:\n content = fh.read\\(\\)\n if '联考平台' in content:\n new_content = content.replace\\('联考平台', '智联青云'\\)\n with open\\(f, 'w', encoding='utf-8'\\) as fh:\n fh.write\\(new_content\\)\n count += 1\n print\\(f' replaced in {f}'\\)\nprint\\(f'Done: {count} files updated'\\)\n\" 2>&1)", "Bash(git stash:*)", - "Bash(git pull:*)" + "Bash(git pull:*)", + "Bash(iconv:*)" ] } } diff --git a/add_visibility_column.py b/add_visibility_column.py new file mode 100644 index 0000000..c3d14ff --- /dev/null +++ b/add_visibility_column.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +添加 exam.visibility 字段的数据库迁移脚本 +""" + +from app import app, db +from models import Exam + +def add_visibility_column(): + """为 Exam 表添加 visibility 字段""" + with app.app_context(): + try: + # 检查字段是否已存在 + inspector = db.inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('exam')] + + if 'visibility' in columns: + print("visibility 字段已存在,无需添加") + return + + # 添加字段 + with db.engine.connect() as conn: + conn.execute(db.text("ALTER TABLE exam ADD COLUMN visibility VARCHAR(20) DEFAULT 'private'")) + conn.commit() + print("成功添加 visibility 字段") + + # 将所有现有考试设置为公开(保持向后兼容) + conn.execute(db.text("UPDATE exam SET visibility = 'public' WHERE visibility IS NULL")) + conn.commit() + print("已将现有考试设置为公开") + + except Exception as e: + print(f"添加字段时出错: {e}") + raise + +if __name__ == '__main__': + print("开始添加 visibility 字段...") + add_visibility_column() + print("完成!") diff --git a/app.py b/app.py index 075eea9..9255c31 100644 --- a/app.py +++ b/app.py @@ -554,6 +554,16 @@ def exam_list(): subject_filter = request.args.get('subject', '').strip() query = Exam.query + + # 只显示公开考试或用户自己创建的私有考试 + from sqlalchemy import or_ + query = query.filter( + or_( + Exam.visibility == 'public', + Exam.creator_id == user.get('id') + ) + ) + if subject_filter: query = query.filter_by(subject=subject_filter) if search_query: @@ -1059,6 +1069,23 @@ def apply_contest(): flash('满分分数必须大于0') return redirect(url_for('apply_contest')) + # 处理图片上传 + image_url = None + if 'image' in request.files: + file = request.files['image'] + if file and file.filename: + if allowed_file(file.filename): + filename = secure_filename(file.filename) + # 生成唯一文件名 + ext = filename.rsplit('.', 1)[1].lower() + unique_filename = f"contest_{user_id}_{int(time.time())}.{ext}" + filepath = os.path.join(UPLOAD_FOLDER, unique_filename) + file.save(filepath) + image_url = f'/static/uploads/{unique_filename}' + else: + flash('不支持的图片格式,请上传 JPG、PNG 或 GIF 格式的图片') + return redirect(url_for('apply_contest')) + # 如果存在申请,更新它;否则创建新申请 if existing: # 更新现有申请 @@ -1073,6 +1100,8 @@ def apply_contest(): existing.responsible_phone = responsible_phone existing.responsible_email = responsible_email existing.organization = organization + if image_url: + existing.image = image_url existing.status = 'pending' existing.applied_at = datetime.utcnow() existing.reviewed_at = None @@ -1092,7 +1121,8 @@ def apply_contest(): responsible_person=responsible_person, responsible_phone=responsible_phone, responsible_email=responsible_email, - organization=organization + organization=organization, + image=image_url ) db.session.add(app) flash_msg = '申请已提交,请等待管理员审核' @@ -2501,20 +2531,26 @@ def api_create_exam(): return jsonify({'success': False, 'message': '请先登录'}), 401 data = request.get_json(force=True, silent=True) contest_id = data.get('contest_id') + visibility = data.get('visibility', 'private') # 默认私有 - # 杯赛考试:只有杯赛负责人或系统管理员可以创建 + # 杯赛考试:只有杯赛负责人、老师或系统管理员可以创建 if contest_id: contest = Contest.query.get(contest_id) if not contest or contest.status == 'abolished': return jsonify({'success': False, 'message': '杯赛不存在或已废止'}), 400 membership = ContestMembership.query.filter_by( - user_id=user['id'], contest_id=contest_id, role='owner').first() + user_id=user['id'], contest_id=contest_id).first() + # 检查是否是负责人、老师或管理员 if not membership and user.get('role') != 'admin': - return jsonify({'success': False, 'message': '只有杯赛负责人才能组织考试'}), 403 + return jsonify({'success': False, 'message': '只有杯赛成员才能创建杯赛考试'}), 403 + # 杯赛考试默认为公开(杯赛内可见) + if visibility == 'private': + visibility = 'public' else: - # 普通考试:需要 teacher 或 admin 角色 - if user.get('role') not in ('teacher', 'admin'): - return jsonify({'success': False, 'message': '无权限'}), 403 + # 普通考试:所有登录用户都可以创建私有考试 + # 只有 teacher 或 admin 可以创建公开考试 + if visibility == 'public' and user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '只有教师和管理员可以创建公开考试'}), 403 title = data.get('title', '') subject = data.get('subject', '') @@ -2532,7 +2568,8 @@ def api_create_exam(): duration=duration, total_score=total_score, creator_id=user.get('id'), - contest_id=contest_id + contest_id=contest_id, + visibility=visibility ) # 解析预定时间 if scheduled_start: @@ -2708,6 +2745,30 @@ def api_update_exam_status(exam_id): db.session.commit() return jsonify({'success': True, 'message': f'试卷已{"发布" if new_status == "available" else "关闭"}'}) +@app.route('/api/exams//visibility', methods=['POST']) +@login_required +def api_update_exam_visibility(exam_id): + user = session.get('user') + exam = Exam.query.get(exam_id) + if not exam: + return jsonify({'success': False, 'message': '试卷不存在'}), 404 + if exam.creator_id != user.get('id'): + return jsonify({'success': False, 'message': '只能修改自己创建的试卷'}), 403 + + data = request.get_json(force=True, silent=True) + new_visibility = data.get('visibility', '') + + if new_visibility not in ('private', 'public'): + return jsonify({'success': False, 'message': '无效的可见性设置'}), 400 + + # 检查权限:只有教师和管理员可以设置为公开 + if new_visibility == 'public' and user.get('role') not in ('teacher', 'admin'): + return jsonify({'success': False, 'message': '只有教师和管理员可以创建公开考试'}), 403 + + exam.visibility = new_visibility + db.session.commit() + return jsonify({'success': True, 'message': f'试卷已设置为{"公开" if new_visibility == "public" else "私有"}'}) + @app.route('/api/exams/', methods=['DELETE']) def api_delete_exam(exam_id): user = session.get('user') @@ -3229,12 +3290,20 @@ def api_search_posts(): uid = user.get('id') if user else None data = [] for p in posts: + # 计算作者等级 + author_post_count = Post.query.filter_by(author_id=p.author_id).count() + author_reply_count = Reply.query.filter_by(author_id=p.author_id).count() + author_likes = db.session.query(db.func.sum(Post.likes)).filter_by(author_id=p.author_id).scalar() or 0 + author_points = author_post_count * 10 + author_reply_count * 3 + author_likes * 2 + author_level = calc_level(author_points) + p_dict = { 'id': p.id, 'title': p.title, 'content': p.content[:200] + ('...' if len(p.content) > 200 else ''), 'author': p.author.name, 'author_id': p.author_id, + 'author_level': author_level, 'tag': p.tag, 'is_official': p.is_official, 'pinned': p.pinned, diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..acf42a7 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""创建管理员账号脚本""" + +from app import app, db +from models import User + +def create_admin(): + with app.app_context(): + # 检查邮箱是否已存在 + existing_user = User.query.filter_by(email='1512344589@qq.com').first() + if existing_user: + print(f'邮箱 1512344589@qq.com 已存在') + print(f'用户名: {existing_user.name}') + print(f'角色: {existing_user.role}') + + # 更新为管理员 + if existing_user.role != 'admin': + existing_user.role = 'admin' + existing_user.password = '1' + db.session.commit() + print('已将该用户更新为管理员') + else: + print('该用户已经是管理员') + return + + # 创建新管理员 + admin = User( + name='管理员', + email='1512344589@qq.com', + password='1', + role='admin' + ) + + db.session.add(admin) + db.session.commit() + + print('管理员账号创建成功!') + print(f'邮箱: {admin.email}') + print(f'密码: 1') + print(f'角色: {admin.role}') + print(f'用户ID: {admin.id}') + +if __name__ == '__main__': + create_admin() diff --git a/database.db b/database.db new file mode 100644 index 0000000..e69de29 diff --git a/fix_question_ids.py b/fix_question_ids.py new file mode 100644 index 0000000..6591a37 --- /dev/null +++ b/fix_question_ids.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +修复考试题目ID不连续的问题 +重新为所有考试的题目分配连续的ID +""" + +from app import app, db +from models import Exam +import json + +def fix_question_ids(): + """修复所有考试的题目ID,使其连续""" + with app.app_context(): + exams = Exam.query.all() + fixed_count = 0 + + for exam in exams: + try: + # 获取题目列表 + if exam.is_encrypted and exam.encrypted_questions: + from app import decrypt_questions + questions = json.loads(decrypt_questions(exam.encrypted_questions)) + else: + questions = exam.get_questions() + + if not questions: + continue + + # 检查ID是否连续 + needs_fix = False + for idx, q in enumerate(questions, 1): + if q.get('id') != idx: + needs_fix = True + break + + if needs_fix: + # 重新分配连续的ID + for idx, q in enumerate(questions, 1): + q['id'] = idx + + # 保存修复后的题目 + questions_json = json.dumps(questions) + exam.set_questions(questions) + + if exam.is_encrypted: + from app import encrypt_questions + exam.encrypted_questions = encrypt_questions(questions_json) + + fixed_count += 1 + print(f"修复考试 #{exam.id}: {exam.title}") + + except Exception as e: + print(f"处理考试 #{exam.id} 时出错: {e}") + continue + + if fixed_count > 0: + db.session.commit() + print(f"\n总共修复了 {fixed_count} 份考试") + else: + print("\n没有需要修复的考试") + +if __name__ == '__main__': + print("开始修复考试题目ID...") + fix_question_ids() + print("修复完成!") diff --git a/instance/database.db b/instance/database.db index ccb214d..3b658c2 100644 Binary files a/instance/database.db and b/instance/database.db differ diff --git a/models.py b/models.py index 9c68053..d61eaf8 100644 --- a/models.py +++ b/models.py @@ -68,6 +68,8 @@ class ContestApplication(db.Model): # 拒绝次数控制 rejection_count = db.Column(db.Integer, default=0) # 被拒绝次数 last_rejected_at = db.Column(db.DateTime) # 最后一次被拒绝的时间 + # 杯赛图片 + image = db.Column(db.String(255)) # 杯赛图片URL user = db.relationship('User', backref='contest_applications') @@ -146,6 +148,7 @@ class Exam(db.Model): score_release_time = db.Column(db.DateTime, nullable=True) # 成绩公布时间 created_at = db.Column(db.DateTime, default=datetime.utcnow) status = db.Column(db.String(20), default='available') + visibility = db.Column(db.String(20), default='private') # 'private' 或 'public' creator = db.relationship('User', back_populates='exams_created') submissions = db.relationship('Submission', back_populates='exam', lazy=True, cascade='all, delete-orphan') diff --git a/static/css/style.css b/static/css/style.css index 92a6859..e473834 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,6 +1,54 @@ /* 自定义样式 */ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background-attachment: fixed; +} + +/* 科幻背景动画 */ +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-20px); } +} + +@keyframes glow-pulse { + 0%, 100% { box-shadow: 0 0 20px rgba(59, 130, 246, 0.4); } + 50% { box-shadow: 0 0 40px rgba(59, 130, 246, 0.8); } +} + +@keyframes shimmer { + 0% { background-position: -1000px 0; } + 100% { background-position: 1000px 0; } +} + +/* 高级卡片样式 */ +.futuristic-card { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(255, 255, 255, 0.1) inset; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.futuristic-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.2) inset; +} + +.futuristic-card-dark { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(20px); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.futuristic-card-dark:hover { + transform: translateY(-8px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border-color: rgba(59, 130, 246, 0.5); } /* 自定义滚动条 */ @@ -181,4 +229,247 @@ button[onclick^="quickScore"]:active { max-height: 120px; overflow-y: auto; } +} + +/* 高级按钮样式 */ +.btn-futuristic { + position: relative; + overflow: hidden; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + border-radius: 16px; + padding: 14px 32px; + color: white; + font-weight: 600; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); +} + +.btn-futuristic:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.6); +} + +.btn-futuristic::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); + transition: left 0.5s; +} + +.btn-futuristic:hover::before { + left: 100%; +} + +.btn-outline-futuristic { + background: transparent; + border: 2px solid rgba(102, 126, 234, 0.5); + color: #667eea; + border-radius: 16px; + padding: 12px 30px; + font-weight: 600; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.btn-outline-futuristic:hover { + background: rgba(102, 126, 234, 0.1); + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); +} + +/* 输入框样式 */ +.input-futuristic { + background: rgba(255, 255, 255, 0.9); + border: 2px solid rgba(102, 126, 234, 0.2); + border-radius: 12px; + padding: 12px 16px; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.input-futuristic:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1); + background: rgba(255, 255, 255, 1); +} + +/* 导航栏样式 */ +.navbar-futuristic { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(102, 126, 234, 0.1); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +/* 标签样式 */ +.badge-futuristic { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 6px 14px; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3); +} + +.badge-outline-futuristic { + background: transparent; + border: 2px solid rgba(102, 126, 234, 0.5); + color: #667eea; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 600; +} + +/* 分隔线 */ +.divider-futuristic { + height: 2px; + background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.5), transparent); + margin: 2rem 0; +} + +/* 加载动画 */ +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(102, 126, 234, 0.2); + border-top-color: #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 通知提示 */ +.notification-futuristic { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-left: 4px solid #667eea; + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + animation: slideInRight 0.3s ease; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* 表格样式 */ +.table-futuristic { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); +} + +.table-futuristic thead { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.table-futuristic tbody tr { + border-bottom: 1px solid rgba(102, 126, 234, 0.1); + transition: all 0.2s ease; +} + +.table-futuristic tbody tr:hover { + background: rgba(102, 126, 234, 0.05); +} + +/* 模态框样式 */ +.modal-futuristic { + background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(30px); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +/* 进度条 */ +.progress-futuristic { + height: 8px; + background: rgba(102, 126, 234, 0.2); + border-radius: 10px; + overflow: hidden; +} + +.progress-bar-futuristic { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + border-radius: 10px; + transition: width 0.3s ease; + box-shadow: 0 0 10px rgba(102, 126, 234, 0.5); +} + +/* 工具提示 */ +.tooltip-futuristic { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(10px); + color: white; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.875rem; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); +} + +/* 头像样式 */ +.avatar-futuristic { + border: 3px solid rgba(102, 126, 234, 0.5); + border-radius: 50%; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + transition: all 0.3s ease; +} + +.avatar-futuristic:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); +} + +/* 侧边栏 */ +.sidebar-futuristic { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-right: 1px solid rgba(102, 126, 234, 0.1); + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05); +} + +/* 页脚 */ +.footer-futuristic { + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(20px); + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); +} + +/* 粒子背景容器 */ +.particles-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + pointer-events: none; } \ No newline at end of file diff --git a/templates/admin_contests.html b/templates/admin_contests.html index af50d6d..ce97f16 100644 --- a/templates/admin_contests.html +++ b/templates/admin_contests.html @@ -4,24 +4,26 @@ {% block admin_content %}
-

杯赛管理

+

杯赛管理

-
- - - - - - - - - - - - - -
ID名称主办方状态开始日期操作
- +
+
+ + + + + + + + + + + + + +
ID名称主办方状态开始日期操作
+
+
{% endblock %} @@ -29,11 +31,11 @@ {% block scripts %} {% endblock %} + diff --git a/templates/admin_exams.html b/templates/admin_exams.html index 3fe7974..330c904 100644 --- a/templates/admin_exams.html +++ b/templates/admin_exams.html @@ -4,41 +4,43 @@ {% block admin_content %}
-

考试管理

+

考试管理

-
- - - - - - - - - - - - - -
ID标题科目出题人状态创建时间操作
- {% endblock %} {% block scripts %} - + + +
+ +
{% block navbar %} -