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 @@
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+ 导入学生
+ 新增学生
+
+
+
+
+ 搜索
+
+
+
+
+
+
+
+
+
+
+
+
+
+
点击选择JSON文件
+
或点击此处上传
+
+
+ 下载示例文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分数变动
+
+
+
+
+ 原因
+
+
+
+
+
+
+
+
\ 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 += `
+ ${escapeHtml(s.student_no || '')}
+ ${escapeHtml(s.name || '')}
+ ${escapeHtml(s.parent_phone || '')}
+ ${escapeHtml(s.password || '123456')}
+ `;
+ });
+
+ html += `
共 ${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 += `${s.subject_name} `;
+ }
+ });
+ 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 += `${s.student_no} - ${s.name} `;
+ });
+ 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 @@
+
+
+