diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eeb8a6e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/__pycache__ diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..759b0ea --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +# Backend package + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..16f41e0 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,31 @@ +""" +数据库配置文件 +""" +import os +from typing import Optional +# MySQL数据库配置 + +DATABASE_CONFIG = { + "host": os.getenv("DB_HOST", "localhost"), + "port": int(os.getenv("DB_PORT", 3306)), + "user": os.getenv("DB_USER", "root"), + "password": os.getenv("DB_PASSWORD", ""), + "database": os.getenv("DB_NAME", "rollcall_system"), + "charset": "utf8mb4" +} +if(mod:=os.getenv("DEV_MOD","DEV")): + if(mod=="TEST"): + DATABASE_CONFIG["database"] = "test_"+DATABASE_CONFIG["database"] + +# FastAPI配置 +API_HOST = "127.0.0.1" +API_PORT = 8000 + + +# 数据库连接URL +def get_database_url() -> str: + """生成数据库连接URL""" + config = DATABASE_CONFIG + return (f"mysql+pymysql://{config['user']}:{config['password']}" + f"@{config['host']}:{config['port']}/{config['database']}" + f"?charset={config['charset']}") diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..9d1856c --- /dev/null +++ b/backend/database.py @@ -0,0 +1,41 @@ +""" +数据库连接和会话管理 +""" +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.session import Session +from backend.config import get_database_url,DATABASE_CONFIG + +# 创建数据库引擎 +engine = create_engine( + get_database_url(), + pool_pre_ping=True, + pool_recycle=3600, + echo=False # 设置为True可以看到SQL语句 +) + +# 创建会话工厂 +SessionLocal: sessionmaker[Session] = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 声明基类 +Base = declarative_base() + +def get_db(): + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() + +def init_db(): + """初始化数据库表""" + from backend.models import Student, RollCallRecord, ScoreRecord + from os import getenv + if(mod:=getenv("DEV_MOD","DEV")): + if(mod=="TEST"): + Base.metadata.drop_all(bind=engine) + print("已清空测试数据库表") + Base.metadata.create_all(bind=engine) + diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..4c9eeec --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,24 @@ +""" +数据库初始化脚本 +""" +from backend.database import init_db, engine +from backend.models import Base + +def main(): + """初始化数据库""" + print("正在初始化数据库...") + try: + # 创建所有表 + Base.metadata.create_all(bind=engine) + print("数据库初始化成功!") + print("表已创建:students, rollcall_records, score_records") + except Exception as e: + print(f"数据库初始化失败: {e}") + print("\n请检查:") + print("1. MySQL数据库是否已安装并运行") + print("2. backend/config.py 中的数据库配置是否正确") + print("3. 数据库是否已创建") + +if __name__ == "__main__": + main() + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..7d13495 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,51 @@ +""" +FastAPI主程序入口 +""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from backend.database import init_db +from backend.routers import students, rollcall, scores + +# 创建FastAPI应用 +app = FastAPI(title="课堂随机点名系统API", description="基于FastAPI的课堂随机点名系统后端API", version="1.0.0") + +# 配置CORS(允许前端跨域访问) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境应设置具体的前端地址 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(students.router) +app.include_router(rollcall.router) +app.include_router(scores.router) + +@app.on_event("startup") +async def startup_event(): + """应用启动时初始化数据库""" + try: + init_db() + print("数据库初始化成功") + except Exception as e: + print(f"数据库初始化失败: {e}") + + +@app.get("/") +def root(): + """根路径""" + return {"message": "课堂随机点名系统API", "version": "1.0.0", "docs": "/docs"} + + +@app.get("/health") +def health_check(): + """健康检查""" + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + from backend.config import API_HOST, API_PORT + uvicorn.run(app, host=API_HOST, port=API_PORT) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..9cf8cff --- /dev/null +++ b/backend/models.py @@ -0,0 +1,57 @@ +""" +数据库模型定义 +""" +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from backend.database import Base +class Student(Base): + """学生表""" + __tablename__ = "students" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + student_id = Column(String(50), unique=True, index=True, nullable=False, comment="学号") + name = Column(String(100), nullable=False, comment="姓名") + major = Column(String(100), comment="专业") + total_score = Column(Float, default=0.0, comment="总积分") + random_rollcall_count = Column(Integer, default=0, comment="随机点名次数") + order_rollcall_count = Column(Integer, default=0, comment="顺序点名次数") + transfer_right_count = Column(Integer, default=0, comment="转移权次数") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") + + # 关系 + rollcall_records = relationship("RollCallRecord", back_populates="student") + score_records = relationship("ScoreRecord", back_populates="student") + +class RollCallRecord(Base): + """点名记录表""" + __tablename__ = "rollcall_records" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + student_id = Column(Integer, ForeignKey("students.id"), nullable=False, comment="学生ID") + rollcall_type = Column(String(20), nullable=False, comment="点名类型:random/order") + is_present = Column(Boolean, default=False, comment="是否到达") + question_repeat_score = Column(Float, default=0.0, comment="重复问题得分") + answer_score = Column(Float, default=0.0, comment="回答问题得分") + attendance_score = Column(Float, default=0.0, comment="到达得分") + total_score_change = Column(Float, default=0.0, comment="本次积分变化") + event_type = Column(String(50), comment="随机事件类型") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + # 关系 + student = relationship("Student", back_populates="rollcall_records") + +class ScoreRecord(Base): + """积分记录表""" + __tablename__ = "score_records" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + student_id = Column(Integer, ForeignKey("students.id"), nullable=False, comment="学生ID") + score_change = Column(Float, nullable=False, comment="积分变化") + reason = Column(String(200), comment="积分变化原因") + created_at = Column(DateTime, default=datetime.now, comment="创建时间") + + # 关系 + student = relationship("Student", back_populates="score_records") + diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..c15851f --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1,2 @@ +# Routers package + diff --git a/backend/routers/rollcall.py b/backend/routers/rollcall.py new file mode 100644 index 0000000..aaabfd7 --- /dev/null +++ b/backend/routers/rollcall.py @@ -0,0 +1,186 @@ +""" +点名相关API路由 +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime +from backend.database import get_db +from backend.models import Student, RollCallRecord, ScoreRecord +from backend.schemas import ( + RollCallRequest, RollCallResult, RollCallRecordCreate, + RollCallRecordResponse +) +from backend.utils.probability import get_random_student, get_next_order_student +from backend.utils.events import trigger_random_event +from typing import Optional, List + +router = APIRouter(prefix="/api/rollcall", tags=["rollcall"]) + +# 存储当前顺序点名的学生ID(简单实现,生产环境应使用Redis等) +current_order_student_id: Optional[int] = None + +@router.post("/start", response_model=RollCallResult) +def start_rollcall(request: RollCallRequest, db: Session = Depends(get_db)): + """开始点名""" + global current_order_student_id + + try: + if request.rollcall_type == "random": + # 随机点名 + student = get_random_student(db) + student.random_rollcall_count += 1 + elif request.rollcall_type == "order": + # 顺序点名 + student = get_next_order_student(db, current_order_student_id) + current_order_student_id = student.id + student.order_rollcall_count += 1 + else: + raise HTTPException(status_code=400, detail="无效的点名类型") + + db.commit() + + return RollCallResult( + student_id=student.id, + student_code=student.student_id, + name=student.name, + major=student.major, + total_score=student.total_score, + random_rollcall_count=student.random_rollcall_count + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"点名失败: {str(e)}") + +@router.post("/record", response_model=RollCallRecordResponse) +def record_rollcall(record: RollCallRecordCreate, db: Session = Depends(get_db)): + """记录点名结果并更新积分""" + student = db.query(Student).filter(Student.id == record.student_id).first() + if not student: + raise HTTPException(status_code=404, detail="学生不存在") + + # 触发随机事件 + event = trigger_random_event() + event_multiplier = event["multiplier"] + + # 计算积分变化 + attendance_score = 1.0 if record.is_present else 0.0 + question_repeat_score = record.question_repeat_score # -1, 0, 0.5 + answer_score: float = record.answer_score # 0-3 + + # 应用事件倍数 + total_score_change = ( + (attendance_score + question_repeat_score + answer_score) * event_multiplier + ) + + # 更新学生积分 + student.total_score += total_score_change + # 检查转移权(被点名两次后获得一次转移权) + total_rollcall_count = student.random_rollcall_count + student.order_rollcall_count + if total_rollcall_count % 2 == 0 and total_rollcall_count > 0: + student.transfer_right_count += 1 + + # 创建点名记录 + rollcall_record = RollCallRecord( + student_id=record.student_id, + rollcall_type=record.rollcall_type, + is_present=record.is_present, + question_repeat_score=question_repeat_score, + answer_score=answer_score, + attendance_score=attendance_score, + total_score_change=total_score_change, + event_type=event["type"] + ) + db.add(rollcall_record) + + # 创建积分记录 + score_record = ScoreRecord( + student_id=record.student_id, + score_change=total_score_change, + reason=f"点名记录: {event['name']}" + ) + db.add(score_record) + + db.commit() + db.refresh(rollcall_record) + tmp = RollCallRecordResponse( + id=rollcall_record.id, + student_id=student.id, + student_code=student.student_id, + student_name=student.name, + rollcall_type=rollcall_record.rollcall_type, + is_present=rollcall_record.is_present, + total_score_change=rollcall_record.total_score_change, + created_at=rollcall_record.created_at + ) + print(tmp.model_dump_json()) + return RollCallRecordResponse( + id=rollcall_record.id, + student_id=student.id, + student_code=student.student_id, + student_name=student.name, + rollcall_type=rollcall_record.rollcall_type, + is_present=rollcall_record.is_present, + total_score_change=rollcall_record.total_score_change, + created_at=rollcall_record.created_at + ) + +@router.get("/records", response_model=List[RollCallRecordResponse]) +def get_rollcall_records(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """获取点名记录列表""" + records = ( + db.query(RollCallRecord) + .join(Student) + .offset(skip) + .limit(limit) + .order_by(RollCallRecord.created_at.desc()) + .all() + ) + + result = [] + for record in records: + result.append(RollCallRecordResponse( + id=record.id, + student_id=record.student_id, + student_code=record.student.student_id, + student_name=record.student.name, + rollcall_type=record.rollcall_type, + is_present=record.is_present, + total_score_change=record.total_score_change, + created_at=record.created_at + )) + + return result + +@router.post("/transfer") +def transfer_rollcall(request_data: dict, db: Session = Depends(get_db)): + """转移点名(使用转移权)""" + from_student_id = request_data.get("from_student_id") + to_student_id = request_data.get("to_student_id") + + if not from_student_id or not to_student_id: + raise HTTPException(status_code=400, detail="缺少必要参数") + + from_student = db.query(Student).filter(Student.id == from_student_id).first() + to_student = db.query(Student).filter(Student.id == to_student_id).first() + + if not from_student or not to_student: + raise HTTPException(status_code=404, detail="学生不存在") + + if from_student.transfer_right_count <= 0: + raise HTTPException(status_code=400, detail="没有可用的转移权") + + # 使用转移权 + from_student.transfer_right_count -= 1 + + db.commit() + + return { + "message": "转移成功", + "from_student": from_student.name, + "to_student": to_student.name, + "remaining_transfer_rights": from_student.transfer_right_count + } + diff --git a/backend/routers/scores.py b/backend/routers/scores.py new file mode 100644 index 0000000..77fa015 --- /dev/null +++ b/backend/routers/scores.py @@ -0,0 +1,99 @@ +""" +积分和排名相关API路由 +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from backend.database import get_db +from backend.models import Student +from backend.schemas import RankingResponse, RankingItem +from backend.utils.excel import export_scores_to_excel +import os +import tempfile + +router = APIRouter(prefix="/api/scores", tags=["scores"]) + + +@router.get("/ranking", response_model=RankingResponse) +def get_ranking(top_n: int = 10, db: Session = Depends(get_db)): + """获取积分排名""" + students = (db.query(Student).order_by(Student.total_score.desc()).limit(top_n).all()) + + rankings = [] + for rank, student in enumerate(students, 1): + rankings.append( + RankingItem(rank=rank, + student_id=student.student_id, + name=student.name, + major=student.major, + total_score=student.total_score, + random_rollcall_count=student.random_rollcall_count)) + + total = db.query(Student).count() + + return RankingResponse(rankings=rankings, total=total) + + +@router.get("/export") +def export_scores(db: Session = Depends(get_db)): + """导出积分详单""" + students = db.query(Student).all() + + # 准备数据 + students_data = [] + for student in students: + students_data.append({ + '学号': student.student_id, + '姓名': student.name, + '专业': student.major or '', + '随机点名次数': student.random_rollcall_count, + '总积分': student.total_score + }) + + # 创建临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_file: + tmp_path = tmp_file.name + + try: + # 导出到Excel + export_scores_to_excel(students_data, tmp_path) + + # 读取文件内容 + with open(tmp_path, 'rb') as f: + file_content = f.read() + + # 删除临时文件 + os.remove(tmp_path) + + from fastapi.responses import Response + return Response(content=file_content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=scores.xlsx"}) + + except Exception as e: + if os.path.exists(tmp_path): + os.remove(tmp_path) + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +from sqlalchemy import func + + +@router.get("/statistics") +def get_statistics(db: Session = Depends(get_db)): + """获取统计信息""" + total_students = db.query(Student).count() + + total_rollcall_count = db.query( + func.sum(Student.random_rollcall_count + Student.order_rollcall_count)).scalar() or 0 + + avg_score = db.query(func.avg(Student.total_score)).scalar() or 0.0 + max_score = db.query(func.max(Student.total_score)).scalar() or 0.0 + min_score = db.query(func.min(Student.total_score)).scalar() or 0.0 + return { + "total_students": total_students, + "total_rollcall_count": total_rollcall_count, + "avg_score": round(float(avg_score), 2), + "max_score": float(max_score), + "min_score": float(min_score), + } diff --git a/backend/routers/students.py b/backend/routers/students.py new file mode 100644 index 0000000..562732c --- /dev/null +++ b/backend/routers/students.py @@ -0,0 +1,111 @@ +""" +学生管理相关API路由 +""" +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from typing import List +from backend.database import get_db +from backend.models import Student, RollCallRecord, ScoreRecord +from backend.schemas import Student as StudentSchema, StudentCreate, ExcelImportResponse +from backend.utils.excel import read_students_from_excel +import os +import tempfile + +router = APIRouter(prefix="/api/students", tags=["students"]) + + +@router.get("/", response_model=List[StudentSchema]) +def get_students(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """获取学生列表""" + students = db.query(Student).offset(skip).limit(limit).all() + return students + + +@router.get("/{student_id}", response_model=StudentSchema) +def get_student(student_id: int, db: Session = Depends(get_db)): + """获取单个学生信息""" + student = db.query(Student).filter(Student.id == student_id).first() + if not student: + raise HTTPException(status_code=404, detail="学生不存在") + return student + + +@router.post("/", response_model=StudentSchema) +def create_student(student: StudentCreate, db: Session = Depends(get_db)): + """创建新学生""" + # 检查学号是否已存在 + existing = db.query(Student).filter(Student.student_id == student.student_id).first() + if existing: + raise HTTPException(status_code=400, detail="学号已存在") + + db_student = Student(**student.dict()) + db.add(db_student) + db.commit() + db.refresh(db_student) + return db_student + + +@router.post("/import-excel", response_model=ExcelImportResponse) +async def import_students_from_excel(file: UploadFile = File(...), db: Session = Depends(get_db)): + """从Excel文件导入学生""" + # 保存上传的文件到临时目录 + with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp_file: + content = await file.read() + tmp_file.write(content) + tmp_path = tmp_file.name + + try: + # 读取Excel文件 + students_data = read_students_from_excel(tmp_path) + + imported_count = 0 + failed_count = 0 + + for student_data in students_data: + try: + # 检查学号是否已存在 + existing = db.query(Student).filter(Student.student_id == student_data['student_id']).first() + + if not existing: + db_student = Student(student_id=student_data['student_id'], + name=student_data['name'], + major=student_data.get('major', '')) + db.add(db_student) + imported_count += 1 + else: + # 更新已存在学生的信息 + existing.name = student_data['name'] + existing.major = student_data.get('major', existing.major) + imported_count += 1 + except Exception as e: + failed_count += 1 + continue + + db.commit() + + return ExcelImportResponse(success=True, + message=f"成功导入{imported_count}条记录,失败{failed_count}条", + imported_count=imported_count, + failed_count=failed_count) + + except Exception as e: + db.rollback() + raise HTTPException(status_code=400, detail=f"导入失败: {str(e)}") + + finally: + # 删除临时文件 + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +@router.delete("/{student_id}") +def delete_student(student_id: int, db: Session = Depends(get_db)): + """删除学生""" + student = db.query(Student).filter(Student.id == student_id).first() + if not student: + raise HTTPException(status_code=404, detail="学生不存在") + db.query(RollCallRecord).filter(RollCallRecord.student_id == student_id).delete() + db.query(ScoreRecord).filter(ScoreRecord.student_id == student_id).delete() + db.delete(student) + db.commit() + return {"message": "删除成功"} diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..12758bb --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,93 @@ +""" +Pydantic模型定义(用于API请求和响应) +""" +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +# 学生相关模型 +class StudentBase(BaseModel): + student_id: str + name: str + major: Optional[str] = None + +class StudentCreate(StudentBase): + pass + +class StudentUpdate(BaseModel): + name: Optional[str] = None + major: Optional[str] = None + +class Student(StudentBase): + id: int + total_score: float + random_rollcall_count: int + order_rollcall_count: int + transfer_right_count: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# 点名相关模型 +class RollCallRequest(BaseModel): + rollcall_type: str # "random" or "order" + student_id: Optional[int] = None # 顺序点名时需要指定学生ID + +class RollCallResult(BaseModel): + student_id: int + student_code: str + name: str + major: Optional[str] + total_score: float + random_rollcall_count: int + +class RollCallRecordCreate(BaseModel): + student_id: int + rollcall_type: str + is_present: bool = True + question_repeat_score: float = 0.0 + answer_score: float = 0.0 + event_type: Optional[str] = None + +class RollCallRecordResponse(BaseModel): + id: int + student_id: int + student_code: str + student_name: str + rollcall_type: str + is_present: bool + total_score_change: float + created_at: datetime + + class Config: + from_attributes = True + +# 积分相关模型 +class ScoreUpdate(BaseModel): + student_id: int + attendance_score: float = 0.0 + question_repeat_score: float = 0.0 + answer_score: float = 0.0 + event_type: Optional[str] = None + +class RankingItem(BaseModel): + rank: int + student_id: str + name: str + major: Optional[str] + total_score: float + random_rollcall_count: int + +class RankingResponse(BaseModel): + rankings: List[RankingItem] + total: int + +# Excel导入导出模型 +class ExcelImportResponse(BaseModel): + success: bool + message: str + imported_count: int + failed_count: int + diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..13fa07c --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1,2 @@ +# Utils package + diff --git a/backend/utils/events.py b/backend/utils/events.py new file mode 100644 index 0000000..d4647e0 --- /dev/null +++ b/backend/utils/events.py @@ -0,0 +1,69 @@ +""" +随机事件系统 +""" +import random +from typing import Optional, Dict + +EVENT_TYPES = { + "double_score": "双倍加分", + "crazy_thursday": "疯狂星期四", + "lucky_star": "幸运星", + "normal": "普通" +} + +def trigger_random_event() -> Dict[str, any]: + """ + 触发随机事件 + + 返回事件信息字典: + { + "type": 事件类型, + "name": 事件名称, + "multiplier": 积分倍数, + "description": 事件描述 + } + """ + # 30%概率触发特殊事件 + tmp = random.random() + print("tmp:" ,tmp) + if tmp < 1: + event_type = random.choice(["double_score", "crazy_thursday", "lucky_star"]) + else: + event_type = "normal" + + events = { + "double_score": { + "type": "double_score", + "name": "双倍加分", + "multiplier": 2.0, + "description": "本次点名积分翻倍!" + }, + "crazy_thursday": { + "type": "crazy_thursday", + "name": "疯狂星期四", + "multiplier": 1.5, + "description": "疯狂星期四!积分增加50%!" + }, + "lucky_star": { + "type": "lucky_star", + "name": "幸运星", + "multiplier": 1.2, + "description": "幸运星降临!积分增加20%!" + }, + "normal": { + "type": "normal", + "name": "普通", + "multiplier": 1.0, + "description": "普通点名" + } + } + + return events[event_type] + +def should_trigger_crazy_thursday_special(student_score: float) -> bool: + """ + 疯狂星期四特殊规则:积分为50的因数的同学被点名几率增加 + """ + factors_of_50 = [1, 2, 5, 10, 25, 50] + return student_score in factors_of_50 + diff --git a/backend/utils/excel.py b/backend/utils/excel.py new file mode 100644 index 0000000..bbf1f4f --- /dev/null +++ b/backend/utils/excel.py @@ -0,0 +1,97 @@ +""" +Excel文件处理工具 +""" +import pandas as pd +from typing import List, Dict, Tuple +from pathlib import Path + +def read_students_from_excel(file_path: str) -> List[Dict]: + """ + 从Excel文件读取学生信息 + + 期望的Excel格式: + - 第一行:表头(学号、姓名、专业) + - 后续行:学生数据 + + 返回:学生信息列表 + """ + try: + df = pd.read_excel(file_path) + + # 标准化列名(支持多种可能的列名) + column_mapping = { + '学号': 'student_id', + 'student_id': 'student_id', + 'Student ID': 'student_id', + '姓名': 'name', + 'name': 'name', + 'Name': 'name', + '专业': 'major', + 'major': 'major', + 'Major': 'major' + } + + # 重命名列 + df.columns = df.columns.str.strip() + for old_name, new_name in column_mapping.items(): + if old_name in df.columns: + df.rename(columns={old_name: new_name}, inplace=True) + + # 检查必需列 + if 'student_id' not in df.columns or 'name' not in df.columns: + raise ValueError("Excel文件必须包含'学号'和'姓名'列") + + # 转换为字典列表 + students = [] + for _, row in df.iterrows(): + student = { + 'student_id': str(row['student_id']).strip(), + 'name': str(row['name']).strip(), + 'major': str(row.get('major', '')).strip() if 'major' in df.columns else '' + } + # 过滤空行 + if student['student_id'] and student['name']: + students.append(student) + + return students + + except Exception as e: + raise ValueError(f"读取Excel文件失败: {str(e)}") + +def export_scores_to_excel(students_data: List[Dict], output_path: str) -> bool: + """ + 导出积分详单到Excel文件 + + students_data: 包含学号、姓名、专业、随机点名次数、总积分的学生数据列表 + output_path: 输出文件路径 + """ + try: + df = pd.DataFrame(students_data) + + # 确保列顺序 + columns_order = ['学号', '姓名', '专业', '随机点名次数', '总积分'] + df = df.reindex(columns=columns_order) + + # 导出到Excel + df.to_excel(output_path, index=False, engine='openpyxl') + return True + + except Exception as e: + raise ValueError(f"导出Excel文件失败: {str(e)}") + +def create_template_excel(output_path: str) -> bool: + """ + 创建Excel模板文件 + """ + try: + template_data = { + '学号': ['2021001', '2021002', '2021003'], + '姓名': ['张三', '李四', '王五'], + '专业': ['软件工程', '计算机科学', '数据科学'] + } + df = pd.DataFrame(template_data) + df.to_excel(output_path, index=False, engine='openpyxl') + return True + except Exception as e: + raise ValueError(f"创建模板文件失败: {str(e)}") + diff --git a/backend/utils/probability.py b/backend/utils/probability.py new file mode 100644 index 0000000..6db923a --- /dev/null +++ b/backend/utils/probability.py @@ -0,0 +1,81 @@ +""" +概率计算工具 +根据积分计算随机点名概率 +积分越高,被点到的概率越低 +""" +import random +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.models import Student + +def calculate_probability_weights(students: List[Student]) -> List[float]: + """ + 计算每个学生的权重(积分越低,权重越高) + + 算法:使用反比例函数,权重 = 1 / (积分 + 1) + 这样可以确保: + 1. 积分为0的学生权重最高 + 2. 积分越高,权重越低 + 3. 所有学生都有被点到的可能 + """ + weights = [] + for student in students: + # 使用反比例函数,+1避免除零 + weight = 1.0 / (student.total_score + 1.0) + weights.append(weight) + return weights + +def weighted_random_select(students: List[Student], weights: List[float]) -> Student: + """ + 根据权重随机选择一个学生 + """ + if not students: + raise ValueError("学生列表为空") + + if len(students) != len(weights): + raise ValueError("学生列表和权重列表长度不匹配") + + # 使用random.choices进行加权随机选择 + selected = random.choices(students, weights=weights, k=1)[0] + return selected + +def get_random_student(db: Session) -> Student: + """ + 从数据库中随机选择一个学生(考虑积分权重) + """ + students = db.query(Student).all() + if not students: + raise ValueError("数据库中没有学生") + + weights = calculate_probability_weights(students) + selected = weighted_random_select(students, weights) + return selected + +def get_next_order_student(db: Session, current_student_id: Optional[int] = None) -> Student: + """ + 按学号顺序获取下一个学生 + """ + if current_student_id is None: + # 获取第一个学生(按学号排序) + student = db.query(Student).order_by(Student.student_id).first() + else: + # 获取当前学生的下一个学生 + current_student = db.query(Student).filter(Student.id == current_student_id).first() + if current_student: + student = ( + db.query(Student) + .filter(Student.student_id > current_student.student_id) + .order_by(Student.student_id) + .first() + ) + # 如果没有下一个,返回第一个(循环) + if not student: + student = db.query(Student).order_by(Student.student_id).first() + else: + student = db.query(Student).order_by(Student.student_id).first() + + if not student: + raise ValueError("数据库中没有学生") + + return student + diff --git a/data/students_template.xlsx b/data/students_template.xlsx new file mode 100644 index 0000000..0d924a5 Binary files /dev/null and b/data/students_template.xlsx differ diff --git a/docs/prototype.md b/docs/prototype.md new file mode 100644 index 0000000..a8bc9b8 --- /dev/null +++ b/docs/prototype.md @@ -0,0 +1,90 @@ +# 原型设计说明文档 + +## 2.1 原型工具的选择 + +本项目选择使用**墨刀(Modao)**作为原型设计工具。 + +### 选择理由: + +1. **免费版功能充足**:墨刀提供免费版本,功能足够完成本次项目的原型设计需求 +2. **上手简单**:界面直观,学习成本低,适合快速原型设计 +3. **交互性强**:支持创建交互式原型,可以模拟真实的用户操作流程 +4. **易于分享**:支持在线分享链接,方便团队成员查看和反馈 +5. **导出方便**:支持导出图片和PDF,便于文档展示 +6. **中文支持好**:作为国产工具,对中文支持完善 + +## 2.2 原型设计流程 + +### 设计步骤: + +1. **需求分析**:根据实验要求,确定需要设计的功能模块 + - 学生管理模块 + - 点名模块(随机/顺序) + - 积分管理模块 + - 数据可视化模块 + +2. **页面结构设计**: + - 主界面采用标签页(Tab)布局 + - 每个功能模块独立一个标签页 + - 保持界面简洁统一 + +3. **交互设计**: + - 设计按钮点击、表单提交等交互流程 + - 考虑用户操作的便捷性 + - 添加必要的提示和反馈 + +4. **视觉设计**: + - 采用简洁现代的设计风格 + - 使用合适的颜色搭配(主色调:绿色系) + - 确保文字清晰可读 + +## 2.3 原型作品链接 + +**墨刀原型链接**:[在此处填入墨刀原型分享链接] + +(注:实际使用时,需要在墨刀中创建原型并获取分享链接) + +## 2.4 原型界面图片展示 + +### 主界面设计 + +主界面采用标签页布局,包含以下四个主要模块: + +1. **点名模块** + - 点名模式选择(随机/顺序) + - 被点学生信息大屏显示 + - 点名结果记录表单 + - 最近点名历史记录 + +2. **学生管理模块** + - 学生列表表格展示 + - Excel导入功能 + - 学生信息增删改查 + +3. **积分管理模块** + - 统计信息展示 + - 积分排名表格 + - 导出积分详单功能 + +4. **数据可视化模块** + - 积分排名柱形图/折线图 + - 可切换图表类型 + - 可调整显示数量 + + +### 设计建议: + +- 保持界面简洁,避免过度设计 +- 使用统一的颜色和字体规范 +- 确保交互流程清晰易懂 +- 考虑实际开发的可实现性 + +## 2.6 原型与实现的对应关系 + +| 原型页面 | 实现文件 | 说明 | +|---------|---------|------| +| 主界面 | `frontend/main_window.py` | 标签页布局 | +| 点名页面 | `frontend/widgets/rollcall.py` | 点名功能实现 | +| 学生管理页面 | `frontend/widgets/student_management.py` | 学生管理功能 | +| 积分管理页面 | `frontend/widgets/score_management.py` | 积分管理功能 | +| 可视化页面 | `frontend/widgets/visualization.py` | 数据可视化 | diff --git a/frontend/__init__.py b/frontend/__init__.py new file mode 100644 index 0000000..cdbb51e --- /dev/null +++ b/frontend/__init__.py @@ -0,0 +1,2 @@ +# Frontend package + diff --git a/frontend/main.py b/frontend/main.py new file mode 100644 index 0000000..1014224 --- /dev/null +++ b/frontend/main.py @@ -0,0 +1,39 @@ +""" +PyQt5前端主程序入口 +""" +import sys +from PyQt5.QtWidgets import QApplication, QMessageBox +from frontend.main_window import MainWindow +from frontend.utils.api_client import APIClient + + +def check_backend_connection(): + """检查后端连接""" + try: + client = APIClient() + client._get("/health") + return True + except Exception as e: + return False + + +def main(): + """主函数""" + app = QApplication(sys.argv) + + # 检查后端连接 + if not check_backend_connection(): + QMessageBox.critical(None, "连接失败", "无法连接到后端服务器!\n" + "请确保后端服务已启动(运行 python backend/main.py)\n" + "错误信息:后端服务未运行或无法访问") + sys.exit(1) + + # 创建主窗口 + window = MainWindow() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/frontend/main_window.py b/frontend/main_window.py new file mode 100644 index 0000000..d1927d7 --- /dev/null +++ b/frontend/main_window.py @@ -0,0 +1,78 @@ +""" +主窗口 +""" +from PyQt5.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QTabWidget, QStatusBar +) +from PyQt5.QtCore import Qt +from frontend.utils.api_client import APIClient +from frontend.widgets.student_management import StudentManagementWidget +from frontend.widgets.rollcall import RollCallWidget +from frontend.widgets.score_management import ScoreManagementWidget +from frontend.widgets.visualization import VisualizationWidget + +class MainWindow(QMainWindow): + """主窗口类""" + + def __init__(self): + super().__init__() + self.api_client = APIClient() + self.init_ui() + + def init_ui(self): + """初始化界面""" + self.setWindowTitle("课堂随机点名系统") + self.setGeometry(100, 100, 1200, 800) + + # 创建中央部件 + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # 创建布局 + layout = QVBoxLayout() + central_widget.setLayout(layout) + + # 创建标签页 + self.tabs = QTabWidget() + + # 点名页面 + self.rollcall_widget = RollCallWidget(self.api_client) + self.tabs.addTab(self.rollcall_widget, "点名") + + # 学生管理页面 + self.student_widget = StudentManagementWidget(self.api_client) + self.tabs.addTab(self.student_widget, "学生管理") + + # 积分管理页面 + self.score_widget = ScoreManagementWidget(self.api_client) + self.tabs.addTab(self.score_widget, "积分管理") + + # 可视化页面 + self.visualization_widget = VisualizationWidget(self.api_client) + self.tabs.addTab(self.visualization_widget, "数据可视化") + + layout.addWidget(self.tabs) + + # 状态栏 + self.statusBar().showMessage("就绪") + + # 设置样式 + self.setStyleSheet(""" + QMainWindow { + background-color: #f5f5f5; + } + QTabWidget::pane { + border: 1px solid #ddd; + background-color: white; + } + QTabBar::tab { + background-color: #e0e0e0; + padding: 8px 20px; + margin-right: 2px; + } + QTabBar::tab:selected { + background-color: white; + border-bottom: 2px solid #4CAF50; + } + """) + diff --git a/frontend/utils/__init__.py b/frontend/utils/__init__.py new file mode 100644 index 0000000..13fa07c --- /dev/null +++ b/frontend/utils/__init__.py @@ -0,0 +1,2 @@ +# Utils package + diff --git a/frontend/utils/api_client.py b/frontend/utils/api_client.py new file mode 100644 index 0000000..afb8d18 --- /dev/null +++ b/frontend/utils/api_client.py @@ -0,0 +1,114 @@ +""" +API客户端工具类 +用于与FastAPI后端通信 +""" +import requests +from typing import Optional, Dict, List +import json + +class APIClient: + """API客户端""" + + def __init__(self, base_url: str = "http://127.0.0.1:8000"): + self.base_url = base_url.rstrip('/') + + def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict: + """GET请求""" + url = f"{self.base_url}{endpoint}" + try: + response = requests.get(url, params=params, timeout=5) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + raise Exception(f"API请求失败: {str(e)}") + + def _post(self, endpoint: str, data: Optional[Dict] = None, files: Optional[Dict] = None) -> Dict: + """POST请求""" + url = f"{self.base_url}{endpoint}" + try: + if files: + response = requests.post(url, files=files, timeout=30) + else: + response = requests.post(url, json=data, timeout=5) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + raise Exception(f"API请求失败: {str(e)}") + + def _delete(self, endpoint: str) -> Dict: + """DELETE请求""" + url = f"{self.base_url}{endpoint}" + try: + response = requests.delete(url, timeout=5) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + raise Exception(f"API请求失败: {str(e)}") + + # 学生相关API + def get_students(self) -> List[Dict]: + """获取学生列表""" + return self._get("/api/students/") + + def get_student(self, student_id: int) -> Dict: + """获取单个学生""" + return self._get(f"/api/students/{student_id}") + + def create_student(self, student_data: Dict) -> Dict: + """创建学生""" + return self._post("/api/students/", data=student_data) + + def import_students_from_excel(self, file_path: str) -> Dict: + """从Excel导入学生""" + with open(file_path, 'rb') as f: + files = {'file': (file_path.split('/')[-1], f, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} + return self._post("/api/students/import-excel", files=files) + + def delete_student(self, student_id: int) -> Dict: + """删除学生""" + return self._delete(f"/api/students/{student_id}") + + # 点名相关API + def start_rollcall(self, rollcall_type: str, student_id: Optional[int] = None) -> Dict: + """开始点名""" + data = {"rollcall_type": rollcall_type} + if student_id: + data["student_id"] = student_id + return self._post("/api/rollcall/start", data=data) + + def record_rollcall(self, record_data: Dict) -> Dict: + """记录点名结果""" + return self._post("/api/rollcall/record", data=record_data) + + def get_rollcall_records(self, skip: int = 0, limit: int = 100) -> List[Dict]: + """获取点名记录""" + return self._get("/api/rollcall/records", params={"skip": skip, "limit": limit}) + + def transfer_rollcall(self, from_student_id: int, to_student_id: int) -> Dict: + """转移点名""" + return self._post("/api/rollcall/transfer", data={ + "from_student_id": from_student_id, + "to_student_id": to_student_id + }) + + # 积分相关API + def get_ranking(self, top_n: int = 10) -> Dict: + """获取积分排名""" + return self._get("/api/scores/ranking", params={"top_n": top_n}) + + def export_scores(self, save_path: str) -> bool: + """导出积分详单""" + url = f"{self.base_url}/api/scores/export" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + with open(save_path, 'wb') as f: + f.write(response.content) + return True + except requests.exceptions.RequestException as e: + raise Exception(f"导出失败: {str(e)}") + + def get_statistics(self) -> Dict: + """获取统计信息""" + return self._get("/api/scores/statistics") + diff --git a/frontend/widgets/__init__.py b/frontend/widgets/__init__.py new file mode 100644 index 0000000..688f936 --- /dev/null +++ b/frontend/widgets/__init__.py @@ -0,0 +1,2 @@ +# Widgets package + diff --git a/frontend/widgets/rollcall.py b/frontend/widgets/rollcall.py new file mode 100644 index 0000000..d23a671 --- /dev/null +++ b/frontend/widgets/rollcall.py @@ -0,0 +1,181 @@ +""" +点名组件 +""" +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QRadioButton, QButtonGroup, + QGroupBox, QSpinBox, QDoubleSpinBox, QCheckBox, QMessageBox, QTextEdit) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QFont +from frontend.utils.api_client import APIClient + + +class RollCallWidget(QWidget): + """点名界面""" + + def __init__(self, api_client: APIClient, parent=None): + super().__init__(parent) + self.api_client: APIClient = api_client + self.current_student = None + self.init_ui() + + def init_ui(self): + """初始化界面""" + layout = QVBoxLayout() + + # 标题 + title = QLabel("课堂点名") + title.setStyleSheet("font-size: 18px; font-weight: bold; margin: 10px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 点名模式选择 + mode_group = QGroupBox("点名模式") + mode_layout = QHBoxLayout() + self.mode_group = QButtonGroup() + + self.random_radio = QRadioButton("随机点名") + self.random_radio.setChecked(True) + self.mode_group.addButton(self.random_radio, 0) + mode_layout.addWidget(self.random_radio) + + self.order_radio = QRadioButton("顺序点名") + self.mode_group.addButton(self.order_radio, 1) + mode_layout.addWidget(self.order_radio) + + mode_group.setLayout(mode_layout) + layout.addWidget(mode_group) + + # 被点学生信息显示区域 + info_group = QGroupBox("被点学生信息") + info_layout = QVBoxLayout() + + self.student_info = QLabel("点击'开始点名'按钮开始") + self.student_info.setAlignment(Qt.AlignCenter) + self.student_info.setStyleSheet("font-size: 24px; font-weight: bold; padding: 20px;" + "background-color: #f0f0f0; border-radius: 10px;") + self.student_info.setMinimumHeight(150) + info_layout.addWidget(self.student_info) + + info_group.setLayout(info_layout) + layout.addWidget(info_group) + + # 点名按钮 + button_layout = QHBoxLayout() + self.start_btn = QPushButton("开始点名") + self.start_btn.setStyleSheet("font-size: 16px; padding: 10px; background-color: #4CAF50; color: white;") + self.start_btn.clicked.connect(self.start_rollcall) + button_layout.addWidget(self.start_btn) + layout.addLayout(button_layout) + + # 记录点名结果 + record_group = QGroupBox("记录点名结果") + record_layout = QVBoxLayout() + + # 到达情况 + self.present_check = QCheckBox("学生已到达") + self.present_check.setChecked(True) + record_layout.addWidget(self.present_check) + + # 重复问题得分 + repeat_layout = QHBoxLayout() + repeat_layout.addWidget(QLabel("重复问题得分:")) + self.repeat_score = QDoubleSpinBox() + self.repeat_score.setRange(-1.0, 0.5) + self.repeat_score.setSingleStep(0.5) + self.repeat_score.setValue(0.0) + self.repeat_score.setSuffix(" 分") + repeat_layout.addWidget(self.repeat_score) + repeat_layout.addWidget(QLabel("(-1:不能重复, 0:未测试, 0.5:准确重复)")) + repeat_layout.addStretch() + record_layout.addLayout(repeat_layout) + + # 回答问题得分 + answer_layout = QHBoxLayout() + answer_layout.addWidget(QLabel("回答问题得分:")) + self.answer_score = QDoubleSpinBox() + self.answer_score.setRange(0.0, 3.0) + self.answer_score.setSingleStep(0.5) + self.answer_score.setValue(0.0) + self.answer_score.setSuffix(" 分") + answer_layout.addWidget(self.answer_score) + answer_layout.addWidget(QLabel("(0-3分,根据回答情况)")) + answer_layout.addStretch() + record_layout.addLayout(answer_layout) + + # 提交按钮 + self.submit_btn = QPushButton("提交记录") + self.submit_btn.setEnabled(False) + self.submit_btn.clicked.connect(self.submit_record) + record_layout.addWidget(self.submit_btn) + + record_group.setLayout(record_layout) + layout.addWidget(record_group) + + # 最近点名记录 + history_group = QGroupBox("最近点名记录") + history_layout = QVBoxLayout() + self.history_text = QTextEdit() + self.history_text.setReadOnly(True) + self.history_text.setMaximumHeight(150) + history_layout.addWidget(self.history_text) + history_group.setLayout(history_layout) + layout.addWidget(history_group) + + layout.addStretch() + self.setLayout(layout) + + def start_rollcall(self): + """开始点名""" + try: + rollcall_type = "random" if self.random_radio.isChecked() else "order" + result = self.api_client.start_rollcall(rollcall_type) + + self.current_student = result + self.student_info.setText(f"学号: {result['student_code']}\n" + f"姓名: {result['name']}\n" + f"专业: {result.get('major', '')}\n" + f"当前积分: {result['total_score']}") + + self.submit_btn.setEnabled(True) + self.start_btn.setEnabled(False) + + # 重置表单 + self.present_check.setChecked(True) + self.repeat_score.setValue(0.0) + self.answer_score.setValue(0.0) + + except Exception as e: + QMessageBox.critical(self, "错误", f"点名失败: {str(e)}") + + def submit_record(self): + """提交点名记录""" + if not self.current_student: + QMessageBox.warning(self, "提示", "请先开始点名") + return + + try: + record_data = { + "student_id": self.current_student['student_id'], + "rollcall_type": "random" if self.random_radio.isChecked() else "order", + "is_present": self.present_check.isChecked(), + "question_repeat_score": self.repeat_score.value(), + "answer_score": self.answer_score.value() + } + + result = self.api_client.record_rollcall(record_data) + + # 更新历史记录 + history_text = (f"[{result['created_at']}] " + f"{result['student_name']} ({result['student_code']}) - " + f"积分变化: {result['total_score_change']:+.1f}\n") + self.history_text.insertPlainText(history_text) + + QMessageBox.information(self, "成功", "点名记录已提交") + + # 重置状态 + self.current_student = None + self.submit_btn.setEnabled(False) + self.start_btn.setEnabled(True) + self.student_info.setText("点击'开始点名'按钮开始") + + except Exception as e: + QMessageBox.critical(self, "错误", f"提交记录失败: {str(e)}") diff --git a/frontend/widgets/score_management.py b/frontend/widgets/score_management.py new file mode 100644 index 0000000..c678c37 --- /dev/null +++ b/frontend/widgets/score_management.py @@ -0,0 +1,105 @@ +""" +积分管理组件 +""" +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTableWidget, QTableWidgetItem, + QHeaderView, QFileDialog, QMessageBox, QGroupBox) +from PyQt5.QtCore import Qt +from frontend.utils.api_client import APIClient + + +class ScoreManagementWidget(QWidget): + """积分管理界面""" + + def __init__(self, api_client: APIClient, parent=None): + super().__init__(parent) + self.api_client = api_client + self.init_ui() + self.load_statistics() + + def init_ui(self): + """初始化界面""" + layout = QVBoxLayout() + + # 标题 + title = QLabel("积分管理") + title.setStyleSheet("font-size: 18px; font-weight: bold; margin: 10px;") + layout.addWidget(title) + + # 统计信息 + stats_group = QGroupBox("统计信息") + stats_layout = QVBoxLayout() + self.stats_label = QLabel("加载中...") + self.stats_label.setStyleSheet("font-size: 14px; padding: 10px;") + stats_layout.addWidget(self.stats_label) + stats_group.setLayout(stats_layout) + layout.addWidget(stats_group) + + # 按钮栏 + button_layout = QHBoxLayout() + + self.export_btn = QPushButton("导出积分详单") + self.export_btn.clicked.connect(self.export_scores) + button_layout.addWidget(self.export_btn) + + self.refresh_btn = QPushButton("刷新") + self.refresh_btn.clicked.connect(self.load_statistics) + button_layout.addWidget(self.refresh_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + + # 排名表格 + ranking_group = QGroupBox("积分排名") + ranking_layout = QVBoxLayout() + + self.ranking_table = QTableWidget() + self.ranking_table.setColumnCount(6) + self.ranking_table.setHorizontalHeaderLabels(["排名", "学号", "姓名", "专业", "总积分", "随机点名次数"]) + self.ranking_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + ranking_layout.addWidget(self.ranking_table) + + ranking_group.setLayout(ranking_layout) + layout.addWidget(ranking_group) + + self.setLayout(layout) + + def load_statistics(self): + """加载统计信息和排名""" + try: + # 加载统计信息 + stats = self.api_client.get_statistics() + stats_text = (f"总学生数: {stats['total_students']} | " + f"总点名次数: {stats['total_rollcall_count']} | " + f"平均积分: {stats['avg_score']:.2f} | " + f"最高积分: {stats['max_score']:.2f} | " + f"最低积分: {stats['min_score']:.2f}") + self.stats_label.setText(stats_text) + + # 加载排名 + ranking_data = self.api_client.get_ranking(top_n=50) + rankings = ranking_data['rankings'] + + self.ranking_table.setRowCount(len(rankings)) + for row, item in enumerate(rankings): + self.ranking_table.setItem(row, 0, QTableWidgetItem(str(item['rank']))) + self.ranking_table.setItem(row, 1, QTableWidgetItem(item['student_id'])) + self.ranking_table.setItem(row, 2, QTableWidgetItem(item['name'])) + self.ranking_table.setItem(row, 3, QTableWidgetItem(item.get('major', ''))) + self.ranking_table.setItem(row, 4, QTableWidgetItem(f"{item['total_score']:.2f}")) + self.ranking_table.setItem(row, 5, QTableWidgetItem(str(item['random_rollcall_count']))) + + except Exception as e: + QMessageBox.critical(self, "错误", f"加载数据失败: {str(e)}") + + def export_scores(self): + """导出积分详单""" + file_path, _ = QFileDialog.getSaveFileName(self, "保存积分详单", "积分详单.xlsx", "Excel Files (*.xlsx)") + + if not file_path: + return + + try: + self.api_client.export_scores(file_path) + QMessageBox.information(self, "成功", f"积分详单已导出到: {file_path}") + except Exception as e: + QMessageBox.critical(self, "错误", f"导出失败: {str(e)}") diff --git a/frontend/widgets/student_management.py b/frontend/widgets/student_management.py new file mode 100644 index 0000000..c515827 --- /dev/null +++ b/frontend/widgets/student_management.py @@ -0,0 +1,106 @@ +""" +学生管理组件 +""" +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, + QFileDialog, QMessageBox, QHeaderView, QLabel) +from PyQt5.QtCore import Qt +from frontend.utils.api_client import APIClient + + +class StudentManagementWidget(QWidget): + """学生管理界面""" + + def __init__(self, api_client: APIClient, parent=None): + super().__init__(parent) + self.api_client = api_client + self.init_ui() + self.load_students() + + def init_ui(self): + """初始化界面""" + layout = QVBoxLayout() + + # 标题 + title = QLabel("学生管理") + title.setStyleSheet("font-size: 18px; font-weight: bold; margin: 10px;") + layout.addWidget(title) + + # 按钮栏 + button_layout = QHBoxLayout() + + self.import_btn = QPushButton("导入Excel") + self.import_btn.clicked.connect(self.import_excel) + button_layout.addWidget(self.import_btn) + + self.refresh_btn = QPushButton("刷新") + self.refresh_btn.clicked.connect(self.load_students) + button_layout.addWidget(self.refresh_btn) + + self.delete_btn = QPushButton("删除选中") + self.delete_btn.clicked.connect(self.delete_selected) + button_layout.addWidget(self.delete_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + + # 表格 + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["ID", "学号", "姓名", "专业", "总积分"]) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + layout.addWidget(self.table) + + self.setLayout(layout) + + def load_students(self): + """加载学生列表""" + try: + students = self.api_client.get_students() + self.table.setRowCount(len(students)) + + for row, student in enumerate(students): + self.table.setItem(row, 0, QTableWidgetItem(str(student['id']))) + self.table.setItem(row, 1, QTableWidgetItem(student['student_id'])) + self.table.setItem(row, 2, QTableWidgetItem(student['name'])) + self.table.setItem(row, 3, QTableWidgetItem(student.get('major', ''))) + self.table.setItem(row, 4, QTableWidgetItem(str(student['total_score']))) + + QMessageBox.information(self, "成功", f"已加载{len(students)}名学生") + except Exception as e: + QMessageBox.critical(self, "错误", f"加载学生列表失败: {str(e)}") + + def import_excel(self): + """导入Excel文件""" + file_path, _ = QFileDialog.getOpenFileName(self, "选择Excel文件", "", "Excel Files (*.xlsx *.xls)") + + if not file_path: + return + + try: + result = self.api_client.import_students_from_excel(file_path) + QMessageBox.information( + self, "导入成功", f"{result['message']}\n成功: {result['imported_count']}, 失败: {result['failed_count']}") + self.load_students() + except Exception as e: + QMessageBox.critical(self, "导入失败", f"导入Excel文件失败: {str(e)}") + + def delete_selected(self): + """删除选中的学生""" + current_row = self.table.currentRow() + if current_row < 0: + QMessageBox.warning(self, "提示", "请先选择要删除的学生") + return + + student_id = int(self.table.item(current_row, 0).text()) + student_name = self.table.item(current_row, 2).text() + + reply = QMessageBox.question(self, "确认删除", f"确定要删除学生 {student_name} 吗?", QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + try: + self.api_client.delete_student(student_id) + QMessageBox.information(self, "成功", "删除成功") + self.load_students() + except Exception as e: + QMessageBox.critical(self, "错误", f"删除失败: {str(e)}") diff --git a/frontend/widgets/visualization.py b/frontend/widgets/visualization.py new file mode 100644 index 0000000..1e3b88e --- /dev/null +++ b/frontend/widgets/visualization.py @@ -0,0 +1,150 @@ +""" +数据可视化组件 +""" +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, + QPushButton, QComboBox +) +from PyQt5.QtCore import Qt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import matplotlib.pyplot as plt +from frontend.utils.api_client import APIClient + +# 设置中文字体 +plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] +plt.rcParams['axes.unicode_minus'] = False + +class VisualizationWidget(QWidget): + """数据可视化界面""" + + def __init__(self, api_client: APIClient, parent=None): + super().__init__(parent) + self.api_client = api_client + self.init_ui() + self.load_ranking() + + def init_ui(self): + """初始化界面""" + layout = QVBoxLayout() + + # 标题 + title = QLabel("积分排名可视化") + title.setStyleSheet("font-size: 18px; font-weight: bold; margin: 10px;") + layout.addWidget(title) + + # 控制栏 + control_layout = QHBoxLayout() + + control_layout.addWidget(QLabel("显示前")) + self.top_n_spin = QSpinBox() + self.top_n_spin.setRange(5, 50) + self.top_n_spin.setValue(10) + control_layout.addWidget(self.top_n_spin) + + control_layout.addWidget(QLabel("名")) + + self.chart_type_combo = QComboBox() + self.chart_type_combo.addItems(["柱形图", "折线图"]) + control_layout.addWidget(self.chart_type_combo) + + self.refresh_btn = QPushButton("刷新") + self.refresh_btn.clicked.connect(self.load_ranking) + control_layout.addWidget(self.refresh_btn) + + control_layout.addStretch() + layout.addLayout(control_layout) + + # 图表区域 + self.figure = Figure(figsize=(10, 6)) + self.canvas = FigureCanvas(self.figure) + layout.addWidget(self.canvas) + + self.setLayout(layout) + + def load_ranking(self): + """加载排名数据并绘制图表""" + try: + top_n = self.top_n_spin.value() + data = self.api_client.get_ranking(top_n=top_n) + + if not data['rankings']: + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.text(0.5, 0.5, '暂无数据', ha='center', va='center', fontsize=16) + self.canvas.draw() + return + + rankings = data['rankings'] + names = [f"{r['name']}\n({r['student_id']})" for r in rankings] + scores = [r['total_score'] for r in rankings] + rollcall_counts = [r['random_rollcall_count'] for r in rankings] + + self.figure.clear() + + chart_type = self.chart_type_combo.currentText() + + if chart_type == "柱形图": + # 创建双Y轴 + ax1 = self.figure.add_subplot(111) + ax2 = ax1.twinx() + + # 绘制积分柱形图 + bars = ax1.bar(range(len(names)), scores, color='skyblue', alpha=0.7, label='总积分') + ax1.set_xlabel('学生') + ax1.set_ylabel('总积分', color='blue') + ax1.set_xticks(range(len(names))) + ax1.set_xticklabels(names, rotation=45, ha='right') + ax1.tick_params(axis='y', labelcolor='blue') + ax1.grid(True, alpha=0.3) + + # 绘制点名次数折线图 + line = ax2.plot(range(len(names)), rollcall_counts, + color='red', marker='o', linewidth=2, label='随机点名次数') + ax2.set_ylabel('随机点名次数', color='red') + ax2.tick_params(axis='y', labelcolor='red') + + # 添加图例 + ax1.legend(loc='upper left') + ax2.legend(loc='upper right') + + self.figure.suptitle(f'积分排名前{top_n}名(柱形图:积分,折线图:点名次数)', fontsize=14) + + else: # 折线图 + ax = self.figure.add_subplot(111) + + # 绘制积分折线 + line1 = ax.plot(range(len(names)), scores, + color='blue', marker='o', linewidth=2, label='总积分') + ax.set_xlabel('学生') + ax.set_ylabel('总积分', color='blue') + ax.tick_params(axis='y', labelcolor='blue') + ax.set_xticks(range(len(names))) + ax.set_xticklabels(names, rotation=45, ha='right') + + # 创建第二个Y轴用于点名次数 + ax2 = ax.twinx() + line2 = ax2.plot(range(len(names)), rollcall_counts, + color='red', marker='s', linewidth=2, label='随机点名次数') + ax2.set_ylabel('随机点名次数', color='red') + ax2.tick_params(axis='y', labelcolor='red') + + # 添加图例 + ax.legend(loc='upper left') + ax2.legend(loc='upper right') + ax.grid(True, alpha=0.3) + + self.figure.suptitle(f'积分排名前{top_n}名(折线图)', fontsize=14) + + self.figure.tight_layout() + self.canvas.draw() + + except Exception as e: + import traceback + error_msg = f"加载排名数据失败: {str(e)}\n{traceback.format_exc()}" + print(error_msg) + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.text(0.5, 0.5, f'加载失败\n{str(e)}', ha='center', va='center', fontsize=12) + self.canvas.draw() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..646d4a8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +pymysql==1.1.0 +cryptography==41.0.7 +pandas==2.1.3 +openpyxl==3.1.2 +PyQt5==5.15.10 +PyQt5-Qt5==5.15.2 +PyQt5-sip==12.13.0 +matplotlib==3.8.2 +requests==2.31.0 +pydantic==2.5.0 +python-multipart==0.0.6 + diff --git a/scripts/create_sample_excel.py b/scripts/create_sample_excel.py new file mode 100644 index 0000000..1206cd2 --- /dev/null +++ b/scripts/create_sample_excel.py @@ -0,0 +1,44 @@ +""" +创建示例Excel文件 +""" +import pandas as pd +from pathlib import Path + +def create_sample_excel(): + """创建示例Excel文件""" + # 创建data目录 + data_dir = Path("data") + data_dir.mkdir(exist_ok=True) + + # 示例数据 + sample_data = { + '学号': [ + '2021001', '2021002', '2021003', '2021004', '2021005', + '2021006', '2021007', '2021008', '2021009', '2021010', + '2021011', '2021012', '2021013', '2021014', '2021015', + '2021016', '2021017', '2021018', '2021019', '2021020' + ], + '姓名': [ + '张三', '李四', '王五', '赵六', '钱七', + '孙八', '周九', '吴十', '郑一', '王二', + '刘三', '陈四', '杨五', '黄六', '周七', + '吴八', '徐九', '孙十', '马一', '朱二' + ], + '专业': [ + '软件工程', '软件工程', '计算机科学', '计算机科学', '数据科学', + '软件工程', '数据科学', '软件工程', '计算机科学', '数据科学', + '软件工程', '计算机科学', '数据科学', '软件工程', '计算机科学', + '数据科学', '软件工程', '计算机科学', '数据科学', '软件工程' + ] + } + + df = pd.DataFrame(sample_data) + output_path = data_dir / "students_template.xlsx" + df.to_excel(output_path, index=False, engine='openpyxl') + + print(f"示例Excel文件已创建: {output_path}") + print(f"包含 {len(sample_data['学号'])} 条学生记录") + +if __name__ == "__main__": + create_sample_excel() + diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..dddab53 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,26 @@ +import requests +import json + +API_URL= "http://127.0.0.1:8000/api" +def test_get_student(): + response = requests.get(f"{API_URL}/students") + assert response.status_code == 200 + assert len(response.json()) == 1 + print("成功获取") +def test_student_creation(): + """测试学生创建功能""" + student_data = { + "student_id": "2021001", + "name": "张三", + "major": "计算机科学" + } + response = requests.post(f"{API_URL}/students/", json=student_data) + assert response.status_code == 200 + assert response.json()["name"] == "张三" + print("成功创建") +data = test_student_creation() +data = test_get_student() + + + + diff --git a/原型.html b/原型.html new file mode 100644 index 0000000..ecc23e7 --- /dev/null +++ b/原型.html @@ -0,0 +1,599 @@ + + + + + + 课堂随机点名系统 + + + +
+ +
+
课堂随机点名系统
+
+
+
+
+
+
+ + +
+
点名
+
学生管理
+
积分管理
+
数据可视化
+
+ + +
+
+
课堂点名
+
+
+
随机点名
+
顺序点名
+
+ +
+
陈四
+
+ 学号:2021012 | 专业:计算机科学 | 当前积分:8.2 +
+ +
+ +
+
记录点名结果
+
+
+ + +
+ +
+ + + (0-1:不能重复,0:未测试,0.5:准确重复) +
+ +
+ + + (0-3分,根据回答情况) +
+ + +
+
+
+
+ +
+
最近点名记录
+
+

暂无记录

+
+
+
+ + +
+
+
学生管理
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID学号姓名专业总积分
12021001张三软件工程4.7
22021012陈四计算机科学8.2
+
+
+
+ + +
+
+
积分管理
+
+
+
+
2
+
总学生数
+
+
+
11
+
总点名次数
+
+
+
6.45
+
平均积分
+
+
+
8.20
+
最高积分
+
+
+
4.70
+
最低积分
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
排名学号姓名专业总积分随机点名次数
12021012陈四计算机科学8.205
22021001张三软件工程4.702
+
+
+
+ + +
+
+
数据可视化
+
+
+ + + +
+ +
+
+

积分排名可视化

+

总积分(柱形图)和随机点名次数(折线图)

+

学生1: 陈四 - 总积分: 8.2 - 随机点名次数: 5

+

学生2: 张三 - 总积分: 4.7 - 随机点名次数: 2

+
+
+
+
+
+ + +
就绪
+
+ + + + \ No newline at end of file diff --git a/项目结构说明.md b/项目结构说明.md new file mode 100644 index 0000000..6e40706 --- /dev/null +++ b/项目结构说明.md @@ -0,0 +1,203 @@ +# 项目结构说明 + +## 目录结构 + +``` +. +├── backend/ # FastAPI后端 +│ ├── __init__.py +│ ├── main.py # 主程序入口 +│ ├── config.py # 配置文件(数据库连接等) +│ ├── database.py # 数据库连接和会话管理 +│ ├── models.py # SQLAlchemy数据库模型 +│ ├── schemas.py # Pydantic模型(API请求/响应) +│ ├── init_db.py # 数据库初始化脚本 +│ ├── routers/ # API路由 +│ │ ├── __init__.py +│ │ ├── students.py # 学生管理相关API +│ │ ├── rollcall.py # 点名相关API +│ │ └── scores.py # 积分和排名相关API +│ └── utils/ # 工具函数 +│ ├── __init__.py +│ ├── excel.py # Excel文件处理 +│ ├── probability.py # 概率计算(随机点名算法) +│ └── events.py # 随机事件系统 +│ +├── frontend/ # PyQt5前端 +│ ├── __init__.py +│ ├── main.py # 主程序入口 +│ ├── main_window.py # 主窗口 +│ ├── utils/ # 工具函数 +│ │ ├── __init__.py +│ │ └── api_client.py # API客户端(与后端通信) +│ └── widgets/ # 自定义组件 +│ ├── __init__.py +│ ├── student_management.py # 学生管理界面 +│ ├── rollcall.py # 点名界面 +│ ├── score_management.py # 积分管理界面 +│ └── visualization.py # 数据可视化界面 +│ +├── data/ # 数据文件目录 +│ └── students_template.xlsx # 示例Excel文件 +│ +├── docs/ # 文档目录 +│ └── prototype.md # 原型设计说明 +│ +├── scripts/ # 脚本目录 +│ └── create_sample_excel.py # 创建示例Excel脚本 +│ +├── requirements.txt # Python依赖包列表 +├── README.md # 项目说明文档 +├── 项目结构说明.md # 本文件 +└── .gitignore # Git忽略文件 +``` + +## 核心模块说明 + +### 后端模块 + +#### 1. 数据库模型 (`backend/models.py`) +- `Student`: 学生表 + - 基本信息:学号、姓名、专业 + - 积分信息:总积分、随机点名次数、顺序点名次数 + - 特殊功能:转移权次数 + +- `RollCallRecord`: 点名记录表 + - 记录每次点名的详细信息 + - 包含积分变化、事件类型等 + +- `ScoreRecord`: 积分记录表 + - 记录每次积分变化的详细历史 + +#### 2. API路由 + +**学生管理 (`backend/routers/students.py`)**: +- `GET /api/students/`: 获取学生列表 +- `GET /api/students/{id}`: 获取单个学生 +- `POST /api/students/`: 创建学生 +- `POST /api/students/import-excel`: 从Excel导入学生 +- `DELETE /api/students/{id}`: 删除学生 + +**点名功能 (`backend/routers/rollcall.py`)**: +- `POST /api/rollcall/start`: 开始点名(随机/顺序) +- `POST /api/rollcall/record`: 记录点名结果并更新积分 +- `GET /api/rollcall/records`: 获取点名记录列表 +- `POST /api/rollcall/transfer`: 转移点名(使用转移权) + +**积分管理 (`backend/routers/scores.py`)**: +- `GET /api/scores/ranking`: 获取积分排名 +- `GET /api/scores/export`: 导出积分详单(Excel) +- `GET /api/scores/statistics`: 获取统计信息 + +#### 3. 核心算法 + +**概率计算 (`backend/utils/probability.py`)**: +- `calculate_probability_weights()`: 计算每个学生的权重 + - 使用反比例函数:权重 = 1 / (积分 + 1) + - 积分越低,权重越高,被点概率越大 + +- `weighted_random_select()`: 加权随机选择 + - 使用 `random.choices()` 实现加权随机 + +- `get_random_student()`: 从数据库随机选择学生 +- `get_next_order_student()`: 按学号顺序获取下一个学生 + +**随机事件 (`backend/utils/events.py`)**: +- `trigger_random_event()`: 触发随机事件 + - 双倍加分:积分翻倍 + - 疯狂星期四:积分增加50% + - 幸运星:积分增加20% + +### 前端模块 + +#### 1. 主窗口 (`frontend/main_window.py`) +- 使用 `QTabWidget` 实现标签页布局 +- 包含四个主要功能模块 + +#### 2. 功能组件 + +**学生管理 (`frontend/widgets/student_management.py`)**: +- 学生列表表格展示 +- Excel导入功能 +- 学生删除功能 + +**点名界面 (`frontend/widgets/rollcall.py`)**: +- 点名模式选择(随机/顺序) +- 被点学生信息大屏显示 +- 点名结果记录表单 +- 最近点名历史记录 + +**积分管理 (`frontend/widgets/score_management.py`)**: +- 统计信息展示 +- 积分排名表格 +- 导出积分详单功能 + +**数据可视化 (`frontend/widgets/visualization.py`)**: +- 使用 Matplotlib 绘制图表 +- 支持柱形图和折线图切换 +- 显示积分和点名次数的双Y轴图表 + +#### 3. API客户端 (`frontend/utils/api_client.py`) +- 封装所有与后端API的通信 +- 提供简洁的接口供前端组件调用 +- 统一的错误处理 + +## 数据流程 + +### 点名流程 + +1. 用户点击"开始点名" +2. 前端调用 `POST /api/rollcall/start` +3. 后端根据模式选择学生: + - 随机模式:使用加权随机算法 + - 顺序模式:按学号顺序 +4. 返回被点学生信息 +5. 用户记录点名结果 +6. 前端调用 `POST /api/rollcall/record` +7. 后端计算积分变化: + - 到达:+1分 + - 重复问题:-1/0/+0.5分 + - 回答问题:0-3分 + - 应用随机事件倍数 +8. 更新学生积分和记录 + +### 积分计算规则 + +1. **基础积分**: + - 到达:+1分 + - 未到达:0分 + +2. **回答问题**: + - 不能重复问题:-1分 + - 未测试:0分 + - 准确重复:+0.5分 + - 准确回答:+0.5-3分(可自定义) + +3. **随机事件**: + - 普通:×1.0 + - 双倍加分:×2.0 + - 疯狂星期四:×1.5 + - 幸运星:×1.2 + +4. **转移权**: + - 被点名两次后获得一次转移权 + - 可以在被点名时转移给其他同学 + +## 技术栈 + +### 后端 +- **FastAPI**: 现代、快速的Web框架 +- **SQLAlchemy**: ORM框架 +- **Pydantic**: 数据验证 +- **Pandas**: 数据处理 +- **OpenPyXL**: Excel文件处理 +- **PyMySQL**: MySQL数据库驱动 + +### 前端 +- **PyQt5**: GUI框架 +- **Matplotlib**: 数据可视化 +- **Requests**: HTTP客户端 + +### 数据库 +- **MySQL**: 关系型数据库 +