first commit:the backend and the fronend

main
zrj 3 months ago
parent a6e8f392f0
commit f688421c9b

1
.gitignore vendored

@ -0,0 +1 @@
**/__pycache__

@ -0,0 +1,2 @@
# Backend package

@ -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']}")

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

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

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

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

@ -0,0 +1,2 @@
# Routers package

@ -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
}

@ -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),
}

@ -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": "删除成功"}

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

@ -0,0 +1,2 @@
# Utils package

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

@ -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)}")

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

Binary file not shown.

@ -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` | 数据可视化 |

@ -0,0 +1,2 @@
# Frontend package

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

@ -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;
}
""")

@ -0,0 +1,2 @@
# Utils package

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

@ -0,0 +1,2 @@
# Widgets package

@ -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)}")

@ -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)}")

@ -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)}")

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

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

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

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

@ -0,0 +1,599 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>课堂随机点名系统</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", sans-serif;
}
body {
background-color: #f0f0f0;
color: #333;
line-height: 1.5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
min-height: 100vh;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
/* 标题栏样式 */
.title-bar {
background: #2e7d32;
color: white;
padding: 8px 15px;
display: flex;
justify-content: space-between;
align-items: center;
-webkit-app-region: drag;
}
.title-text {
font-size: 16px;
font-weight: bold;
}
.window-controls {
display: flex;
-webkit-app-region: no-drag;
}
.window-control {
width: 12px;
height: 12px;
border-radius: 50%;
margin-left: 8px;
cursor: pointer;
}
.minimize { background: #ffbd2e; }
.maximize { background: #28ca42; }
.close { background: #ff5f57; }
/* 标签页样式 */
.tabs {
display: flex;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab.active {
background: white;
border-bottom-color: #2e7d32;
color: #2e7d32;
font-weight: bold;
}
.tab-content {
display: none;
padding: 20px;
}
.tab-content.active {
display: block;
}
/* 通用卡片样式 */
.card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background: #f5f5f5;
padding: 10px 15px;
border-bottom: 1px solid #e0e0e0;
font-weight: bold;
}
.card-body {
padding: 15px;
}
/* 按钮样式 */
.btn {
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f9f9f9;
cursor: pointer;
font-size: 14px;
margin-right: 8px;
margin-bottom: 8px;
}
.btn:hover {
background: #e9e9e9;
}
.btn-primary {
background: #2e7d32;
color: white;
border-color: #2e7d32;
}
.btn-primary:hover {
background: #1b5e20;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f5f5f5;
font-weight: bold;
}
tr:hover {
background: #f9f9f9;
}
/* 点名模块特定样式 */
.rollcall-display {
text-align: center;
padding: 20px 0;
}
.student-name {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.student-info {
font-size: 14px;
color: #666;
margin-bottom: 20px;
}
.mode-selector {
display: flex;
justify-content: center;
margin: 15px 0;
}
.mode-btn {
padding: 8px 16px;
border: 1px solid #ccc;
background: #f9f9f9;
cursor: pointer;
}
.mode-btn:first-child {
border-radius: 4px 0 0 4px;
}
.mode-btn:last-child {
border-radius: 0 4px 4px 0;
}
.mode-btn.active {
background: #2e7d32;
color: white;
border-color: #2e7d32;
}
/* 表单元素样式 */
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-control {
width: 100%;
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
}
.checkbox-group {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.checkbox-group input {
margin-right: 8px;
}
/* 统计信息样式 */
.stats-container {
display: flex;
flex-wrap: wrap;
margin-bottom: 20px;
}
.stat-item {
flex: 1;
min-width: 150px;
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 10px;
margin: 0 10px 10px 0;
text-align: center;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #2e7d32;
}
.stat-label {
font-size: 12px;
color: #666;
}
/* 图表容器样式 */
.chart-container {
height: 300px;
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-top: 15px;
position: relative;
}
.chart-placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #999;
text-align: center;
}
.chart-controls {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.chart-controls select {
margin-right: 10px;
}
/* 状态栏样式 */
.status-bar {
background: #f5f5f5;
border-top: 1px solid #e0e0e0;
padding: 5px 15px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<!-- 标题栏 -->
<div class="title-bar">
<div class="title-text">课堂随机点名系统</div>
<div class="window-controls">
<div class="window-control minimize"></div>
<div class="window-control maximize"></div>
<div class="window-control close"></div>
</div>
</div>
<!-- 标签页导航 -->
<div class="tabs">
<div class="tab active" data-tab="rollcall">点名</div>
<div class="tab" data-tab="students">学生管理</div>
<div class="tab" data-tab="scores">积分管理</div>
<div class="tab" data-tab="visualization">数据可视化</div>
</div>
<!-- 点名模块 -->
<div id="rollcall" class="tab-content active">
<div class="card">
<div class="card-header">课堂点名</div>
<div class="card-body">
<div class="mode-selector">
<div class="mode-btn active">随机点名</div>
<div class="mode-btn">顺序点名</div>
</div>
<div class="rollcall-display">
<div class="student-name">陈四</div>
<div class="student-info">
学号2021012 | 专业:计算机科学 | 当前积分8.2
</div>
<button class="btn btn-primary">开始点名</button>
</div>
<div class="form-group">
<div class="card-header">记录点名结果</div>
<div class="card-body">
<div class="checkbox-group">
<input type="checkbox" id="arrived" checked>
<label for="arrived">学生已到达</label>
</div>
<div class="form-group">
<label class="form-label">重复问题得分0.00分</label>
<input type="range" min="0" max="1" step="0.1" value="0" class="form-control">
<small>(0-1不能重复0未测试0.5:准确重复)</small>
</div>
<div class="form-group">
<label class="form-label">回答问题得分0.00分</label>
<input type="range" min="0" max="3" step="0.1" value="0" class="form-control">
<small>(0-3分根据回答情况)</small>
</div>
<button class="btn btn-primary">提交记录</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">最近点名记录</div>
<div class="card-body">
<p style="text-align: center; color: #999;">暂无记录</p>
</div>
</div>
</div>
<!-- 学生管理模块 -->
<div id="students" class="tab-content">
<div class="card">
<div class="card-header">学生管理</div>
<div class="card-body">
<div style="margin-bottom: 15px;">
<button class="btn">导入Excel</button>
<button class="btn">刷新</button>
<button class="btn">删除选中</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>学号</th>
<th>姓名</th>
<th>专业</th>
<th>总积分</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2021001</td>
<td>张三</td>
<td>软件工程</td>
<td>4.7</td>
</tr>
<tr>
<td>2</td>
<td>2021012</td>
<td>陈四</td>
<td>计算机科学</td>
<td>8.2</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 积分管理模块 -->
<div id="scores" class="tab-content">
<div class="card">
<div class="card-header">积分管理</div>
<div class="card-body">
<div class="stats-container">
<div class="stat-item">
<div class="stat-value">2</div>
<div class="stat-label">总学生数</div>
</div>
<div class="stat-item">
<div class="stat-value">11</div>
<div class="stat-label">总点名次数</div>
</div>
<div class="stat-item">
<div class="stat-value">6.45</div>
<div class="stat-label">平均积分</div>
</div>
<div class="stat-item">
<div class="stat-value">8.20</div>
<div class="stat-label">最高积分</div>
</div>
<div class="stat-item">
<div class="stat-value">4.70</div>
<div class="stat-label">最低积分</div>
</div>
</div>
<div style="margin-bottom: 15px;">
<button class="btn">导出积分详单</button>
<button class="btn">刷新</button>
</div>
<table>
<thead>
<tr>
<th>排名</th>
<th>学号</th>
<th>姓名</th>
<th>专业</th>
<th>总积分</th>
<th>随机点名次数</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2021012</td>
<td>陈四</td>
<td>计算机科学</td>
<td>8.20</td>
<td>5</td>
</tr>
<tr>
<td>2</td>
<td>2021001</td>
<td>张三</td>
<td>软件工程</td>
<td>4.70</td>
<td>2</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 数据可视化模块 -->
<div id="visualization" class="tab-content">
<div class="card">
<div class="card-header">数据可视化</div>
<div class="card-body">
<div class="chart-controls">
<select class="form-control" style="width: auto;">
<option>显示前10名</option>
</select>
<select class="form-control" style="width: auto;">
<option>柱形图</option>
</select>
<button class="btn">刷新</button>
</div>
<div class="chart-container">
<div class="chart-placeholder">
<p>积分排名可视化</p>
<p>总积分(柱形图)和随机点名次数(折线图)</p>
<p>学生1: 陈四 - 总积分: 8.2 - 随机点名次数: 5</p>
<p>学生2: 张三 - 总积分: 4.7 - 随机点名次数: 2</p>
</div>
</div>
</div>
</div>
</div>
<!-- 状态栏 -->
<div class="status-bar">就绪</div>
</div>
<script>
// 标签页切换功能
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function() {
// 移除所有激活状态
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// 添加当前激活状态
this.classList.add('active');
const tabId = this.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
});
});
// 点名模式切换
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
});
});
// 开始点名按钮功能
document.querySelector('.btn-primary').addEventListener('click', function() {
const students = [
{ name: "张三", id: "2021001", major: "软件工程", score: "4.7" },
{ name: "陈四", id: "2021012", major: "计算机科学", score: "8.2" },
{ name: "王五", id: "2021005", major: "人工智能", score: "7.5" },
{ name: "赵六", id: "2021010", major: "数据科学", score: "6.8" }
];
// 随机选择学生
const randomStudent = students[Math.floor(Math.random() * students.length)];
// 更新显示
document.querySelector('.student-name').textContent = randomStudent.name;
document.querySelector('.student-info').textContent =
`学号:${randomStudent.id} | 专业:${randomStudent.major} | 当前积分:${randomStudent.score}`;
// 添加点名记录
const now = new Date();
const timeString = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
const historyContainer = document.querySelector('#rollcall .card:last-child .card-body');
historyContainer.innerHTML = `
<table>
<thead>
<tr>
<th>时间</th>
<th>学生</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>${timeString}</td>
<td>${randomStudent.name}</td>
<td>已点名</td>
</tr>
</tbody>
</table>
`;
});
// 窗口控制按钮功能
document.querySelector('.close').addEventListener('click', function() {
if (confirm('确定要关闭应用吗?')) {
window.close(); // 注意:这通常只在特定环境下有效
}
});
document.querySelector('.minimize').addEventListener('click', function() {
// 最小化窗口逻辑(在浏览器中无法真正最小化,这里只是示例)
console.log('最小化窗口');
});
document.querySelector('.maximize').addEventListener('click', function() {
// 最大化/还原窗口逻辑
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
</script>
</body>
</html>

@ -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**: 关系型数据库
Loading…
Cancel
Save