From 4121e9624f6e7535a7bdde16f8455f5607e42aed Mon Sep 17 00:00:00 2001 From: canglan Date: Wed, 22 Apr 2026 00:59:29 +0800 Subject: [PATCH] =?UTF-8?q?v1.2=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + backend/.env.example | 19 +- backend/config.py | 6 + backend/main.py | 3 +- backend/middleware/auth_middleware.py | 5 +- backend/models/admin_role.py | 3 +- backend/models/semester.py | 166 ++++++++++++++ backend/models/student.py | 7 + backend/routes/admin.py | 16 +- backend/routes/debug.py | 5 + backend/routes/semester.py | 151 +++++++++++++ backend/routes/student.py | 18 +- backend/schemas/semester.py | 20 ++ backend/services/conduct_service.py | 36 ++- backend/services/parent_service.py | 12 +- backend/services/semester_service.py | 244 ++++++++++++++++++++ backend/services/student_service.py | 4 +- frontend/.env.example | 3 + frontend/admin/conduct.php | 3 +- frontend/admin/dashboard.php | 14 +- frontend/admin/semesters.php | 307 ++++++++++++++++++++++++++ frontend/includes/nav.php | 3 + frontend/parent/dashboard.php | 9 +- frontend/student/dashboard.php | 3 +- frontend/student/semester_history.php | 189 ++++++++++++++++ sql/init.sql | 137 +++++++++--- 26 files changed, 1323 insertions(+), 61 deletions(-) create mode 100644 backend/models/semester.py create mode 100644 backend/routes/semester.py create mode 100644 backend/schemas/semester.py create mode 100644 backend/services/semester_service.py create mode 100644 frontend/admin/semesters.php create mode 100644 frontend/student/semester_history.php diff --git a/README.md b/README.md index d3a9aee..8ba0d68 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ classmanager/ |------|---------|------| | v1.0 | 2026.4.19 | 初始版本发布,包含基础功能 | | v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 | +| v1.2 | 2026.4.22 | 新增学期功能 | ## 许可证 diff --git a/backend/.env.example b/backend/.env.example index ad03abb..18bfb2c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,6 +54,9 @@ PASSWORD_SALT=your-fixed-salt-string-for-password-hash # 调试入口配置 # =========================================== +# 调试功能开关 - 设为 true 启用调试路由,生产环境必须为 false +DEBUG_ENABLED=false +# 调试入口路径 - 自定义随机路径增强安全性 DEBUG_PATH=/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 # =========================================== @@ -83,12 +86,26 @@ LABOR_POINTS_ADD=1 LABOR_POINTS_SUBTRACT=-1 # =========================================== -# 班长加减分限制配置 +# 各角色加减分限制配置 # =========================================== +# 班长单次加分上限 MONITOR_MAX_ADD=5 +# 班长单次扣分上限(负数) MONITOR_MAX_SUBTRACT=-5 +# 学习委员单次加减分上限(绝对值)- 正负均不可超过此值 +STUDY_COMMISSIONER_MAX_POINTS=5 + +# 考勤委员单次扣分上限(绝对值) +ATTENDANCE_REP_MAX_POINTS=5 + +# 劳动委员单次加减分上限(绝对值) +LABOR_REP_MAX_POINTS=1 + +# 志愿委员单次加分上限 +VOLUNTEER_REP_MAX_POINTS=5 + # =========================================== # 日志配置 # =========================================== diff --git a/backend/config.py b/backend/config.py index 3f02124..15d7b49 100644 --- a/backend/config.py +++ b/backend/config.py @@ -47,6 +47,7 @@ class Settings: JWT_EXPIRE_MINUTES: int = int(os.getenv("JWT_EXPIRE_MINUTES", "30")) PASSWORD_SALT: str = os.getenv("PASSWORD_SALT", "") + DEBUG_ENABLED: bool = os.getenv("DEBUG_ENABLED", "False").lower() == "true" DEBUG_PATH: str = os.getenv("DEBUG_PATH", "/debug_add_admin") DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2")) @@ -61,6 +62,11 @@ class Settings: MONITOR_MAX_ADD: int = int(os.getenv("MONITOR_MAX_ADD", "5")) MONITOR_MAX_SUBTRACT: int = int(os.getenv("MONITOR_MAX_SUBTRACT", "-5")) + STUDY_COMMISSIONER_MAX_POINTS: int = int(os.getenv("STUDY_COMMISSIONER_MAX_POINTS", "5")) + ATTENDANCE_REP_MAX_POINTS: int = int(os.getenv("ATTENDANCE_REP_MAX_POINTS", "5")) + LABOR_REP_MAX_POINTS: int = int(os.getenv("LABOR_REP_MAX_POINTS", "1")) + VOLUNTEER_REP_MAX_POINTS: int = int(os.getenv("VOLUNTEER_REP_MAX_POINTS", "5")) + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") LOG_MAX_BYTES: int = int(os.getenv("LOG_MAX_BYTES", "104857600")) LOG_BACKUP_COUNT: int = int(os.getenv("LOG_BACKUP_COUNT", "30")) diff --git a/backend/main.py b/backend/main.py index df88275..eea76e8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,7 +21,7 @@ from utils.logger import setup_logger, log_access from utils.database import init_db_pool, close_db_pool from utils.redis_client import init_redis_pool, close_redis_pool from middleware.auth_middleware import AuthMiddleware -from routes import auth, student, parent, admin, subject, debug +from routes import auth, student, parent, admin, subject, semester, debug # 设置日志 @@ -116,6 +116,7 @@ app.include_router(student.router, prefix="/api/student", tags=["学生端"]) app.include_router(parent.router, prefix="/api/parent", tags=["家长端"]) app.include_router(admin.router, prefix="/api/admin", tags=["管理端"]) app.include_router(subject.router, prefix="/api/subject", tags=["科目管理"]) +app.include_router(semester.router, prefix="/api/semester", tags=["学期管理"]) app.include_router(debug.router, tags=["调试"]) diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py index dd288bb..f6fcbfd 100644 --- a/backend/middleware/auth_middleware.py +++ b/backend/middleware/auth_middleware.py @@ -36,11 +36,10 @@ def is_public_path(path: str) -> bool: for pattern in PUBLIC_PATHS: if re.match(pattern, path): return True - # 动态匹配调试入口路径 - if settings.DEBUG_PATH and path == settings.DEBUG_PATH: + # 动态匹配调试入口路径(需同时启用调试功能) + if settings.DEBUG_ENABLED and settings.DEBUG_PATH and path == settings.DEBUG_PATH: return True return False - return False class AuthMiddleware(BaseHTTPMiddleware): diff --git a/backend/models/admin_role.py b/backend/models/admin_role.py index 13a09b6..c40de9b 100644 --- a/backend/models/admin_role.py +++ b/backend/models/admin_role.py @@ -32,12 +32,11 @@ class AdminRoleModel: sql = """ SELECT ar.*, u.real_name, u.username, s.subject_name FROM admin_roles ar - JOIN users u ON ar.user_id = u.user_id + JOIN users u ON ar.user_id = u.user_id AND u.status = 1 LEFT JOIN subjects s ON ar.subject_id = s.subject_id ORDER BY ar.role_type """ return await execute_query(sql) - @staticmethod async def create(user_id: int, role_type: str, subject_id: int = None) -> int: sql = """ diff --git a/backend/models/semester.py b/backend/models/semester.py new file mode 100644 index 0000000..f2be317 --- /dev/null +++ b/backend/models/semester.py @@ -0,0 +1,166 @@ +# =========================================== +# 班级操行分管理系统 - 学期数据模型 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, List, Dict, Any +from utils.database import execute_one, execute_query, execute_insert, execute_update, execute_many +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class SemesterModel: + """学期数据模型""" + + @staticmethod + async def create( + semester_name: str, + start_date: str = None, + end_date: str = None + ) -> int: + """创建学期""" + sql = """ + INSERT INTO semesters (semester_name, start_date, end_date) + VALUES (%s, %s, %s) + """ + return await execute_insert(sql, (semester_name, start_date, end_date)) + + @staticmethod + async def get_by_id(semester_id: int) -> Optional[Dict[str, Any]]: + """根据ID获取学期信息""" + sql = "SELECT * FROM semesters WHERE semester_id = %s" + return await execute_one(sql, (semester_id,)) + + @staticmethod + async def get_all() -> List[Dict[str, Any]]: + """获取所有学期列表""" + sql = """ + SELECT semester_id, semester_name, start_date, end_date, + is_active, is_archived, created_at + FROM semesters + ORDER BY created_at DESC + """ + return await execute_query(sql) + + @staticmethod + async def get_active() -> Optional[Dict[str, Any]]: + """获取当前活跃学期""" + sql = """ + SELECT semester_id, semester_name, start_date, end_date, + is_active, is_archived, created_at + FROM semesters + WHERE is_active = 1 AND is_archived = 0 + LIMIT 1 + """ + return await execute_one(sql) + + @staticmethod + async def deactivate_all() -> int: + """将所有学期设为非活跃""" + sql = "UPDATE semesters SET is_active = 0 WHERE is_active = 1" + return await execute_update(sql) + + @staticmethod + async def activate(semester_id: int) -> bool: + """设为当前活跃学期""" + sql = """ + UPDATE semesters SET is_active = 1 + WHERE semester_id = %s AND is_archived = 0 + """ + result = await execute_update(sql, (semester_id,)) + return result > 0 + + @staticmethod + async def archive(semester_id: int) -> bool: + """归档学期""" + sql = """ + UPDATE semesters SET is_archived = 1, is_active = 0 + WHERE semester_id = %s AND is_archived = 0 + """ + result = await execute_update(sql, (semester_id,)) + return result > 0 + + @staticmethod + async def is_archived(semester_id: int) -> bool: + """检查学期是否已归档""" + sql = "SELECT is_archived FROM semesters WHERE semester_id = %s" + result = await execute_one(sql, (semester_id,)) + if not result: + return False + return bool(result['is_archived']) + + @staticmethod + async def get_record_semester_id(record_id: int) -> Optional[int]: + """获取操行分记录所属的学期ID""" + sql = "SELECT semester_id FROM conduct_records WHERE record_id = %s" + result = await execute_one(sql, (record_id,)) + return result['semester_id'] if result else None + + +class SemesterArchiveModel: + """学期归档快照数据模型""" + + @staticmethod + async def batch_create(archives_data: List[Dict]) -> int: + """批量创建归档快照""" + if not archives_data: + return 0 + sql = """ + INSERT INTO semester_archives + (semester_id, student_id, student_no, student_name, final_points, rank_position, total_students) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """ + params_list = [ + ( + a['semester_id'], a['student_id'], a['student_no'], + a['student_name'], a['final_points'], + a.get('rank_position'), a.get('total_students') + ) + for a in archives_data + ] + return await execute_many(sql, params_list) + + @staticmethod + async def get_by_semester(semester_id: int) -> List[Dict[str, Any]]: + """获取学期的归档数据""" + sql = """ + SELECT archive_id, semester_id, student_id, student_no, + student_name, final_points, rank_position, total_students, archived_at + FROM semester_archives + WHERE semester_id = %s + ORDER BY rank_position ASC + """ + return await execute_query(sql, (semester_id,)) + + @staticmethod + async def get_by_semester_and_student(semester_id: int, student_id: int) -> Optional[Dict[str, Any]]: + """获取指定学期指定学生的归档数据""" + sql = """ + SELECT archive_id, semester_id, student_id, student_no, + student_name, final_points, rank_position, total_students, archived_at + FROM semester_archives + WHERE semester_id = %s AND student_id = %s + """ + return await execute_one(sql, (semester_id, student_id)) + + @staticmethod + async def get_by_student(student_id: int) -> List[Dict[str, Any]]: + """获取学生在所有已归档学期的数据""" + sql = """ + SELECT sa.archive_id, sa.semester_id, sa.student_id, sa.student_no, + sa.student_name, sa.final_points, sa.rank_position, + sa.total_students, sa.archived_at, + s.semester_name, s.start_date, s.end_date + FROM semester_archives sa + JOIN semesters s ON sa.semester_id = s.semester_id + WHERE sa.student_id = %s + ORDER BY sa.archived_at DESC + """ + return await execute_query(sql, (student_id,)) diff --git a/backend/models/student.py b/backend/models/student.py index 8cde5de..d31a253 100644 --- a/backend/models/student.py +++ b/backend/models/student.py @@ -120,6 +120,13 @@ class StudentModel: row['rank'] = i + 1 return results + @staticmethod + async def get_total_count() -> int: + """获取活跃学生总数""" + sql = "SELECT COUNT(*) as total FROM students WHERE status = 1" + result = await execute_one(sql) + return result["total"] if result else 0 + @staticmethod async def batch_create(students_data: List[Dict], initial_points: int = 60) -> List[Dict]: """批量创建学生""" diff --git a/backend/routes/admin.py b/backend/routes/admin.py index a66785a..4da04b6 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -161,10 +161,17 @@ async def revoke_conduct_record(request: Request, req: RevokeRequest): ) if result["success"]: role = await PermissionChecker.get_user_role(user["user_id"]) + record = result.get("record", {}) await LogService.write_operation_log( operator_id=user["user_id"], operator_name=user["username"], operator_role=role, operation_type="revoke_record", target_type="conduct", target_id=req.record_id, + details=( + f"撤销记录ID: {req.record_id}, " + f"原操作人: {record.get('recorder_name', '未知')}, " + f"原分值变动: {'+' if record.get('points_change', 0) > 0 else ''}{record.get('points_change', 0)}分, " + f"撤销操作人: {user['username']}" + ), ip=request.client.host ) return success_response(message="撤销成功") @@ -447,7 +454,6 @@ async def reset_admin_password(request: Request, user_id: int, req: ResetPasswor return error_response(message="仅班主任可重置密码", code=403) from models.user import UserModel - from utils.security import SecurityUtils # 获取管理员信息 target_user = await UserModel.get_by_user_id(user_id) @@ -457,12 +463,8 @@ async def reset_admin_password(request: Request, user_id: int, req: ResetPasswor if target_user["user_type"] != "admin": return error_response(message="只能重置管理员密码", code=400) - # 使用传入的新密码 - new_password = req.new_password - password_hash = SecurityUtils.sha1_md5_password(new_password) - - # 更新密码 - updated = await UserModel.update_password(user_id, password_hash) + # 使用传入的新密码(UserModel.update_password 内部会进行哈希) + updated = await UserModel.update_password(user_id, req.new_password) if updated: await LogService.write_operation_log( operator_id=user["user_id"], operator_name=user["username"], diff --git a/backend/routes/debug.py b/backend/routes/debug.py index ad5d80f..45eb778 100644 --- a/backend/routes/debug.py +++ b/backend/routes/debug.py @@ -32,6 +32,11 @@ class AddAdminDebugRequest(BaseModel): @router.post(settings.DEBUG_PATH) async def debug_add_admin(request: Request, req: AddAdminDebugRequest): + # 检查调试功能是否启用 + if not settings.DEBUG_ENABLED: + from fastapi.responses import JSONResponse + return JSONResponse(status_code=404, content={"detail": "Not Found"}) + from models.user import UserModel valid_roles = ["班主任", "班长", "学习委员", "考勤委员", "劳动委员", "志愿委员"] diff --git a/backend/routes/semester.py b/backend/routes/semester.py new file mode 100644 index 0000000..2a7db42 --- /dev/null +++ b/backend/routes/semester.py @@ -0,0 +1,151 @@ +# =========================================== +# 班级操行分管理系统 - 学期管理路由 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import APIRouter, Request, Query +from typing import Optional + +from middleware.permission import ( + get_current_user, + PermissionChecker +) +from services.semester_service import SemesterService +from services.log_service import LogService +from schemas.semester import CreateSemesterRequest +from utils.response import success_response, error_response +from utils.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/list") +async def list_semesters(request: Request): + """获取学期列表(仅班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可查看学期列表", code=403) + result = await SemesterService.list_semesters() + if result["success"]: + return success_response(data=result["semesters"]) + else: + return error_response(message=result["message"]) + + +@router.get("/active") +async def get_active_semester(request: Request): + """获取当前活跃学期""" + user = await get_current_user(request) + result = await SemesterService.get_active_semester() + if result["success"]: + return success_response(data=result.get("semester")) + else: + return error_response(message=result["message"]) + + +@router.post("/create") +async def create_semester(request: Request, req: CreateSemesterRequest): + """创建学期(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可创建学期", code=403) + + result = await SemesterService.create_semester( + semester_name=req.semester_name, + start_date=req.start_date, + end_date=req.end_date, + operator_id=user["user_id"] + ) + if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["username"], + operator_role="班主任", operation_type="create_semester", + target_type="semester", target_id=result.get("semester_id"), + details=f"创建学期: {req.semester_name}", + ip=request.client.host + ) + return success_response(data=result, message="学期创建成功") + else: + return error_response(message=result["message"]) + + +@router.put("/activate/{semester_id}") +async def activate_semester(request: Request, semester_id: int): + """设为当前学期(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可设置当前学期", code=403) + + result = await SemesterService.activate_semester( + semester_id=semester_id, + operator_id=user["user_id"] + ) + if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["username"], + operator_role="班主任", operation_type="activate_semester", + target_type="semester", target_id=semester_id, + details=f"激活学期ID: {semester_id}", + ip=request.client.host + ) + return success_response(message=result["message"]) + else: + return error_response(message=result["message"]) + + +@router.post("/archive/{semester_id}") +async def archive_semester(request: Request, semester_id: int): + """归档学期(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可归档学期", code=403) + + result = await SemesterService.archive_semester( + semester_id=semester_id, + operator_id=user["user_id"] + ) + if result["success"]: + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["username"], + operator_role="班主任", operation_type="archive_semester", + target_type="semester", target_id=semester_id, + details=f"归档学期ID: {semester_id}", + ip=request.client.host + ) + return success_response(message=result["message"]) + else: + return error_response(message=result["message"]) + + +@router.get("/archive/{semester_id}/records") +async def get_archive_records( + request: Request, + semester_id: int, + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200) +): + """查看归档数据(仅班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可查看归档数据", code=403) + result = await SemesterService.get_archive_records( + semester_id=semester_id, + page=page, + page_size=page_size + ) + if result["success"]: + return success_response(data=result["data"]) + else: + return error_response(message=result["message"]) diff --git a/backend/routes/student.py b/backend/routes/student.py index bcaa87b..74592ed 100644 --- a/backend/routes/student.py +++ b/backend/routes/student.py @@ -119,4 +119,20 @@ async def get_my_info(request: Request): result = await StudentService.get_student_info(user["student_id"]) - return success_response(data=result) \ No newline at end of file + return success_response(data=result) + + +@router.get("/semester-records") +async def get_student_semester_records(request: Request): + """ + 获取当前学生的历史学期归档记录 + """ + user = await get_current_user(request) + + if user["user_type"] != "student": + return error_response(message="仅限学生访问", code=403) + + from models.semester import SemesterArchiveModel + records = await SemesterArchiveModel.get_by_student(user["student_id"]) + + return success_response(data={"records": records}) \ No newline at end of file diff --git a/backend/schemas/semester.py b/backend/schemas/semester.py new file mode 100644 index 0000000..b87668e --- /dev/null +++ b/backend/schemas/semester.py @@ -0,0 +1,20 @@ +# =========================================== +# 班级操行分管理系统 - 学期请求模型 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from pydantic import BaseModel, Field +from typing import Optional + + +class CreateSemesterRequest(BaseModel): + """创建学期请求""" + semester_name: str = Field(..., min_length=1, max_length=100, description="学期名称") + start_date: Optional[str] = Field(None, description="学期开始日期 (YYYY-MM-DD)") + end_date: Optional[str] = Field(None, description="学期结束日期 (YYYY-MM-DD)") diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py index b2cf3b9..7d0e53f 100644 --- a/backend/services/conduct_service.py +++ b/backend/services/conduct_service.py @@ -52,17 +52,25 @@ class ConductService: if points_change > settings.MONITOR_MAX_ADD or points_change < settings.MONITOR_MAX_SUBTRACT: return {"success": False, "message": f"班长单次只能加减{settings.MONITOR_MAX_ADD}分以内"} elif role == "劳动委员": - # 劳动委员固定 ±1分 - if points_change not in [settings.LABOR_POINTS_ADD, settings.LABOR_POINTS_SUBTRACT]: - return {"success": False, "message": "劳动委员只能进行±1分操作"} + # 劳动委员可加减分,±LABOR_REP_MAX_POINTS以内 + if abs(points_change) > settings.LABOR_REP_MAX_POINTS: + return {"success": False, "message": f"劳动委员单次只能加减{settings.LABOR_REP_MAX_POINTS}分以内"} elif role == "志愿委员": - # 志愿委员只能加分,不限制正分上限 + # 志愿委员只能加分,上限VOLUNTEER_REP_MAX_POINTS if points_change < 0: return {"success": False, "message": "志愿委员只能加分"} - elif role in ["学习委员", "考勤委员"]: - # 学习委员和考勤委员只能扣分 + if points_change > settings.VOLUNTEER_REP_MAX_POINTS: + return {"success": False, "message": f"志愿委员单次最多加{settings.VOLUNTEER_REP_MAX_POINTS}分"} + elif role == "学习委员": + # 学习委员可加减分,±STUDY_COMMISSIONER_MAX_POINTS以内 + if abs(points_change) > settings.STUDY_COMMISSIONER_MAX_POINTS: + return {"success": False, "message": f"学习委员单次只能加减{settings.STUDY_COMMISSIONER_MAX_POINTS}分以内"} + elif role == "考勤委员": + # 考勤委员只能扣分,上限ATTENDANCE_REP_MAX_POINTS if points_change > 0: - return {"success": False, "message": "该角色只能进行扣分操作"} + return {"success": False, "message": "考勤委员只能进行扣分操作"} + if abs(points_change) > settings.ATTENDANCE_REP_MAX_POINTS: + return {"success": False, "message": f"考勤委员单次最多扣{settings.ATTENDANCE_REP_MAX_POINTS}分"} else: return {"success": False, "message": "无权进行此操作"} @@ -121,6 +129,9 @@ class ConductService: if not record: return {"success": False, "message": "记录不存在"} + # 归档后班主任仍可撤销/修改记录(任务需求#8) + # 归档操作本身不可逆,但归档数据可由班主任修改 + # 撤销记录 result = await ConductModel.revoke_record(record_id, revoker_id) @@ -128,7 +139,16 @@ class ConductService: # 反向恢复学生总分 await StudentModel.update_total_points(record["student_id"], -record["points_change"]) logger.info(f"用户[{revoker_id}] 撤销了记录[{record_id}]") - return {"success": True, "message": "撤销成功"} + return { + "success": True, + "message": "撤销成功", + "record": { + "student_id": record["student_id"], + "recorder_name": record.get("recorder_name", "未知"), + "points_change": record["points_change"], + "reason": record.get("reason", "") + } + } else: return {"success": False, "message": "撤销失败"} diff --git a/backend/services/parent_service.py b/backend/services/parent_service.py index 1e01a13..5c587b4 100644 --- a/backend/services/parent_service.py +++ b/backend/services/parent_service.py @@ -9,6 +9,7 @@ # 版权所有 © Sea Network Technology Studio # =========================================== +import math from typing import Dict, Any, Optional, List from models.user import UserModel @@ -93,22 +94,27 @@ class ParentService: # 获取全班排名 ranking = await StudentModel.get_ranking(limit=1000) + total_students = await StudentModel.get_total_count() # 查找当前学生排名 student_rank = None - total_students = 0 for r in ranking: - total_students += 1 if r["student_id"] == user["student_id"]: student_rank = r["rank"] + # 计算百分比排名 + percentile = None + if student_rank and total_students and total_students > 0: + percentile = math.ceil(student_rank / total_students * 100) + return { "student_id": student["student_id"], "student_name": student["name"], "student_no": student["student_no"], "total_points": student["total_points"], "rank": student_rank, - "total_students": total_students + "total_students": total_students, + "percentile": percentile } @staticmethod diff --git a/backend/services/semester_service.py b/backend/services/semester_service.py new file mode 100644 index 0000000..c538122 --- /dev/null +++ b/backend/services/semester_service.py @@ -0,0 +1,244 @@ +# =========================================== +# 班级操行分管理系统 - 学期服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional + +from models.semester import SemesterModel, SemesterArchiveModel +from models.student import StudentModel +from middleware.permission import PermissionChecker +from config import settings +from utils.logger import get_logger +from utils.database import execute_query + +logger = get_logger(__name__) + + +class SemesterService: + """学期管理服务""" + + @staticmethod + async def list_semesters() -> Dict[str, Any]: + """获取学期列表""" + try: + semesters = await SemesterModel.get_all() + return { + "success": True, + "semesters": semesters + } + except Exception as e: + logger.error(f"获取学期列表失败: {e}") + return {"success": False, "message": f"获取学期列表失败: {str(e)}"} + + @staticmethod + async def create_semester( + semester_name: str, + start_date: str = None, + end_date: str = None, + operator_id: int = None + ) -> Dict[str, Any]: + """创建新学期""" + if not semester_name or not semester_name.strip(): + return {"success": False, "message": "学期名称不能为空"} + + try: + # 自动将之前的活跃学期设为非活跃 + await SemesterModel.deactivate_all() + + # 创建新学期并自动设为活跃 + semester_id = await SemesterModel.create( + semester_name=semester_name.strip(), + start_date=start_date, + end_date=end_date + ) + + # 设为活跃学期 + await SemesterModel.activate(semester_id) + + logger.info(f"用户[{operator_id}] 创建了新学期: {semester_name}") + + return { + "success": True, + "message": "学期创建成功", + "semester_id": semester_id + } + except Exception as e: + logger.error(f"创建学期失败: {e}") + return {"success": False, "message": f"创建学期失败: {str(e)}"} + + @staticmethod + async def activate_semester( + semester_id: int, + operator_id: int = None + ) -> Dict[str, Any]: + """设为当前活跃学期""" + try: + # 检查学期是否存在 + semester = await SemesterModel.get_by_id(semester_id) + if not semester: + return {"success": False, "message": "学期不存在"} + + # 已归档的学期不能激活 + if semester['is_archived']: + return {"success": False, "message": "已归档的学期不能设为当前学期"} + + # 自动将之前的活跃学期设为非活跃 + await SemesterModel.deactivate_all() + + # 设为活跃 + result = await SemesterModel.activate(semester_id) + + if result: + logger.info(f"用户[{operator_id}] 激活了学期: {semester['semester_name']}") + return {"success": True, "message": "已设为当前学期"} + else: + return {"success": False, "message": "激活失败"} + except Exception as e: + logger.error(f"激活学期失败: {e}") + return {"success": False, "message": f"激活学期失败: {str(e)}"} + + @staticmethod + async def archive_semester( + semester_id: int, + operator_id: int = None + ) -> Dict[str, Any]: + """归档学期""" + try: + # 检查学期是否存在 + semester = await SemesterModel.get_by_id(semester_id) + if not semester: + return {"success": False, "message": "学期不存在"} + + # 已归档的不能重复归档 + if semester['is_archived']: + return {"success": False, "message": "该学期已归档"} + + # 获取所有活跃学生及其当前分数 + students = await StudentModel.get_all(include_disabled=False) + if not students: + return {"success": False, "message": "没有可归档的学生数据"} + + total_students = len(students) + + # 按分数降序排列以计算排名 + sorted_students = sorted(students, key=lambda s: s['total_points'], reverse=True) + + # 构建归档快照数据 + archives_data = [] + for rank, student in enumerate(sorted_students, 1): + archives_data.append({ + 'semester_id': semester_id, + 'student_id': student['student_id'], + 'student_no': student['student_no'], + 'student_name': student['name'], + 'final_points': student['total_points'], + 'rank_position': rank, + 'total_students': total_students + }) + + # 保存归档快照 + await SemesterArchiveModel.batch_create(archives_data) + + # 标记学期为已归档 + await SemesterModel.archive(semester_id) + + logger.info( + f"用户[{operator_id}] 归档了学期: {semester['semester_name']}, " + f"共 {total_students} 名学生" + ) + + return { + "success": True, + "message": f"学期归档成功,共归档 {total_students} 名学生数据" + } + except Exception as e: + logger.error(f"归档学期失败: {e}") + return {"success": False, "message": f"归档学期失败: {str(e)}"} + + @staticmethod + async def get_archive_records( + semester_id: int, + page: int = 1, + page_size: int = 50 + ) -> Dict[str, Any]: + """获取归档数据(只读)""" + try: + # 检查学期是否存在 + semester = await SemesterModel.get_by_id(semester_id) + if not semester: + return {"success": False, "message": "学期不存在"} + + archives = await SemesterArchiveModel.get_by_semester(semester_id) + + total = len(archives) + offset = (page - 1) * page_size + paged_archives = archives[offset:offset + page_size] + + return { + "success": True, + "data": { + "semester": { + "semester_id": semester['semester_id'], + "semester_name": semester['semester_name'], + "start_date": semester.get('start_date'), + "end_date": semester.get('end_date'), + "is_archived": bool(semester['is_archived']) + }, + "archives": paged_archives, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + } + except Exception as e: + logger.error(f"获取归档数据失败: {e}") + return {"success": False, "message": f"获取归档数据失败: {str(e)}"} + + @staticmethod + async def reset_student_points(initial_points: int = None) -> Dict[str, Any]: + """重置所有学生的操行分为初始分""" + if initial_points is None: + initial_points = settings.STUDENT_INITIAL_POINTS + + try: + from utils.database import execute_update + sql = "UPDATE students SET total_points = %s WHERE status = 1" + affected = await execute_update(sql, (initial_points,)) + + logger.info(f"已重置 {affected} 名学生的操行分为 {initial_points}") + return { + "success": True, + "message": f"已重置 {affected} 名学生的操行分为 {initial_points} 分", + "affected": affected + } + except Exception as e: + logger.error(f"重置学生分数失败: {e}") + return {"success": False, "message": f"重置学生分数失败: {str(e)}"} + + @staticmethod + async def get_active_semester() -> Dict[str, Any]: + """获取当前活跃学期""" + try: + active = await SemesterModel.get_active() + if active: + return { + "success": True, + "semester": active + } + else: + return { + "success": True, + "semester": None, + "message": "当前没有活跃学期" + } + except Exception as e: + logger.error(f"获取活跃学期失败: {e}") + return {"success": False, "message": f"获取活跃学期失败: {str(e)}"} diff --git a/backend/services/student_service.py b/backend/services/student_service.py index c9a400d..8ce50be 100644 --- a/backend/services/student_service.py +++ b/backend/services/student_service.py @@ -123,9 +123,11 @@ class StudentService: ) -> Dict[str, Any]: """获取排行榜(单班级系统)""" ranking = await StudentModel.get_ranking(limit=limit) + total_students = await StudentModel.get_total_count() return { - "ranking": ranking + "ranking": ranking, + "total_students": total_students } @staticmethod diff --git a/frontend/.env.example b/frontend/.env.example index 1d76873..47fe8ee 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -44,6 +44,9 @@ DEDUCTION_HOMEWORK_LATE=1 # 作业-每次加减分上限(绝对值) HOMEWORK_MAX_POINTS=3 +# 学习委员单次加减分上限(绝对值) +STUDY_COMMISSIONER_MAX_POINTS=5 + # 考勤-缺勤扣分 DEDUCTION_ATTENDANCE_ABSENT=5 # 考勤-迟到扣分 diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php index 1e23e20..acd0708 100644 --- a/frontend/admin/conduct.php +++ b/frontend/admin/conduct.php @@ -20,7 +20,7 @@ if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') { $page_title = '操行分管理'; $role = $_SESSION['role'] ?? ''; -if (!in_array($role, ['班主任', '班长', '劳动委员', '志愿委员'])) { +if (!in_array($role, ['班主任', '班长', '学习委员', '劳动委员', '志愿委员'])) { header('Location: /admin/dashboard.php'); exit(); } @@ -209,6 +209,7 @@ loadStudents(); - +
排名学号姓名操行分
排名学号姓名操行分前%
@@ -69,17 +69,25 @@ async function loadDashboard() { const rankingRes = await apiGet('/api/student/ranking', { limit: 100 }); if (rankingRes && rankingRes.success) { + const totalStudents = rankingRes.data.total_students || 0; let html = ''; rankingRes.data.ranking.forEach((student, index) => { + const rank = index + 1; + let percentile = '--'; + if (totalStudents > 0) { + const pct = Math.floor(rank / totalStudents * 100); + percentile = (pct === 0 ? 1 : pct) + '%'; + } html += ` - ${index + 1} + ${rank} ${escapeHtml(student.student_no)} ${escapeHtml(student.name)} ${student.total_points} + 前${percentile} `; }); if (rankingRes.data.ranking.length === 0) { - html = '暂无数据'; + html = '暂无数据'; } document.getElementById('rankingList').innerHTML = html; } diff --git a/frontend/admin/semesters.php b/frontend/admin/semesters.php new file mode 100644 index 0000000..35081f5 --- /dev/null +++ b/frontend/admin/semesters.php @@ -0,0 +1,307 @@ + + + + +
+
+
+ +
+
+ + + + + + + + + + + + +
学期名称开始日期结束日期状态创建时间操作
+
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/includes/nav.php b/frontend/includes/nav.php index 8320836..142f41f 100644 --- a/frontend/includes/nav.php +++ b/frontend/includes/nav.php @@ -16,6 +16,9 @@ 管理员管理 + + 学期管理 + 历史记录 修改密码 diff --git a/frontend/parent/dashboard.php b/frontend/parent/dashboard.php index ad8f071..f00231f 100644 --- a/frontend/parent/dashboard.php +++ b/frontend/parent/dashboard.php @@ -73,14 +73,17 @@ async function loadDashboard() { document.getElementById('totalPoints').textContent = res.data.total_points; } + // 加载排名信息 // 加载排名信息 const rankRes = await apiGet('/api/parent/child/ranking'); if (rankRes && rankRes.success) { const rank = rankRes.data.rank; - const total = rankRes.data.total_students; - document.getElementById('studentRank').textContent = rank ? `第${rank}名` : '--'; + if (rank) { + document.getElementById('studentRank').textContent = `第${rank}名`; + } else { + document.getElementById('studentRank').textContent = '--'; + } } - // 显示初始分提示 const initialPoints = window.STUDENT_INITIAL_POINTS || 60; document.getElementById('initialPointsHint').textContent = `初始操行分为 ${initialPoints} 分`; diff --git a/frontend/student/dashboard.php b/frontend/student/dashboard.php index fcf76e6..a093053 100644 --- a/frontend/student/dashboard.php +++ b/frontend/student/dashboard.php @@ -77,6 +77,7 @@ include __DIR__ . '/../includes/header.php'; + 学期记录 @@ -301,7 +302,7 @@ include __DIR__ . '/../includes/header.php'; const ranking = rankingRes.data.ranking || []; const rank = ranking.find(s => s.student_id === parseInt(STUDENT_ID)); if (rank) { - document.getElementById('studentRank').textContent = rank.rank; + document.getElementById('studentRank').textContent = `第${rank.rank}名`; } else { document.getElementById('studentRank').textContent = '--'; } diff --git a/frontend/student/semester_history.php b/frontend/student/semester_history.php new file mode 100644 index 0000000..0cb992b --- /dev/null +++ b/frontend/student/semester_history.php @@ -0,0 +1,189 @@ + + + + + + +
+
+
历史学期记录
+
+
+
+ + + + + diff --git a/sql/init.sql b/sql/init.sql index 06beee8..bed315a 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -19,21 +19,19 @@ USE `classmanagerdb`; SET FOREIGN_KEY_CHECKS = 0; -DROP TABLE IF EXISTS `conduct_records`; -DROP TABLE IF EXISTS `homework_submissions`; -DROP TABLE IF EXISTS `attendance_records`; -DROP TABLE IF EXISTS `admin_roles`; -DROP TABLE IF EXISTS `assignments`; -DROP TABLE IF EXISTS `users`; -DROP TABLE IF EXISTS `students`; -DROP TABLE IF EXISTS `subjects`; -DROP TABLE IF EXISTS `operation_logs`; -DROP TABLE IF EXISTS `login_logs`; - -SET FOREIGN_KEY_CHECKS = 1; +-- 学期表 +CREATE TABLE IF NOT EXISTS `semesters` ( + `semester_id` INT PRIMARY KEY AUTO_INCREMENT, + `semester_name` VARCHAR(100) NOT NULL COMMENT '学期名称,如 2025春季学期', + `start_date` DATE DEFAULT NULL COMMENT '学期开始日期', + `end_date` DATE DEFAULT NULL COMMENT '学期结束日期', + `is_active` TINYINT DEFAULT 0 COMMENT '是否为当前活跃学期', + `is_archived` TINYINT DEFAULT 0 COMMENT '是否已归档', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 科目表(仅保留语数英) -CREATE TABLE `subjects` ( +CREATE TABLE IF NOT EXISTS `subjects` ( `subject_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '科目ID', `subject_name` VARCHAR(50) NOT NULL COMMENT '科目名称', `subject_code` VARCHAR(20) DEFAULT NULL COMMENT '科目代码', @@ -44,7 +42,7 @@ CREATE TABLE `subjects` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 学生表(无班级ID) -CREATE TABLE `students` ( +CREATE TABLE IF NOT EXISTS `students` ( `student_id` INT PRIMARY KEY AUTO_INCREMENT, `student_no` VARCHAR(20) NOT NULL UNIQUE, `name` VARCHAR(50) NOT NULL, @@ -56,7 +54,7 @@ CREATE TABLE `students` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 用户表 -CREATE TABLE `users` ( +CREATE TABLE IF NOT EXISTS `users` ( `user_id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password_hash` VARCHAR(64) NOT NULL, @@ -72,7 +70,7 @@ CREATE TABLE `users` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 管理员角色表(无班级ID) -CREATE TABLE `admin_roles` ( +CREATE TABLE IF NOT EXISTS `admin_roles` ( `admin_role_id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `role_type` ENUM('班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员') NOT NULL, @@ -84,7 +82,7 @@ CREATE TABLE `admin_roles` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 操行分记录表 -CREATE TABLE `conduct_records` ( +CREATE TABLE IF NOT EXISTS `conduct_records` ( `record_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `student_id` INT NOT NULL, `points_change` INT NOT NULL, @@ -96,14 +94,16 @@ CREATE TABLE `conduct_records` ( `is_revoked` TINYINT DEFAULT 0, `revoked_by` INT DEFAULT NULL, `revoked_at` DATETIME DEFAULT NULL, + `semester_id` INT DEFAULT NULL COMMENT '所属学期ID', `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`), - FOREIGN KEY (`revoked_by`) REFERENCES `users`(`user_id`) + FOREIGN KEY (`revoked_by`) REFERENCES `users`(`user_id`), + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 作业表(无班级ID) -CREATE TABLE `assignments` ( +CREATE TABLE IF NOT EXISTS `assignments` ( `assignment_id` INT PRIMARY KEY AUTO_INCREMENT, `subject_id` INT NOT NULL, `title` VARCHAR(100) NOT NULL, @@ -116,7 +116,7 @@ CREATE TABLE `assignments` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 作业提交记录表 -CREATE TABLE `homework_submissions` ( +CREATE TABLE IF NOT EXISTS `homework_submissions` ( `submission_id` INT PRIMARY KEY AUTO_INCREMENT, `assignment_id` INT NOT NULL, `student_id` INT NOT NULL, @@ -134,7 +134,7 @@ CREATE TABLE `homework_submissions` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 考勤记录表 -CREATE TABLE `attendance_records` ( +CREATE TABLE IF NOT EXISTS `attendance_records` ( `attendance_id` INT PRIMARY KEY AUTO_INCREMENT, `student_id` INT NOT NULL, `date` DATE NOT NULL, @@ -143,15 +143,17 @@ CREATE TABLE `attendance_records` ( `recorder_id` INT NOT NULL, `deduction_applied` TINYINT DEFAULT 0, `deduction_record_id` BIGINT DEFAULT NULL, + `semester_id` INT DEFAULT NULL COMMENT '所属学期ID', `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`), FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL, + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL, UNIQUE KEY `uk_student_date` (`student_id`, `date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 操作日志表 -CREATE TABLE `operation_logs` ( +CREATE TABLE IF NOT EXISTS `operation_logs` ( `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `operator_id` INT NOT NULL, `operator_name` VARCHAR(50) DEFAULT NULL, @@ -165,7 +167,7 @@ CREATE TABLE `operation_logs` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 登录日志表 -CREATE TABLE `login_logs` ( +CREATE TABLE IF NOT EXISTS `login_logs` ( `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `login_result` TINYINT NOT NULL, @@ -175,10 +177,93 @@ CREATE TABLE `login_logs` ( `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- 插入初始科目(仅语数英) -INSERT INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VALUES +-- 学期归档快照表 +CREATE TABLE IF NOT EXISTS `semester_archives` ( + `archive_id` INT PRIMARY KEY AUTO_INCREMENT, + `semester_id` INT NOT NULL, + `student_id` INT NOT NULL, + `student_no` VARCHAR(20) NOT NULL, + `student_name` VARCHAR(50) NOT NULL, + `final_points` INT NOT NULL COMMENT '学期最终操行分', + `rank_position` INT DEFAULT NULL COMMENT '排名', + `total_students` INT DEFAULT NULL COMMENT '班级总人数', + `archived_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`), + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET FOREIGN_KEY_CHECKS = 1; + +-- =========================================== +-- 迁移语句:为现有表新增字段(仅在字段不存在时添加) +-- =========================================== + +-- conduct_records 表:添加 semester_id 字段(如不存在) +SET @column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'classmanagerdb' + AND TABLE_NAME = 'conduct_records' + AND COLUMN_NAME = 'semester_id' +); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `conduct_records` ADD COLUMN `semester_id` INT DEFAULT NULL COMMENT ''所属学期ID'' AFTER `revoked_at`', + 'SELECT ''conduct_records.semester_id already exists'' AS message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 为新增的 semester_id 添加外键(如不存在) +SET @fk_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = 'classmanagerdb' + AND TABLE_NAME = 'conduct_records' + AND COLUMN_NAME = 'semester_id' + AND REFERENCED_TABLE_NAME IS NOT NULL +); +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE `conduct_records` ADD FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL', + 'SELECT ''conduct_records semester_id FK already exists'' AS message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- attendance_records 表:添加 semester_id 字段(如不存在) +SET @column_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'classmanagerdb' + AND TABLE_NAME = 'attendance_records' + AND COLUMN_NAME = 'semester_id' +); +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE `attendance_records` ADD COLUMN `semester_id` INT DEFAULT NULL COMMENT ''所属学期ID'' AFTER `deduction_record_id`', + 'SELECT ''attendance_records.semester_id already exists'' AS message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 为新增的 semester_id 添加外键(如不存在) +SET @fk_exists = ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = 'classmanagerdb' + AND TABLE_NAME = 'attendance_records' + AND COLUMN_NAME = 'semester_id' + AND REFERENCED_TABLE_NAME IS NOT NULL +); +SET @sql = IF(@fk_exists = 0, + 'ALTER TABLE `attendance_records` ADD FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL', + 'SELECT ''attendance_records semester_id FK already exists'' AS message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 插入初始科目(仅语数英,如不存在) +INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VALUES ('语文', 'CHI', 1), ('数学', 'MATH', 2), ('英语', 'ENG', 3); -SELECT '数据库初始化完成!' AS message; \ No newline at end of file +SELECT '数据库初始化/迁移完成!' AS message;