parent
a6e8f392f0
commit
f688421c9b
@ -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,2 @@
|
||||
# Routers package
|
||||
|
||||
@ -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,2 @@
|
||||
# Utils package
|
||||
|
||||
Binary file not shown.
@ -0,0 +1,2 @@
|
||||
# Frontend package
|
||||
|
||||
@ -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,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()
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in new issue