From 6b1b586fe347757946f07c46f0e6e21487fd0cd0 Mon Sep 17 00:00:00 2001 From: canglan Date: Tue, 7 Apr 2026 17:07:13 +0800 Subject: [PATCH] =?UTF-8?q?v0.1=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 15 - README.md | 69 ++- backend/.env.example | 164 +++++ backend/config.py | 107 ++++ backend/main.py | 97 +++ backend/middleware/__init__.py | 11 + backend/middleware/auth_middleware.py | 111 ++++ backend/middleware/permission.py | 197 ++++++ backend/middleware/sanitize.py | 121 ++++ backend/models/__init__.py | 11 + backend/models/admin_role.py | 60 ++ backend/models/attenddance.py | 98 +++ backend/models/class_model.py | 35 ++ backend/models/conduct.py | 166 +++++ backend/models/homework.py | 118 ++++ backend/models/student.py | 146 +++++ backend/models/subject.py | 82 +++ backend/models/user.py | 101 ++++ backend/requirements.txt | 11 + backend/routes/__init__.py | 11 + backend/routes/admin.py | 64 ++ backend/routes/auth.py | 100 ++++ backend/routes/debug.py | 69 +++ backend/routes/parent.py | 66 ++ backend/routes/student.py | 120 ++++ backend/routes/subject.py | 105 ++++ backend/schemas/__init__.py | 11 + backend/schemas/admin.py | 81 +++ backend/schemas/auth.py | 54 ++ backend/schemas/student.py | 79 +++ backend/schemas/subject.py | 43 ++ backend/services/__init__.py | 11 + backend/services/admin_service.py | 298 +++++++++ backend/services/attendance_service.py | 114 ++++ backend/services/auth_service.py | 169 ++++++ backend/services/conduct_service.py | 182 ++++++ backend/services/homework_service.py | 131 ++++ backend/services/parent_service.py | 82 +++ backend/services/student_service.py | 146 +++++ backend/services/subject_service.py | 77 +++ backend/utils/__init__.py | 11 + backend/utils/database.py | 136 +++++ backend/utils/jwt_handler.py | 86 +++ backend/utils/logger.py | 102 ++++ backend/utils/redus_client.py | 140 +++++ backend/utils/response.py | 106 ++++ backend/utils/security.py | 131 ++++ docs/admin.md | 0 docs/parent.md | 0 docs/student.md | 0 frontend/.env.example | 28 + frontend/admin/admins.php | 126 ++++ frontend/admin/attendance.php | 184 ++++++ frontend/admin/conduct.php | 145 +++++ frontend/admin/dashboard.php | 114 ++++ frontend/admin/history.php | 166 +++++ frontend/admin/homework.php | 170 ++++++ frontend/admin/passwork.php | 100 ++++ frontend/admin/students.php | 243 ++++++++ frontend/admin/subjects.php | 157 +++++ frontend/assets/css/admin.css | 130 ++++ frontend/assets/css/style.css | 666 +++++++++++++++++++++ frontend/assets/js/admin.js | 385 ++++++++++++ frontend/assets/js/common.js | 242 ++++++++ frontend/assets/js/parent.js | 13 + frontend/assets/js/student.js | 13 + frontend/assets/uploads/sample_import.json | 44 ++ frontend/config.php | 53 ++ frontend/includes/footer.php | 17 + frontend/includes/header.php | 49 ++ frontend/index.php | 112 ++++ frontend/parent/attendance.php | 83 +++ frontend/parent/dashboard.php | 272 +++++++++ frontend/parent/homework.php | 66 ++ frontend/student/attendance.php | 83 +++ frontend/student/conduct_history.php | 0 frontend/student/dashboard.php | 513 ++++++++++++++++ frontend/student/homework.php | 74 +++ frontend/student/password.php | 81 +++ sql/init.sql | 331 ++++++++++ 80 files changed, 9073 insertions(+), 32 deletions(-) delete mode 100644 .env.example create mode 100644 backend/.env.example create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/auth_middleware.py create mode 100644 backend/middleware/permission.py create mode 100644 backend/middleware/sanitize.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/admin_role.py create mode 100644 backend/models/attenddance.py create mode 100644 backend/models/class_model.py create mode 100644 backend/models/conduct.py create mode 100644 backend/models/homework.py create mode 100644 backend/models/student.py create mode 100644 backend/models/subject.py create mode 100644 backend/models/user.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/debug.py create mode 100644 backend/routes/parent.py create mode 100644 backend/routes/student.py create mode 100644 backend/routes/subject.py create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/admin.py create mode 100644 backend/schemas/auth.py create mode 100644 backend/schemas/student.py create mode 100644 backend/schemas/subject.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/admin_service.py create mode 100644 backend/services/attendance_service.py create mode 100644 backend/services/auth_service.py create mode 100644 backend/services/conduct_service.py create mode 100644 backend/services/homework_service.py create mode 100644 backend/services/parent_service.py create mode 100644 backend/services/student_service.py create mode 100644 backend/services/subject_service.py create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/database.py create mode 100644 backend/utils/jwt_handler.py create mode 100644 backend/utils/logger.py create mode 100644 backend/utils/redus_client.py create mode 100644 backend/utils/response.py create mode 100644 backend/utils/security.py create mode 100644 docs/admin.md create mode 100644 docs/parent.md create mode 100644 docs/student.md create mode 100644 frontend/.env.example create mode 100644 frontend/admin/admins.php create mode 100644 frontend/admin/attendance.php create mode 100644 frontend/admin/conduct.php create mode 100644 frontend/admin/dashboard.php create mode 100644 frontend/admin/history.php create mode 100644 frontend/admin/homework.php create mode 100644 frontend/admin/passwork.php create mode 100644 frontend/admin/students.php create mode 100644 frontend/admin/subjects.php create mode 100644 frontend/assets/css/admin.css create mode 100644 frontend/assets/css/style.css create mode 100644 frontend/assets/js/admin.js create mode 100644 frontend/assets/js/common.js create mode 100644 frontend/assets/js/parent.js create mode 100644 frontend/assets/js/student.js create mode 100644 frontend/assets/uploads/sample_import.json create mode 100644 frontend/config.php create mode 100644 frontend/includes/footer.php create mode 100644 frontend/includes/header.php create mode 100644 frontend/index.php create mode 100644 frontend/parent/attendance.php create mode 100644 frontend/parent/dashboard.php create mode 100644 frontend/parent/homework.php create mode 100644 frontend/student/attendance.php create mode 100644 frontend/student/conduct_history.php create mode 100644 frontend/student/dashboard.php create mode 100644 frontend/student/homework.php create mode 100644 frontend/student/password.php create mode 100644 sql/init.sql diff --git a/.env.example b/.env.example deleted file mode 100644 index 5aa56d3..0000000 --- a/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# MySQL 数据库配置 -DB_HOST=localhost -DB_PORT=3306 -DB_USER=ClassManagerUser -DB_PASSWORD=your_secure_password -DB_NAME=ClassManagerDB - -# JWT 密钥(32 bytes) -JWT_SECRET_KEY=your_32_byte_jwt_secret_key - -# aliyun 短信配置 -ALIYUN_SMS_ACCESS_KEY_ID=your_aliyun_sms_access_key_id -ALIYUN_SMS_ACCESS_KEY_SECRET=your_aliyun_sms_access_key_secret -ALIYUN_SMS_SIGN_NAME=your_aliyun_sms_sign_name -ALIYUN_SMS_TEMPLATE_CODE=your_aliyun_sms_template_code diff --git a/README.md b/README.md index 4f2da45..ee4e231 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,67 @@ # 班级操行分管理系统 -基于 Python FastAPI 开发的班级操行分管理系统,支持学生端、管理端、家长端三端访问,实现操行分的加减记录与查询功能。 +基于 Python FastAPI 开发的班级操行分管理系统,支持学生端、管理端、家长端三端访问,实现操行分管理、作业提交跟踪、考勤记录等功能。 ## 主要功能 ### 学生端 - 查询个人当前操行总分 -- 查看个人加减分历史明细(时间、分数变化、原因) -- 修改个人登录密码 -- 查看全班学生操行分排行榜 -- 查看任意学生的加减分历史记录 - -### 管理端(班主任/班委) -- 对学生进行加分/减分操作(需填写原因) -- 导出操行分数据(Excel) -- 批量导入学生 +- 查看个人加减分历史明细(时间、分数变化、原因、操作人) +- 查看个人作业提交情况 +- 查看个人考勤记录 +- 修改个人登录密码(首次登录强制修改) ### 家长端 -- 通过学生学号查询子女操行分 -- 查看子女加减分历史明细 +- 查询子女当前操行总分 +- 查看子女作业提交情况 +- 查看子女考勤记录 + +### 管理端 + +**班主任权限:** +- 学生管理:新增/编辑/删除学生、批量导入学生(JSON) +- 操行分管理:对学生进行加减分、撤销任何扣分记录、查看全班历史记录 +- 作业管理:发布作业、查看提交情况 +- 考勤管理:查看全班考勤记录 +- 科目管理:动态增删学科 +- 管理员管理:添加班长/科代表/考勤委员/劳动委员 + +**班长权限:** +- 操行分管理:对学生进行加减分(±5分以内)、撤销任何人的扣分记录、查看全班历史记录 + +**科代表权限:** +- 作业管理:更新作业提交状态、关联扣分(仅扣分,按规则) +- 历史记录:仅查看自己提交的操作记录 + +**考勤委员权限:** +- 考勤管理:记录考勤状态、关联扣分(仅扣分,按规则) +- 历史记录:仅查看自己提交的操作记录 + +**劳动委员权限:** +- 操行分管理:以卫生值日为理由进行加减分(固定 ±1 分) +- 历史记录:仅查看自己提交的操作记录 ## 技术栈 -- **后端框架**: FastAPI -- **数据库**: MySQL -- **认证**: JWT -- **配置管理**: python-dotenv(.env) +| 层级 | 技术 | 版本 | +|------|------|------| +| 后端框架 | FastAPI | 0.104+ | +| 数据库 | MySQL | 5.7 | +| 缓存 | Redis | 7.x | +| 前端 | PHP | 8.0 | +| Web服务器 | Nginx | 1.28+ | +## 角色权限一览表 + +| 角色 | 操行分查看 | 操行分加减 | 撤销扣分 | 历史记录查看 | +|------|-----------|-----------|---------|-------------| +| 班主任 | 全班 | 无限制 | 可撤销任何记录 | 全班所有记录 | +| 班长 | 全班 | ±5分 | 可撤销任何记录 | 全班所有记录 | +| 科代表 | 全班 | 仅扣分(按规则) | 不可撤销 | 仅自己提交的 | +| 考勤委员 | 全班 | 仅扣分(按规则) | 不可撤销 | 仅自己提交的 | +| 劳动委员 | 全班 | 仅±1分(卫生值日) | 不可撤销 | 仅自己提交的 | +| 学生 | 自己 | 无 | 无 | 自己的历史 | +| 家长 | 子女总分 | 无 | 无 | 不可见详情 | ## 安装部署 @@ -38,4 +73,4 @@ ## 许可证 -本项目使用[MIT License](LICENSE)许可证 +本项目使用 [MIT License](LICENSE) 许可证 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8658d2b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,164 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +# =========================================== +# FastAPI 应用配置 +# =========================================== + +# 应用名称 - 显示在API文档和日志中 +APP_NAME=班级操行分管理系统 +# 运行环境 - production(生产) / development(开发) / testing(测试) +# 生产环境会自动启用HTTPS重定向 +APP_ENV=production +# 调试模式 - true开启详细错误信息,生产环境必须为false +DEBUG=False +# 应用密钥 - 用于会话加密,必须32位以上随机字符串 +# 生成方法: openssl rand -hex 32 +SECRET_KEY=your-super-secret-key-min-32-characters-long +# API版本号 - 用于路由前缀,如 /api/v1 +API_VERSION=v1 + +# =========================================== +# MySQL 数据库配置 +# =========================================== + +# 数据库主机地址 - 本地用127.0.0.1,远程用实际IP +DB_HOST=127.0.0.1 +# 数据库端口 - MySQL默认3306 +DB_PORT=3306 +# 数据库用户名 - 建议创建专用账户,不要用root +DB_USER=class_admin +# 数据库密码 - 使用强密码,包含大小写数字特殊字符 +DB_PASSWORD=your-strong-db-password +# 数据库名称 - 固定为 classmanagerdb +DB_NAME=classmanagerdb +# 连接池大小 - 同时保持的数据库连接数,根据并发量调整 +DB_POOL_SIZE=10 +# 最大溢出连接 - 连接池满后最多额外创建的连接数 +DB_MAX_OVERFLOW=20 + +# =========================================== +# Redis 缓存配置 +# =========================================== + +# Redis主机地址 +REDIS_HOST=127.0.0.1 +# Redis端口 - 默认6379 +REDIS_PORT=6379 +# Redis密码 - 建议设置,防止未授权访问 +REDIS_PASSWORD=your-redis-password +# Redis数据库编号 - 0-15,建议使用独立数据库避免冲突 +REDIS_DB=0 +# 最大连接数 - 根据并发量调整,建议50-200 +REDIS_MAX_CONNECTIONS=50 + +# =========================================== +# JWT 认证配置 +# =========================================== + +# JWT密钥 - 用于签名Token,必须32位以上随机字符串 +# 生成方法: openssl rand -hex 32 +JWT_SECRET_KEY=your-jwt-secret-key-min-32-chars +# JWT签名算法 - HS256对称加密 +JWT_ALGORITHM=HS256 +# Token过期时间(分钟)- 30分钟无操作需重新登录 +JWT_EXPIRE_MINUTES=30 + +# =========================================== +# 密码加密配置 +# =========================================== + +# 密码盐值 - 固定字符串,用于SHA1+MD5双重加密 +# 生成后不可更改,否则所有密码失效 +# 生成方法: openssl rand -hex 16 +PASSWORD_SALT=your-fixed-salt-string-for-password-hash + +# =========================================== +# 调试入口配置 +# =========================================== + +# 调试入口路径 - 随机字符串,用于添加第一批管理员 +# 建议使用32位随机字符串,只有开发人员知道此路径 +# 添加完管理员后建议注释掉debug路由 +# 生成方法: openssl rand -hex 16 | sed 's/\(..\)/\1/g' | cut -c1-32 +DEBUG_PATH=/a7k9x2m4q8w1e3r5t6y7u8i9o0p1z2x3 + +# =========================================== +# 扣分规则配置 +# =========================================== +# 注意:这些规则仅用于建议,实际操作时可选择是否应用扣分 + +# 作业未提交扣分 - 科代表标记未提交时建议扣分数值 +DEDUCTION_HOMEWORK_NOT_SUBMIT=2 +# 作业迟交扣分 - 迟交作业建议扣分数值 +DEDUCTION_HOMEWORK_LATE=1 +# 缺勤扣分 - 缺勤建议扣分数值 +DEDUCTION_ATTENDANCE_ABSENT=5 +# 迟到扣分 - 迟到建议扣分数值 +DEDUCTION_ATTENDANCE_LATE=2 +# 请假扣分 - 请假建议扣分数值 +DEDUCTION_ATTENDANCE_LEAVE=1 + +# =========================================== +# 劳动委员固定分值配置 +# =========================================== + +# 劳动委员加分值 - 固定为1分 +LABOR_POINTS_ADD=1 +# 劳动委员扣分值 - 固定为-1分 +LABOR_POINTS_SUBTRACT=-1 + +# =========================================== +# 班长加减分限制配置 +# =========================================== + +# 班长最大单次加分值 +MONITOR_MAX_ADD=5 +# 班长最大单次扣分值(负数) +MONITOR_MAX_SUBTRACT=-5 + +# =========================================== +# 日志配置 +# =========================================== + +# 日志级别 - DEBUG/INFO/WARNING/ERROR +# 生产环境建议INFO,开发环境可用DEBUG +LOG_LEVEL=INFO +# 单日志文件最大大小(字节)- 100MB = 104857600 +LOG_MAX_BYTES=104857600 +# 日志备份数量 - 保留30个历史日志文件 +LOG_BACKUP_COUNT=30 +# 日志保留天数 - 操作日志保留1年,访问日志保留90天 +LOG_RETENTION_DAYS=365 + +# =========================================== +# CORS 跨域配置 +# =========================================== + +# 允许的跨域域名 - 多个域名用英文逗号分隔,不要有空格 +# 生产环境必须指定具体域名,不能用 * +CORS_ORIGINS=https://your-frontend-domain.com,http://localhost:8080 + +# =========================================== +# 上传文件配置 +# =========================================== + +# 最大上传文件大小(字节)- 5MB = 5242880 +MAX_UPLOAD_SIZE=5242880 +# 允许的文件扩展名 - 多个用英文逗号分隔 +ALLOWED_EXTENSIONS=json + +# =========================================== +# 学生初始配置 +# =========================================== + +# 学生初始操行分 - 新生导入时的默认分数,默认60分 +STUDENT_INITIAL_POINTS=60 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..cff1ca5 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,107 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +import os +from dotenv import load_dotenv +from typing import List + +# 加载环境变量 +load_dotenv() + + +class Settings: + """应用配置类""" + + # ========== 应用配置 ========== + APP_NAME: str = os.getenv("APP_NAME", "班级操行分管理系统") + APP_ENV: str = os.getenv("APP_ENV", "production") + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + SECRET_KEY: str = os.getenv("SECRET_KEY", "") + API_VERSION: str = os.getenv("API_VERSION", "v1") + + # ========== 数据库配置 ========== + DB_HOST: str = os.getenv("DB_HOST", "127.0.0.1") + DB_PORT: int = int(os.getenv("DB_PORT", "3306")) + DB_USER: str = os.getenv("DB_USER", "root") + DB_PASSWORD: str = os.getenv("DB_PASSWORD", "") + DB_NAME: str = os.getenv("DB_NAME", "classmanagerdb") + DB_POOL_SIZE: int = int(os.getenv("DB_POOL_SIZE", "10")) + DB_MAX_OVERFLOW: int = int(os.getenv("DB_MAX_OVERFLOW", "20")) + + # ========== Redis配置 ========== + REDIS_HOST: str = os.getenv("REDIS_HOST", "127.0.0.1") + REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379")) + REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "") + REDIS_DB: int = int(os.getenv("REDIS_DB", "0")) + REDIS_MAX_CONNECTIONS: int = int(os.getenv("REDIS_MAX_CONNECTIONS", "50")) + + @property + def REDIS_URL(self) -> str: + """获取Redis连接URL""" + if self.REDIS_PASSWORD: + return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + # ========== JWT配置 ========== + JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "") + JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256") + JWT_EXPIRE_MINUTES: int = int(os.getenv("JWT_EXPIRE_MINUTES", "30")) + + # ========== 密码加密 ========== + PASSWORD_SALT: str = os.getenv("PASSWORD_SALT", "") + + # ========== 调试入口 ========== + DEBUG_PATH: str = os.getenv("DEBUG_PATH", "/debug_add_admin") + + # ========== 扣分规则 ========== + DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2")) + DEDUCTION_HOMEWORK_LATE: int = int(os.getenv("DEDUCTION_HOMEWORK_LATE", "1")) + DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "5")) + DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "2")) + DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "1")) + + # ========== 劳动委员固定分值 ========== + LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1")) + LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1")) + + # ========== 班长加减分限制 ========== + MONITOR_MAX_ADD: int = int(os.getenv("MONITOR_MAX_ADD", "5")) + MONITOR_MAX_SUBTRACT: int = int(os.getenv("MONITOR_MAX_SUBTRACT", "-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")) + LOG_RETENTION_DAYS: int = int(os.getenv("LOG_RETENTION_DAYS", "365")) + + # ========== CORS配置 ========== + CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:8080").split(",") + + # ========== 上传配置 ========== + MAX_UPLOAD_SIZE: int = int(os.getenv("MAX_UPLOAD_SIZE", "5242880")) + ALLOWED_EXTENSIONS: set = set(os.getenv("ALLOWED_EXTENSIONS", "json").split(",")) + + # ========== 学生初始配置 ========== + STUDENT_INITIAL_POINTS: int = int(os.getenv("STUDENT_INITIAL_POINTS", "60")) + + def validate(self) -> None: + """验证必要配置是否存在""" + required_configs = [ + ("SECRET_KEY", self.SECRET_KEY), + ("JWT_SECRET_KEY", self.JWT_SECRET_KEY), + ("PASSWORD_SALT", self.PASSWORD_SALT), + ] + for name, value in required_configs: + if not value: + raise ValueError(f"配置 {name} 不能为空") + + +settings = Settings() \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f50ed33 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,97 @@ +# =========================================== +# 班级操行分管理系统 - FastAPI 主入口 +# =========================================== +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# 版权所有: Copyright (c) 2024 Sea Network Technology Studio +# =========================================== + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import uvicorn + +from config import settings +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 routes import auth, student, parent, admin, subject, debug + + +# 设置日志 +logger = setup_logger() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + logger.info("正在启动应用...") + await init_db_pool() + await init_redis_pool() + logger.info(f"{settings.APP_NAME} 启动完成") + + yield + + logger.info("正在关闭应用...") + await close_db_pool() + await close_redis_pool() + logger.info("应用已关闭") + + +# 创建FastAPI应用 +app = FastAPI( + title=settings.APP_NAME, + version=settings.API_VERSION, + debug=settings.DEBUG, + lifespan=lifespan +) + + +# 访问日志中间件 +@app.middleware("http") +async def access_log_middleware(request: Request, call_next): + log_access(request) + response = await call_next(request) + return response + + +# CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# 注册路由 +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +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(debug.router, tags=["调试"]) + + +@app.get("/") +async def root(): + """根路径健康检查""" + return {"status": "ok", "message": f"{settings.APP_NAME} API 运行中"} + + +@app.get("/health") +async def health_check(): + """健康检查接口""" + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG + ) \ No newline at end of file diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/middleware/auth_middleware.py b/backend/middleware/auth_middleware.py new file mode 100644 index 0000000..639eb44 --- /dev/null +++ b/backend/middleware/auth_middleware.py @@ -0,0 +1,111 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import Request, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from starlette.middleware.base import BaseHTTPMiddleware +from typing import Optional, Dict, Any, Tuple +import re + +from utils.jwt_handler import jwt_handler +from utils.redis_client import RedisClient +from utils.response import unauthorized_response +from utils.logger import get_logger + +logger = get_logger(__name__) + +# 不需要认证的路由 +PUBLIC_PATHS = [ + r'^/$', + r'^/health$', + r'^/api/auth/login$', + r'^/api/auth/logout$', + r'^/debug/.*$', # 调试入口 +] + +# 不需要Token验证但需要记录访问的路由 +OPEN_PATHS = [ + r'^/api/auth/change-password$', +] + + +def is_public_path(path: str) -> bool: + """检查是否为公开路径""" + for pattern in PUBLIC_PATHS: + if re.match(pattern, path): + return True + return False + + +class AuthMiddleware(BaseHTTPMiddleware): + """JWT认证中间件""" + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # 公开路径跳过认证 + if is_public_path(path): + return await call_next(request) + + # 获取Authorization头 + auth_header = request.headers.get("Authorization") + + if not auth_header: + return unauthorized_response("缺少认证令牌") + + # 解析Bearer Token + try: + scheme, token = auth_header.split() + if scheme.lower() != "bearer": + return unauthorized_response("认证格式错误") + except ValueError: + return unauthorized_response("认证格式错误") + + # 验证Token + payload = jwt_handler.verify_token(token) + if not payload: + return unauthorized_response("令牌无效或已过期") + + # 验证Redis中的Token + user_id = payload.get("user_id") + stored_token = await RedisClient.get_user_token(user_id) + + if not stored_token or stored_token != token: + return unauthorized_response("令牌已失效,请重新登录") + + # 将用户信息存储到request.state + request.state.user_id = payload.get("user_id") + request.state.username = payload.get("username") + request.state.user_type = payload.get("user_type") + request.state.student_id = payload.get("student_id") + request.state.role = payload.get("role") + + # 刷新Token过期时间 + from config import settings + await RedisClient.expire(f"user_token:{user_id}", settings.JWT_EXPIRE_MINUTES * 60) + + return await call_next(request) + + +async def get_current_user(request: Request) -> Dict[str, Any]: + """获取当前登录用户信息""" + return { + "user_id": request.state.user_id, + "username": request.state.username, + "user_type": request.state.user_type, + "student_id": request.state.student_id, + "role": request.state.role + } + + +async def get_current_user_id(request: Request) -> int: + """获取当前用户ID""" + return request.state.user_id \ No newline at end of file diff --git a/backend/middleware/permission.py b/backend/middleware/permission.py new file mode 100644 index 0000000..34b823c --- /dev/null +++ b/backend/middleware/permission.py @@ -0,0 +1,197 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import Request, HTTPException +from typing import List, Optional, Callable +from functools import wraps + +from utils.response import forbidden_response +from utils.database import execute_one +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class PermissionChecker: + """权限检查器""" + + @staticmethod + async def get_user_role(user_id: int) -> Optional[str]: + """获取用户的管理员角色""" + sql = """ + SELECT role_type FROM admin_roles + WHERE user_id = %s + LIMIT 1 + """ + result = await execute_one(sql, (user_id,)) + return result["role_type"] if result else None + + @staticmethod + async def get_user_class_id(user_id: int) -> Optional[int]: + """获取用户管理的班级ID""" + sql = """ + SELECT class_id FROM admin_roles + WHERE user_id = %s + LIMIT 1 + """ + result = await execute_one(sql, (user_id,)) + return result["class_id"] if result else None + + @staticmethod + async def get_user_subject_ids(user_id: int) -> List[int]: + """获取科代表管理的科目ID列表""" + sql = """ + SELECT subject_id FROM admin_roles + WHERE user_id = %s AND role_type = '科代表' + """ + results = await execute_one(sql, (user_id,)) + if results: + return [r["subject_id"] for r in results] if isinstance(results, list) else [results["subject_id"]] + return [] + + @staticmethod + async def check_is_teacher(user_id: int) -> bool: + """检查是否为班主任""" + role = await PermissionChecker.get_user_role(user_id) + return role == "班主任" + + @staticmethod + async def check_is_monitor(user_id: int) -> bool: + """检查是否为班长""" + role = await PermissionChecker.get_user_role(user_id) + return role == "班长" + + @staticmethod + async def check_is_subject_rep(user_id: int, subject_id: int = None) -> bool: + """检查是否为科代表""" + role = await PermissionChecker.get_user_role(user_id) + if role != "科代表": + return False + if subject_id: + subject_ids = await PermissionChecker.get_user_subject_ids(user_id) + return subject_id in subject_ids + return True + + @staticmethod + async def check_is_attendance_rep(user_id: int) -> bool: + """检查是否为考勤委员""" + role = await PermissionChecker.get_user_role(user_id) + return role == "考勤委员" + + @staticmethod + async def check_is_labor_rep(user_id: int) -> bool: + """检查是否为劳动委员""" + role = await PermissionChecker.get_user_role(user_id) + return role == "劳动委员" + + @staticmethod + async def check_can_revoke(user_id: int, record_id: int) -> bool: + """ + 检查是否可以撤销扣分记录 + 班主任:可以撤销任何记录 + 班长:可以撤销任何记录 + 其他:只能撤销自己的记录 + """ + # 获取记录信息 + sql = "SELECT recorder_id FROM conduct_records WHERE record_id = %s" + record = await execute_one(sql, (record_id,)) + if not record: + return False + + role = await PermissionChecker.get_user_role(user_id) + + # 班主任或班长可以撤销任何记录 + if role in ["班主任", "班长"]: + return True + + # 其他人只能撤销自己的记录 + return record["recorder_id"] == user_id + + @staticmethod + async def check_can_manage_student(user_id: int, student_id: int) -> bool: + """检查是否可以管理该学生(同班级)""" + # 获取学生班级 + sql = "SELECT class_id FROM students WHERE student_id = %s" + student = await execute_one(sql, (student_id,)) + if not student: + return False + + # 获取管理员管理的班级 + admin_class_id = await PermissionChecker.get_user_class_id(user_id) + + return admin_class_id == student["class_id"] + + +def require_auth(func: Callable): + """需要认证的装饰器""" + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request or not hasattr(request, 'state') or not hasattr(request.state, 'user_id'): + return forbidden_response("请先登录") + return await func(*args, **kwargs) + return wrapper + + +def require_role(roles: List[str]): + """需要特定角色的装饰器""" + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request or not hasattr(request, 'state'): + return forbidden_response("请先登录") + + user_id = request.state.user_id + user_role = await PermissionChecker.get_user_role(user_id) + + if user_role not in roles: + return forbidden_response(f"需要{','.join(roles)}权限") + + return await func(*args, **kwargs) + return wrapper + return decorator + + +def require_teacher(func: Callable): + """需要班主任权限的装饰器""" + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request or not hasattr(request, 'state'): + return forbidden_response("请先登录") + + user_id = request.state.user_id + is_teacher = await PermissionChecker.check_is_teacher(user_id) + + if not is_teacher: + return forbidden_response("需要班主任权限") + + return await func(*args, **kwargs) + return wrapper + + +def require_monitor(func: Callable): + """需要班长权限的装饰器""" + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request or not hasattr(request, 'state'): + return forbidden_response("请先登录") + + user_id = request.state.user_id + is_monitor = await PermissionChecker.check_is_monitor(user_id) + + if not is_monitor: + return forbidden_response("需要班长权限") + + return await func(*args, **kwargs) + return wrapper \ No newline at end of file diff --git a/backend/middleware/sanitize.py b/backend/middleware/sanitize.py new file mode 100644 index 0000000..50d06a8 --- /dev/null +++ b/backend/middleware/sanitize.py @@ -0,0 +1,121 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from typing import Dict, Any +import re + + +class SanitizeMiddleware(BaseHTTPMiddleware): + """输入过滤中间件""" + + async def dispatch(self, request: Request, call_next): + # 只处理POST、PUT、PATCH请求 + if request.method in ["POST", "PUT", "PATCH"]: + # 获取请求体 + body = await request.body() + if body: + import json + try: + data = json.loads(body) + # 清理数据 + cleaned_data = self._sanitize_data(data) + # 替换请求体 + request._body = json.dumps(cleaned_data).encode() + except: + pass + + response = await call_next(request) + return response + + def _sanitize_data(self, data: Any) -> Any: + """递归清理数据""" + if isinstance(data, dict): + return {k: self._sanitize_data(v) for k, v in data.items()} + elif isinstance(data, list): + return [self._sanitize_data(item) for item in data] + elif isinstance(data, str): + return self._sanitize_string(data) + else: + return data + + def _sanitize_string(self, value: str) -> str: + """清理字符串""" + if not value: + return "" + + # 去除首尾空格 + value = value.strip() + + # 限制长度 + if len(value) > 1000: + value = value[:1000] + + # 转义HTML特殊字符 + html_chars = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + } + for char, escape in html_chars.items(): + value = value.replace(char, escape) + + return value + + +def sanitize_input(value: str, max_length: int = 255) -> str: + """清理单个输入值""" + if not value: + return "" + + value = value.strip() + if len(value) > max_length: + value = value[:max_length] + + return value + + +def validate_points(points: int, min_val: int = -100, max_val: int = 100) -> tuple: + """ + 验证分值 + 返回: (是否有效, 错误信息) + """ + if points == 0: + return False, "分值不能为0" + if points < min_val or points > max_val: + return False, f"分值必须在{min_val}到{max_val}之间" + return True, "" + + +def validate_reason(reason: str) -> tuple: + """ + 验证原因 + 返回: (是否有效, 错误信息) + """ + if not reason or not reason.strip(): + return False, "原因不能为空" + if len(reason) > 255: + return False, "原因长度不能超过255个字符" + return True, "" + + +def validate_date(date_str: str) -> bool: + """验证日期格式 YYYY-MM-DD""" + if not date_str: + return False + pattern = r'^\d{4}-\d{2}-\d{2}$' + if not re.match(pattern, date_str): + return False + return True \ No newline at end of file diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/models/admin_role.py b/backend/models/admin_role.py new file mode 100644 index 0000000..aa33835 --- /dev/null +++ b/backend/models/admin_role.py @@ -0,0 +1,60 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, Dict, Any, List +from utils.database import execute_one, execute_query, execute_insert, execute_update + + +class AdminRoleModel: + """管理员角色数据模型""" + + @staticmethod + async def get_by_user_id(user_id: int) -> Optional[Dict[str, Any]]: + sql = """ + SELECT ar.*, c.class_name, s.subject_name + FROM admin_roles ar + LEFT JOIN classes c ON ar.class_id = c.class_id + LEFT JOIN subjects s ON ar.subject_id = s.subject_id + WHERE ar.user_id = %s + LIMIT 1 + """ + return await execute_one(sql, (user_id,)) + + @staticmethod + async def get_class_id_by_user(user_id: int) -> Optional[int]: + sql = "SELECT class_id FROM admin_roles WHERE user_id = %s LIMIT 1" + result = await execute_one(sql, (user_id,)) + return result["class_id"] if result else None + + @staticmethod + async def get_by_class(class_id: int) -> List[Dict[str, Any]]: + sql = """ + SELECT ar.*, u.real_name, u.username + FROM admin_roles ar + JOIN users u ON ar.user_id = u.user_id + WHERE ar.class_id = %s + ORDER BY ar.role_type + """ + return await execute_query(sql, (class_id,)) + + @staticmethod + async def create(user_id: int, role_type: str, class_id: int, subject_id: int = None) -> int: + sql = """ + INSERT INTO admin_roles (user_id, role_type, class_id, subject_id) + VALUES (%s, %s, %s, %s) + """ + return await execute_insert(sql, (user_id, role_type, class_id, subject_id)) + + @staticmethod + async def delete(user_id: int) -> bool: + sql = "DELETE FROM admin_roles WHERE user_id = %s" + result = await execute_update(sql, (user_id,)) + return result > 0 \ No newline at end of file diff --git a/backend/models/attenddance.py b/backend/models/attenddance.py new file mode 100644 index 0000000..aea8eb4 --- /dev/null +++ b/backend/models/attenddance.py @@ -0,0 +1,98 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, Dict, Any, List +from datetime import datetime +from utils.database import execute_one, execute_query, execute_insert, execute_update + + +class AttendanceModel: + """考勤数据模型""" + + @staticmethod + async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]: + sql = """ + SELECT attendance_id, date, status, reason, deduction_applied, created_at + FROM attendance_records + WHERE student_id = %s + """ + params = [student_id] + + if month: + sql += " AND DATE_FORMAT(date, '%%Y-%%m') = %s" + params.append(month) + + sql += " ORDER BY date DESC" + + return await execute_query(sql, tuple(params)) + + @staticmethod + async def get_class_records( + class_id: int, + date: str = None, + student_id: int = None + ) -> List[Dict[str, Any]]: + sql = """ + SELECT ar.*, s.name as student_name, s.student_no + FROM attendance_records ar + JOIN students s ON ar.student_id = s.student_id + WHERE s.class_id = %s + """ + params = [class_id] + + if date: + sql += " AND ar.date = %s" + params.append(date) + + if student_id: + sql += " AND ar.student_id = %s" + params.append(student_id) + + sql += " ORDER BY ar.date DESC, s.student_no" + + return await execute_query(sql, tuple(params)) + + @staticmethod + async def create_record( + student_id: int, + date: str, + status: str, + reason: str = None, + recorder_id: int = None + ) -> int: + # 检查是否已存在当天记录 + existing = await execute_one( + "SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s", + (student_id, date) + ) + + if existing: + # 更新已有记录 + sql = """ + UPDATE attendance_records + SET status = %s, reason = %s, recorder_id = %s + WHERE student_id = %s AND date = %s + """ + await execute_update(sql, (status, reason, recorder_id, student_id, date)) + return existing["attendance_id"] + else: + # 插入新记录 + sql = """ + INSERT INTO attendance_records (student_id, date, status, reason, recorder_id) + VALUES (%s, %s, %s, %s, %s) + """ + return await execute_insert(sql, (student_id, date, status, reason, recorder_id)) + + @staticmethod + async def mark_deduction_applied(attendance_id: int) -> bool: + sql = "UPDATE attendance_records SET deduction_applied = 1 WHERE attendance_id = %s" + result = await execute_update(sql, (attendance_id,)) + return result > 0 \ No newline at end of file diff --git a/backend/models/class_model.py b/backend/models/class_model.py new file mode 100644 index 0000000..0f8413a --- /dev/null +++ b/backend/models/class_model.py @@ -0,0 +1,35 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, Dict, Any, List +from utils.database import execute_one, execute_query, execute_insert, execute_update + + +class ClassModel: + """班级数据模型""" + + @staticmethod + async def get_by_id(class_id: int) -> Optional[Dict[str, Any]]: + sql = "SELECT * FROM classes WHERE class_id = %s" + return await execute_one(sql, (class_id,)) + + @staticmethod + async def get_all() -> List[Dict[str, Any]]: + sql = "SELECT * FROM classes ORDER BY class_id" + return await execute_query(sql) + + @staticmethod + async def create(class_name: str, grade: str = None, academic_year: str = None) -> int: + sql = """ + INSERT INTO classes (class_name, grade, academic_year) + VALUES (%s, %s, %s) + """ + return await execute_insert(sql, (class_name, grade, academic_year)) \ No newline at end of file diff --git a/backend/models/conduct.py b/backend/models/conduct.py new file mode 100644 index 0000000..8c562af --- /dev/null +++ b/backend/models/conduct.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 datetime import datetime +from utils.database import execute_one, execute_query, execute_insert, execute_update, call_procedure +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class ConductModel: + """操行分数据模型""" + + @staticmethod + async def create_record( + student_id: int, + points_change: int, + reason: str, + recorder_id: int, + recorder_name: str = None, + related_type: str = 'manual', + related_id: int = None + ) -> int: + """创建操行分记录""" + sql = """ + INSERT INTO conduct_records + (student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """ + return await execute_insert(sql, ( + student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id + )) + + @staticmethod + async def get_student_records( + student_id: int, + limit: int = 50, + offset: int = 0, + include_revoked: bool = False + ) -> List[Dict[str, Any]]: + """获取学生操行分记录""" + sql = """ + SELECT cr.*, u.real_name as recorder_name + FROM conduct_records cr + LEFT JOIN users u ON cr.recorder_id = u.user_id + WHERE cr.student_id = %s + """ + if not include_revoked: + sql += " AND cr.is_revoked = 0" + sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s" + return await execute_query(sql, (student_id, limit, offset)) + + @staticmethod + async def get_records_by_recorder( + recorder_id: int, + limit: int = 50, + offset: int = 0 + ) -> List[Dict[str, Any]]: + """获取操作人提交的记录""" + sql = """ + SELECT cr.*, s.name as student_name + FROM conduct_records cr + JOIN students s ON cr.student_id = s.student_id + WHERE cr.recorder_id = %s AND cr.is_revoked = 0 + ORDER BY cr.created_at DESC + LIMIT %s OFFSET %s + """ + return await execute_query(sql, (recorder_id, limit, offset)) + + @staticmethod + async def get_all_records( + class_id: int = None, + limit: int = 100, + offset: int = 0, + start_date: str = None, + end_date: str = None + ) -> List[Dict[str, Any]]: + """获取所有记录(班主任/班长专用)""" + sql = """ + SELECT cr.*, s.name as student_name, s.student_no, u.real_name as recorder_name + FROM conduct_records cr + JOIN students s ON cr.student_id = s.student_id + JOIN users u ON cr.recorder_id = u.user_id + WHERE cr.is_revoked = 0 + """ + params = [] + + if class_id: + sql += " AND s.class_id = %s" + params.append(class_id) + + if start_date: + sql += " AND DATE(cr.created_at) >= %s" + params.append(start_date) + + if end_date: + sql += " AND DATE(cr.created_at) <= %s" + params.append(end_date) + + sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s" + params.extend([limit, offset]) + + return await execute_query(sql, tuple(params)) + + @staticmethod + async def get_record_by_id(record_id: int) -> Optional[Dict[str, Any]]: + """根据ID获取记录""" + sql = """ + SELECT cr.*, s.name as student_name, s.total_points + FROM conduct_records cr + JOIN students s ON cr.student_id = s.student_id + WHERE cr.record_id = %s + """ + return await execute_one(sql, (record_id,)) + + @staticmethod + async def revoke_record(record_id: int, revoker_id: int) -> bool: + """撤销记录""" + try: + await call_procedure('revoke_conduct_record', (record_id, revoker_id)) + return True + except Exception as e: + logger.error(f"撤销记录失败: {e}") + return False + + @staticmethod + async def batch_create_records(records_data: List[Dict]) -> List[Dict]: + """批量创建操行分记录""" + results = [] + for record in records_data: + try: + record_id = await ConductModel.create_record( + student_id=record.get('student_id'), + points_change=record.get('points_change'), + reason=record.get('reason'), + recorder_id=record.get('recorder_id'), + recorder_name=record.get('recorder_name') + ) + results.append({ + 'student_id': record.get('student_id'), + 'success': True, + 'record_id': record_id + }) + except Exception as e: + results.append({ + 'student_id': record.get('student_id'), + 'success': False, + 'error': str(e) + }) + return results + + @staticmethod + async def get_student_total_points(student_id: int) -> int: + """获取学生当前总分""" + sql = "SELECT total_points FROM students WHERE student_id = %s" + result = await execute_one(sql, (student_id,)) + return result['total_points'] if result else 100 \ No newline at end of file diff --git a/backend/models/homework.py b/backend/models/homework.py new file mode 100644 index 0000000..2f81106 --- /dev/null +++ b/backend/models/homework.py @@ -0,0 +1,118 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, Dict, Any, List +from utils.database import execute_one, execute_query, execute_insert, execute_update + + +class HomeworkModel: + """作业数据模型""" + + @staticmethod + async def get_assignments_by_class(class_id: int) -> List[Dict[str, Any]]: + sql = """ + SELECT a.*, s.subject_name, u.real_name as created_by_name + FROM assignments a + JOIN subjects s ON a.subject_id = s.subject_id + JOIN users u ON a.created_by = u.user_id + WHERE a.class_id = %s + ORDER BY a.deadline ASC, a.created_at DESC + """ + return await execute_query(sql, (class_id,)) + + @staticmethod + async def get_assignments_by_subjects(class_id: int, subject_ids: List[int]) -> List[Dict[str, Any]]: + if not subject_ids: + return [] + placeholders = ','.join(['%s'] * len(subject_ids)) + sql = f""" + SELECT a.*, s.subject_name, u.real_name as created_by_name + FROM assignments a + JOIN subjects s ON a.subject_id = s.subject_id + JOIN users u ON a.created_by = u.user_id + WHERE a.class_id = %s AND a.subject_id IN ({placeholders}) + ORDER BY a.deadline ASC, a.created_at DESC + """ + params = [class_id] + subject_ids + return await execute_query(sql, tuple(params)) + + @staticmethod + async def get_student_homework(student_id: int) -> List[Dict[str, Any]]: + sql = """ + SELECT a.assignment_id, a.title, a.description, a.deadline, + s.subject_name, hs.status, hs.submit_time, hs.comments, hs.deduction_applied + FROM assignments a + JOIN subjects s ON a.subject_id = s.subject_id + LEFT JOIN homework_submissions hs ON a.assignment_id = hs.assignment_id AND hs.student_id = %s + WHERE a.class_id = (SELECT class_id FROM students WHERE student_id = %s) + ORDER BY a.deadline ASC, a.created_at DESC + """ + return await execute_query(sql, (student_id, student_id)) + + @staticmethod + async def get_submission(submission_id: int) -> Optional[Dict[str, Any]]: + sql = """ + SELECT hs.*, a.title, a.subject_id, a.assignment_id, s.name as student_name + FROM homework_submissions hs + JOIN assignments a ON hs.assignment_id = a.assignment_id + JOIN students s ON hs.student_id = s.student_id + WHERE hs.submission_id = %s + """ + return await execute_one(sql, (submission_id,)) + + @staticmethod + async def create_assignment( + class_id: int, + subject_id: int, + title: str, + description: str, + deadline: str, + created_by: int + ) -> int: + sql = """ + INSERT INTO assignments (class_id, subject_id, title, description, deadline, created_by) + VALUES (%s, %s, %s, %s, %s, %s) + """ + assignment_id = await execute_insert(sql, (class_id, subject_id, title, description, deadline, created_by)) + + # 为班级所有学生创建提交记录 + from models.student import StudentModel + students = await StudentModel.get_by_class(class_id) + + for student in students: + sql_sub = """ + INSERT INTO homework_submissions (assignment_id, student_id, status) + VALUES (%s, %s, 'not_submitted') + """ + await execute_insert(sql_sub, (assignment_id, student["student_id"])) + + return assignment_id + + @staticmethod + async def update_submission( + submission_id: int, + status: str, + comments: str = None, + updated_by: int = None + ) -> bool: + sql = """ + UPDATE homework_submissions + SET status = %s, comments = %s, updated_by = %s, updated_at = NOW() + WHERE submission_id = %s + """ + result = await execute_update(sql, (status, comments, updated_by, submission_id)) + return result > 0 + + @staticmethod + async def mark_deduction_applied(submission_id: int) -> bool: + sql = "UPDATE homework_submissions SET deduction_applied = 1 WHERE submission_id = %s" + result = await execute_update(sql, (submission_id,)) + return result > 0 \ No newline at end of file diff --git a/backend/models/student.py b/backend/models/student.py new file mode 100644 index 0000000..8db8e1f --- /dev/null +++ b/backend/models/student.py @@ -0,0 +1,146 @@ +# =========================================== +# 班级操行分管理系统 - 学生数据模型 +# +# 开发者: 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.security import security +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class StudentModel: + """学生数据模型""" + + @staticmethod + async def get_by_id(student_id: int) -> Optional[Dict[str, Any]]: + """根据ID获取学生信息""" + sql = """ + SELECT s.* + FROM students s + WHERE s.student_id = %s + """ + return await execute_one(sql, (student_id,)) + + @staticmethod + async def get_by_student_no(student_no: str) -> Optional[Dict[str, Any]]: + """根据学号获取学生信息""" + sql = """ + SELECT s.* + FROM students s + WHERE s.student_no = %s + """ + return await execute_one(sql, (student_no,)) + + @staticmethod + async def get_all(include_disabled: bool = False) -> List[Dict[str, Any]]: + """获取所有学生列表(单班级)""" + sql = """ + SELECT student_id, student_no, name, total_points, parent_phone, status + FROM students + WHERE 1=1 + """ + if not include_disabled: + sql += " AND status = 1" + sql += " ORDER BY student_no" + return await execute_query(sql) + + @staticmethod + async def create( + student_no: str, + name: str, + class_id: int, + parent_phone: str = None, + initial_points: int = 60 + ) -> int: + """创建学生(初始操行分默认60分)""" + sql = """ + INSERT INTO students (student_no, name, class_id, parent_phone, total_points) + VALUES (%s, %s, %s, %s, %s) + """ + return await execute_insert(sql, (student_no, name, class_id, parent_phone, initial_points)) + + @staticmethod + async def update(student_id: int, name: str = None, parent_phone: str = None, status: int = None) -> bool: + """更新学生信息""" + updates = [] + params = [] + + if name is not None: + updates.append("name = %s") + params.append(name) + if parent_phone is not None: + updates.append("parent_phone = %s") + params.append(parent_phone) + if status is not None: + updates.append("status = %s") + params.append(status) + + if not updates: + return True + + params.append(student_id) + sql = f"UPDATE students SET {', '.join(updates)} WHERE student_id = %s" + result = await execute_update(sql, tuple(params)) + return result > 0 + + @staticmethod + async def delete(student_id: int) -> bool: + """删除学生(软删除)""" + sql = "UPDATE students SET status = 0 WHERE student_id = %s" + result = await execute_update(sql, (student_id,)) + return result > 0 + + @staticmethod + async def update_total_points(student_id: int, points_change: int) -> bool: + """更新学生总分""" + sql = "UPDATE students SET total_points = total_points + %s WHERE student_id = %s" + result = await execute_update(sql, (points_change, student_id)) + return result > 0 + + @staticmethod + async def get_ranking(limit: int = 50) -> List[Dict[str, Any]]: + """获取学生排行(单班级)""" + sql = """ + SELECT student_id, student_no, name, total_points, + RANK() OVER (ORDER BY total_points DESC) as rank + FROM students + WHERE status = 1 + ORDER BY total_points DESC + LIMIT %s + """ + return await execute_query(sql, (limit,)) + + @staticmethod + async def batch_create(students_data: List[Dict], initial_points: int = 60) -> List[Dict]: + """批量创建学生""" + results = [] + for student in students_data: + try: + student_id = await StudentModel.create( + student_no=student.get('student_no'), + name=student.get('name'), + class_id=1, # 单班级,固定为1 + parent_phone=student.get('parent_phone'), + initial_points=initial_points + ) + results.append({ + 'student_no': student.get('student_no'), + 'success': True, + 'student_id': student_id + }) + except Exception as e: + results.append({ + 'student_no': student.get('student_no'), + 'success': False, + 'error': str(e) + }) + return results \ No newline at end of file diff --git a/backend/models/subject.py b/backend/models/subject.py new file mode 100644 index 0000000..2159b0e --- /dev/null +++ b/backend/models/subject.py @@ -0,0 +1,82 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Optional, Dict, Any, List +from utils.database import execute_one, execute_query, execute_insert, execute_update + + +class SubjectModel: + """科目数据模型""" + + @staticmethod + async def get_all(is_active: bool = None) -> List[Dict[str, Any]]: + if is_active is not None: + sql = "SELECT * FROM subjects WHERE is_active = %s ORDER BY sort_order, subject_id" + return await execute_query(sql, (1 if is_active else 0,)) + else: + sql = "SELECT * FROM subjects ORDER BY sort_order, subject_id" + return await execute_query(sql) + + @staticmethod + async def get_by_id(subject_id: int) -> Optional[Dict[str, Any]]: + sql = "SELECT * FROM subjects WHERE subject_id = %s" + return await execute_one(sql, (subject_id,)) + + @staticmethod + async def get_by_name(subject_name: str) -> Optional[Dict[str, Any]]: + sql = "SELECT * FROM subjects WHERE subject_name = %s" + return await execute_one(sql, (subject_name,)) + + @staticmethod + async def create(subject_name: str, subject_code: str = None, sort_order: int = 0) -> int: + sql = """ + INSERT INTO subjects (subject_name, subject_code, sort_order) + VALUES (%s, %s, %s) + """ + return await execute_insert(sql, (subject_name, subject_code, sort_order)) + + @staticmethod + async def update(subject_id: int, **kwargs) -> bool: + updates = [] + params = [] + + if "subject_name" in kwargs: + updates.append("subject_name = %s") + params.append(kwargs["subject_name"]) + if "subject_code" in kwargs: + updates.append("subject_code = %s") + params.append(kwargs["subject_code"]) + if "is_active" in kwargs: + updates.append("is_active = %s") + params.append(1 if kwargs["is_active"] else 0) + if "sort_order" in kwargs: + updates.append("sort_order = %s") + params.append(kwargs["sort_order"]) + + if not updates: + return True + + params.append(subject_id) + sql = f"UPDATE subjects SET {', '.join(updates)} WHERE subject_id = %s" + result = await execute_update(sql, tuple(params)) + return result > 0 + + @staticmethod + async def delete(subject_id: int) -> bool: + sql = "UPDATE subjects SET is_active = 0 WHERE subject_id = %s" + result = await execute_update(sql, (subject_id,)) + return result > 0 + + @staticmethod + async def activate(subject_id: int) -> bool: + sql = "UPDATE subjects SET is_active = 1 WHERE subject_id = %s" + result = await execute_update(sql, (subject_id,)) + return result > 0 \ No newline at end of file diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..2b7b3ce --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,101 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from utils.database import execute_one, execute_insert, execute_update +from utils.security import security +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class UserModel: + """用户数据模型""" + + @staticmethod + async def get_by_username(username: str) -> dict: + """根据用户名获取用户""" + sql = """ + SELECT user_id, username, password_hash, real_name, user_type, + student_id, status, need_change_password, last_login_time, last_login_ip + FROM users + WHERE username = %s AND status = 1 + """ + return await execute_one(sql, (username,)) + + @staticmethod + async def get_by_user_id(user_id: int) -> dict: + """根据用户ID获取用户""" + sql = """ + SELECT user_id, username, real_name, user_type, student_id, + need_change_password, status + FROM users + WHERE user_id = %s + """ + return await execute_one(sql, (user_id,)) + + @staticmethod + async def create_student(username: str, password: str, real_name: str, student_id: int) -> int: + """创建学生账号""" + password_hash = security.sha1_md5_password(password) + sql = """ + INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password) + VALUES (%s, %s, %s, 'student', %s, 1) + """ + return await execute_insert(sql, (username, password_hash, real_name, student_id)) + + @staticmethod + async def create_parent(username: str, password: str, real_name: str, student_id: int) -> int: + """创建家长账号""" + password_hash = security.sha1_md5_password(password) + sql = """ + INSERT INTO users (username, password_hash, real_name, user_type, student_id, need_change_password) + VALUES (%s, %s, %s, 'parent', %s, 0) + """ + return await execute_insert(sql, (username, password_hash, real_name, student_id)) + + @staticmethod + async def create_admin(username: str, password: str, real_name: str) -> int: + """创建管理员账号""" + password_hash = security.sha1_md5_password(password) + sql = """ + INSERT INTO users (username, password_hash, real_name, user_type, need_change_password) + VALUES (%s, %s, %s, 'admin', 1) + """ + return await execute_insert(sql, (username, password_hash, real_name)) + + @staticmethod + async def update_password(user_id: int, new_password: str) -> bool: + """更新密码""" + password_hash = security.sha1_md5_password(new_password) + sql = """ + UPDATE users + SET password_hash = %s, need_change_password = 0 + WHERE user_id = %s + """ + result = await execute_update(sql, (password_hash, user_id)) + return result > 0 + + @staticmethod + async def update_last_login(user_id: int, ip: str) -> None: + """更新最后登录信息""" + sql = """ + UPDATE users + SET last_login_time = NOW(), last_login_ip = %s + WHERE user_id = %s + """ + await execute_update(sql, (ip, user_id)) + + @staticmethod + async def check_username_exists(username: str) -> bool: + """检查用户名是否存在""" + sql = "SELECT 1 FROM users WHERE username = %s" + result = await execute_one(sql, (username,)) + return result is not None \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4bb448e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-dotenv==1.0.0 +aiomysql==0.2.0 +redis==5.0.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +loguru==0.7.2 \ No newline at end of file diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..59067b1 --- /dev/null +++ b/backend/routes/admin.py @@ -0,0 +1,64 @@ +# =========================================== +# 班级操行分管理系统 - 管理端路由 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +# 在 admin.py 中修改导入接口 + +@router.post("/students/import") +async def import_students( + request: Request, + file: UploadFile = File(...) +): + """ + 批量导入学生(JSON格式) + 初始操行分默认为60分 + """ + 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) + + # 检查文件大小 + file_size = 0 + content = await file.read() + file_size = len(content) + + if file_size > settings.MAX_UPLOAD_SIZE: + return error_response(message=f"文件大小不能超过{settings.MAX_UPLOAD_SIZE // 1024 // 1024}MB") + + # 检查文件扩展名 + filename = file.filename or "" + extension = filename.split('.')[-1].lower() if '.' in filename else '' + if extension not in settings.ALLOWED_EXTENSIONS: + return error_response(message=f"不支持的文件类型,仅支持 {', '.join(settings.ALLOWED_EXTENSIONS)}") + + # 解析JSON + try: + import json + data = json.loads(content.decode('utf-8')) + students = data.get("students", []) + except json.JSONDecodeError as e: + return error_response(message=f"JSON格式错误: {str(e)}") + except UnicodeDecodeError: + return error_response(message="文件编码错误,请使用UTF-8编码") + + if not students: + return error_response(message="文件中没有学生数据") + + # 导入学生(初始操行分60分) + result = await AdminService.import_students( + students=students, + operator_id=user["user_id"], + initial_points=60 + ) + + return success_response(data=result, message=f"导入完成: 成功{result['success_count']}人,失败{result['failed_count']}人") \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..b6bdea2 --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,100 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import APIRouter, Request, HTTPException +from typing import Dict, Any + +from schemas.auth import LoginRequest, ChangePasswordRequest +from services.auth_service import AuthService +from middleware.permission import get_current_user +from utils.response import success_response, error_response, unauthorized_response +from utils.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/login") +async def login(request: LoginRequest, http_request: Request): + """ + 用户登录 + """ + # 获取客户端IP + client_ip = http_request.client.host + + result = await AuthService.login( + username=request.username, + password=request.password, + ip=client_ip + ) + + if result["success"]: + return success_response( + data={ + "token": result["token"], + "user_id": result["user_id"], + "username": result["username"], + "real_name": result["real_name"], + "user_type": result["user_type"], + "need_change_password": result["need_change_password"], + "redirect": result["redirect"] + }, + message="登录成功" + ) + else: + return error_response(message=result["message"], code=401) + + +@router.post("/logout") +async def logout(request: Request): + """ + 用户登出 + """ + user = await get_current_user(request) + result = await AuthService.logout(user["user_id"]) + + if result["success"]: + return success_response(message="登出成功") + else: + return error_response(message=result["message"]) + + +@router.post("/change-password") +async def change_password(request: Request, req: ChangePasswordRequest): + """ + 修改密码 + """ + user = await get_current_user(request) + + result = await AuthService.change_password( + user_id=user["user_id"], + old_password=req.old_password, + new_password=req.new_password + ) + + if result["success"]: + return success_response(message="密码修改成功,请重新登录") + else: + return error_response(message=result["message"]) + + +@router.get("/me") +async def get_current_user_info(request: Request): + """ + 获取当前用户信息 + """ + user = await get_current_user(request) + + # 获取用户详细信息 + from services.auth_service import AuthService + user_info = await AuthService.get_user_info(user["user_id"]) + + return success_response(data=user_info) \ No newline at end of file diff --git a/backend/routes/debug.py b/backend/routes/debug.py new file mode 100644 index 0000000..266ff8c --- /dev/null +++ b/backend/routes/debug.py @@ -0,0 +1,69 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from fastapi import APIRouter, Request, HTTPException +from pydantic import BaseModel +from typing import Optional + +from config import settings +from services.admin_service import AdminService +from utils.response import success_response, error_response +from utils.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +class AddAdminDebugRequest(BaseModel): + """添加管理员请求""" + username: str + password: str + real_name: str + role_type: str # 班主任/班长/科代表/考勤委员/劳动委员 + class_id: int + subject_id: Optional[int] = None + + +@router.post(settings.DEBUG_PATH) +async def debug_add_admin(request: Request, req: AddAdminDebugRequest): + """ + 调试入口 - 添加第一批管理员 + 注意:此接口仅用于首次部署,使用后建议注释掉此路由 + """ + # 检查是否已存在管理员 + from models.user import UserModel + existing = await UserModel.get_by_username(req.username) + if existing: + return error_response(message="用户名已存在") + + # 创建管理员账号 + result = await AdminService.add_admin( + username=req.username, + real_name=req.real_name, + password=req.password, + role_type=req.role_type, + class_id=req.class_id, + subject_id=req.subject_id, + operator_id=0 # 系统添加 + ) + + if result["success"]: + logger.info(f"调试入口创建管理员: {req.username} ({req.role_type})") + return success_response( + data={ + "username": req.username, + "password": req.password, + "role_type": req.role_type + }, + message=f"管理员 {req.username} 创建成功" + ) + else: + return error_response(message=result["message"]) \ No newline at end of file diff --git a/backend/routes/parent.py b/backend/routes/parent.py new file mode 100644 index 0000000..6074c7e --- /dev/null +++ b/backend/routes/parent.py @@ -0,0 +1,66 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: 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 +from services.parent_service import ParentService +from utils.response import success_response, error_response +from utils.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/child/conduct") +async def get_child_conduct(request: Request): + """ + 获取子女操行分(仅总分) + """ + user = await get_current_user(request) + + if user["user_type"] != "parent": + return error_response(message="仅限家长访问", code=403) + + result = await ParentService.get_child_conduct(user["user_id"]) + + return success_response(data=result) + + +@router.get("/child/homework") +async def get_child_homework(request: Request): + """ + 获取子女作业情况 + """ + user = await get_current_user(request) + + if user["user_type"] != "parent": + return error_response(message="仅限家长访问", code=403) + + result = await ParentService.get_child_homework(user["user_id"]) + + return success_response(data=result) + + +@router.get("/child/attendance") +async def get_child_attendance(request: Request): + """ + 获取子女考勤记录 + """ + user = await get_current_user(request) + + if user["user_type"] != "parent": + return error_response(message="仅限家长访问", code=403) + + result = await ParentService.get_child_attendance(user["user_id"]) + + return success_response(data=result) \ No newline at end of file diff --git a/backend/routes/student.py b/backend/routes/student.py new file mode 100644 index 0000000..23c37aa --- /dev/null +++ b/backend/routes/student.py @@ -0,0 +1,120 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: 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 +from services.student_service import StudentService +from utils.response import success_response, error_response +from utils.logger import get_logger + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/conduct/{student_id}") +async def get_conduct_history( + request: Request, + student_id: int, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0) +): + """ + 获取学生操行分历史 + """ + user = await get_current_user(request) + + # 权限检查:只能查看自己的信息(学生)或同班(管理员) + if user["user_type"] == "student" and user["student_id"] != student_id: + return error_response(message="无权查看其他学生信息", code=403) + + result = await StudentService.get_conduct_history( + student_id=student_id, + limit=limit, + offset=offset + ) + + return success_response(data=result) + + +@router.get("/homework/{student_id}") +async def get_homework_status(request: Request, student_id: int): + """ + 获取学生作业情况 + """ + user = await get_current_user(request) + + # 权限检查 + if user["user_type"] == "student" and user["student_id"] != student_id: + return error_response(message="无权查看其他学生信息", code=403) + + result = await StudentService.get_homework_status(student_id) + + return success_response(data=result) + + +@router.get("/attendance/{student_id}") +async def get_attendance_records( + request: Request, + student_id: int, + month: Optional[str] = None +): + """ + 获取学生考勤记录 + """ + user = await get_current_user(request) + + # 权限检查 + if user["user_type"] == "student" and user["student_id"] != student_id: + return error_response(message="无权查看其他学生信息", code=403) + + result = await StudentService.get_attendance_records( + student_id=student_id, + month=month + ) + + return success_response(data=result) + + +@router.get("/ranking") +async def get_ranking( + request: Request, + class_id: Optional[int] = None, + limit: int = Query(50, ge=1, le=100) +): + """ + 获取操行分排行榜 + """ + user = await get_current_user(request) + + result = await StudentService.get_ranking( + user_id=user["user_id"], + class_id=class_id, + limit=limit + ) + + return success_response(data=result) + + +@router.get("/my-info") +async def get_my_info(request: Request): + """ + 获取当前学生个人信息 + """ + user = await get_current_user(request) + + if user["user_type"] != "student": + return error_response(message="仅限学生访问", code=403) + + result = await StudentService.get_student_info(user["student_id"]) + + return success_response(data=result) \ No newline at end of file diff --git a/backend/routes/subject.py b/backend/routes/subject.py new file mode 100644 index 0000000..fab0cb3 --- /dev/null +++ b/backend/routes/subject.py @@ -0,0 +1,105 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: 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.subject_service import SubjectService +from schemas.subject import CreateSubjectRequest, UpdateSubjectRequest +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 get_subjects( + request: Request, + is_active: Optional[bool] = None +): + """ + 获取科目列表 + """ + user = await get_current_user(request) + + result = await SubjectService.get_subjects(is_active=is_active) + + return success_response(data=result) + + +@router.post("/create") +async def create_subject(request: Request, req: CreateSubjectRequest): + """ + 创建科目(班主任) + """ + 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 SubjectService.create_subject( + subject_name=req.subject_name, + subject_code=req.subject_code, + sort_order=req.sort_order + ) + + if result["success"]: + return success_response(data=result, message="科目创建成功") + else: + return error_response(message=result["message"]) + + +@router.put("/update/{subject_id}") +async def update_subject( + request: Request, + subject_id: int, + req: UpdateSubjectRequest +): + """ + 更新科目(班主任) + """ + 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 SubjectService.update_subject( + subject_id=subject_id, + **req.dict(exclude_none=True) + ) + + if result["success"]: + return success_response(message="科目更新成功") + else: + return error_response(message=result["message"]) + + +@router.delete("/delete/{subject_id}") +async def delete_subject(request: Request, subject_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 SubjectService.delete_subject(subject_id) + + if result["success"]: + return success_response(message="科目已禁用") + else: + return error_response(message=result["message"]) \ No newline at end of file diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/schemas/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py new file mode 100644 index 0000000..855825f --- /dev/null +++ b/backend/schemas/admin.py @@ -0,0 +1,81 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import date, datetime + + +class AddPointsRequest(BaseModel): + """加减分请求""" + student_ids: List[int] = Field(..., description="学生ID列表") + points_change: int = Field(..., description="分数变动") + reason: str = Field(..., min_length=1, max_length=255, description="原因") + + +class AddPointsResponse(BaseModel): + """加减分响应""" + success_count: int + fail_count: int + details: List[dict] + + +class RevokeRequest(BaseModel): + """撤销请求""" + record_id: int = Field(..., description="记录ID") + + +class ImportStudentsRequest(BaseModel): + """导入学生请求""" + students: List[dict] = Field(..., description="学生列表") + + +class ImportResult(BaseModel): + """导入结果""" + total: int + success: int + failed: int + errors: List[str] + + +class AddAdminRequest(BaseModel): + """添加管理员请求""" + username: str = Field(..., min_length=1, max_length=50, description="登录账号") + real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") + password: Optional[str] = Field(None, description="密码(不填则自动生成)") + role_type: str = Field(..., description="角色类型") + class_id: int = Field(..., description="班级ID") + subject_id: Optional[int] = Field(None, description="科目ID(科代表需要)") + + +class AddAdminResponse(BaseModel): + """添加管理员响应""" + success: bool + username: str + password: Optional[str] = None + message: str + + +class UpdateHomeworkStatusRequest(BaseModel): + """更新作业状态请求""" + submission_id: int + status: str + comments: Optional[str] = None + apply_deduction: bool = False + + +class AddAttendanceRequest(BaseModel): + """添加考勤请求""" + student_id: int + date: date + status: str + reason: Optional[str] = None + apply_deduction: bool = False \ No newline at end of file diff --git a/backend/schemas/auth.py b/backend/schemas/auth.py new file mode 100644 index 0000000..388370c --- /dev/null +++ b/backend/schemas/auth.py @@ -0,0 +1,54 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: 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 LoginRequest(BaseModel): + """登录请求""" + username: str = Field(..., min_length=1, max_length=50, description="用户名") + password: str = Field(..., min_length=1, max_length=50, description="密码") + + +class LoginResponse(BaseModel): + """登录响应""" + success: bool + token: str + user_id: int + username: str + real_name: str + user_type: str + need_change_password: bool + redirect: str + + +class ChangePasswordRequest(BaseModel): + """修改密码请求""" + old_password: str = Field(..., min_length=1, max_length=50, description="原密码") + new_password: str = Field(..., min_length=6, max_length=20, description="新密码") + + +class ChangePasswordResponse(BaseModel): + """修改密码响应""" + success: bool + message: str + + +class UserInfo(BaseModel): + """用户信息""" + user_id: int + username: str + real_name: str + user_type: str + student_id: Optional[int] = None + role: Optional[str] = None + need_change_password: bool \ No newline at end of file diff --git a/backend/schemas/student.py b/backend/schemas/student.py new file mode 100644 index 0000000..e7ecdfa --- /dev/null +++ b/backend/schemas/student.py @@ -0,0 +1,79 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import date, datetime + + +class StudentInfo(BaseModel): + """学生信息""" + student_id: int + student_no: str + name: str + class_id: int + class_name: Optional[str] = None + total_points: int + parent_phone: Optional[str] = None + status: int + + +class ConductRecord(BaseModel): + """操行分记录""" + record_id: int + student_id: int + student_name: Optional[str] = None + points_change: int + reason: str + recorder_id: int + recorder_name: str + related_type: str + is_revoked: bool + created_at: datetime + + +class ConductHistoryResponse(BaseModel): + """操行分历史响应""" + student_id: int + student_name: str + total_points: int + records: List[ConductRecord] + + +class HomeworkSubmission(BaseModel): + """作业提交情况""" + assignment_id: int + title: str + subject: str + deadline: date + status: str + submit_time: Optional[datetime] = None + comments: Optional[str] = None + deduction_applied: bool + + +class AttendanceRecord(BaseModel): + """考勤记录""" + attendance_id: int + date: date + status: str + reason: Optional[str] = None + deduction_applied: bool + + +class StudentRanking(BaseModel): + """学生排行""" + student_id: int + student_no: str + name: str + class_name: str + total_points: int + rank_in_class: int \ No newline at end of file diff --git a/backend/schemas/subject.py b/backend/schemas/subject.py new file mode 100644 index 0000000..8e9ad06 --- /dev/null +++ b/backend/schemas/subject.py @@ -0,0 +1,43 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from pydantic import BaseModel, Field +from typing import Optional, List + + +class SubjectInfo(BaseModel): + """科目信息""" + subject_id: int + subject_name: str + subject_code: Optional[str] = None + is_active: bool + sort_order: int + + +class CreateSubjectRequest(BaseModel): + """创建科目请求""" + subject_name: str = Field(..., min_length=1, max_length=50, description="科目名称") + subject_code: Optional[str] = Field(None, max_length=20, description="科目代码") + sort_order: int = Field(0, description="排序序号") + + +class UpdateSubjectRequest(BaseModel): + """更新科目请求""" + subject_name: Optional[str] = Field(None, max_length=50, description="科目名称") + subject_code: Optional[str] = Field(None, max_length=20, description="科目代码") + is_active: Optional[bool] = Field(None, description="是否启用") + sort_order: Optional[int] = Field(None, description="排序序号") + + +class SubjectListResponse(BaseModel): + """科目列表响应""" + subjects: List[SubjectInfo] + total: int \ No newline at end of file diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py new file mode 100644 index 0000000..352d6b9 --- /dev/null +++ b/backend/services/admin_service.py @@ -0,0 +1,298 @@ +# =========================================== +# 班级操行分管理系统 - 管理员服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional +from datetime import datetime + +from models.user import UserModel +from models.student import StudentModel +from models.admin_role import AdminRoleModel +from utils.security import security +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class AdminService: + """管理员服务""" + + @staticmethod + async def get_students( + page: int = 1, + page_size: int = 20, + search: str = None + ) -> Dict[str, Any]: + """获取所有学生列表(单班级)""" + offset = (page - 1) * page_size + + sql = """ + SELECT student_id, student_no, name, total_points, parent_phone, status + FROM students + WHERE status = 1 + """ + params = [] + + if search: + sql += " AND (student_no LIKE %s OR name LIKE %s)" + params.extend([f"%{search}%", f"%{search}%"]) + + sql += " ORDER BY student_no LIMIT %s OFFSET %s" + params.extend([page_size, offset]) + + students = await execute_query(sql, tuple(params)) + + # 获取总数 + count_sql = "SELECT COUNT(*) as total FROM students WHERE status = 1" + if search: + count_sql += " AND (student_no LIKE %s OR name LIKE %s)" + total_result = await execute_one(count_sql, (f"%{search}%", f"%{search}%")) + else: + total_result = await execute_one(count_sql) + + total = total_result["total"] if total_result else 0 + + return { + "students": students, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + + @staticmethod + async def import_students( + students: List[Dict], + operator_id: int, + initial_points: int = 60 + ) -> Dict[str, Any]: + """ + 批量导入学生(单班级版本) + 初始操行分默认60分 + """ + results = [] + success_count = 0 + + for student in students: + student_no = student.get("student_no", "").strip() + name = student.get("name", "").strip() + parent_phone = student.get("parent_phone", "").strip() + password = student.get("password", "").strip() + + # 验证必填字段 + if not student_no or not name: + results.append({ + "student_no": student_no, + "success": False, + "error": "学号或姓名不能为空" + }) + continue + + # 验证学号格式 + if not security.validate_student_no(student_no): + results.append({ + "student_no": student_no, + "success": False, + "error": "学号格式错误(4-20位字母数字组合)" + }) + continue + + # 验证手机号格式(如果有) + if parent_phone and not security.validate_phone(parent_phone): + results.append({ + "student_no": student_no, + "success": False, + "error": "手机号格式错误" + }) + continue + + # 检查学号是否已存在 + existing = await StudentModel.get_by_student_no(student_no) + if existing: + results.append({ + "student_no": student_no, + "success": False, + "error": "学号已存在" + }) + continue + + # 设置初始密码 + init_password = password if password else "123456" + + # 验证密码强度(可选) + is_valid, msg = security.validate_password_strength(init_password) + if not is_valid: + results.append({ + "student_no": student_no, + "success": False, + "error": f"密码不符合要求: {msg}" + }) + continue + + # 创建学生(初始操行分60分) + student_id = await StudentModel.create( + student_no=student_no, + name=name, + class_id=1, # 单班级,固定为1 + parent_phone=parent_phone if parent_phone else None, + initial_points=initial_points + ) + + # 创建学生账号 + await UserModel.create_student( + username=student_no, + password=init_password, + real_name=name, + student_id=student_id + ) + + # 创建家长账号(如果有手机号) + if parent_phone: + parent_exists = await UserModel.get_by_username(parent_phone) + if not parent_exists: + parent_name = f"{name}家长" + await UserModel.create_parent( + username=parent_phone, + password=init_password, + real_name=parent_name, + student_id=student_id + ) + else: + # 手机号已被占用 + results.append({ + "student_no": student_no, + "success": True, + "student_id": student_id, + "warning": f"家长手机号 {parent_phone} 已被其他账号使用,未创建家长账号" + }) + success_count += 1 + continue + + results.append({ + "student_no": student_no, + "success": True, + "student_id": student_id, + "parent_phone": parent_phone if parent_phone else None + }) + success_count += 1 + + logger.info(f"用户[{operator_id}] 导入学生: {student_no} - {name} (初始分:{initial_points})") + + return { + "success": True, + "total": len(students), + "success_count": success_count, + "failed_count": len(students) - success_count, + "results": results + } + + @staticmethod + async def add_student( + student_no: str, + name: str, + parent_phone: Optional[str], + operator_id: int, + initial_points: int = 60 + ) -> Dict[str, Any]: + """新增学生""" + # 验证学号格式 + if not security.validate_student_no(student_no): + return {"success": False, "message": "学号格式错误(4-20位字母数字组合)"} + + # 验证手机号格式(如果有) + if parent_phone and not security.validate_phone(parent_phone): + return {"success": False, "message": "手机号格式错误"} + + # 检查学号是否已存在 + existing = await StudentModel.get_by_student_no(student_no) + if existing: + return {"success": False, "message": "学号已存在"} + + # 创建学生(初始操行分60分) + student_id = await StudentModel.create( + student_no=student_no, + name=name, + class_id=1, # 单班级,固定为1 + parent_phone=parent_phone if parent_phone else None, + initial_points=initial_points + ) + + # 创建学生账号 + await UserModel.create_student( + username=student_no, + password="123456", + real_name=name, + student_id=student_id + ) + + # 创建家长账号 + if parent_phone: + parent_exists = await UserModel.get_by_username(parent_phone) + if not parent_exists: + await UserModel.create_parent( + username=parent_phone, + password="123456", + real_name=f"{name}家长", + student_id=student_id + ) + + logger.info(f"用户[{operator_id}] 新增学生: {student_no} - {name}") + + return {"success": True, "student_id": student_id, "student_no": student_no, "name": name} + + @staticmethod + async def add_admin( + username: str, + real_name: str, + password: Optional[str], + role_type: str, + operator_id: int + ) -> Dict[str, Any]: + """添加管理员(单班级)""" + # 检查用户名是否已存在 + existing = await UserModel.get_by_username(username) + if existing: + return {"success": False, "message": "用户名已存在"} + + # 生成随机密码(如果未提供) + if not password: + password = security.generate_random_password() + + # 创建管理员账号 + user_id = await UserModel.create_admin( + username=username, + password=password, + real_name=real_name + ) + + # 分配角色(班级ID固定为1) + await AdminRoleModel.create( + user_id=user_id, + role_type=role_type, + class_id=1, # 单班级,固定为1 + subject_id=None # 单班级版本暂不支持科目关联 + ) + + logger.info(f"用户[{operator_id}] 添加管理员: {username} ({role_type})") + + return { + "success": True, + "user_id": user_id, + "username": username, + "password": password, + "role_type": role_type + } + + @staticmethod + async def get_admins() -> Dict[str, Any]: + """获取管理员列表(单班级)""" + admins = await AdminRoleModel.get_by_class(1) # 班级ID固定为1 + + return {"admins": admins} \ No newline at end of file diff --git a/backend/services/attendance_service.py b/backend/services/attendance_service.py new file mode 100644 index 0000000..826ff6a --- /dev/null +++ b/backend/services/attendance_service.py @@ -0,0 +1,114 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, Optional +from datetime import datetime + +from models.attendance import AttendanceModel +from models.student import StudentModel +from models.conduct import ConductModel +from middleware.permission import PermissionChecker +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class AttendanceService: + """考勤服务""" + + @staticmethod + async def add_attendance( + student_id: int, + date: str, + status: str, + reason: Optional[str], + apply_deduction: bool, + recorder_id: int + ) -> Dict[str, Any]: + """添加考勤记录""" + # 检查权限 + role = await PermissionChecker.get_user_role(recorder_id) + if role not in ["班主任", "考勤委员"]: + return {"success": False, "message": "无权进行此操作"} + + # 检查是否同班级 + can_manage = await PermissionChecker.check_can_manage_student(recorder_id, student_id) + if not can_manage: + return {"success": False, "message": "无权操作该学生"} + + # 添加考勤记录 + attendance_id = await AttendanceModel.create_record( + student_id=student_id, + date=date, + status=status, + reason=reason, + recorder_id=recorder_id + ) + + if not attendance_id: + return {"success": False, "message": "添加考勤记录失败"} + + # 应用扣分 + if apply_deduction and status in ["absent", "late", "leave"]: + # 确定扣分数值 + if status == "absent": + points_change = -settings.DEDUCTION_ATTENDANCE_ABSENT + elif status == "late": + points_change = -settings.DEDUCTION_ATTENDANCE_LATE + else: + points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE + + # 创建扣分记录 + student = await StudentModel.get_by_id(student_id) + if student: + await ConductModel.create_record( + student_id=student_id, + points_change=points_change, + reason=f"考勤异常: {status}", + recorder_id=recorder_id, + related_type="attendance", + related_id=attendance_id + ) + + # 标记已应用扣分 + await AttendanceModel.mark_deduction_applied(attendance_id) + + logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status}") + + return {"success": True, "message": "考勤记录添加成功"} + + @staticmethod + async def get_records( + user_id: int, + date: Optional[str] = None, + student_id: Optional[int] = None + ) -> Dict[str, Any]: + """获取考勤记录""" + role = await PermissionChecker.get_user_role(user_id) + + if role in ["班主任", "考勤委员"]: + class_id = await PermissionChecker.get_user_class_id(user_id) + records = await AttendanceModel.get_class_records( + class_id=class_id, + date=date, + student_id=student_id + ) + elif student_id: + # 查看指定学生 + can_manage = await PermissionChecker.check_can_manage_student(user_id, student_id) + if not can_manage: + return {"error": "无权查看该学生记录"} + records = await AttendanceModel.get_student_records(student_id) + else: + records = [] + + return {"records": records} \ No newline at end of file diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py new file mode 100644 index 0000000..01cf62a --- /dev/null +++ b/backend/services/auth_service.py @@ -0,0 +1,169 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, Optional +from datetime import datetime + +from models.user import UserModel +from models.student import StudentModel +from models.admin_role import AdminRoleModel +from utils.security import security +from utils.jwt_handler import jwt_handler +from utils.redis_client import RedisClient +from utils.database import execute_update +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class AuthService: + """认证服务""" + + @staticmethod + async def login(username: str, password: str, ip: str) -> Dict[str, Any]: + """ + 用户登录 + """ + # 检查登录失败次数 + attempts = await RedisClient.get(f"login_attempts:{username}") + if attempts and int(attempts) >= 5: + return {"success": False, "message": "登录失败次数过多,请15分钟后重试"} + + # 获取用户信息 + user = await UserModel.get_by_username(username) + + if not user: + await RedisClient.set_login_attempts(username) + return {"success": False, "message": "用户名或密码错误"} + + # 验证密码 + if not security.verify_password(password, user["password_hash"]): + await RedisClient.set_login_attempts(username) + return {"success": False, "message": "用户名或密码错误"} + + # 检查账号状态 + if user["status"] != 1: + return {"success": False, "message": "账号已被禁用"} + + # 清除登录失败记录 + await RedisClient.clear_login_attempts(username) + + # 更新最后登录信息 + await UserModel.update_last_login(user["user_id"], ip) + + # 获取用户角色(如果是管理员) + role = None + if user["user_type"] == "admin": + admin_role = await AdminRoleModel.get_by_user_id(user["user_id"]) + role = admin_role["role_type"] if admin_role else None + + # 生成Token + token = jwt_handler.create_token( + user_id=user["user_id"], + username=user["username"], + user_type=user["user_type"], + student_id=user["student_id"], + role=role + ) + + # 存储Token到Redis + await RedisClient.set_user_token(user["user_id"], token) + + # 确定跳转路径 + redirect = AuthService._get_redirect_path(user["user_type"], role) + + return { + "success": True, + "token": token, + "user_id": user["user_id"], + "username": user["username"], + "real_name": user["real_name"], + "user_type": user["user_type"], + "need_change_password": user["need_change_password"] == 1, + "redirect": redirect + } + + @staticmethod + async def logout(user_id: int) -> Dict[str, Any]: + """用户登出""" + await RedisClient.delete_user_token(user_id) + return {"success": True, "message": "登出成功"} + + @staticmethod + async def change_password(user_id: int, old_password: str, new_password: str) -> Dict[str, Any]: + """修改密码""" + # 获取用户信息 + user = await UserModel.get_by_user_id(user_id) + if not user: + return {"success": False, "message": "用户不存在"} + + # 验证原密码 + if not security.verify_password(old_password, user["password_hash"]): + return {"success": False, "message": "原密码错误"} + + # 验证新密码强度 + is_valid, msg = security.validate_password_strength(new_password) + if not is_valid: + return {"success": False, "message": msg} + + # 更新密码 + result = await UserModel.update_password(user_id, new_password) + + if result: + # 清除所有Token + await RedisClient.delete_user_token(user_id) + return {"success": True, "message": "密码修改成功"} + else: + return {"success": False, "message": "密码修改失败"} + + @staticmethod + async def get_user_info(user_id: int) -> Optional[Dict[str, Any]]: + """获取用户信息""" + user = await UserModel.get_by_user_id(user_id) + if not user: + return None + + result = { + "user_id": user["user_id"], + "username": user["username"], + "real_name": user["real_name"], + "user_type": user["user_type"], + "need_change_password": user["need_change_password"] == 1 + } + + # 获取学生信息 + if user["student_id"]: + student = await StudentModel.get_by_id(user["student_id"]) + if student: + result["student_no"] = student["student_no"] + result["student_name"] = student["name"] + result["class_id"] = student["class_id"] + result["class_name"] = student["class_name"] + result["total_points"] = student["total_points"] + + # 获取管理员角色 + if user["user_type"] == "admin": + admin_role = await AdminRoleModel.get_by_user_id(user_id) + if admin_role: + result["role"] = admin_role["role_type"] + result["class_id"] = admin_role["class_id"] + + return result + + @staticmethod + def _get_redirect_path(user_type: str, role: str = None) -> str: + """获取跳转路径""" + if user_type == "student": + return "/student/dashboard.php" + elif user_type == "parent": + return "/parent/dashboard.php" + else: + return "/admin/dashboard.php" \ No newline at end of file diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py new file mode 100644 index 0000000..a6b58d9 --- /dev/null +++ b/backend/services/conduct_service.py @@ -0,0 +1,182 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional +from datetime import datetime + +from models.student import StudentModel +from models.conduct import ConductModel +from models.user import UserModel +from middleware.permission import PermissionChecker +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class ConductService: + """操行分服务""" + + @staticmethod + async def add_points( + student_ids: List[int], + points_change: int, + reason: str, + recorder_id: int, + recorder_name: str + ) -> Dict[str, Any]: + """ + 批量加减分 + """ + # 验证分值 + if points_change == 0: + return {"success": False, "message": "分值不能为0"} + + # 获取操作人角色 + role = await PermissionChecker.get_user_role(recorder_id) + + # 权限验证 + if role == "班主任": + # 班主任无限制 + pass + elif role == "班长": + # 班长限制 ±5分 + 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分操作"} + elif role in ["科代表", "考勤委员"]: + # 科代表和考勤委员只能扣分 + if points_change > 0: + return {"success": False, "message": "该角色只能进行扣分操作"} + else: + return {"success": False, "message": "无权进行此操作"} + + # 批量处理 + success_count = 0 + fail_count = 0 + details = [] + + for student_id in student_ids: + try: + # 检查学生是否存在 + student = await StudentModel.get_by_id(student_id) + if not student: + details.append({"student_id": student_id, "error": "学生不存在"}) + fail_count += 1 + continue + + # 创建记录 + record_id = await ConductModel.create_record( + student_id=student_id, + points_change=points_change, + reason=reason, + recorder_id=recorder_id, + recorder_name=recorder_name + ) + + details.append({"student_id": student_id, "success": True, "record_id": record_id}) + success_count += 1 + + logger.info(f"用户[{recorder_id}] 对学生[{student_id}] 进行 {points_change} 分操作") + + except Exception as e: + details.append({"student_id": student_id, "error": str(e)}) + fail_count += 1 + + return { + "success": True, + "success_count": success_count, + "fail_count": fail_count, + "details": details + } + + @staticmethod + async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]: + """撤销扣分记录""" + # 检查权限 + can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id) + if not can_revoke: + return {"success": False, "message": "无权撤销此记录"} + + # 撤销记录 + result = await ConductModel.revoke_record(record_id, revoker_id) + + if result: + logger.info(f"用户[{revoker_id}] 撤销了记录[{record_id}]") + return {"success": True, "message": "撤销成功"} + else: + return {"success": False, "message": "撤销失败"} + + @staticmethod + async def get_history( + user_id: int, + student_id: Optional[int] = None, + page: int = 1, + page_size: int = 20, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> Dict[str, Any]: + """获取历史记录""" + role = await PermissionChecker.get_user_role(user_id) + offset = (page - 1) * page_size + + # 班主任/班长可查看全班 + if role in ["班主任", "班长"]: + user_class = await PermissionChecker.get_user_class_id(user_id) + records = await ConductModel.get_all_records( + class_id=user_class, + limit=page_size, + offset=offset, + start_date=start_date, + end_date=end_date + ) + + # 获取总数 + from utils.database import execute_one + count_sql = """ + SELECT COUNT(*) as total FROM conduct_records cr + JOIN students s ON cr.student_id = s.student_id + WHERE s.class_id = %s AND cr.is_revoked = 0 + """ + total_result = await execute_one(count_sql, (user_class,)) + total = total_result["total"] if total_result else 0 + + elif student_id: + # 查看指定学生(需权限验证) + can_manage = await PermissionChecker.check_can_manage_student(user_id, student_id) + if not can_manage: + return {"error": "无权查看该学生记录"} + + records = await ConductModel.get_student_records( + student_id=student_id, + limit=page_size, + offset=offset + ) + total = len(await ConductModel.get_student_records(student_id, limit=10000)) + else: + # 查看自己提交的记录 + records = await ConductModel.get_records_by_recorder( + recorder_id=user_id, + limit=page_size, + offset=offset + ) + total = len(await ConductModel.get_records_by_recorder(user_id, limit=10000)) + + return { + "records": records, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": (total + page_size - 1) // page_size + } \ No newline at end of file diff --git a/backend/services/homework_service.py b/backend/services/homework_service.py new file mode 100644 index 0000000..e0e373e --- /dev/null +++ b/backend/services/homework_service.py @@ -0,0 +1,131 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional +from datetime import datetime + +from models.homework import HomeworkModel +from models.student import StudentModel +from models.conduct import ConductModel +from middleware.permission import PermissionChecker +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class HomeworkService: + """作业服务""" + + @staticmethod + async def get_assignments(user_id: int) -> Dict[str, Any]: + """获取作业列表""" + role = await PermissionChecker.get_user_role(user_id) + + if role == "班主任": + class_id = await PermissionChecker.get_user_class_id(user_id) + assignments = await HomeworkModel.get_assignments_by_class(class_id) + elif role == "科代表": + class_id = await PermissionChecker.get_user_class_id(user_id) + subject_ids = await PermissionChecker.get_user_subject_ids(user_id) + assignments = await HomeworkModel.get_assignments_by_subjects(class_id, subject_ids) + else: + assignments = [] + + return {"assignments": assignments} + + @staticmethod + async def create_assignment( + subject_id: int, + title: str, + description: Optional[str], + deadline: str, + created_by: int + ) -> Dict[str, Any]: + """创建作业""" + class_id = await PermissionChecker.get_user_class_id(created_by) + + assignment_id = await HomeworkModel.create_assignment( + class_id=class_id, + subject_id=subject_id, + title=title, + description=description, + deadline=deadline, + created_by=created_by + ) + + if assignment_id: + logger.info(f"用户[{created_by}] 创建作业[{assignment_id}]: {title}") + return {"success": True, "assignment_id": assignment_id} + else: + return {"success": False, "message": "创建作业失败"} + + @staticmethod + async def update_submission_status( + submission_id: int, + status: str, + comments: Optional[str], + apply_deduction: bool, + operator_id: int + ) -> Dict[str, Any]: + """更新作业提交状态""" + # 获取提交记录信息 + submission = await HomeworkModel.get_submission(submission_id) + if not submission: + return {"success": False, "message": "提交记录不存在"} + + # 检查权限 + role = await PermissionChecker.get_user_role(operator_id) + if role == "科代表": + # 检查是否管理该科目 + subject_ids = await PermissionChecker.get_user_subject_ids(operator_id) + if submission["subject_id"] not in subject_ids: + return {"success": False, "message": "无权操作此作业"} + elif role != "班主任": + return {"success": False, "message": "无权进行此操作"} + + # 更新状态 + result = await HomeworkModel.update_submission( + submission_id=submission_id, + status=status, + comments=comments, + updated_by=operator_id + ) + + if not result: + return {"success": False, "message": "更新失败"} + + # 应用扣分 + if apply_deduction and status in ["not_submitted", "late"]: + # 确定扣分数值 + if status == "not_submitted": + points_change = -settings.DEDUCTION_HOMEWORK_NOT_SUBMIT + else: + points_change = -settings.DEDUCTION_HOMEWORK_LATE + + # 创建扣分记录 + student = await StudentModel.get_by_id(submission["student_id"]) + if student: + await ConductModel.create_record( + student_id=submission["student_id"], + points_change=points_change, + reason=f"作业未提交/迟交: {submission['title']}", + recorder_id=operator_id, + related_type="homework", + related_id=submission["assignment_id"] + ) + + # 标记已应用扣分 + await HomeworkModel.mark_deduction_applied(submission_id) + + logger.info(f"用户[{operator_id}] 更新作业提交状态[{submission_id}] -> {status}") + + return {"success": True, "message": "状态更新成功"} \ No newline at end of file diff --git a/backend/services/parent_service.py b/backend/services/parent_service.py new file mode 100644 index 0000000..48c1013 --- /dev/null +++ b/backend/services/parent_service.py @@ -0,0 +1,82 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, Optional + +from models.user import UserModel +from models.student import StudentModel +from models.conduct import ConductModel +from models.homework import HomeworkModel +from models.attendance import AttendanceModel +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class ParentService: + """家长服务""" + + @staticmethod + async def get_child_conduct(parent_id: int) -> Dict[str, Any]: + """获取子女操行分(仅总分,家长端不显示详细记录)""" + # 获取家长关联的学生 + user = await UserModel.get_by_user_id(parent_id) + if not user or not user["student_id"]: + return {"error": "未关联学生"} + + student = await StudentModel.get_by_id(user["student_id"]) + if not student: + return {"error": "学生不存在"} + + return { + "student_id": student["student_id"], + "student_name": student["name"], + "student_no": student["student_no"], + "total_points": student["total_points"] + } + + @staticmethod + async def get_child_homework(parent_id: int) -> Dict[str, Any]: + """获取子女作业情况""" + user = await UserModel.get_by_user_id(parent_id) + if not user or not user["student_id"]: + return {"error": "未关联学生"} + + student = await StudentModel.get_by_id(user["student_id"]) + if not student: + return {"error": "学生不存在"} + + homework = await HomeworkModel.get_student_homework(user["student_id"]) + + return { + "student_id": student["student_id"], + "student_name": student["name"], + "homework": homework + } + + @staticmethod + async def get_child_attendance(parent_id: int) -> Dict[str, Any]: + """获取子女考勤记录""" + user = await UserModel.get_by_user_id(parent_id) + if not user or not user["student_id"]: + return {"error": "未关联学生"} + + student = await StudentModel.get_by_id(user["student_id"]) + if not student: + return {"error": "学生不存在"} + + records = await AttendanceModel.get_student_records(user["student_id"]) + + return { + "student_id": student["student_id"], + "student_name": student["name"], + "records": records + } \ No newline at end of file diff --git a/backend/services/student_service.py b/backend/services/student_service.py new file mode 100644 index 0000000..cb05b73 --- /dev/null +++ b/backend/services/student_service.py @@ -0,0 +1,146 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta + +from models.student import StudentModel +from models.conduct import ConductModel +from models.homework import HomeworkModel +from models.attendance import AttendanceModel +from middleware.permission import PermissionChecker +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class StudentService: + """学生服务""" + + @staticmethod + async def get_conduct_history( + student_id: int, + limit: int = 50, + offset: int = 0 + ) -> Dict[str, Any]: + """获取学生操行分历史(学生端显示,扣分项操作人显示为班主任)""" + student = await StudentModel.get_by_id(student_id) + if not student: + return {"error": "学生不存在"} + + records = await ConductModel.get_student_records( + student_id=student_id, + limit=limit, + offset=offset + ) + + # 处理记录:扣分项的操作人统一显示为"班主任" + for record in records: + if record["points_change"] < 0: # 扣分项 + record["recorder_name"] = "班主任" + # 加分项保持原操作人不变 + + return { + "student_id": student_id, + "student_name": student["name"], + "total_points": student["total_points"], + "records": records + } + + @staticmethod + async def get_homework_status(student_id: int) -> Dict[str, Any]: + """获取学生作业情况""" + student = await StudentModel.get_by_id(student_id) + if not student: + return {"error": "学生不存在"} + + homework = await HomeworkModel.get_student_homework(student_id) + + # 统计 + total = len(homework) + submitted = sum(1 for h in homework if h["status"] == "submitted") + not_submitted = sum(1 for h in homework if h["status"] == "not_submitted") + late = sum(1 for h in homework if h["status"] == "late") + + return { + "student_id": student_id, + "student_name": student["name"], + "statistics": { + "total": total, + "submitted": submitted, + "not_submitted": not_submitted, + "late": late + }, + "homework": homework + } + + @staticmethod + async def get_attendance_records( + student_id: int, + month: Optional[str] = None + ) -> Dict[str, Any]: + """获取学生考勤记录""" + student = await StudentModel.get_by_id(student_id) + if not student: + return {"error": "学生不存在"} + + records = await AttendanceModel.get_student_records( + student_id=student_id, + month=month + ) + + # 统计 + present = sum(1 for r in records if r["status"] == "present") + absent = sum(1 for r in records if r["status"] == "absent") + late = sum(1 for r in records if r["status"] == "late") + leave = sum(1 for r in records if r["status"] == "leave") + + return { + "student_id": student_id, + "student_name": student["name"], + "statistics": { + "present": present, + "absent": absent, + "late": late, + "leave": leave, + "total": len(records) + }, + "records": records + } + + @staticmethod + async def get_ranking( + user_id: int, + class_id: Optional[int] = None, + limit: int = 50 + ) -> Dict[str, Any]: + """获取排行榜""" + # 如果未指定班级,获取用户所在班级 + if not class_id: + user = await StudentModel.get_by_id(user_id) if user_id else None + if user: + class_id = user["class_id"] + else: + admin_class = await PermissionChecker.get_user_class_id(user_id) + if admin_class: + class_id = admin_class + + ranking = await StudentModel.get_ranking(class_id=class_id, limit=limit) + + return { + "class_id": class_id, + "ranking": ranking + } + + @staticmethod + async def get_student_info(student_id: int) -> Optional[Dict[str, Any]]: + """获取学生个人信息""" + return await StudentModel.get_by_id(student_id) \ No newline at end of file diff --git a/backend/services/subject_service.py b/backend/services/subject_service.py new file mode 100644 index 0000000..67cde98 --- /dev/null +++ b/backend/services/subject_service.py @@ -0,0 +1,77 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Dict, Any, List, Optional + +from models.subject import SubjectModel +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class SubjectService: + """科目服务""" + + @staticmethod + async def get_subjects(is_active: Optional[bool] = None) -> Dict[str, Any]: + """获取科目列表""" + subjects = await SubjectModel.get_all(is_active=is_active) + + return { + "subjects": subjects, + "total": len(subjects) + } + + @staticmethod + async def create_subject( + subject_name: str, + subject_code: Optional[str], + sort_order: int = 0 + ) -> Dict[str, Any]: + """创建科目""" + # 检查是否已存在 + existing = await SubjectModel.get_by_name(subject_name) + if existing: + return {"success": False, "message": "科目名称已存在"} + + subject_id = await SubjectModel.create( + subject_name=subject_name, + subject_code=subject_code, + sort_order=sort_order + ) + + if subject_id: + logger.info(f"创建科目: {subject_name}") + return {"success": True, "subject_id": subject_id} + else: + return {"success": False, "message": "创建科目失败"} + + @staticmethod + async def update_subject(subject_id: int, **kwargs) -> Dict[str, Any]: + """更新科目""" + result = await SubjectModel.update(subject_id, **kwargs) + + if result: + logger.info(f"更新科目: {subject_id}") + return {"success": True} + else: + return {"success": False, "message": "更新科目失败"} + + @staticmethod + async def delete_subject(subject_id: int) -> Dict[str, Any]: + """删除科目(软删除)""" + result = await SubjectModel.delete(subject_id) + + if result: + logger.info(f"禁用科目: {subject_id}") + return {"success": True} + else: + return {"success": False, "message": "禁用科目失败"} \ No newline at end of file diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..638547a --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1,11 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + diff --git a/backend/utils/database.py b/backend/utils/database.py new file mode 100644 index 0000000..822b7ef --- /dev/null +++ b/backend/utils/database.py @@ -0,0 +1,136 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +import aiomysql +from typing import Optional, Dict, Any, List +from contextlib import asynccontextmanager + +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + +# 连接池实例 +_pool: Optional[aiomysql.Pool] = None + + +async def init_db_pool() -> None: + """初始化数据库连接池""" + global _pool + try: + _pool = await aiomysql.create_pool( + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + db=settings.DB_NAME, + minsize=1, + maxsize=settings.DB_POOL_SIZE, + maxsize=settings.DB_MAX_OVERFLOW, + autocommit=False, + charset='utf8mb4', + cursorclass=aiomysql.DictCursor + ) + logger.info("数据库连接池初始化成功") + except Exception as e: + logger.error(f"数据库连接池初始化失败: {e}") + raise + + +async def close_db_pool() -> None: + """关闭数据库连接池""" + global _pool + if _pool: + _pool.close() + await _pool.wait_closed() + logger.info("数据库连接池已关闭") + + +def get_pool() -> aiomysql.Pool: + """获取连接池实例""" + if _pool is None: + raise RuntimeError("数据库连接池未初始化") + return _pool + + +@asynccontextmanager +async def get_connection(): + """获取数据库连接(上下文管理器)""" + pool = get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cursor: + yield cursor + await conn.commit() + + +@asynccontextmanager +async def get_transaction(): + """获取事务连接""" + pool = get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cursor: + try: + yield cursor + await conn.commit() + except Exception: + await conn.rollback() + raise + + +async def execute_query(sql: str, params: tuple = None) -> List[Dict[str, Any]]: + """执行查询SQL""" + async with get_connection() as cursor: + await cursor.execute(sql, params) + return await cursor.fetchall() + + +async def execute_one(sql: str, params: tuple = None) -> Optional[Dict[str, Any]]: + """执行查询SQL(单条)""" + async with get_connection() as cursor: + await cursor.execute(sql, params) + return await cursor.fetchone() + + +async def execute_insert(sql: str, params: tuple = None) -> int: + """执行插入SQL,返回自增ID""" + async with get_connection() as cursor: + await cursor.execute(sql, params) + return cursor.lastrowid + + +async def execute_update(sql: str, params: tuple = None) -> int: + """执行更新SQL,返回影响行数""" + async with get_connection() as cursor: + result = await cursor.execute(sql, params) + return result + + +async def execute_many(sql: str, params_list: list) -> int: + """批量执行SQL""" + async with get_connection() as cursor: + await cursor.executemany(sql, params_list) + return cursor.rowcount + + +async def call_procedure(proc_name: str, args: tuple = None) -> List[Dict[str, Any]]: + """调用存储过程""" + async with get_connection() as cursor: + if args: + await cursor.callproc(proc_name, args) + else: + await cursor.callproc(proc_name) + + # 获取结果 + result = [] + for result_set in cursor.fetchall(): + if result_set: + result.extend(result_set) + return result \ No newline at end of file diff --git a/backend/utils/jwt_handler.py b/backend/utils/jwt_handler.py new file mode 100644 index 0000000..62b3d76 --- /dev/null +++ b/backend/utils/jwt_handler.py @@ -0,0 +1,86 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from jose import jwt, JWTError +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class JWTHandler: + """JWT Token处理类""" + + @staticmethod + def create_token(user_id: int, username: str, user_type: str, student_id: int = None, role: str = None) -> str: + """ + 创建JWT Token + """ + payload = { + 'user_id': user_id, + 'username': username, + 'user_type': user_type, + 'student_id': student_id, + 'role': role, + 'exp': datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES), + 'iat': datetime.utcnow(), + 'iss': settings.APP_NAME + } + + token = jwt.encode( + payload, + settings.JWT_SECRET_KEY, + algorithm=settings.JWT_ALGORITHM + ) + return token + + @staticmethod + def verify_token(token: str) -> Optional[Dict[str, Any]]: + """ + 验证JWT Token + 返回: 解码后的payload,失败返回None + """ + try: + payload = jwt.decode( + token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + options={'verify_exp': True} + ) + return payload + except jwt.ExpiredSignatureError: + logger.warning("JWT Token已过期") + return None + except jwt.JWTError as e: + logger.warning(f"JWT Token验证失败: {e}") + return None + + @staticmethod + def get_user_id_from_token(token: str) -> Optional[int]: + """从Token中获取用户ID""" + payload = JWTHandler.verify_token(token) + if payload: + return payload.get('user_id') + return None + + @staticmethod + def get_user_type_from_token(token: str) -> Optional[str]: + """从Token中获取用户类型""" + payload = JWTHandler.verify_token(token) + if payload: + return payload.get('user_type') + return None + + +jwt_handler = JWTHandler() \ No newline at end of file diff --git a/backend/utils/logger.py b/backend/utils/logger.py new file mode 100644 index 0000000..ff36b03 --- /dev/null +++ b/backend/utils/logger.py @@ -0,0 +1,102 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +import sys +from loguru import logger +from pathlib import Path +from fastapi import Request + +from config import settings + + +# 日志目录 +LOG_DIR = Path(__file__).parent.parent / "logs" +LOG_DIR.mkdir(exist_ok=True) + + +def setup_logger(): + """配置日志系统""" + + # 移除默认处理器 + logger.remove() + + # 控制台输出(仅INFO及以上) + logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} - {message}", + level=settings.LOG_LEVEL, + colorize=True + ) + + # 应用日志(轮转) + logger.add( + LOG_DIR / "app.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", + rotation=settings.LOG_MAX_BYTES, + retention=settings.LOG_RETENTION_DAYS, + compression="gz", + encoding="utf-8", + level="DEBUG" + ) + + # 错误日志(单独记录) + logger.add( + LOG_DIR / "error.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name} | {message}", + rotation=settings.LOG_MAX_BYTES, + retention=settings.LOG_RETENTION_DAYS * 2, + compression="gz", + encoding="utf-8", + level="ERROR" + ) + + # 访问日志 + logger.add( + LOG_DIR / "access.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {message}", + rotation="1 day", + retention="90 days", + compression="gz", + encoding="utf-8", + filter=lambda record: record["extra"].get("type") == "access" + ) + + # 操作日志 + logger.add( + LOG_DIR / "operation.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {message}", + rotation=settings.LOG_MAX_BYTES, + retention=settings.LOG_RETENTION_DAYS, + compression="gz", + encoding="utf-8", + filter=lambda record: record["extra"].get("type") == "operation" + ) + + return logger + + +def get_logger(name: str): + """获取日志记录器""" + return logger.bind(name=name) + + +def log_access(request: Request): + """记录访问日志""" + logger.bind(type="access").info(f"{request.method} {request.url.path} - {request.client.host}") + + +def log_operation(operator_id: int, operator_name: str, action: str, details: str = ""): + """记录操作日志""" + logger.bind(type="operation").info(f"用户[{operator_id}:{operator_name}] 执行 {action} - {details}") + + +# 导出logger +__all__ = ["setup_logger", "get_logger", "log_access", "log_operation", "logger"] \ No newline at end of file diff --git a/backend/utils/redus_client.py b/backend/utils/redus_client.py new file mode 100644 index 0000000..9551030 --- /dev/null +++ b/backend/utils/redus_client.py @@ -0,0 +1,140 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +import redis.asyncio as redis +from typing import Optional, Any +import json + +from config import settings +from utils.logger import get_logger + +logger = get_logger(__name__) + +# Redis客户端实例 +_redis_client: Optional[redis.Redis] = None + + +async def init_redis_pool() -> None: + """初始化Redis连接池""" + global _redis_client + try: + _redis_client = redis.from_url( + settings.REDIS_URL, + max_connections=settings.REDIS_MAX_CONNECTIONS, + decode_responses=True + ) + # 测试连接 + await _redis_client.ping() + logger.info("Redis连接池初始化成功") + except Exception as e: + logger.error(f"Redis连接池初始化失败: {e}") + raise + + +async def close_redis_pool() -> None: + """关闭Redis连接池""" + global _redis_client + if _redis_client: + await _redis_client.close() + logger.info("Redis连接池已关闭") + + +def get_redis() -> redis.Redis: + """获取Redis客户端""" + if _redis_client is None: + raise RuntimeError("Redis客户端未初始化") + return _redis_client + + +class RedisClient: + """Redis操作封装类""" + + @staticmethod + async def set(key: str, value: Any, expire: int = None) -> bool: + """设置缓存""" + client = get_redis() + if isinstance(value, (dict, list)): + value = json.dumps(value, ensure_ascii=False) + else: + value = str(value) + + if expire: + return await client.setex(key, expire, value) + return await client.set(key, value) + + @staticmethod + async def get(key: str) -> Optional[str]: + """获取缓存""" + client = get_redis() + return await client.get(key) + + @staticmethod + async def get_json(key: str) -> Optional[Any]: + """获取JSON格式缓存""" + value = await RedisClient.get(key) + if value: + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return None + + @staticmethod + async def delete(key: str) -> int: + """删除缓存""" + client = get_redis() + return await client.delete(key) + + @staticmethod + async def exists(key: str) -> bool: + """检查key是否存在""" + client = get_redis() + return await client.exists(key) > 0 + + @staticmethod + async def expire(key: str, seconds: int) -> bool: + """设置过期时间""" + client = get_redis() + return await client.expire(key, seconds) + + @staticmethod + async def set_user_token(user_id: int, token: str, expire: int = None) -> bool: + """设置用户Token缓存""" + key = f"user_token:{user_id}" + expire = expire or settings.JWT_EXPIRE_MINUTES * 60 + return await RedisClient.set(key, token, expire) + + @staticmethod + async def get_user_token(user_id: int) -> Optional[str]: + """获取用户Token""" + key = f"user_token:{user_id}" + return await RedisClient.get(key) + + @staticmethod + async def delete_user_token(user_id: int) -> int: + """删除用户Token""" + key = f"user_token:{user_id}" + return await RedisClient.delete(key) + + @staticmethod + async def set_login_attempts(username: str) -> int: + """记录登录失败次数""" + key = f"login_attempts:{username}" + attempts = await RedisClient.get(key) + attempts = int(attempts) + 1 if attempts else 1 + await RedisClient.set(key, attempts, 900) # 15分钟锁定 + return attempts + + @staticmethod + async def clear_login_attempts(username: str) -> None: + """清除登录失败记录""" + key = f"login_attempts:{username}" + await RedisClient.delete(key) \ No newline at end of file diff --git a/backend/utils/response.py b/backend/utils/response.py new file mode 100644 index 0000000..d994066 --- /dev/null +++ b/backend/utils/response.py @@ -0,0 +1,106 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +from typing import Any, Optional, Dict, List +from fastapi.responses import JSONResponse + + +class ResponseCode: + """响应状态码""" + SUCCESS = 200 + CREATED = 201 + BAD_REQUEST = 400 + UNAUTHORIZED = 401 + FORBIDDEN = 403 + NOT_FOUND = 404 + CONFLICT = 409 + UNPROCESSABLE = 422 + INTERNAL_ERROR = 500 + + +def success_response(data: Any = None, message: str = "操作成功") -> JSONResponse: + """成功响应""" + return JSONResponse( + status_code=ResponseCode.SUCCESS, + content={ + "success": True, + "code": ResponseCode.SUCCESS, + "message": message, + "data": data + } + ) + + +def error_response( + message: str = "操作失败", + code: int = ResponseCode.BAD_REQUEST, + data: Any = None +) -> JSONResponse: + """错误响应""" + return JSONResponse( + status_code=code, + content={ + "success": False, + "code": code, + "message": message, + "data": data + } + ) + + +def unauthorized_response(message: str = "未授权,请重新登录") -> JSONResponse: + """未授权响应""" + return JSONResponse( + status_code=ResponseCode.UNAUTHORIZED, + content={ + "success": False, + "code": ResponseCode.UNAUTHORIZED, + "message": message, + "data": None + } + ) + + +def forbidden_response(message: str = "权限不足") -> JSONResponse: + """禁止访问响应""" + return JSONResponse( + status_code=ResponseCode.FORBIDDEN, + content={ + "success": False, + "code": ResponseCode.FORBIDDEN, + "message": message, + "data": None + } + ) + + +def not_found_response(message: str = "资源不存在") -> JSONResponse: + """资源不存在响应""" + return JSONResponse( + status_code=ResponseCode.NOT_FOUND, + content={ + "success": False, + "code": ResponseCode.NOT_FOUND, + "message": message, + "data": None + } + ) + + +def paginated_response(items: List[Any], total: int, page: int, page_size: int) -> Dict: + """分页响应数据""" + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } \ No newline at end of file diff --git a/backend/utils/security.py b/backend/utils/security.py new file mode 100644 index 0000000..ef7dbff --- /dev/null +++ b/backend/utils/security.py @@ -0,0 +1,131 @@ +# =========================================== +# 班级操行分管理系统 - 后端服务 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +import hashlib +import secrets +import re +from config import settings + + +class SecurityUtils: + """安全工具类""" + + @staticmethod + def sha1_md5_password(password: str) -> str: + """ + 双重加密:sha1 + md5 + 流程:原始密码 -> sha1 -> 加盐 -> md5 + """ + # 第一层:SHA1 + sha1_hash = hashlib.sha1(password.encode('utf-8')).hexdigest() + # 加盐 + salted = sha1_hash + settings.PASSWORD_SALT + # 第二层:MD5 + md5_hash = hashlib.md5(salted.encode('utf-8')).hexdigest() + return md5_hash + + @staticmethod + def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return SecurityUtils.sha1_md5_password(plain_password) == hashed_password + + @staticmethod + def generate_random_password(length: int = 8) -> str: + """生成随机密码""" + alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + @staticmethod + def validate_password_strength(password: str) -> tuple: + """ + 验证密码强度 + 返回: (是否有效, 错误信息) + """ + if len(password) < 6: + return False, "密码长度至少6位" + if len(password) > 20: + return False, "密码长度不能超过20位" + + # 检查是否包含至少一个数字 + if not any(c.isdigit() for c in password): + return False, "密码必须包含至少一个数字" + + # 检查是否包含至少一个字母 + if not any(c.isalpha() for c in password): + return False, "密码必须包含至少一个字母" + + return True, "" + + @staticmethod + def sanitize_string(value: str, max_length: int = 255) -> str: + """ + 清理字符串输入 + - 去除首尾空格 + - 限制长度 + - 转义特殊字符 + """ + if not value: + return "" + + # 去除首尾空格 + value = value.strip() + + # 限制长度 + if len(value) > max_length: + value = value[:max_length] + + # 转义HTML特殊字符(防止XSS) + html_chars = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + } + for char, escape in html_chars.items(): + value = value.replace(char, escape) + + return value + + @staticmethod + def validate_student_no(student_no: str) -> bool: + """验证学号格式(数字+字母,长度4-20)""" + if not student_no: + return False + if len(student_no) < 4 or len(student_no) > 20: + return False + # 字母数字组合 + return student_no.isalnum() + + @staticmethod + def validate_phone(phone: str) -> bool: + """验证手机号格式(中国手机号)""" + if not phone: + return False + pattern = r'^1[3-9]\d{9}$' + return bool(re.match(pattern, phone)) + + @staticmethod + def validate_points_change(points: int, max_abs: int = 100) -> tuple: + """ + 验证分值变动 + 返回: (是否有效, 错误信息) + """ + if points == 0: + return False, "分值不能为0" + if abs(points) > max_abs: + return f"单次分值变动不能超过{max_abs}分" + return True, "" + + +# 单例导出 +security = SecurityUtils() \ No newline at end of file diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/parent.md b/docs/parent.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/student.md b/docs/student.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..f97ccf5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,28 @@ +# =========================================== +# 班级操行分管理系统 - 前端配置 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: MIT License +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +# 后端API地址 +API_BASE_URL=https://api.your-domain.com + +# API超时时间(秒) +API_TIMEOUT=30 + +# JWT存储Key +JWT_STORAGE_KEY=class_system_token + +# 用户信息存储Key +USER_STORAGE_KEY=class_system_user + +# 站点名称 +SITE_NAME=班级操行分管理系统 + +# 会话超时时间(分钟) +SESSION_TIMEOUT=30 \ No newline at end of file diff --git a/frontend/admin/admins.php b/frontend/admin/admins.php new file mode 100644 index 0000000..9162aab --- /dev/null +++ b/frontend/admin/admins.php @@ -0,0 +1,126 @@ + + + + +
+
+
+ +
+ +
+ + + + + + + + + +
用户名姓名角色
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/admin/attendance.php b/frontend/admin/attendance.php new file mode 100644 index 0000000..9978f29 --- /dev/null +++ b/frontend/admin/attendance.php @@ -0,0 +1,184 @@ + + + + +
+
+
+ + +
+ +
+ + + + + +
学号姓名状态原因记录人扣分
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php new file mode 100644 index 0000000..04c2550 --- /dev/null +++ b/frontend/admin/conduct.php @@ -0,0 +1,145 @@ + + + + +
+
+
+
+ +
+
+ +
+ + + + + + + + + + + +
学号姓名当前操行分操作
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/admin/dashboard.php b/frontend/admin/dashboard.php new file mode 100644 index 0000000..deccbb3 --- /dev/null +++ b/frontend/admin/dashboard.php @@ -0,0 +1,114 @@ + + + + +
+
+ +
+
快捷操作
+
+
+ +
+
操行分排行榜 (Top 10)
+
+ + + + + +
排名学号姓名操行分
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/admin/history.php b/frontend/admin/history.php new file mode 100644 index 0000000..1e7dc7f --- /dev/null +++ b/frontend/admin/history.php @@ -0,0 +1,166 @@ + + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + +
时间学生分数变动原因操作人操作
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/frontend/admin/homework.php b/frontend/admin/homework.php new file mode 100644 index 0000000..38f6f4f --- /dev/null +++ b/frontend/admin/homework.php @@ -0,0 +1,170 @@ + + + + +
+
+
+ + + +
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/admin/passwork.php b/frontend/admin/passwork.php new file mode 100644 index 0000000..6edf3e9 --- /dev/null +++ b/frontend/admin/passwork.php @@ -0,0 +1,100 @@ + + + + +
+
+
修改密码
+
+
+ + +
+
+ + + 密码长度6-20位,需包含字母和数字 +
+
+ + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/admin/students.php b/frontend/admin/students.php new file mode 100644 index 0000000..2b72f80 --- /dev/null +++ b/frontend/admin/students.php @@ -0,0 +1,243 @@ + + + + +
+
+
+
+ + + + +
+ +
+ +
+ + + + + + + + + + + + +
学号姓名操行分家长手机号操作
+
+ +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/admin/subjects.php b/frontend/admin/subjects.php new file mode 100644 index 0000000..0bb5194 --- /dev/null +++ b/frontend/admin/subjects.php @@ -0,0 +1,157 @@ + + + + +
+
+
+ +
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/frontend/assets/css/admin.css b/frontend/assets/css/admin.css new file mode 100644 index 0000000..4b33422 --- /dev/null +++ b/frontend/assets/css/admin.css @@ -0,0 +1,130 @@ +/** + * 班级操行分管理系统 - 管理端样式 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +/* 批量操作栏 */ +.batch-bar { + background: #f0f4ff; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.batch-info { + color: #667eea; + font-weight: 500; +} + +/* 导入区域 */ +.import-area { + border: 2px dashed #ddd; + border-radius: 12px; + padding: 30px; + text-align: center; + margin-bottom: 20px; + transition: border-color 0.3s; + cursor: pointer; +} + +.import-area:hover { + border-color: #667eea; +} + +.import-area input { + display: none; +} + +.import-label { + color: #667eea; + text-decoration: underline; + cursor: pointer; +} + +/* 预览表格 */ +.preview-table { + max-height: 300px; + overflow-y: auto; + margin-top: 16px; +} + +/* 筛选栏 */ +.filter-bar { + background: #f8f9fa; + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; +} + +.filter-group { + flex: 1; + min-width: 150px; +} + +.filter-group label { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: #666; +} + +.filter-group input, +.filter-group select { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; +} + +/* 作业卡片 */ +.assignment-card { + margin-bottom: 20px; +} + +.assignment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 10px; +} + +.assignment-title { + font-size: 16px; + font-weight: bold; + color: #333; +} + +.assignment-meta { + color: #999; + font-size: 12px; +} + +/* 状态选择器 */ +.status-select { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; +} + +/* 复选框 */ +.student-checkbox { + width: 18px; + height: 18px; + cursor: pointer; +} \ No newline at end of file diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css new file mode 100644 index 0000000..ca77119 --- /dev/null +++ b/frontend/assets/css/style.css @@ -0,0 +1,666 @@ +/** + * 班级操行分管理系统 - 全局样式 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: #f5f7fb; + min-height: 100vh; + font-size: 14px; + color: #333; +} + +/* ========== 登录页面 ========== */ +.login-container { + background: white; + border-radius: 16px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + padding: 40px; + width: 400px; + max-width: 90%; + margin: 100px auto; +} + +.login-header { + text-align: center; + margin-bottom: 30px; +} + +.login-header h1 { + font-size: 24px; + color: #333; + margin-bottom: 8px; +} + +.login-header p { + color: #666; + font-size: 14px; +} + +.login-form .form-group { + margin-bottom: 20px; +} + +.login-form label { + display: block; + margin-bottom: 6px; + color: #555; + font-weight: 500; +} + +.login-form input { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.3s; +} + +.login-form input:focus { + outline: none; + border-color: #667eea; +} + +.btn-login { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: opacity 0.3s; +} + +.btn-login:hover { + opacity: 0.9; +} + +.error-msg { + background: #fee; + color: #c33; + padding: 10px; + border-radius: 8px; + margin-top: 15px; + text-align: center; + font-size: 13px; +} + +.login-footer { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eee; + color: #999; + font-size: 12px; +} + +/* ========== 公共头部 ========== */ +.header { + background: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + font-size: 18px; + color: #333; +} + +.header-info { + display: flex; + align-items: center; + gap: 16px; +} + +.user-name { + color: #666; + font-weight: 500; +} + +.user-role { + background: #667eea; + color: white; + padding: 2px 8px; + border-radius: 20px; + font-size: 11px; +} + +.btn-logout { + background: #e53e3e; + color: white; + border: none; + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: background 0.3s; +} + +.btn-logout:hover { + background: #c53030; +} + +/* ========== 导航菜单 ========== */ +.nav { + background: white; + padding: 0 24px; + border-bottom: 1px solid #eee; + display: flex; + gap: 4px; + overflow-x: auto; +} + +.nav-item { + padding: 12px 20px; + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 14px; + transition: all 0.3s; + border-bottom: 2px solid transparent; + text-decoration: none; + display: inline-block; +} + +.nav-item:hover { + color: #667eea; +} + +.nav-item.active { + color: #667eea; + border-bottom-color: #667eea; +} + +/* ========== 容器 ========== */ +.container { + max-width: 1200px; + margin: 24px auto; + padding: 0 24px; +} + +/* ========== 卡片 ========== */ +.card { + background: white; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.card-title { + font-size: 18px; + font-weight: bold; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid #667eea; + color: #333; +} + +/* ========== 统计卡片网格 ========== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: white; + border-radius: 12px; + padding: 20px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.stat-value { + font-size: 32px; + font-weight: bold; + color: #667eea; + margin: 10px 0; +} + +.stat-label { + color: #666; + font-size: 13px; +} + +/* ========== 表格 ========== */ +.table-wrapper { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #eee; +} + +th { + background: #f8f9fa; + font-weight: 600; + color: #555; +} + +tr:hover { + background: #f8f9fa; +} + +/* ========== 状态标签 ========== */ +.status-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; +} + +.status-submitted { + background: #c6f6d5; + color: #22543d; +} + +.status-not_submitted { + background: #fed7d7; + color: #742a2a; +} + +.status-late { + background: #feebc8; + color: #7c2d12; +} + +.status-present { + background: #c6f6d5; + color: #22543d; +} + +.status-absent { + background: #fed7d7; + color: #742a2a; +} + +.status-leave { + background: #e9d8fd; + color: #553c9a; +} + +/* ========== 按钮 ========== */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.3s; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5a67d8; +} + +.btn-danger { + background: #e53e3e; + color: white; +} + +.btn-danger:hover { + background: #c53030; +} + +.btn-success { + background: #38a169; + color: white; +} + +.btn-success:hover { + background: #2f855a; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ========== 模态框 ========== */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + border-radius: 12px; + padding: 24px; + width: 500px; + max-width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid #eee; +} + +.modal-header h3 { + font-size: 18px; + color: #333; +} + +.modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; +} + +.modal-footer { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid #eee; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* ========== 表单 ========== */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: #555; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; +} + +.form-group small { + display: block; + color: #999; + font-size: 12px; + margin-top: 4px; +} + +/* ========== 复选框组 ========== */ +.checkbox-group { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.checkbox-group input { + width: auto; +} + +/* ========== 操作栏 ========== */ +.action-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} + +.action-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.search-bar { + display: flex; + gap: 10px; +} + +.search-bar input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 6px; + width: 200px; +} + +/* ========== 分页 ========== */ +.pagination { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 20px; +} + +.pagination a, .pagination span { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 4px; + text-decoration: none; + color: #666; + cursor: pointer; +} + +.pagination .active { + background: #667eea; + color: white; + border-color: #667eea; +} + +/* ========== 提示消息 ========== */ +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + border-radius: 8px; + color: white; + font-size: 14px; + z-index: 1100; + animation: fadeInUp 0.3s ease; +} + +.toast-success { + background: #38a169; +} + +.toast-error { + background: #e53e3e; +} + +.toast-warning { + background: #ed8936; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* ========== 加载动画 ========== */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #ddd; + border-top-color: #667eea; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ========== 底部 ========== */ +.footer { + text-align: center; + padding: 20px; + color: #999; + font-size: 12px; +} + +/* ========== 记录项 ========== */ +.record-item { + padding: 12px 0; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; +} + +.record-points { + font-weight: bold; +} + +.record-points.plus { + color: #38a169; +} + +.record-points.minus { + color: #e53e3e; +} + +.record-reason { + flex: 1; + margin: 0 15px; + color: #555; +} + +.record-time { + font-size: 12px; + color: #999; +} + +.view-more { + text-align: center; + margin-top: 15px; +} + +.view-more a { + color: #667eea; + text-decoration: none; +} + +.conduct-score { + text-align: center; + padding: 20px; +} + +.score-number { + font-size: 64px; + font-weight: bold; + color: #667eea; +} + +/* ========== 响应式 ========== */ +@media (max-width: 768px) { + .container { + padding: 0 16px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + th, td { + padding: 8px; + font-size: 12px; + } + + .card { + padding: 16px; + } + + .nav { + padding: 0 16px; + } + + .nav-item { + padding: 10px 14px; + font-size: 13px; + } + + .action-bar { + flex-direction: column; + align-items: stretch; + } + + .search-bar { + width: 100%; + } + + .search-bar input { + flex: 1; + } +} \ No newline at end of file diff --git a/frontend/assets/js/admin.js b/frontend/assets/js/admin.js new file mode 100644 index 0000000..2cd90b8 --- /dev/null +++ b/frontend/assets/js/admin.js @@ -0,0 +1,385 @@ +/** + * 班级操行分管理系统 - 管理端JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +// 全局变量 +let selectedStudentIds = []; +let currentPage = 1; +let totalPages = 1; +let currentHistoryPage = 1; + +// 显示批量加减分模态框 +function showBatchPointsModal() { + selectedStudentIds = []; + document.querySelectorAll('.student-checkbox:checked').forEach(cb => { + selectedStudentIds.push(parseInt(cb.dataset.id)); + }); + + if (selectedStudentIds.length === 0) { + showToast('请先选择学生', 'warning'); + return; + } + + document.getElementById('selectedStudentsCount').innerHTML = `${selectedStudentIds.length} 人`; + document.getElementById('pointsChange').value = ''; + document.getElementById('pointsReason').value = ''; + document.getElementById('batchPointsModal').style.display = 'flex'; +} + +// 提交批量加减分 +async function submitBatchPoints() { + const pointsChange = parseInt(document.getElementById('pointsChange').value); + const reason = document.getElementById('pointsReason').value; + + if (isNaN(pointsChange) || pointsChange === 0) { + showToast('分值不能为0', 'error'); + return; + } + + if (!reason.trim()) { + showToast('请填写原因', 'error'); + return; + } + + const res = await apiPost('/api/admin/conduct/add', { + student_ids: selectedStudentIds, + points_change: pointsChange, + reason: reason + }); + + if (res && res.success) { + showToast(`操作成功: ${res.data.success_count} 人成功`); + closeModal('batchPointsModal'); + loadStudents(); + if (typeof loadConductStudents === 'function') loadConductStudents(); + } else { + showToast(res?.message || '操作失败', 'error'); + } +} + +// 显示导入模态框 +function showImportModal() { + document.getElementById('importModal').style.display = 'flex'; + document.getElementById('importPreview').style.display = 'none'; + document.getElementById('importPreview').innerHTML = ''; + document.getElementById('importBtn').style.display = 'none'; + document.getElementById('importFile').value = ''; +} + +// 预览导入文件 +function previewImportFile() { + const file = document.getElementById('importFile').files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const data = JSON.parse(e.target.result); + const students = data.students || []; + + let html = '

预览数据

'; + html += ''; + html += ''; + + students.forEach(s => { + html += ` + + + + + `; + }); + + html += `
学号姓名家长手机号初始密码
${escapeHtml(s.student_no || '')}${escapeHtml(s.name || '')}${escapeHtml(s.parent_phone || '')}${escapeHtml(s.password || '123456')}

共 ${students.length} 条记录,初始操行分默认为60分

`; + document.getElementById('importPreview').innerHTML = html; + document.getElementById('importPreview').style.display = 'block'; + document.getElementById('importBtn').style.display = 'inline-block'; + } catch (error) { + showToast('JSON格式错误', 'error'); + } + }; + reader.readAsText(file); +} + +// 执行导入 +async function doImport() { + const file = document.getElementById('importFile').files[0]; + if (!file) { + showToast('请选择文件', 'warning'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + const token = getToken(); + const response = await fetch(`${API_BASE_URL}/api/admin/students/import`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const result = await response.json(); + + if (result.success) { + showToast(result.message); + closeModal('importModal'); + loadStudents(); + } else { + showToast(result.message, 'error'); + } +} + +// 显示新增学生模态框 +function showAddStudentModal() { + document.getElementById('addStudentModal').style.display = 'flex'; + document.getElementById('addStudentForm').reset(); +} + +// 提交新增学生 +async function submitAddStudent() { + const studentNo = document.getElementById('studentNo').value.trim(); + const name = document.getElementById('studentName').value.trim(); + const parentPhone = document.getElementById('parentPhone').value.trim(); + + if (!studentNo || !name) { + showToast('请填写学号和姓名', 'warning'); + return; + } + + const res = await apiPost('/api/admin/students', { + student_no: studentNo, + name: name, + parent_phone: parentPhone + }); + + if (res && res.success) { + showToast('学生添加成功'); + closeModal('addStudentModal'); + loadStudents(); + } else { + showToast(res?.message || '添加失败', 'error'); + } +} + +// 显示添加作业模态框 +function showAddAssignmentModal() { + document.getElementById('addAssignmentModal').style.display = 'flex'; + loadSubjectsForSelect(); +} + +// 加载科目下拉框 +async function loadSubjectsForSelect() { + const res = await apiGet('/api/subject/list'); + if (res && res.success) { + let html = ''; + res.data.subjects.forEach(s => { + if (s.is_active) { + html += ``; + } + }); + document.getElementById('assignmentSubjectId').innerHTML = html; + } +} + +// 提交添加作业 +async function submitAddAssignment() { + const subjectId = document.getElementById('assignmentSubjectId').value; + const title = document.getElementById('assignmentTitle').value.trim(); + const description = document.getElementById('assignmentDescription').value; + const deadline = document.getElementById('assignmentDeadline').value; + + if (!subjectId || !title || !deadline) { + showToast('请填写完整信息', 'warning'); + return; + } + + const res = await apiPost('/api/admin/homework/assignment', { + subject_id: parseInt(subjectId), + title: title, + description: description, + deadline: deadline + }); + + if (res && res.success) { + showToast('作业发布成功'); + closeModal('addAssignmentModal'); + loadAssignments(); + } else { + showToast(res?.message || '发布失败', 'error'); + } +} + +// 显示添加考勤模态框 +async function showAddAttendanceModal() { + await loadStudentsForSelect(); + document.getElementById('addAttendanceModal').style.display = 'flex'; + document.getElementById('attendanceDate').value = new Date().toISOString().split('T')[0]; +} + +// 加载学生下拉框 +async function loadStudentsForSelect() { + const res = await apiGet('/api/admin/students'); + if (res && res.success) { + let html = ''; + res.data.students.forEach(s => { + html += ``; + }); + document.getElementById('attendanceStudentId').innerHTML = html; + } +} + +// 提交添加考勤 +async function submitAddAttendance() { + const studentId = document.getElementById('attendanceStudentId').value; + const date = document.getElementById('attendanceDate').value; + const status = document.getElementById('attendanceStatus').value; + const reason = document.getElementById('attendanceReason').value; + const applyDeduction = document.getElementById('attendanceDeduct').checked; + + if (!studentId || !date || !status) { + showToast('请填写完整信息', 'warning'); + return; + } + + const res = await apiPost('/api/admin/attendance', { + student_id: parseInt(studentId), + date: date, + status: status, + reason: reason, + apply_deduction: applyDeduction + }); + + if (res && res.success) { + showToast('考勤记录添加成功'); + closeModal('addAttendanceModal'); + loadAttendanceRecords(); + } else { + showToast(res?.message || '添加失败', 'error'); + } +} + +// 显示添加管理员模态框 +function showAddAdminModal() { + document.getElementById('addAdminModal').style.display = 'flex'; + document.getElementById('addAdminForm').reset(); +} + +// 提交添加管理员 +async function submitAddAdmin() { + const username = document.getElementById('adminUsername').value.trim(); + const realName = document.getElementById('adminRealName').value.trim(); + const password = document.getElementById('adminPassword').value; + const roleType = document.getElementById('adminRole').value; + + if (!username || !realName || !roleType) { + showToast('请填写完整信息', 'warning'); + return; + } + + const res = await apiPost('/api/admin/add', { + username: username, + real_name: realName, + password: password || undefined, + role_type: roleType + }); + + if (res && res.success) { + let msg = `管理员 ${res.data.username} 添加成功`; + if (res.data.password) msg += `,密码: ${res.data.password}`; + showToast(msg); + closeModal('addAdminModal'); + loadAdmins(); + } else { + showToast(res?.message || '添加失败', 'error'); + } +} + +// 显示添加科目模态框 +function showAddSubjectModal() { + document.getElementById('addSubjectModal').style.display = 'flex'; + document.getElementById('addSubjectForm').reset(); +} + +// 提交添加科目 +async function submitAddSubject() { + const subjectName = document.getElementById('subjectName').value.trim(); + const subjectCode = document.getElementById('subjectCode').value.trim(); + + if (!subjectName) { + showToast('请填写科目名称', 'warning'); + return; + } + + const res = await apiPost('/api/subject/create', { + subject_name: subjectName, + subject_code: subjectCode + }); + + if (res && res.success) { + showToast('科目添加成功'); + closeModal('addSubjectModal'); + loadSubjects(); + } else { + showToast(res?.message || '添加失败', 'error'); + } +} + +// 撤销扣分记录 +async function revokeRecord(recordId) { + if (!confirm('确定要撤销这条扣分记录吗?')) return; + + const res = await apiPost('/api/admin/conduct/revoke', { record_id: recordId }); + if (res && res.success) { + showToast('撤销成功'); + loadHistory(currentHistoryPage); + } else { + showToast(res?.message || '撤销失败', 'error'); + } +} + +// 关闭模态框 +function closeModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = 'none'; + } +} + +// HTML转义 +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/[&<>]/g, function(m) { + if (m === '&') return '&'; + if (m === '<') return '<'; + if (m === '>') return '>'; + return m; + }); +} + +// 全选功能 +function toggleSelectAll() { + const selectAll = document.getElementById('selectAll'); + if (selectAll) { + document.querySelectorAll('.student-checkbox').forEach(cb => { + cb.checked = selectAll.checked; + }); + } +} + +// 绑定文件选择事件 +document.addEventListener('DOMContentLoaded', () => { + const fileInput = document.getElementById('importFile'); + if (fileInput) { + fileInput.addEventListener('change', previewImportFile); + } +}); \ No newline at end of file diff --git a/frontend/assets/js/common.js b/frontend/assets/js/common.js new file mode 100644 index 0000000..92ba6b9 --- /dev/null +++ b/frontend/assets/js/common.js @@ -0,0 +1,242 @@ +/** + * 班级操行分管理系统 - 公共JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +// API基础地址 +const API_BASE_URL = window.API_BASE_URL || 'http://localhost:8000'; +const JWT_STORAGE_KEY = 'class_system_token'; +const USER_STORAGE_KEY = 'class_system_user'; + +// 获取Token +function getToken() { + return localStorage.getItem(JWT_STORAGE_KEY); +} + +// 获取用户信息 +function getUserInfo() { + const userStr = localStorage.getItem(USER_STORAGE_KEY); + if (!userStr) return null; + try { + return JSON.parse(userStr); + } catch { + return null; + } +} + +// 保存用户信息 +function setUserInfo(user) { + localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(user)); +} + +// 清除登录信息 +function clearAuth() { + localStorage.removeItem(JWT_STORAGE_KEY); + localStorage.removeItem(USER_STORAGE_KEY); +} + +// 检查登录状态 +function checkAuth() { + const token = getToken(); + if (!token) { + window.location.href = '/index.php'; + return false; + } + return true; +} + +// API请求封装 +async function apiRequest(url, options = {}) { + const token = getToken(); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const config = { + ...options, + headers + }; + + try { + const response = await fetch(`${API_BASE_URL}${url}`, config); + const data = await response.json(); + + if (response.status === 401) { + clearAuth(); + window.location.href = '/index.php'; + return null; + } + + return data; + } catch (error) { + console.error('API请求错误:', error); + showToast('网络错误,请稍后重试', 'error'); + return null; + } +} + +// GET请求 +async function apiGet(url, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const fullUrl = queryString ? `${url}?${queryString}` : url; + return apiRequest(fullUrl, { method: 'GET' }); +} + +// POST请求 +async function apiPost(url, data = {}) { + return apiRequest(url, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +// PUT请求 +async function apiPut(url, data = {}) { + return apiRequest(url, { + method: 'PUT', + body: JSON.stringify(data) + }); +} + +// DELETE请求 +async function apiDelete(url) { + return apiRequest(url, { method: 'DELETE' }); +} + +// 显示提示消息 +function showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 3000); +} + +// 格式化日期 +function formatDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +// 格式化日期时间 +function formatDateTime(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +// 获取状态标签HTML +function getStatusBadge(status, type = 'homework') { + const statusMap = { + homework: { + 'submitted': '已提交', + 'not_submitted': '未提交', + 'late': '迟交' + }, + attendance: { + 'present': '出勤', + 'absent': '缺勤', + 'late': '迟到', + 'leave': '请假' + } + }; + + const texts = statusMap[type] || statusMap.homework; + const text = texts[status] || status; + + let className = 'status-badge '; + switch (status) { + case 'submitted': + case 'present': + className += 'status-submitted'; + break; + case 'not_submitted': + case 'absent': + className += 'status-not_submitted'; + break; + case 'late': + className += 'status-late'; + break; + case 'leave': + className += 'status-leave'; + break; + default: + className += 'status-not_submitted'; + } + + return `${text}`; +} + +// 退出登录 +async function logout() { + await apiPost('/api/auth/logout'); + clearAuth(); + window.location.href = '/index.php'; +} + +// 加载用户信息 +function loadUserInfo() { + const user = getUserInfo(); + const userNameSpan = document.getElementById('userName'); + if (userNameSpan && user) { + userNameSpan.textContent = user.real_name || user.username; + } +} + +// 检查是否需要修改密码 +function checkNeedChangePassword() { + const user = getUserInfo(); + if (user && user.need_change_password) { + const newPassword = prompt('首次登录,请设置新密码(6-20位,需包含字母和数字):'); + if (newPassword) { + changePassword(newPassword); + } + } +} + +// 修改密码 +async function changePassword(newPassword) { + const res = await apiPost('/api/auth/change-password', { + old_password: newPassword, + new_password: newPassword + }); + + if (res && res.success) { + showToast('密码修改成功,请重新登录'); + setTimeout(() => logout(), 1500); + } else { + showToast(res?.message || '密码修改失败', 'error'); + checkNeedChangePassword(); + } +} + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', () => { + loadUserInfo(); + + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', logout); + } + + // 学生端检查强制修改密码 + if (window.location.pathname.includes('/student/')) { + checkNeedChangePassword(); + } +}); \ No newline at end of file diff --git a/frontend/assets/js/parent.js b/frontend/assets/js/parent.js new file mode 100644 index 0000000..a6db5b3 --- /dev/null +++ b/frontend/assets/js/parent.js @@ -0,0 +1,13 @@ +/** + * 班级操行分管理系统 - 家长端JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +// 家长端专用功能 +console.log('家长端已加载'); \ No newline at end of file diff --git a/frontend/assets/js/student.js b/frontend/assets/js/student.js new file mode 100644 index 0000000..eb00687 --- /dev/null +++ b/frontend/assets/js/student.js @@ -0,0 +1,13 @@ +/** + * 班级操行分管理系统 - 学生端JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: MIT License + * + * 版权所有 © Sea Network Technology Studio + */ + +// 学生端专用功能 +console.log('学生端已加载'); \ No newline at end of file diff --git a/frontend/assets/uploads/sample_import.json b/frontend/assets/uploads/sample_import.json new file mode 100644 index 0000000..944c300 --- /dev/null +++ b/frontend/assets/uploads/sample_import.json @@ -0,0 +1,44 @@ +{ + "_comment1": "================================================", + "_comment2": "班级操行分管理系统 - 学生批量导入模板", + "_comment3": "开发者: Canglan | 版权: Sea Network Technology Studio", + "_comment4": "================================================", + "_comment5": "字段说明:", + "_comment6": " student_no - 必填,学生学号,唯一标识", + "_comment7": " name - 必填,学生姓名", + "_comment8": " parent_phone - 可选,家长手机号(11位手机号)", + "_comment9": " password - 可选,初始密码,不填则默认 123456", + "_comment10": "================================================", + "_comment11": "导入规则:", + "_comment12": " 1. 学生操行分初始值 = 60分", + "_comment13": " 2. 学生账号 = 学号,密码 = 指定的password或123456", + "_comment14": " 3. 家长账号 = 手机号(若parent_phone有值),密码 = 指定的password或123456", + "_comment15": " 4. 家长姓名默认显示为 '学生姓名家长'", + "_comment16": "================================================", + "students": [ + { + "student_no": "20240001", + "name": "张三", + "parent_phone": "13800138001", + "password": "123456" + }, + { + "student_no": "20240002", + "name": "李四", + "parent_phone": "13800138002", + "password": "123456" + }, + { + "student_no": "20240003", + "name": "王五", + "parent_phone": "", + "password": "" + }, + { + "student_no": "20240004", + "name": "赵六", + "parent_phone": "13800138004", + "password": "" + } + ] +} \ No newline at end of file diff --git a/frontend/config.php b/frontend/config.php new file mode 100644 index 0000000..cb61c18 --- /dev/null +++ b/frontend/config.php @@ -0,0 +1,53 @@ + \ No newline at end of file diff --git a/frontend/includes/footer.php b/frontend/includes/footer.php new file mode 100644 index 0000000..cc46a9e --- /dev/null +++ b/frontend/includes/footer.php @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/frontend/includes/header.php b/frontend/includes/header.php new file mode 100644 index 0000000..7d31963 --- /dev/null +++ b/frontend/includes/header.php @@ -0,0 +1,49 @@ + + + + + + + <?php echo SITE_NAME; ?> - <?php echo $page_title; ?> + + + + + + +
+

+
+ + + () + + +
+
+ + \ No newline at end of file diff --git a/frontend/index.php b/frontend/index.php new file mode 100644 index 0000000..055f600 --- /dev/null +++ b/frontend/index.php @@ -0,0 +1,112 @@ + '/student/dashboard.php', + 'parent' => '/parent/dashboard.php', + 'admin' => '/admin/dashboard.php' + ]; + $target = $redirect[$_SESSION['user_type']] ?? '/index.php'; + header("Location: $target"); + exit(); +} +?> + + + + + + <?php echo SITE_NAME; ?> - 登录 + + + +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/frontend/parent/attendance.php b/frontend/parent/attendance.php new file mode 100644 index 0000000..0b48017 --- /dev/null +++ b/frontend/parent/attendance.php @@ -0,0 +1,83 @@ + + + + +
+
+
出勤
0
+
缺勤
0
+
迟到
0
+
请假
0
+
+ +
+
考勤记录明细
+
+ + + +
日期状态原因
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/parent/dashboard.php b/frontend/parent/dashboard.php new file mode 100644 index 0000000..82f1736 --- /dev/null +++ b/frontend/parent/dashboard.php @@ -0,0 +1,272 @@ + + + + + + + <?php echo SITE_NAME; ?> - 家长端 + + + + +
+

- 家长端

+
+ + +
+
+ + + +
+ +
+
+
--
+
--
+
+
+
+
--
+
当前操行分
+
+
+
+ + + + + + +
+ + + + + \ No newline at end of file diff --git a/frontend/parent/homework.php b/frontend/parent/homework.php new file mode 100644 index 0000000..064c161 --- /dev/null +++ b/frontend/parent/homework.php @@ -0,0 +1,66 @@ + + + + +
+
+
作业列表
+
+ + + +
科目作业标题截止日期状态备注
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/student/attendance.php b/frontend/student/attendance.php new file mode 100644 index 0000000..0bf9866 --- /dev/null +++ b/frontend/student/attendance.php @@ -0,0 +1,83 @@ + + + + +
+
+
出勤
0
+
缺勤
0
+
迟到
0
+
请假
0
+
+ +
+
考勤记录明细
+
+ + + +
日期状态原因
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/student/conduct_history.php b/frontend/student/conduct_history.php new file mode 100644 index 0000000..e69de29 diff --git a/frontend/student/dashboard.php b/frontend/student/dashboard.php new file mode 100644 index 0000000..69a92b9 --- /dev/null +++ b/frontend/student/dashboard.php @@ -0,0 +1,513 @@ + + + + + + + <?php echo SITE_NAME; ?> - 学生端 + + + + +
+

- 学生端

+
+ + +
+
+ + + +
+ +
+
+
+
当前操行分
+
--
+
+
+
作业完成率
+
--%
+
+
+
本月出勤率
+
--%
+
+
+ +
+
最新操行分记录
+
+ +
+
+ + + + + + + + + + + + +
+ + + + + + + + \ No newline at end of file diff --git a/frontend/student/homework.php b/frontend/student/homework.php new file mode 100644 index 0000000..6a6558b --- /dev/null +++ b/frontend/student/homework.php @@ -0,0 +1,74 @@ + + + + +
+
+
作业列表
+
+ + + + + +
科目作业标题截止日期状态备注
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/student/password.php b/frontend/student/password.php new file mode 100644 index 0000000..79fb945 --- /dev/null +++ b/frontend/student/password.php @@ -0,0 +1,81 @@ + + + + +
+
+
修改密码
+
+
+ + +
+
+ + + 密码长度6-20位,需包含字母和数字 +
+
+ + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..8c0f985 --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,331 @@ +-- =========================================== +-- 班级操行分管理系统 - 数据库初始化脚本 +-- =========================================== +-- 开发者: Canglan +-- 联系方式: admin@sea-studio.top +-- 版权归属: Sea Network Technology Studio +-- 许可证: MIT License +-- 版权所有: Copyright (c) 2024 Sea Network Technology Studio +-- =========================================== +-- 数据库: classmanagerdb +-- 字符集: utf8mb4 +-- =========================================== + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS `classmanagerdb` +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE `classmanagerdb`; + +-- =========================================== +-- 1. 班级表 +-- =========================================== +DROP TABLE IF EXISTS `classes`; +CREATE TABLE `classes` ( + `class_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '班级ID', + `class_name` VARCHAR(50) NOT NULL COMMENT '班级名称', + `grade` VARCHAR(20) DEFAULT NULL COMMENT '年级', + `academic_year` VARCHAR(20) DEFAULT NULL COMMENT '学年', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_class_name` (`class_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='班级表'; + +-- =========================================== +-- 2. 科目表(支持动态增删) +-- =========================================== +DROP TABLE IF EXISTS `subjects`; +CREATE TABLE `subjects` ( + `subject_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '科目ID', + `subject_name` VARCHAR(50) NOT NULL COMMENT '科目名称', + `subject_code` VARCHAR(20) DEFAULT NULL COMMENT '科目代码', + `is_active` TINYINT DEFAULT 1 COMMENT '是否启用(0禁用/1启用)', + `sort_order` INT DEFAULT 0 COMMENT '排序序号', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_active` (`is_active`), + UNIQUE KEY `uk_subject_name` (`subject_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='科目表'; + +-- =========================================== +-- 3. 学生表 +-- =========================================== +DROP TABLE IF EXISTS `students`; +CREATE TABLE `students` ( + `student_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '学生ID', + `student_no` VARCHAR(20) NOT NULL COMMENT '学号', + `name` VARCHAR(50) NOT NULL COMMENT '学生姓名', + `class_id` INT NOT NULL COMMENT '班级ID', + `total_points` INT DEFAULT 100 COMMENT '操行分总分', + `parent_phone` VARCHAR(20) DEFAULT NULL COMMENT '家长手机号', + `status` TINYINT DEFAULT 1 COMMENT '状态(0离校/1在校)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT, + UNIQUE KEY `uk_student_no` (`student_no`), + INDEX `idx_class_id` (`class_id`), + INDEX `idx_parent_phone` (`parent_phone`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生表'; + +-- =========================================== +-- 4. 用户表(统一账号) +-- =========================================== +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `user_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', + `username` VARCHAR(50) NOT NULL COMMENT '登录账号', + `password_hash` VARCHAR(64) NOT NULL COMMENT '密码哈希(SHA1+MD5)', + `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', + `user_type` ENUM('student', 'parent', 'admin') NOT NULL COMMENT '用户类型', + `student_id` INT DEFAULT NULL COMMENT '关联学生ID(学生/家长)', + `status` TINYINT DEFAULT 1 COMMENT '状态(0禁用/1启用)', + `need_change_password` TINYINT DEFAULT 1 COMMENT '是否需要修改密码(1需要/0不需要)', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` VARCHAR(45) DEFAULT NULL COMMENT '最后登录IP', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + UNIQUE KEY `uk_username` (`username`), + INDEX `idx_user_type` (`user_type`), + INDEX `idx_student_id` (`student_id`), + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- =========================================== +-- 5. 管理员角色表 +-- =========================================== +DROP TABLE IF EXISTS `admin_roles`; +CREATE TABLE `admin_roles` ( + `admin_role_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '管理员角色ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `role_type` ENUM('班主任', '班长', '科代表', '考勤委员', '劳动委员') NOT NULL COMMENT '角色类型', + `class_id` INT NOT NULL COMMENT '管理的班级ID', + `subject_id` INT DEFAULT NULL COMMENT '关联科目ID(科代表专用)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`) ON DELETE CASCADE, + INDEX `idx_user_id` (`user_id`), + INDEX `idx_role_type` (`role_type`), + UNIQUE KEY `uk_user_class_subject` (`user_id`, `class_id`, `subject_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员角色表'; + +-- =========================================== +-- 6. 操行分记录表 +-- =========================================== +DROP TABLE IF EXISTS `conduct_records`; +CREATE TABLE `conduct_records` ( + `record_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID', + `student_id` INT NOT NULL COMMENT '学生ID', + `points_change` INT NOT NULL COMMENT '分数变动(正数加分,负数扣分)', + `reason` VARCHAR(255) NOT NULL COMMENT '变动原因', + `recorder_id` INT NOT NULL COMMENT '操作人ID', + `recorder_name` VARCHAR(50) DEFAULT NULL COMMENT '操作人姓名(冗余字段)', + `related_type` ENUM('manual', 'homework', 'attendance') DEFAULT 'manual' COMMENT '关联类型', + `related_id` INT DEFAULT NULL COMMENT '关联ID(作业ID/考勤ID)', + `is_revoked` TINYINT DEFAULT 0 COMMENT '是否已撤销(0未撤销/1已撤销)', + `revoked_by` INT DEFAULT NULL COMMENT '撤销人ID', + `revoked_at` DATETIME DEFAULT NULL COMMENT '撤销时间', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`) ON DELETE RESTRICT, + FOREIGN KEY (`revoked_by`) REFERENCES `users`(`user_id`) ON DELETE RESTRICT, + INDEX `idx_student_id` (`student_id`), + INDEX `idx_recorder_id` (`recorder_id`), + INDEX `idx_created_at` (`created_at`), + INDEX `idx_related` (`related_type`, `related_id`), + INDEX `idx_is_revoked` (`is_revoked`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操行分记录表'; + +-- =========================================== +-- 7. 作业表 +-- =========================================== +DROP TABLE IF EXISTS `assignments`; +CREATE TABLE `assignments` ( + `assignment_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '作业ID', + `class_id` INT NOT NULL COMMENT '班级ID', + `subject_id` INT NOT NULL COMMENT '科目ID', + `title` VARCHAR(100) NOT NULL COMMENT '作业标题', + `description` TEXT COMMENT '作业描述', + `deadline` DATE NOT NULL COMMENT '截止日期', + `created_by` INT NOT NULL COMMENT '发布人ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`) ON DELETE RESTRICT, + FOREIGN KEY (`created_by`) REFERENCES `users`(`user_id`) ON DELETE RESTRICT, + INDEX `idx_class_id` (`class_id`), + INDEX `idx_deadline` (`deadline`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作业表'; + +-- =========================================== +-- 8. 作业提交记录表 +-- =========================================== +DROP TABLE IF EXISTS `homework_submissions`; +CREATE TABLE `homework_submissions` ( + `submission_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '提交记录ID', + `assignment_id` INT NOT NULL COMMENT '作业ID', + `student_id` INT NOT NULL COMMENT '学生ID', + `status` ENUM('submitted', 'not_submitted', 'late') DEFAULT 'not_submitted' COMMENT '提交状态', + `submit_time` DATETIME DEFAULT NULL COMMENT '提交时间', + `comments` TEXT COMMENT '备注', + `deduction_applied` TINYINT DEFAULT 0 COMMENT '是否已应用扣分', + `deduction_record_id` BIGINT DEFAULT NULL COMMENT '关联的扣分记录ID', + `updated_by` INT DEFAULT NULL COMMENT '最后更新人ID', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (`assignment_id`) REFERENCES `assignments`(`assignment_id`) ON DELETE CASCADE, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`updated_by`) REFERENCES `users`(`user_id`) ON DELETE RESTRICT, + FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL, + INDEX `idx_assignment_id` (`assignment_id`), + INDEX `idx_student_id` (`student_id`), + INDEX `idx_status` (`status`), + UNIQUE KEY `uk_assignment_student` (`assignment_id`, `student_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作业提交记录表'; + +-- =========================================== +-- 9. 考勤记录表 +-- =========================================== +DROP TABLE IF EXISTS `attendance_records`; +CREATE TABLE `attendance_records` ( + `attendance_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '考勤记录ID', + `student_id` INT NOT NULL COMMENT '学生ID', + `date` DATE NOT NULL COMMENT '考勤日期', + `status` ENUM('present', 'absent', 'late', 'leave') DEFAULT 'present' COMMENT '考勤状态', + `reason` VARCHAR(255) DEFAULT NULL COMMENT '原因', + `recorder_id` INT NOT NULL COMMENT '记录人ID', + `deduction_applied` TINYINT DEFAULT 0 COMMENT '是否已应用扣分', + `deduction_record_id` BIGINT DEFAULT NULL COMMENT '关联的扣分记录ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`) ON DELETE RESTRICT, + FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL, + INDEX `idx_student_id` (`student_id`), + INDEX `idx_date` (`date`), + INDEX `idx_status` (`status`), + UNIQUE KEY `uk_student_date` (`student_id`, `date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考勤记录表'; + +-- =========================================== +-- 10. 操作日志表 +-- =========================================== +DROP TABLE IF EXISTS `operation_logs`; +CREATE TABLE `operation_logs` ( + `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID', + `operator_id` INT NOT NULL COMMENT '操作人ID', + `operator_name` VARCHAR(50) DEFAULT NULL COMMENT '操作人姓名', + `operator_role` VARCHAR(50) DEFAULT NULL COMMENT '操作人角色', + `operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型', + `target_type` VARCHAR(50) DEFAULT NULL COMMENT '目标类型', + `target_id` INT DEFAULT NULL COMMENT '目标ID', + `details` TEXT COMMENT '详细信息', + `ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'IP地址', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_operator_id` (`operator_id`), + INDEX `idx_operation_type` (`operation_type`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表'; + +-- =========================================== +-- 11. 登录日志表 +-- =========================================== +DROP TABLE IF EXISTS `login_logs`; +CREATE TABLE `login_logs` ( + `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID', + `username` VARCHAR(50) NOT NULL COMMENT '登录账号', + `login_result` TINYINT NOT NULL COMMENT '登录结果(0失败/1成功)', + `fail_reason` VARCHAR(100) DEFAULT NULL COMMENT '失败原因', + `ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'IP地址', + `user_agent` VARCHAR(255) DEFAULT NULL COMMENT '用户代理', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + INDEX `idx_username` (`username`), + INDEX `idx_created_at` (`created_at`), + INDEX `idx_login_result` (`login_result`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表'; + +-- =========================================== +-- 创建存储过程:撤销扣分记录 +-- =========================================== +DROP PROCEDURE IF EXISTS `revoke_conduct_record`; +DELIMITER // + +CREATE PROCEDURE `revoke_conduct_record`( + IN p_record_id BIGINT, + IN p_revoker_id INT +) +BEGIN + DECLARE v_student_id INT; + DECLARE v_points_change INT; + DECLARE v_is_revoked TINYINT; + + SELECT `student_id`, `points_change`, `is_revoked` + INTO v_student_id, v_points_change, v_is_revoked + FROM `conduct_records` + WHERE `record_id` = p_record_id; + + IF v_is_revoked = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '该记录已被撤销'; + END IF; + + UPDATE `conduct_records` + SET `is_revoked` = 1, + `revoked_by` = p_revoker_id, + `revoked_at` = NOW() + WHERE `record_id` = p_record_id; + + UPDATE `students` + SET `total_points` = `total_points` - v_points_change + WHERE `student_id` = v_student_id; + +END // + +DELIMITER ; + +-- =========================================== +-- 创建触发器:更新学生总分 +-- =========================================== +DROP TRIGGER IF EXISTS `update_student_points_on_insert`; +DELIMITER // + +CREATE TRIGGER `update_student_points_on_insert` +AFTER INSERT ON `conduct_records` +FOR EACH ROW +BEGIN + IF NEW.is_revoked = 0 THEN + UPDATE `students` + SET `total_points` = `total_points` + NEW.points_change + WHERE `student_id` = NEW.student_id; + END IF; +END // + +DELIMITER ; + +-- =========================================== +-- 创建视图:学生操行分排行榜 +-- =========================================== +DROP VIEW IF EXISTS `v_student_ranking`; +CREATE VIEW `v_student_ranking` AS +SELECT + s.student_id, + s.student_no, + s.name, + c.class_name, + s.total_points, + RANK() OVER (PARTITION BY s.class_id ORDER BY s.total_points DESC) as rank_in_class +FROM `students` s +JOIN `classes` c ON s.class_id = c.class_id +WHERE s.status = 1; + +-- =========================================== +-- 创建视图:今日考勤汇总 +-- =========================================== +DROP VIEW IF EXISTS `v_today_attendance`; +CREATE VIEW `v_today_attendance` AS +SELECT + c.class_name, + COUNT(CASE WHEN a.status = 'present' THEN 1 END) as present_count, + COUNT(CASE WHEN a.status = 'absent' THEN 1 END) as absent_count, + COUNT(CASE WHEN a.status = 'late' THEN 1 END) as late_count, + COUNT(CASE WHEN a.status = 'leave' THEN 1 END) as leave_count, + COUNT(*) as total_count +FROM `attendance_records` a +JOIN `students` s ON a.student_id = s.student_id +JOIN `classes` c ON s.class_id = c.class_id +WHERE a.date = CURDATE() +GROUP BY c.class_id; \ No newline at end of file