commit d3c60b2fbf9ed956724c8632f7ef61cd3159c79d Author: 李可欣 <17608355+likexin_666@user.noreply.gitee.com> Date: Mon Sep 21 23:17:51 2026 +0800 学生管理系统代码提交 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f17feb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.git/ +.gitignore +*.md +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ee4ef2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Dockerfile:FastAPI 应用镜像(gunicorn + uvicorn worker 多进程生产模式) +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + TZ=Asia/Shanghai + +WORKDIR /app + +# 先装依赖,利用 Docker 层缓存 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +# 拷贝项目代码 +COPY . . + +EXPOSE 8000 + +# gunicorn 多 worker 启动(uvicorn.workers.UvicornWorker 兼容 FastAPI 异步) +# 生产环境建议 worker 数 = CPU 核数 * 2 + 1 +CMD ["gunicorn", "main:app", \ + "--workers", "4", \ + "--worker-class", "uvicorn.workers.UvicornWorker", \ + "--bind", "0.0.0.0:8000", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1ffd3e --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# 沃林学生管理系统 + +基于 **FastAPI + SQLAlchemy(同步)+ MySQL + Pydantic v2** 的学生管理系统,提供学生信息、考核成绩、就业管理、班级/老师管理与统计分析能力,内置 JWT 认证(RBAC 角色权限)与 Vue3 前端管理页面,附 Docker / Nginx 生产部署配置。 + +## 目录结构 + +``` +walin_student_system/ +├── main.py # 项目入口:创建应用、注册路由、建表、挂载前端 +├── config.py # 全局配置(环境变量可覆盖) +├── database.py # 数据库连接 / 会话 / 依赖注入 +├── core/ +│ ├── security.py # 密码哈希(PBKDF2) + JWT 签发/校验 +│ └── deps.py # 认证依赖 + 角色级 RBAC +├── model/ # Model 层:SQLAlchemy 表模型 +├── scheme/ # Pydantic 请求/响应模型 +├── dao/ # 数据访问层(含统计动态查询、高级筛选器) +├── api/ # 路由层:auth/classes/teachers/students/scores/employment/statistics +├── static/ # Vue3 前端管理页面(index.html + app.js + style.css) +├── sql/init.sql # 修正版建表语句 + 种子数据(含初始账号) +├── Dockerfile # gunicorn + uvicorn worker 生产镜像 +├── docker-compose.yml # MySQL + API + Nginx 一键部署 +└── nginx.conf # 反向代理配置 +``` + +## 本地运行 + +1. 准备 MySQL,执行初始化脚本(建库建表 + 种子数据 + 初始账号): + + ```bash + mysql -uroot -p < sql/init.sql + ``` + +2. 安装依赖并启动(Python 3.10): + + ```bash + pip install -r requirements.txt + python main.py # 或 uvicorn main:app --reload --port 8000 + ``` + +3. 访问: + - 前端管理页面:(自动跳转到 /static/index.html) + - Swagger 文档: + +4. 默认账号(见 `sql/init.sql`): + + | 账号 | 密码 | 角色 | + |---|---|---| + | admin | admin123 | 管理员(全部权限) | + | teacher1 | teacher123 | 教师(班级1,可管理本班学生) | + | s2026010003 | student123 | 学生(只读本人信息) | + + 本地默认数据库连接为 `root:123456@localhost:3306/walin_db`,与 compose 不同;可用环境变量 `DATABASE_URL` 覆盖。 + +## Docker 一键部署 + +```bash +docker compose up -d --build +``` + +- Nginx 监听 80 端口反向代理 API 容器; +- MySQL 数据持久化在 `mysql_data` 卷,首次启动自动执行 `sql/init.sql`; +- 访问 `http://服务器IP/`。 + +生产环境注意: +- 修改 `docker-compose.yml` 中的 `MYSQL_ROOT_PASSWORD` 与 `SECRET_KEY`; +- 建议在 Nginx 层配置 HTTPS(证书挂载后改 `listen 443 ssl`)。 + +## 认证与权限(RBAC) + +- 登录:`POST /api/auth/login`,返回 JWT;后续请求携带 `Authorization: Bearer `。 +- 角色权限: + - **admin**:全部接口; + - **teacher**:查询类接口 + 本班学生的成绩/就业/学生信息管理(资源归属校验:`teacher.class_id == student.class_id`); + - **student**:只读本人信息与成绩。 + +## 主要接口一览 + +| 模块 | 路径前缀 | 说明 | +|---|---|---| +| 认证 | /api/auth | login / register(仅admin) / me | +| 班级 | /api/classes | add / total_query / single_query / update / delete | +| 老师 | /api/teachers | teacher/add、teacher/delete、teacher/update、teacher/total_query、teacher/single_query | +| 学生 | /api/students | add(学号自动生成)/ total_query(多条件分页)/ single_query / update / delete | +| 成绩 | /api/scores | add(60分红线预警)/ query / update / delete | +| 就业 | /api/employment | open(状态联动→进入就业)/ offer(状态联动→已就业)/ students/{id} / class/{id} / total_query / update / delete | +| 统计 | /api/statistics | 见下 | + +### 统计分析接口 + +| 接口 | 说明 | +|---|---| +| GET /statistics/students/by-age | 动态年龄查询(gt/lt/eq/gte/lte/between) | +| GET /statistics/class/gender-stats | 每班总人数 + 男女分布 | +| GET /statistics/score/all-above?line=80 | 每次考试都在分数线以上的学生 | +| GET /statistics/score/fail?times=2 | 不及格次数 ≥ N 的学生(含明细) | +| GET /statistics/score/class-avg?order=desc | 每次考试每班平均分(动态排序) | +| GET /statistics/employment/top-salary?n=10 | 薪资 Top N | +| GET /statistics/employment/duration | 每个学生就业时长(offer时间-开放时间) | +| GET /statistics/employment/class-avg-duration | 每班平均就业时长 | +| GET /statistics/score/volatility?top_n=5 | 成绩波动最大 Top N(最大分差) | +| GET /statistics/employment/funnel | 班级就业漏斗(总人数→已就业→高薪→就业率) | +| POST /statistics/filter | 通用高级筛选器(AND/OR 嵌套规则,见下) | + +### 高级筛选器示例 + +```json +POST /api/statistics/filter +{ + "model": "student", + "rules": [ + { "field": "age", "operator": ">", "value": 20 }, + { "logic": "OR", "sub_rules": [ + { "field": "salary", "operator": ">=", "value": 10000 }, + { "field": "class_name", "operator": "like", "value": "Java" } + ]} + ] +} +``` + +支持字段:`stu_id, stu_name, age, gender, education, major, native_place, status, class_id, class_name, salary, company_name`;操作符:`> < = != >= <= like in`。 + +## 设计说明(对原始建表语句的修正) + +1. `c_lass` 补充 `class_name`(原表无班级名称,展示/统计都需要); +2. `student` 补充 `status`(在读/进入就业/已就业),就业联动依赖该字段; +3. `score` 补充 `is_deleted`,统一逻辑删除风格; +4. `employment_base` 补充冗余字段 `stu_name / class_name`(需求 2.3 设计提示),登记时从 student/class 同步写入保证一致性; +5. 新增 `user` 表支撑 JWT 登录与 RBAC(原建表语句缺失); +6. 学号生成规则:`入学年份(4位) + 班级号(2位) + 序号(4位)`,如 2026010001。 + +## 待扩展(需求文档第 5 节) + +- Excel 批量导入学生(python-multipart 已装,可加 pandas/openpyxl) +- 部门管理、顾问管理独立模块 +- 强制下线/单点登录管控(需引入 Redis Session) diff --git a/__pycache__/config.cpython-310.pyc b/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..c12c72b Binary files /dev/null and b/__pycache__/config.cpython-310.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..ce4fb5e Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/database.cpython-310.pyc b/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000..934f80d Binary files /dev/null and b/__pycache__/database.cpython-310.pyc differ diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..a787f38 Binary files /dev/null and b/__pycache__/database.cpython-313.pyc differ diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..4ee0d90 Binary files /dev/null and b/__pycache__/main.cpython-310.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..99d6b9f Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..753f86f --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,2 @@ +# api/__init__.py +# 路由层包 diff --git a/api/__pycache__/__init__.cpython-310.pyc b/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..a3eee0e Binary files /dev/null and b/api/__pycache__/__init__.cpython-310.pyc differ diff --git a/api/__pycache__/auth.cpython-310.pyc b/api/__pycache__/auth.cpython-310.pyc new file mode 100644 index 0000000..7bfe444 Binary files /dev/null and b/api/__pycache__/auth.cpython-310.pyc differ diff --git a/api/__pycache__/auth.cpython-313.pyc b/api/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..ecb388d Binary files /dev/null and b/api/__pycache__/auth.cpython-313.pyc differ diff --git a/api/__pycache__/classes.cpython-310.pyc b/api/__pycache__/classes.cpython-310.pyc new file mode 100644 index 0000000..13340d2 Binary files /dev/null and b/api/__pycache__/classes.cpython-310.pyc differ diff --git a/api/__pycache__/classes.cpython-313.pyc b/api/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000..4f6d107 Binary files /dev/null and b/api/__pycache__/classes.cpython-313.pyc differ diff --git a/api/__pycache__/employment.cpython-310.pyc b/api/__pycache__/employment.cpython-310.pyc new file mode 100644 index 0000000..af25406 Binary files /dev/null and b/api/__pycache__/employment.cpython-310.pyc differ diff --git a/api/__pycache__/employment.cpython-313.pyc b/api/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000..92021c2 Binary files /dev/null and b/api/__pycache__/employment.cpython-313.pyc differ diff --git a/api/__pycache__/scores.cpython-310.pyc b/api/__pycache__/scores.cpython-310.pyc new file mode 100644 index 0000000..60db9f6 Binary files /dev/null and b/api/__pycache__/scores.cpython-310.pyc differ diff --git a/api/__pycache__/scores.cpython-313.pyc b/api/__pycache__/scores.cpython-313.pyc new file mode 100644 index 0000000..b72c166 Binary files /dev/null and b/api/__pycache__/scores.cpython-313.pyc differ diff --git a/api/__pycache__/statistics.cpython-310.pyc b/api/__pycache__/statistics.cpython-310.pyc new file mode 100644 index 0000000..86098c8 Binary files /dev/null and b/api/__pycache__/statistics.cpython-310.pyc differ diff --git a/api/__pycache__/statistics.cpython-313.pyc b/api/__pycache__/statistics.cpython-313.pyc new file mode 100644 index 0000000..a596ec7 Binary files /dev/null and b/api/__pycache__/statistics.cpython-313.pyc differ diff --git a/api/__pycache__/students.cpython-310.pyc b/api/__pycache__/students.cpython-310.pyc new file mode 100644 index 0000000..b216054 Binary files /dev/null and b/api/__pycache__/students.cpython-310.pyc differ diff --git a/api/__pycache__/students.cpython-313.pyc b/api/__pycache__/students.cpython-313.pyc new file mode 100644 index 0000000..b3ef175 Binary files /dev/null and b/api/__pycache__/students.cpython-313.pyc differ diff --git a/api/__pycache__/teachers.cpython-310.pyc b/api/__pycache__/teachers.cpython-310.pyc new file mode 100644 index 0000000..671aba9 Binary files /dev/null and b/api/__pycache__/teachers.cpython-310.pyc differ diff --git a/api/__pycache__/teachers.cpython-313.pyc b/api/__pycache__/teachers.cpython-313.pyc new file mode 100644 index 0000000..e6ab402 Binary files /dev/null and b/api/__pycache__/teachers.cpython-313.pyc differ diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..ce9ca99 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,52 @@ +# api/auth.py +# 认证模块:登录(签发 JWT)、创建用户(仅管理员)、获取当前用户信息 +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from core.security import verify_password, create_access_token +from core.deps import get_current_user, require_roles +from dao.users_dao import UserDAO +from database import get_db +from model.user import User +from scheme.users import UserAdd, UserLogin, UserResponse, TokenResponse + +router = APIRouter() + + +# ---------- 登录 ---------- +@router.post("/login", response_model=TokenResponse, summary="登录获取 JWT token") +async def login(body: UserLogin, db: Session = Depends(get_db)): + user = UserDAO.get_by_username(db, body.username) + # 用户不存在 / 已被软删除 / 密码错误,统一返回 401(避免暴露用户是否存在) + if (user is None) or user.is_deleted == 1 or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = create_access_token(user.user_id, user.role) + return TokenResponse( + access_token=token, + user=UserResponse.model_validate(user), + ) + + +# ---------- 创建用户(仅管理员) ---------- +@router.post("/register", response_model=UserResponse, summary="创建用户(仅管理员)") +async def register( + body: UserAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + existing = UserDAO.get_by_username(db, body.username) + if existing: + raise HTTPException(status_code=409, detail="用户名已存在") + # 绑定业务身份的合法性校验 + if body.role == "student" and body.stu_id is None: + raise HTTPException(status_code=400, detail="student 角色必须提供 stu_id") + if body.role == "teacher" and body.teacher_id is None: + raise HTTPException(status_code=400, detail="teacher 角色必须提供 teacher_id") + user = UserDAO.add_user(db, body) + return user + + +# ---------- 获取当前登录用户 ---------- +@router.get("/me", response_model=UserResponse, summary="获取当前登录用户信息") +async def me(current_user: User = Depends(get_current_user)): + return current_user diff --git a/api/classes.py b/api/classes.py new file mode 100644 index 0000000..4a585ac --- /dev/null +++ b/api/classes.py @@ -0,0 +1,80 @@ +# api/classes.py +# 班级模块接口:增、查(分页)、改、逻辑删除 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles +from dao.classes_dao import ClassDAO +from database import get_db +from model.user import User +from scheme.classes import ClassAdd, ClassUpdate, ClassResponse, ClassListResponse + +router = APIRouter() + + +# ---------- 创建班级 ---------- +@router.post("/add", response_model=ClassResponse, summary="创建班级") +async def add_class( + class_data: ClassAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + if class_data.class_id is not None: + existing = ClassDAO.inspect_class_id_unq(db, class_data.class_id) + if existing: + raise HTTPException(status_code=409, detail="班级ID已存在(包括软删除记录)") + return ClassDAO.add_class(db, class_data) + + +# ---------- 查询班级列表(分页) ---------- +@router.get("/total_query", response_model=ClassListResponse, summary="查询班级列表(分页)") +async def get_classes( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + total, items = ClassDAO.get_all(db, skip=skip, limit=limit) + return ClassListResponse(total=total, items=items) + + +# ---------- 根据 ID 查询单个班级 ---------- +@router.get("/single_query", response_model=ClassResponse, summary="根据ID查询班级") +async def get_class( + class_id: int = Query(..., ge=1), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + db_class = ClassDAO.get_active(db, class_id) + if db_class is None: + raise HTTPException(status_code=404, detail="班级不存在,或已被软删除") + return db_class + + +# ---------- 更新班级 ---------- +@router.put("/update", response_model=ClassResponse, summary="更新班级信息") +async def update_class( + class_data: ClassUpdate, + class_id: int = Query(..., ge=1, description="要更新的班级ID"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + updated = ClassDAO.update(db, class_id, class_data) + if updated is None: + raise HTTPException(status_code=404, detail="班级不存在,或已被软删除") + return updated + + +# ---------- 逻辑删除班级 ---------- +@router.delete("/delete", status_code=204, summary="逻辑删除班级(班级下有学生时拒绝)") +async def delete_class( + class_id: int = Query(..., ge=1, description="要删除的班级ID"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + result = ClassDAO.delete_light(db, class_id) + if result is False: + raise HTTPException(status_code=404, detail="班级不存在,或已被软删除") + if result == -1: + raise HTTPException(status_code=409, detail="班级下仍有学生,不能删除") + return None diff --git a/api/employment.py b/api/employment.py new file mode 100644 index 0000000..f68b620 --- /dev/null +++ b/api/employment.py @@ -0,0 +1,160 @@ +# api/employment.py +# 就业模块接口:就业开放登记、offer登记(状态联动)、多条件查询、修改、删除 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles, get_teacher_class_ids +from dao.employment_dao import EmploymentDAO +from dao.students_dao import StudentDAO +from database import get_db +from model.user import User +from scheme.employment import ( + EmploymentOpen, + OfferAdd, + EmploymentUpdate, + OfferResponse, + EmploymentResponse, + EmploymentListResponse, +) + +router = APIRouter() + + +def _can_manage_student_employment(db: Session, user: User, student) -> bool: + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +def _check_student_manage(db: Session, user: User, stu_id: int): + """公共校验:学生存在 + 操作者有权管理该学生就业信息""" + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_employment(db, user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的就业信息") + return student + + +def _to_response(db_base) -> EmploymentResponse: + resp = EmploymentResponse.model_validate(db_base) + resp.offers = [OfferResponse.model_validate(o) for o in (db_base.offers or [])] + return resp + + +# ---------- 登记就业开放 ---------- +@router.post("/open", response_model=EmploymentResponse, summary="登记就业开放(学生状态联动→进入就业)") +async def open_employment( + body: EmploymentOpen, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, body.stu_id) + existing = EmploymentDAO.get_base(db, body.stu_id) + if existing: + raise HTTPException(status_code=409, detail="该学生已登记就业开放,请勿重复登记") + db_base = EmploymentDAO.open_employment(db, body.stu_id, body.employment_open_time) + return _to_response(db_base) + + +# ---------- 登记 offer ---------- +@router.post("/offer", response_model=EmploymentResponse, summary="登记offer(学生状态联动→已就业)") +async def add_offer( + body: OfferAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, body.stu_id) + db_base = EmploymentDAO.get_base(db, body.stu_id) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生尚未登记就业开放,请先调用 /employment/open") + if body.offer_time < db_base.employment_open_time: + raise HTTPException(status_code=400, detail="offer下发时间不能早于就业开放时间") + EmploymentDAO.add_offer(db, body.stu_id, body.offer_time, body.company_name, body.salary) + return _to_response(EmploymentDAO.get_base(db, body.stu_id)) + + +# ---------- 查询学生就业信息 ---------- +@router.get("/students/{stu_id}", response_model=EmploymentResponse, summary="查询学生就业信息") +async def get_student_employment( + stu_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.role == "student" and current_user.stu_id != stu_id: + raise HTTPException(status_code=403, detail="学生角色只能查看自己的就业信息") + db_base = EmploymentDAO.get_by_student(db, stu_id) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return _to_response(db_base) + + +# ---------- 查询班级就业信息 ---------- +@router.get("/class/{class_id}", response_model=EmploymentListResponse, summary="查询班级学生就业信息") +async def get_class_employment( + class_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + total, items = EmploymentDAO.get_by_class(db, class_id, skip=skip, limit=limit) + return EmploymentListResponse(total=total, items=[_to_response(b) for b in items]) + + +# ---------- 多条件查询就业信息 ---------- +@router.get("/total_query", response_model=EmploymentListResponse, summary="多条件查询就业信息(学号/公司/薪资范围)") +async def query_employment( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + stu_id: int = Query(None, ge=1, description="按学号精确查询"), + company_name: str = Query(None, description="按公司名称模糊查询"), + salary_min: float = Query(None, ge=0, description="薪资下限"), + salary_max: float = Query(None, ge=0, description="薪资上限"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + total, items = EmploymentDAO.get_all( + db, + skip=skip, + limit=limit, + stu_id=stu_id, + company_name=company_name, + salary_min=salary_min, + salary_max=salary_max, + ) + return EmploymentListResponse(total=total, items=[_to_response(b) for b in items]) + + +# ---------- 修改就业基础信息 ---------- +@router.put("/update", response_model=EmploymentResponse, summary="修改学生就业基础信息") +async def update_employment( + body: EmploymentUpdate, + stu_id: int = Query(..., ge=1, description="学生编号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, stu_id) + update_data = body.model_dump(exclude_unset=True, exclude_none=True) + if not update_data: + raise HTTPException(status_code=400, detail="至少提供一个要修改的字段") + db_base = EmploymentDAO.update_base(db, stu_id, update_data) + if db_base is None: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return _to_response(db_base) + + +# ---------- 删除就业信息 ---------- +@router.delete("/delete", status_code=204, summary="删除学生就业信息(逻辑删除,状态回退为在读)") +async def delete_employment( + stu_id: int = Query(..., ge=1, description="学生编号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + _check_student_manage(db, current_user, stu_id) + success = EmploymentDAO.delete_light(db, stu_id) + if not success: + raise HTTPException(status_code=404, detail="该学生暂无就业信息") + return None diff --git a/api/scores.py b/api/scores.py new file mode 100644 index 0000000..2115fcf --- /dev/null +++ b/api/scores.py @@ -0,0 +1,123 @@ +# api/scores.py +# 成绩模块接口:录入(含 60 分红线预警)、修改、删除、查询 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles, get_teacher_class_ids +from dao.scores_dao import ScoreDAO +from dao.students_dao import StudentDAO +from database import get_db +from model.user import User +from scheme.scores import ( + ScoreAdd, + ScoreUpdate, + ScoreDelete, + ScoreResponse, + ScoreListResponse, + ScoreAddResponse, +) + +router = APIRouter() + + +def _can_manage_student_scores(db: Session, user: User, student) -> bool: + """教师只能管理自己所带班级学生的成绩;管理员不受限""" + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +# ---------- 录入成绩 ---------- +@router.post("/add", response_model=ScoreAddResponse, summary="录入成绩(低于60分触发预警)") +async def add_score( + score_data: ScoreAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + # 外键校验:学生必须存在 + student = StudentDAO.get_active(db, score_data.stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + # 复合主键冲突校验 + existing = ScoreDAO.get_one(db, score_data.stu_id, score_data.exam_id, include_deleted=True) + if existing: + raise HTTPException(status_code=409, detail="该学生此考核序次的成绩已存在,请使用修改接口") + db_score = ScoreDAO.add_score(db, score_data) + item = ScoreDAO.build_score_item(db_score, stu_name=student.stu_name) + warning = None + if item["is_warning"]: + warning = f"预警:学生 {student.stu_name}(学号 {student.stu_id})第 {score_data.exam_id} 次考核成绩 {score_data.score} 分低于 {ScoreDAO.WARNING_LINE} 分红线,需要多加关注!" + return ScoreAddResponse(score=ScoreResponse(**item), warning=warning) + + +# ---------- 查询学生成绩 ---------- +@router.get("/query", response_model=ScoreListResponse, summary="查询成绩(按学号或考核序次)") +async def get_scores( + stu_id: int = Query(None, ge=1, description="按学号查询(学生角色只能查自己)"), + exam_id: int = Query(None, ge=1, description="按考核序次查询"), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # 学生角色只能查自己的成绩 + if current_user.role == "student": + stu_id = current_user.stu_id + if stu_id is None and exam_id is None: + raise HTTPException(status_code=400, detail="stu_id 与 exam_id 至少提供一个") + if stu_id is not None: + rows = ScoreDAO.get_by_student(db, stu_id) + student = StudentDAO.get_active(db, stu_id) + stu_name = student.stu_name if student else None + items = [ScoreResponse(**ScoreDAO.build_score_item(s, stu_name)) for s in rows] + return ScoreListResponse(total=len(items), items=items) + total, rows = ScoreDAO.get_by_exam(db, exam_id, skip=skip, limit=limit) + items = [] + for s in rows: + student = StudentDAO.get_active(db, s.stu_id) + items.append( + ScoreResponse(**ScoreDAO.build_score_item(s, student.stu_name if student else None)) + ) + return ScoreListResponse(total=total, items=items) + + +# ---------- 修改成绩 ---------- +@router.put("/update", response_model=ScoreResponse, summary="修改指定学生的某次成绩") +async def update_score( + score_data: ScoreUpdate, + stu_id: int = Query(..., ge=1, description="学生编号"), + exam_id: int = Query(..., ge=1, description="考核序次"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + db_score = ScoreDAO.update_score(db, stu_id, exam_id, score_data.score) + if db_score is None: + raise HTTPException(status_code=404, detail="该学生此考核序次的成绩不存在") + return ScoreResponse(**ScoreDAO.build_score_item(db_score, student.stu_name)) + + +# ---------- 删除成绩 ---------- +@router.post("/delete", status_code=204, summary="删除指定学生的某次成绩(逻辑删除)") +async def delete_score( + body: ScoreDelete, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, body.stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student_scores(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级学生的成绩") + success = ScoreDAO.delete_score(db, body.stu_id, body.exam_id) + if not success: + raise HTTPException(status_code=404, detail="该成绩不存在,或已被删除") + return None diff --git a/api/statistics.py b/api/statistics.py new file mode 100644 index 0000000..e7e6d38 --- /dev/null +++ b/api/statistics.py @@ -0,0 +1,147 @@ +# api/statistics.py +# 统计分析模块接口:动态年龄查询、班级统计、成绩统计、就业统计、高级筛选器、聚合统计 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import require_roles +from dao.statistics_dao import StatisticsDAO, FilterBuilder +from database import get_db +from scheme.statistics import ( + FilterRequest, + FilterResponse, + ClassGenderStat, + AllAboveStudent, + FailStudent, + ClassExamAvg, + TopSalaryStudent, + EmploymentDuration, + ClassAvgDuration, + ScoreVolatility, + EmploymentFunnel, +) +from scheme.students import StudentResponse + +router = APIRouter() + +# 统计模块统一要求 admin / teacher 角色(各接口通过 Depends(require_roles(...)) 校验) + + +# ==================== 2.6.1 基本信息动态统计 ==================== +@router.get("/students/by-age", response_model=list[StudentResponse], summary="动态年龄范围查询") +async def students_by_age( + op: str = Query(..., description="比较条件:gt/lt/eq/gte/lte/between"), + value: int = Query(None, description="op 为 gt/lt/eq/gte/lte 时必填"), + min_value: int = Query(None, description="op=between 时的下界"), + max_value: int = Query(None, description="op=between 时的上界"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + try: + items = StatisticsDAO.students_by_age(db, op=op, value=value, min_value=min_value, max_value=max_value) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return items + + +@router.get("/class/gender-stats", response_model=list[ClassGenderStat], summary="多维度班级统计(总人数+男女分布)") +async def class_gender_stats( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_gender_stats(db) + + +# ==================== 2.6.2 成绩综合统计 ==================== +@router.get("/score/all-above", response_model=list[AllAboveStudent], summary="每次考试都在分数线以上的学生") +async def score_all_above( + line: float = Query(..., ge=0, le=100, description="分数线,如 80"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.students_all_above(db, line) + + +@router.get("/score/fail", response_model=list[FailStudent], summary="不及格次数>=N的学生(含明细)") +async def score_fail( + times: int = Query(..., ge=1, description="不及格次数阈值,如 2"), + line: float = Query(60.0, ge=0, le=100, description="及格线,默认 60"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.fail_students(db, times, line) + + +@router.get("/score/class-avg", response_model=list[ClassExamAvg], summary="每次考试每个班级平均分(支持动态排序)") +async def score_class_avg( + exam_id: int = Query(None, ge=1, description="考核序次(不传则返回所有考核)"), + order: str = Query("desc", pattern="^(asc|desc)$", description="按平均分排序方向"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_exam_avg(db, exam_id=exam_id, order=order) + + +# ==================== 2.6.3 就业数据统计 ==================== +@router.get("/employment/top-salary", response_model=list[TopSalaryStudent], summary="就业薪资排名 Top N") +async def employment_top_salary( + n: int = Query(..., ge=1, le=100, description="取前 N 名"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.top_salary(db, n) + + +@router.get("/employment/duration", response_model=list[EmploymentDuration], summary="每个学生的就业时长(天)") +async def employment_duration( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.employment_durations(db) + + +@router.get("/employment/class-avg-duration", response_model=list[ClassAvgDuration], summary="每个班级平均就业时长") +async def employment_class_avg_duration( + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.class_avg_duration(db) + + +# ==================== 2.7.1 通用高级筛选器 ==================== +@router.post("/filter", response_model=FilterResponse, summary="通用高级筛选器(AND/OR 嵌套规则)") +async def advanced_filter( + body: FilterRequest, + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + try: + total, items = FilterBuilder.query_students(db, body.rules) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + # 组装为字典响应(含班级名称) + data = [] + for s in items: + row = StudentResponse.model_validate(s).model_dump() + if s.classes: + row["class_name"] = s.classes.class_name + data.append(row) + return FilterResponse(total=total, items=data) + + +# ==================== 2.7.2 多维度聚合统计 ==================== +@router.get("/score/volatility", response_model=list[ScoreVolatility], summary="成绩波动最大 Top N(最大分差)") +async def score_volatility( + top_n: int = Query(5, ge=1, le=50, description="取前 N 名,默认 5"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.score_volatility(db, top_n) + + +@router.get("/employment/funnel", response_model=list[EmploymentFunnel], summary="班级就业漏斗(总人数→已就业→高薪→就业率)") +async def employment_funnel( + high_salary_line: float = Query(10000.0, ge=0, description="高薪线,默认 10000"), + db: Session = Depends(get_db), + _user=Depends(require_roles("admin", "teacher")), +): + return StatisticsDAO.employment_funnel(db, high_salary_line) diff --git a/api/students.py b/api/students.py new file mode 100644 index 0000000..5a25a72 --- /dev/null +++ b/api/students.py @@ -0,0 +1,146 @@ +# api/students.py +# 学生模块接口:增、查(多条件分页)、改、逻辑删除 +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles, get_teacher_class_ids +from dao.students_dao import StudentDAO +from database import get_db +from model.user import User +from scheme.students import StudentAdd, StudentUpdate, StudentResponse, StudentListResponse + +router = APIRouter() + + +def _can_read_student(user: User, student) -> bool: + """学生角色只能读取自己的信息""" + if user.role == "student": + return user.stu_id == student.stu_id + return True + + +def _can_manage_student(db: Session, user: User, student) -> bool: + """教师角色只能管理自己所带班级的学生;管理员不受限""" + if user.role == "admin": + return True + if user.role == "teacher": + return student.class_id in get_teacher_class_ids(db, user.teacher_id) + return False + + +# ---------- 创建新学生 ---------- +@router.post("/add", response_model=StudentResponse, summary="创建学生(学号可自动生成)") +async def add_student( + student: StudentAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + # 外键校验:班级 / 顾问 + if not StudentDAO.inspect_class_id_unq(db, student.class_id): + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + if not StudentDAO.inspect_advisor_id_unq(db, student.advisor_id): + raise HTTPException(status_code=404, detail="顾问ID不存在,或已被软删除") + # 教师只能给自己班上的学生建档 + if current_user.role == "teacher" and student.class_id not in get_teacher_class_ids( + db, current_user.teacher_id + ): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + # 主键校验(显式传入学号时) + if student.stu_id is not None: + existing = StudentDAO.inspect_student_id_unq(db, student.stu_id) + if existing: + raise HTTPException(status_code=409, detail="学号已存在(包括软删除记录)") + return StudentDAO.add_student(db, student) + + +# ---------- 查询学生列表(多条件 + 分页) ---------- +@router.get("/total_query", response_model=StudentListResponse, summary="查询学生列表(多条件筛选+分页)") +async def get_students( + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=200, description="返回的最大记录数"), + stu_id: Optional[int] = Query(None, description="按学号精确查询"), + stu_name: Optional[str] = Query(None, description="按姓名模糊查询"), + class_id: Optional[int] = Query(None, description="按班级ID筛选"), + gender: Optional[str] = Query(None, description="按性别筛选:男/女"), + status: Optional[str] = Query(None, description="按状态筛选:在读/进入就业/已就业"), + education: Optional[str] = Query(None, description="按学历筛选"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + total, items = StudentDAO.get_all( + db, + skip=skip, + limit=limit, + stu_id=stu_id, + stu_name=stu_name, + class_id=class_id, + gender=gender, + status=status, + education=education, + ) + # 学生角色只能看到自己 + if current_user.role == "student": + items = [s for s in items if s.stu_id == current_user.stu_id] + total = len(items) + return StudentListResponse( + total=total, + items=[StudentResponse.model_validate(s) for s in items], + ) + + +# ---------- 根据 ID 查询单个学生 ---------- +@router.get("/single_query", response_model=StudentResponse, summary="根据学号查询学生") +async def get_student( + stu_id: int = Query(..., ge=1, description="学号"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_read_student(current_user, student): + raise HTTPException(status_code=403, detail="学生角色只能查看自己的信息") + resp = StudentResponse.model_validate(student) + # 联表补充班级名与顾问名 + if student.classes: + resp.class_name = student.classes.class_name + if student.advisor: + resp.advisor_name = student.advisor.advisor_name + return resp + + +# ---------- 更新学生信息 ---------- +@router.put("/update", response_model=StudentResponse, summary="更新学生信息") +async def update_student( + student_data: StudentUpdate, + stu_id: int = Query(..., ge=1, description="要更新的学号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + updated = StudentDAO.update(db, stu_id, student_data) + return StudentResponse.model_validate(updated) + + +# ---------- 逻辑删除学生 ---------- +@router.delete("/delete", status_code=204, summary="逻辑删除学生") +async def delete_student( + stu_id: int = Query(..., ge=1, description="要删除的学号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "teacher")), +): + student = StudentDAO.get_active(db, stu_id) + if student is None: + raise HTTPException(status_code=404, detail="学生不存在,或已被软删除") + if not _can_manage_student(db, current_user, student): + raise HTTPException(status_code=403, detail="教师只能管理自己所带班级的学生") + success = StudentDAO.delete_light(db, stu_id) + if not success: + raise HTTPException(status_code=404, detail="学生不存在") + return None diff --git a/api/teachers.py b/api/teachers.py new file mode 100644 index 0000000..6da2867 --- /dev/null +++ b/api/teachers.py @@ -0,0 +1,87 @@ +# api/teachers.py +# 老师模块接口:增、查(分页)、改、逻辑删除(沿用原 teacher 模块接口风格) +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.deps import get_current_user, require_roles +from dao.teachers_dao import TeacherDAO +from database import get_db +from model.user import User +from scheme.teachers import TeacherAdd, TeacherUpdate, TeacherResponse, TeacherListResponse + +router = APIRouter() + + +# ---------- 创建新教师 ---------- +@router.post("/teacher/add", response_model=TeacherResponse, summary="创建教师(工号可自动生成)") +async def add_teacher( + teacher: TeacherAdd, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + # 外键校验:班级必须存在 + existing_class = TeacherDAO.inspect_class_id_unq(db, teacher.class_id) + if not existing_class: + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + # 主键校验(显式传入工号时) + if teacher.teacher_id is not None: + existing_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher.teacher_id) + if existing_teacher: + raise HTTPException(status_code=409, detail="工号已存在(包括软删除记录)") + return TeacherDAO.add_teacher(db, teacher) + + +# ---------- 软删除教师 ---------- +@router.delete("/teacher/delete", status_code=204, summary="逻辑删除教师") +async def light_delete_teacher( + teacher_id: int = Query(..., ge=1, description="要删除的教师工号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + success = TeacherDAO.delete_light(db, teacher_id) + if not success: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被删除") + return None + + +# ---------- 更新教师信息 ---------- +@router.put("/teacher/update", response_model=TeacherResponse, summary="更新教师信息") +async def update_teacher( + teacher_data: TeacherUpdate, + teacher_id: int = Query(..., ge=1, description="要更新的教师工号"), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin")), +): + if teacher_data.class_id is not None: + existing_class = TeacherDAO.inspect_class_id_unq(db, teacher_data.class_id) + if not existing_class: + raise HTTPException(status_code=404, detail="班级ID不存在,或已被软删除") + updated = TeacherDAO.update(db, teacher_id, teacher_data) + if updated is None: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被软删除") + return TeacherDAO.get_by_id(db, teacher_id) + + +# ---------- 查询所有教师(分页) ---------- +@router.get("/teacher/total_query", response_model=TeacherListResponse, summary="查询教师列表(分页,含班级名称)") +async def get_teachers( + skip: int = Query(0, ge=0, description="跳过的记录数"), + limit: int = Query(100, ge=1, le=200, description="返回的最大记录数"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + total, items = TeacherDAO.get_all(db, skip=skip, limit=limit) + return TeacherListResponse(total=total, items=items) + + +# ---------- 根据 ID 查询单个教师 ---------- +@router.get("/teacher/single_query", response_model=TeacherResponse, summary="根据工号查询教师") +async def get_teacher( + teacher_id: int = Query(..., ge=1), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + teacher = TeacherDAO.get_by_id(db, teacher_id) + if teacher is None: + raise HTTPException(status_code=404, detail="教师工号不存在,或已被删除") + return teacher diff --git a/config.py b/config.py new file mode 100644 index 0000000..a8f7e48 --- /dev/null +++ b/config.py @@ -0,0 +1,36 @@ +# config.py +# 全局配置:通过环境变量覆盖,便于 Docker 部署时注入 +import os + + +class Settings: + """集中管理项目配置,生产环境请通过环境变量覆盖默认值""" + + # ---------- 应用 ---------- + APP_NAME: str = "沃林学生管理系统" + APP_VERSION: str = "1.0.0" + DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + + # ---------- 数据库 ---------- + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + # 默认本地开发环境:请替换为你自己的 MySQL 信息 + "mysql+pymysql://root:123456@localhost:3306/walin_db?charset=utf8mb4", + ) + DB_ECHO: bool = os.getenv("DB_ECHO", "false").lower() == "true" + + # ---------- JWT ---------- + SECRET_KEY: str = os.getenv( + "SECRET_KEY", + "CHANGE_ME_IN_PRODUCTION_please_use_a_long_random_string_here", + ) + JWT_ALGORITHM: str = "HS256" + # token 有效期(分钟),默认 12 小时 + ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720")) + + # ---------- 初始管理员(仅用于 seeding 参考,见 sql/init.sql) ---------- + ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") + ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") + + +settings = Settings() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..9bde55f --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# core 包:安全与依赖 diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..cb122a2 Binary files /dev/null and b/core/__pycache__/__init__.cpython-310.pyc differ diff --git a/core/__pycache__/deps.cpython-310.pyc b/core/__pycache__/deps.cpython-310.pyc new file mode 100644 index 0000000..9cf33c6 Binary files /dev/null and b/core/__pycache__/deps.cpython-310.pyc differ diff --git a/core/__pycache__/deps.cpython-313.pyc b/core/__pycache__/deps.cpython-313.pyc new file mode 100644 index 0000000..87ffd4f Binary files /dev/null and b/core/__pycache__/deps.cpython-313.pyc differ diff --git a/core/__pycache__/security.cpython-310.pyc b/core/__pycache__/security.cpython-310.pyc new file mode 100644 index 0000000..da5d0a2 Binary files /dev/null and b/core/__pycache__/security.cpython-310.pyc differ diff --git a/core/__pycache__/security.cpython-313.pyc b/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000..2a595fc Binary files /dev/null and b/core/__pycache__/security.cpython-313.pyc differ diff --git a/core/deps.py b/core/deps.py new file mode 100644 index 0000000..f576450 --- /dev/null +++ b/core/deps.py @@ -0,0 +1,73 @@ +# core/deps.py +# 认证与权限依赖:从请求头解析 JWT -> 加载用户 -> 角色级 RBAC 校验 + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import jwt +from sqlalchemy.orm import Session + +from core.security import decode_access_token +from database import get_db +from model.user import User + +# 自动从 Authorization: Bearer 中提取 token +_bearer_scheme = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), + db: Session = Depends(get_db), +) -> User: + """ + 解析 JWT 并返回当前登录用户(model.user.User 对象) + 未登录 / token 无效 / 用户不存在 均抛 401 + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录,请先获取 token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_access_token(credentials.credentials) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="token 已过期,请重新登录") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="token 无效") + + user_id = int(payload["sub"]) + user = ( + db.query(User) + .filter(User.user_id == user_id, User.is_deleted == 0) + .first() + ) + if user is None: + raise HTTPException(status_code=401, detail="用户不存在或已被禁用") + return user + + +def require_roles(*roles: str): + """ + 角色级 RBAC 依赖工厂。 + 用法:user: User = Depends(require_roles("admin", "teacher")) + """ + def checker(user: User = Depends(get_current_user)) -> User: + if user.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"权限不足,需要角色:{' / '.join(roles)},当前角色:{user.role}", + ) + return user + return checker + + +def get_teacher_class_ids(db: Session, teacher_id: int) -> list[int]: + """获取某教师所带班级 ID 列表(用于资源归属校验)""" + from model.teachers import Teacher + + rows = ( + db.query(Teacher.class_id) + .filter(Teacher.teacher_id == teacher_id, Teacher.is_deleted == 0) + .all() + ) + return [r[0] for r in rows] diff --git a/core/security.py b/core/security.py new file mode 100644 index 0000000..bd35ea1 --- /dev/null +++ b/core/security.py @@ -0,0 +1,70 @@ +# core/security.py +# 安全工具:密码哈希(PBKDF2-SHA256,标准库实现,无额外依赖)+ JWT 签发与校验 + +import hashlib +import hmac +import os +from datetime import datetime, timedelta, timezone + +import jwt + +from config import settings + +# PBKDF2 迭代次数与哈希长度 +_ITERATIONS = 120_000 +_KEY_LEN = 32 + + +# ==================== 密码哈希 ==================== +def hash_password(plain_password: str) -> str: + """ + 生成密码哈希,格式:pbkdf2_sha256$$$ + 盐值每次随机生成,同一密码两次加密结果不同 + """ + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac( + "sha256", plain_password.encode("utf-8"), salt, _ITERATIONS, dklen=_KEY_LEN + ) + return f"pbkdf2_sha256${_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(plain_password: str, password_hash: str) -> bool: + """校验密码:用同样的盐和迭代次数重新计算,恒定时间比较""" + try: + algorithm, iterations, salt_hex, hash_hex = password_hash.split("$") + if algorithm != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac( + "sha256", + plain_password.encode("utf-8"), + bytes.fromhex(salt_hex), + int(iterations), + dklen=len(bytes.fromhex(hash_hex)), + ) + return hmac.compare_digest(digest, bytes.fromhex(hash_hex)) + except (ValueError, TypeError): + return False + + +# ==================== JWT ==================== +def create_access_token(user_id: int, role: str) -> str: + """签发 JWT,payload 携带用户 ID 与角色""" + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + payload = { + "sub": str(user_id), # sub 建议为字符串 + "role": role, + "exp": expire, + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict: + """ + 解码并校验 JWT + :raises jwt.ExpiredSignatureError: token 过期 + :raises jwt.InvalidTokenError: token 无效 + """ + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) diff --git a/dao/__init__.py b/dao/__init__.py new file mode 100644 index 0000000..f0c27f1 --- /dev/null +++ b/dao/__init__.py @@ -0,0 +1,2 @@ +# dao/__init__.py +# 数据访问层包 diff --git a/dao/__pycache__/__init__.cpython-310.pyc b/dao/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..bc69293 Binary files /dev/null and b/dao/__pycache__/__init__.cpython-310.pyc differ diff --git a/dao/__pycache__/classes_dao.cpython-310.pyc b/dao/__pycache__/classes_dao.cpython-310.pyc new file mode 100644 index 0000000..d98ec10 Binary files /dev/null and b/dao/__pycache__/classes_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/classes_dao.cpython-313.pyc b/dao/__pycache__/classes_dao.cpython-313.pyc new file mode 100644 index 0000000..8ea626e Binary files /dev/null and b/dao/__pycache__/classes_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/employment_dao.cpython-310.pyc b/dao/__pycache__/employment_dao.cpython-310.pyc new file mode 100644 index 0000000..e08992d Binary files /dev/null and b/dao/__pycache__/employment_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/employment_dao.cpython-313.pyc b/dao/__pycache__/employment_dao.cpython-313.pyc new file mode 100644 index 0000000..77a7d77 Binary files /dev/null and b/dao/__pycache__/employment_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/scores_dao.cpython-310.pyc b/dao/__pycache__/scores_dao.cpython-310.pyc new file mode 100644 index 0000000..28dd1de Binary files /dev/null and b/dao/__pycache__/scores_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/scores_dao.cpython-313.pyc b/dao/__pycache__/scores_dao.cpython-313.pyc new file mode 100644 index 0000000..bde0e88 Binary files /dev/null and b/dao/__pycache__/scores_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/statistics_dao.cpython-310.pyc b/dao/__pycache__/statistics_dao.cpython-310.pyc new file mode 100644 index 0000000..daf3572 Binary files /dev/null and b/dao/__pycache__/statistics_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/statistics_dao.cpython-313.pyc b/dao/__pycache__/statistics_dao.cpython-313.pyc new file mode 100644 index 0000000..846363c Binary files /dev/null and b/dao/__pycache__/statistics_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/students_dao.cpython-310.pyc b/dao/__pycache__/students_dao.cpython-310.pyc new file mode 100644 index 0000000..d7947b5 Binary files /dev/null and b/dao/__pycache__/students_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/students_dao.cpython-313.pyc b/dao/__pycache__/students_dao.cpython-313.pyc new file mode 100644 index 0000000..ec950eb Binary files /dev/null and b/dao/__pycache__/students_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/teachers_dao.cpython-310.pyc b/dao/__pycache__/teachers_dao.cpython-310.pyc new file mode 100644 index 0000000..cda9451 Binary files /dev/null and b/dao/__pycache__/teachers_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/teachers_dao.cpython-313.pyc b/dao/__pycache__/teachers_dao.cpython-313.pyc new file mode 100644 index 0000000..d8ff812 Binary files /dev/null and b/dao/__pycache__/teachers_dao.cpython-313.pyc differ diff --git a/dao/__pycache__/users_dao.cpython-310.pyc b/dao/__pycache__/users_dao.cpython-310.pyc new file mode 100644 index 0000000..a20f229 Binary files /dev/null and b/dao/__pycache__/users_dao.cpython-310.pyc differ diff --git a/dao/__pycache__/users_dao.cpython-313.pyc b/dao/__pycache__/users_dao.cpython-313.pyc new file mode 100644 index 0000000..b111e2e Binary files /dev/null and b/dao/__pycache__/users_dao.cpython-313.pyc differ diff --git a/dao/classes_dao.py b/dao/classes_dao.py new file mode 100644 index 0000000..f508404 --- /dev/null +++ b/dao/classes_dao.py @@ -0,0 +1,76 @@ +# dao/classes_dao.py +# 班级表的数据访问层(增、查、改、逻辑删除) +from typing import Tuple + +from sqlalchemy.orm import Session + +from model.classes import Classinfo +from model.students import Student +from scheme.classes import ClassAdd, ClassUpdate + + +class ClassDAO: + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int): + """ + 根据班级ID获取班级对象(用于主键唯一性检查,查询结果包含被软删除的对象) + """ + return db.query(Classinfo).filter(Classinfo.class_id == class_id).first() + + @staticmethod + def get_active(db: Session, class_id: int): + """获取未被软删除的班级对象(用于外键校验)""" + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def add_class(db: Session, class_data: ClassAdd) -> Classinfo: + """新增班级;class_id 不传则使用自增""" + db_class = Classinfo(**class_data.model_dump(exclude_none=True)) + db.add(db_class) + db.commit() + db.refresh(db_class) + return db_class + + @staticmethod + def update(db: Session, class_id: int, class_data: ClassUpdate): + """更新班级(只更新传入的非 None 字段)""" + db_class = ClassDAO.get_active(db, class_id) + if db_class is None: + return None + for key, value in class_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_class, key, value) + db.commit() + db.refresh(db_class) + return db_class + + @staticmethod + def delete_light(db: Session, class_id: int) -> bool: + """ + 逻辑删除班级;若班级下仍有未删除学生则拒绝删除 + :return: True 成功 / False 不存在 / -1 班级下还有学生 + """ + db_class = ClassDAO.get_active(db, class_id) + if db_class is None: + return False + student_count = ( + db.query(Student) + .filter(Student.class_id == class_id, Student.is_deleted == 0) + .count() + ) + if student_count > 0: + return -1 + db_class.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_all(db: Session, skip: int = 0, limit: int = 100) -> Tuple[int, list]: + """分页获取所有班级(不含软删除),返回 (总数, 列表)""" + query = db.query(Classinfo).filter(Classinfo.is_deleted == 0) + total = query.count() + items = query.order_by(Classinfo.class_id).offset(skip).limit(limit).all() + return total, items diff --git a/dao/employment_dao.py b/dao/employment_dao.py new file mode 100644 index 0000000..1bb81b6 --- /dev/null +++ b/dao/employment_dao.py @@ -0,0 +1,166 @@ +# dao/employment_dao.py +# 就业表的数据访问层(基础表 + offer 表联动 + 学生状态联动) +from datetime import date +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session, joinedload + +from model.employment import EmploymentBase, EmploymentOffer +from model.students import Student +from model.classes import Classinfo + + +class EmploymentDAO: + @staticmethod + def get_base(db: Session, stu_id: int, include_deleted: bool = False) -> Optional[EmploymentBase]: + """获取学生就业基础记录""" + query = db.query(EmploymentBase).filter(EmploymentBase.stu_id == stu_id) + if not include_deleted: + query = query.filter(EmploymentBase.is_deleted == 0) + return query.first() + + # ==================== 就业开放登记 ==================== + @staticmethod + def open_employment(db: Session, stu_id: int, open_time: date) -> EmploymentBase: + """ + 登记就业开放(开放简历): + 1. 插入就业基础表记录(冗余字段从 student/class 同步写入) + 2. 学生表状态更新为『进入就业』(需求 2.3 联动) + """ + student = db.query(Student).filter(Student.stu_id == stu_id).one() + class_row = ( + db.query(Classinfo).filter(Classinfo.class_id == student.class_id).one() + ) + db_base = EmploymentBase( + stu_id=stu_id, + employment_open_time=open_time, + stu_name=student.stu_name, + class_name=class_row.class_name, + ) + student.status = "进入就业" + db.add(db_base) + db.commit() + db.refresh(db_base) + return db_base + + # ==================== offer 登记 ==================== + @staticmethod + def add_offer(db: Session, stu_id: int, offer_time: date, company_name: str, salary: float) -> EmploymentOffer: + """ + 登记offer(拿到 offer): + 1. offer 表插入记录(offer_id 同一学生内自增) + 2. 就业基础表更新 offer 时间 / 公司 / 薪资 + 3. 学生表状态更新为『已就业』(需求 2.3 联动) + """ + max_offer_id = ( + db.query(EmploymentOffer.offer_id) + .filter(EmploymentOffer.stu_id == stu_id) + .order_by(EmploymentOffer.offer_id.desc()) + .first() + ) + offer_id = (max_offer_id[0] + 1) if max_offer_id else 1 + db_offer = EmploymentOffer( + stu_id=stu_id, + offer_id=offer_id, + offer_time=offer_time, + company_name=company_name, + salary=salary, + ) + # 更新基础表(以最新 offer 为准) + db_base = EmploymentDAO.get_base(db, stu_id) + db_base.job_time = offer_time + db_base.company_name = company_name + db_base.salary = salary + # 学生状态联动 + student = db.query(Student).filter(Student.stu_id == stu_id).one() + student.status = "已就业" + db.add(db_offer) + db.commit() + db.refresh(db_offer) + return db_offer + + # ==================== 查询 ==================== + @staticmethod + def get_by_student(db: Session, stu_id: int) -> Optional[EmploymentBase]: + """获取学生就业信息(含 offer 列表)""" + return ( + db.query(EmploymentBase) + .options(joinedload(EmploymentBase.offers)) + .filter(EmploymentBase.stu_id == stu_id, EmploymentBase.is_deleted == 0) + .first() + ) + + @staticmethod + def get_by_class(db: Session, class_id: int, skip: int = 0, limit: int = 100) -> Tuple[int, List[EmploymentBase]]: + """获取班级学生的就业信息(通过学生表关联班级)""" + query = ( + db.query(EmploymentBase) + .join(Student, EmploymentBase.stu_id == Student.stu_id) + .filter(Student.class_id == class_id, EmploymentBase.is_deleted == 0) + ) + total = query.count() + items = ( + query.options(joinedload(EmploymentBase.offers)) + .order_by(EmploymentBase.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items + + @staticmethod + def get_all( + db: Session, + skip: int = 0, + limit: int = 100, + stu_id: Optional[int] = None, + company_name: Optional[str] = None, + salary_min: Optional[float] = None, + salary_max: Optional[float] = None, + ) -> Tuple[int, List[EmploymentBase]]: + """多条件查询就业信息:学号精确、公司名模糊、薪资范围""" + query = db.query(EmploymentBase).filter(EmploymentBase.is_deleted == 0) + if stu_id is not None: + query = query.filter(EmploymentBase.stu_id == stu_id) + if company_name: + query = query.filter(EmploymentBase.company_name.like(f"%{company_name}%")) + if salary_min is not None: + query = query.filter(EmploymentBase.salary >= salary_min) + if salary_max is not None: + query = query.filter(EmploymentBase.salary <= salary_max) + total = query.count() + items = ( + query.options(joinedload(EmploymentBase.offers)) + .order_by(EmploymentBase.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items + + # ==================== 修改 / 删除 ==================== + @staticmethod + def update_base(db: Session, stu_id: int, update_data: dict) -> Optional[EmploymentBase]: + """修改就业基础信息(只更新传入的非 None 字段)""" + db_base = EmploymentDAO.get_base(db, stu_id) + if db_base is None: + return None + for key, value in update_data.items(): + setattr(db_base, key, value) + db.commit() + db.refresh(db_base) + return db_base + + @staticmethod + def delete_light(db: Session, stu_id: int) -> bool: + """逻辑删除学生就业基础记录""" + db_base = EmploymentDAO.get_base(db, stu_id) + if db_base is None: + return False + db_base.is_deleted = 1 + # 学生状态回退为在读 + student = db.query(Student).filter(Student.stu_id == stu_id).first() + if student: + student.status = "在读" + db.commit() + return True diff --git a/dao/scores_dao.py b/dao/scores_dao.py new file mode 100644 index 0000000..7e8445e --- /dev/null +++ b/dao/scores_dao.py @@ -0,0 +1,90 @@ +# dao/scores_dao.py +# 成绩表的数据访问层(录入、修改、删除、查询 + 60分红线预警) +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from model.scores import Score +from model.students import Student +from scheme.scores import ScoreAdd + + +class ScoreDAO: + WARNING_LINE = 60 # 成绩红线 + + @staticmethod + def get_one(db: Session, stu_id: int, exam_id: int, include_deleted: bool = False) -> Optional[Score]: + """获取指定学生某次考核的成绩(复合主键查询)""" + query = db.query(Score).filter(Score.stu_id == stu_id, Score.exam_id == exam_id) + if not include_deleted: + query = query.filter(Score.is_deleted == 0) + return query.first() + + @staticmethod + def add_score(db: Session, score_data: ScoreAdd) -> Score: + """录入成绩(调用方需先校验学生存在与复合主键冲突)""" + db_score = Score(**score_data.model_dump(), is_deleted=0) + db.add(db_score) + db.commit() + db.refresh(db_score) + return db_score + + @staticmethod + def update_score(db: Session, stu_id: int, exam_id: int, new_score: float) -> Optional[Score]: + """修改指定学生的某次成绩""" + db_score = ScoreDAO.get_one(db, stu_id, exam_id) + if db_score is None: + return None + db_score.score = new_score + db_score.is_deleted = 0 # 若曾被软删除,修改视为恢复 + db.commit() + db.refresh(db_score) + return db_score + + @staticmethod + def delete_score(db: Session, stu_id: int, exam_id: int) -> bool: + """ + 逻辑删除指定学生的某次成绩 + :return: True 成功 / False 不存在或已删除 + """ + db_score = ScoreDAO.get_one(db, stu_id, exam_id) + if db_score is None: + return False + db_score.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_by_student(db: Session, stu_id: int) -> List[Score]: + """获取学生的全部成绩(按考核序次排序)""" + return ( + db.query(Score) + .filter(Score.stu_id == stu_id, Score.is_deleted == 0) + .order_by(Score.exam_id) + .all() + ) + + @staticmethod + def get_by_exam(db: Session, exam_id: int, skip: int = 0, limit: int = 100) -> Tuple[int, List[Score]]: + """按考核序次查询成绩列表(分页)""" + query = db.query(Score).filter(Score.exam_id == exam_id, Score.is_deleted == 0) + total = query.count() + items = query.order_by(Score.stu_id).offset(skip).limit(limit).all() + return total, items + + @staticmethod + def attach_student_name(db: Session, score: Score) -> None: + """把学生姓名附加到 Score 对象上(供响应模型冗余展示)""" + student = db.query(Student).filter(Student.stu_id == score.stu_id).first() + score.stu_name = student.stu_name if student else None + + @staticmethod + def build_score_item(score: Score, stu_name: Optional[str] = None) -> dict: + """组装成绩响应字典:附带红线预警标记""" + return { + "stu_id": score.stu_id, + "exam_id": score.exam_id, + "score": score.score, + "stu_name": stu_name, + "is_warning": score.score is not None and score.score < ScoreDAO.WARNING_LINE, + } diff --git a/dao/statistics_dao.py b/dao/statistics_dao.py new file mode 100644 index 0000000..1758548 --- /dev/null +++ b/dao/statistics_dao.py @@ -0,0 +1,461 @@ +# dao/statistics_dao.py +# 统计分析模块的数据访问层: +# - 全部使用 SQLAlchemy 表达式动态拼装查询(避免原生 SQL 字符串拼接带来的注入风险) +# - 覆盖需求 2.6(动态年龄/班级统计/成绩统计/就业统计)与 2.7(高级筛选器/聚合统计) +from typing import List, Tuple + +from sqlalchemy import and_, or_, func, case +from sqlalchemy.orm import Session + +from model.students import Student +from model.classes import Classinfo +from model.scores import Score +from model.employment import EmploymentBase +from scheme.statistics import FilterRule, FilterGroup + +# 高级筛选器允许的字段白名单 -> (SQLAlchemy 列, 是否需要外联 employment_base) +_FILTER_FIELDS = { + "stu_id": (Student.stu_id, False), + "stu_name": (Student.stu_name, False), + "age": (Student.age, False), + "gender": (Student.gender, False), + "education": (Student.education, False), + "major": (Student.major, False), + "native_place": (Student.native_place, False), + "status": (Student.status, False), + "class_id": (Student.class_id, False), + "class_name": (Classinfo.class_name, False), + "salary": (EmploymentBase.salary, True), + "company_name": (EmploymentBase.company_name, True), +} + + +class StatisticsDAO: + # ==================== 2.6.1 动态年龄范围查询 ==================== + @staticmethod + def students_by_age( + db: Session, + op: str, + value: int = None, + min_value: int = None, + max_value: int = None, + ) -> List[Student]: + """ + 动态年龄查询 + :param op: 比较条件 gt/lt/eq/gte/lte/between + """ + query = ( + db.query(Student) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0) + ) + if op == "gt": + query = query.filter(Student.age > value) + elif op == "lt": + query = query.filter(Student.age < value) + elif op == "eq": + query = query.filter(Student.age == value) + elif op == "gte": + query = query.filter(Student.age >= value) + elif op == "lte": + query = query.filter(Student.age <= value) + elif op == "between": + query = query.filter(Student.age >= min_value, Student.age <= max_value) + else: + raise ValueError(f"不支持的年龄比较条件: {op}") + return query.order_by(Student.age).all() + + # ==================== 2.6.1 多维度班级统计 ==================== + @staticmethod + def class_gender_stats(db: Session) -> List[dict]: + """统计每个班级总人数及男女分布""" + male_cnt = func.sum(case((Student.gender == "男", 1), else_=0)) + female_cnt = func.sum(case((Student.gender == "女", 1), else_=0)) + rows = ( + db.query( + Classinfo.class_id, + Classinfo.class_name, + func.count(Student.stu_id).label("total"), + male_cnt.label("male"), + female_cnt.label("female"), + ) + .join(Student, Student.class_id == Classinfo.class_id, isouter=True) + .filter(Classinfo.is_deleted == 0, Student.is_deleted == 0) + .group_by(Classinfo.class_id, Classinfo.class_name) + .order_by(Classinfo.class_id) + .all() + ) + return [ + { + "class_id": r.class_id, + "class_name": r.class_name, + "total": r.total or 0, + "male": r.male or 0, + "female": r.female or 0, + } + for r in rows + ] + + # ==================== 2.6.2 每次考试都在分数线以上的学生 ==================== + @staticmethod + def students_all_above(db: Session, line: float) -> List[dict]: + """查询每次考试成绩都在 line 分以上的学生(按最低分聚合判断)""" + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.count(Score.exam_id).label("exam_count"), + func.min(Score.score).label("min_score"), + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .having(func.min(Score.score) >= line) + .all() + ) + result = [] + for r in rows: + details = ( + db.query(Score.exam_id, Score.score) + .filter(Score.stu_id == r.stu_id, Score.is_deleted == 0) + .order_by(Score.exam_id) + .all() + ) + result.append( + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "exam_count": r.exam_count, + "min_score": r.min_score, + "scores": [{"exam_id": d.exam_id, "score": d.score} for d in details], + } + ) + return result + + # ==================== 2.6.2 不及格次数 >= N 的学生 ==================== + @staticmethod + def fail_students(db: Session, min_times: int, line: float = 60.0) -> List[dict]: + """查询不及格(< line)次数 >= min_times 的学生及其不及格明细""" + fail_cond = and_(Score.score < line, Score.is_deleted == 0) + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.count(Score.exam_id).label("fail_count"), + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0, fail_cond) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .having(func.count(Score.exam_id) >= min_times) + .all() + ) + result = [] + for r in rows: + details = ( + db.query(Score.exam_id, Score.score) + .filter(fail_cond, Score.stu_id == r.stu_id) + .order_by(Score.exam_id) + .all() + ) + result.append( + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "fail_count": r.fail_count, + "fail_details": [{"exam_id": d.exam_id, "score": d.score} for d in details], + } + ) + return result + + # ==================== 2.6.2 每次考试每个班级的平均分(动态排序) ==================== + @staticmethod + def class_exam_avg(db: Session, exam_id: int = None, order: str = "desc") -> List[dict]: + """统计每次考试每个班级的平均分,order: asc/desc""" + avg_expr = func.round(func.avg(Score.score), 2) + query = ( + db.query( + Score.exam_id, + Classinfo.class_id, + Classinfo.class_name, + avg_expr.label("avg_score"), + ) + .join(Student, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Score.exam_id, Classinfo.class_id, Classinfo.class_name) + ) + if exam_id is not None: + query = query.filter(Score.exam_id == exam_id) + query = query.order_by(avg_expr.desc() if order == "desc" else avg_expr.asc()) + return [ + { + "exam_id": r.exam_id, + "class_id": r.class_id, + "class_name": r.class_name, + "avg_score": float(r.avg_score), + } + for r in query.all() + ] + + # ==================== 2.6.3 就业薪资 Top N ==================== + @staticmethod + def top_salary(db: Session, n: int) -> List[dict]: + """薪资排名 Top N(从就业基础表取最新薪资)""" + rows = ( + db.query( + EmploymentBase.stu_id, + EmploymentBase.stu_name, + EmploymentBase.class_name, + EmploymentBase.job_time, + EmploymentBase.company_name, + EmploymentBase.salary, + ) + .join(Student, EmploymentBase.stu_id == Student.stu_id) + .filter( + EmploymentBase.is_deleted == 0, + Student.is_deleted == 0, + EmploymentBase.salary > 0, + ) + .order_by(EmploymentBase.salary.desc()) + .limit(n) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "job_time": r.job_time, + "company_name": r.company_name, + "salary": r.salary, + } + for r in rows + ] + + # ==================== 2.6.3 每个学生的就业时长 ==================== + @staticmethod + def employment_durations(db: Session) -> List[dict]: + """就业时长 = offer下发时间(job_time) - 就业开放时间(employment_open_time),单位天""" + duration_days = func.datediff(EmploymentBase.job_time, EmploymentBase.employment_open_time) + rows = ( + db.query( + EmploymentBase.stu_id, + EmploymentBase.stu_name, + EmploymentBase.class_name, + EmploymentBase.employment_open_time, + EmploymentBase.job_time, + duration_days.label("duration_days"), + ) + .filter(EmploymentBase.is_deleted == 0) + .order_by(EmploymentBase.stu_id) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "employment_open_time": r.employment_open_time, + "job_time": r.job_time, + # 未拿到 offer 记为 -1,前端展示为"未就业" + "duration_days": int(r.duration_days) if r.duration_days is not None else -1, + } + for r in rows + ] + + # ==================== 2.6.3 每个班级平均就业时长 ==================== + @staticmethod + def class_avg_duration(db: Session) -> List[dict]: + """平均就业时长:仅统计进入就业阶段(有就业开放时间)的学生; + 平均值仅对已拿到 offer 的学生计算""" + opened = func.count(EmploymentBase.stu_id) + offered = func.sum(case((EmploymentBase.job_time.isnot(None), 1), else_=0)) + avg_days = func.round( + func.avg( + case( + ( + EmploymentBase.job_time.isnot(None), + func.datediff(EmploymentBase.job_time, EmploymentBase.employment_open_time), + ) + ) + ), + 1, + ) + rows = ( + db.query( + Student.class_id, + Classinfo.class_name, + opened.label("opened_count"), + offered.label("offered_count"), + avg_days.label("avg_duration_days"), + ) + .join(EmploymentBase, EmploymentBase.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Student.is_deleted == 0, EmploymentBase.is_deleted == 0) + .group_by(Student.class_id, Classinfo.class_name) + .order_by(Student.class_id) + .all() + ) + return [ + { + "class_id": r.class_id, + "class_name": r.class_name, + "opened_count": r.opened_count or 0, + "offered_count": int(r.offered_count or 0), + "avg_duration_days": float(r.avg_duration_days) if r.avg_duration_days is not None else 0.0, + } + for r in rows + ] + + # ==================== 2.7.2 成绩波动分析(最大分差 Top N) ==================== + @staticmethod + def score_volatility(db: Session, top_n: int = 5) -> List[dict]: + """成绩波动最大 Top N(最大分差 = 最高分 - 最低分,SQL 聚合计算)""" + diff_expr = (func.max(Score.score) - func.min(Score.score)).label("diff") + rows = ( + db.query( + Student.stu_id, + Student.stu_name, + Classinfo.class_name, + func.max(Score.score).label("max_score"), + func.min(Score.score).label("min_score"), + diff_expr, + ) + .join(Score, Score.stu_id == Student.stu_id) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .filter(Score.is_deleted == 0, Student.is_deleted == 0) + .group_by(Student.stu_id, Student.stu_name, Classinfo.class_name) + .order_by(diff_expr.desc()) + .limit(top_n) + .all() + ) + return [ + { + "stu_id": r.stu_id, + "stu_name": r.stu_name, + "class_name": r.class_name, + "max_score": r.max_score, + "min_score": r.min_score, + "diff": float(r.diff), + } + for r in rows + ] + + # ==================== 2.7.2 班级就业漏斗 ==================== + @staticmethod + def employment_funnel(db: Session, high_salary_line: float = 10000.0) -> List[dict]: + """每个班级:总人数 -> 已就业人数 -> 高薪人数(>10k) -> 就业率,按就业率降序""" + employed_cnt = func.sum(case((EmploymentBase.stu_id.isnot(None), 1), else_=0)) + high_salary_cnt = func.sum( + case((and_(EmploymentBase.stu_id.isnot(None), EmploymentBase.salary > high_salary_line), 1), else_=0) + ) + rows = ( + db.query( + Classinfo.class_id, + Classinfo.class_name, + func.count(Student.stu_id).label("total"), + employed_cnt.label("employed"), + high_salary_cnt.label("high_salary"), + ) + .join(Student, Student.class_id == Classinfo.class_id) + .join( + EmploymentBase, + and_( + EmploymentBase.stu_id == Student.stu_id, + EmploymentBase.is_deleted == 0, + ), + isouter=True, + ) + .filter(Student.is_deleted == 0, Classinfo.is_deleted == 0) + .group_by(Classinfo.class_id, Classinfo.class_name) + .all() + ) + result = [] + for r in rows: + total = r.total or 0 + employed = int(r.employed or 0) + rate = round(employed / total * 100, 2) if total else 0.0 + result.append( + { + "class_id": r.class_id, + "class_name": r.class_name, + "total": total, + "employed": employed, + "high_salary": int(r.high_salary or 0), + "employment_rate": rate, + } + ) + result.sort(key=lambda x: x["employment_rate"], reverse=True) + return result + + +# ============================================================ +# 2.7.1 通用高级筛选器:把规则树递归翻译为 SQLAlchemy 表达式 +# ============================================================ +class FilterBuilder: + @staticmethod + def _rule_to_expr(rule: FilterRule): + """把单条规则翻译为 SQLAlchemy 比较表达式""" + if rule.field not in _FILTER_FIELDS: + raise ValueError(f"不支持筛选的字段: {rule.field},允许的字段: {sorted(_FILTER_FIELDS)}") + column, _ = _FILTER_FIELDS[rule.field] + op = rule.operator + if op == ">": + return column > rule.value + if op == "<": + return column < rule.value + if op == "=": + return column == rule.value + if op == "!=": + return column != rule.value + if op == ">=": + return column >= rule.value + if op == "<=": + return column <= rule.value + if op == "like": + return column.like(f"%{rule.value}%") + if op == "in": + if not isinstance(rule.value, (list, tuple)): + raise ValueError("operator=in 时 value 必须是列表") + return column.in_(list(rule.value)) + raise ValueError(f"不支持的操作符: {op}") + + @classmethod + def to_expr(cls, rules: list): + """把规则列表(顶层默认 AND)翻译为一个组合表达式""" + if not rules: + raise ValueError("筛选规则不能为空") + exprs = [] + for r in rules: + exprs.append(cls._node_to_expr(r)) + return and_(*exprs) if len(exprs) > 1 else exprs[0] + + @classmethod + def _node_to_expr(cls, node): + """递归处理规则节点:FilterGroup 组合子规则,FilterRule 直接翻译""" + if isinstance(node, FilterGroup): + sub = [cls._node_to_expr(r) for r in node.sub_rules] + return or_(*sub) if node.logic == "OR" else and_(*sub) + if isinstance(node, FilterRule): + return cls._rule_to_expr(node) + raise ValueError(f"无法识别的筛选规则节点: {type(node)}") + + @staticmethod + def query_students(db: Session, rules: list) -> Tuple[int, list]: + """执行高级筛选查询(student 模型,自动关联班级与就业表)""" + expr = FilterBuilder.to_expr(rules) + query = ( + db.query(Student) + .join(Classinfo, Student.class_id == Classinfo.class_id) + .outerjoin(EmploymentBase, EmploymentBase.stu_id == Student.stu_id) + .filter(Student.is_deleted == 0, expr) + ) + total = query.count() + items = query.order_by(Student.stu_id).all() + return total, items diff --git a/dao/students_dao.py b/dao/students_dao.py new file mode 100644 index 0000000..96b7361 --- /dev/null +++ b/dao/students_dao.py @@ -0,0 +1,150 @@ +# dao/students_dao.py +# 学生表的数据访问层(增、查、改、逻辑删除 + 学号自动生成规则) +from datetime import date +from typing import Optional, Tuple + +from sqlalchemy import or_ +from sqlalchemy.orm import Session, joinedload + +from model.students import Student +from model.classes import Classinfo +from model.advisor import Advisor +from scheme.students import StudentAdd, StudentUpdate + + +class StudentDAO: + @staticmethod + def inspect_student_id_unq(db: Session, stu_id: int) -> Optional[Student]: + """根据学号获取学生对象(用于主键唯一性检查,包含被软删除的对象)""" + return db.query(Student).filter(Student.stu_id == stu_id).first() + + @staticmethod + def get_active(db: Session, stu_id: int) -> Optional[Student]: + """获取未被软删除的学生对象(用于外键校验与查询)""" + return ( + db.query(Student) + .filter(Student.stu_id == stu_id, Student.is_deleted == 0) + .first() + ) + + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int) -> Optional[Classinfo]: + """班级外键校验(排除软删除)""" + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def inspect_advisor_id_unq(db: Session, advisor_id: int) -> Optional[Advisor]: + """顾问外键校验(排除软删除)""" + return ( + db.query(Advisor) + .filter(Advisor.advisor_id == advisor_id, Advisor.is_deleted == 0) + .first() + ) + + # ==================== 学号生成规则 ==================== + # 规则:入学年份(4位) + 班级号(2位) + 班内序号(4位) + # 示例:2026 年入学、班级 3 的第 1 个学生 -> 2026030001 + # 说明:班级号超过 99 时会溢出到序号位,业务上建议班级数控制在 99 以内 + @staticmethod + def generate_stu_id(db: Session, enroll_time: date, class_id: int) -> int: + prefix = enroll_time.year * 10000 + class_id # 前6位:年份+班级 + # 找到同一前缀下最大的学号,在其基础上 +1 + max_stu_id = ( + db.query(Student.stu_id) + .filter( + Student.stu_id >= prefix * 10000, + Student.stu_id <= prefix * 10000 + 9999, + ) + .order_by(Student.stu_id.desc()) + .first() + ) + seq = (max_stu_id[0] % 10000) + 1 if max_stu_id else 1 + return prefix * 10000 + seq + + # ==================== CRUD ==================== + @staticmethod + def add_student(db: Session, student_data: StudentAdd) -> Student: + """新增学生;stu_id 不传则按规则自动生成""" + data = student_data.model_dump(exclude_none=True) + data.pop("is_deleted", None) + if "stu_id" not in data: + data["stu_id"] = StudentDAO.generate_stu_id( + db, data["enroll_time"], data["class_id"] + ) + db_student = Student(**data) + db.add(db_student) + db.commit() + db.refresh(db_student) + return db_student + + @staticmethod + def update(db: Session, stu_id: int, student_data: StudentUpdate) -> Optional[Student]: + """更新学生信息(只更新传入的非 None 字段)""" + db_student = StudentDAO.get_active(db, stu_id) + if db_student is None: + return None + for key, value in student_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_student, key, value) + db.commit() + db.refresh(db_student) + return db_student + + @staticmethod + def delete_light(db: Session, stu_id: int) -> bool: + """ + 逻辑删除学生 + :return: True 成功 / False 学号不存在或已被软删除 + """ + db_student = StudentDAO.get_active(db, stu_id) + if db_student is None: + return False + db_student.is_deleted = 1 + db.commit() + return True + + @staticmethod + def get_all( + db: Session, + skip: int = 0, + limit: int = 100, + stu_id: Optional[int] = None, + stu_name: Optional[str] = None, + class_id: Optional[int] = None, + gender: Optional[str] = None, + status: Optional[str] = None, + education: Optional[str] = None, + ) -> Tuple[int, list]: + """ + 分页 + 多条件筛选查询学生列表(不含软删除) + 支持按编号精确、姓名模糊、班级、性别、状态、学历筛选 + """ + query = ( + db.query(Student) + .options(joinedload(Student.classes), joinedload(Student.advisor)) + .filter(Student.is_deleted == 0) + ) + if stu_id is not None: + query = query.filter(Student.stu_id == stu_id) + if stu_name: + query = query.filter(Student.stu_name.like(f"%{stu_name}%")) + if class_id is not None: + query = query.filter(Student.class_id == class_id) + if gender: + query = query.filter(Student.gender == gender) + if status: + query = query.filter(Student.status == status) + if education: + query = query.filter(Student.education == education) + + total = query.count() + items = ( + query.order_by(Student.stu_id) + .offset(skip) + .limit(limit) + .all() + ) + return total, items diff --git a/dao/teachers_dao.py b/dao/teachers_dao.py new file mode 100644 index 0000000..87167f0 --- /dev/null +++ b/dao/teachers_dao.py @@ -0,0 +1,105 @@ +# dao/teachers_dao.py +# 老师表的数据访问层(沿用原 teacher 模块风格,修复原代码中 Classinfo 引用不一致的问题) +from typing import Optional, Tuple + +from sqlalchemy.orm import Session + +from model.teachers import Teacher +from model.classes import Classinfo +from scheme.teachers import TeacherAdd, TeacherUpdate + + +class TeacherDAO: + @staticmethod + def inspect_teacher_id_unq(db: Session, teacher_id: int) -> Optional[Teacher]: + """ + 根据教师ID获取教师对象(用于主键唯一性检查,查询结果包含被软删除的对象) + """ + return db.query(Teacher).filter(Teacher.teacher_id == teacher_id).first() + + @staticmethod + def inspect_class_id_unq(db: Session, class_id: int) -> Optional[Classinfo]: + """ + 根据班级ID获取班级对象(用于外键检查,排除被软删除对象) + """ + return ( + db.query(Classinfo) + .filter(Classinfo.class_id == class_id, Classinfo.is_deleted == 0) + .first() + ) + + @staticmethod + def next_teacher_id(db: Session) -> int: + """生成自增教师ID(max + 1,保证逻辑删除后不复用旧 ID)""" + max_id = db.query(Teacher.teacher_id).order_by(Teacher.teacher_id.desc()).first() + return (max_id[0] + 1) if max_id else 1 + + @staticmethod + def add_teacher(db: Session, teacher_data: TeacherAdd) -> Teacher: + """新增教师;teacher_id 不传则自动生成""" + data = teacher_data.model_dump(exclude_none=True) + data.pop("is_deleted", None) + if "teacher_id" not in data: + data["teacher_id"] = TeacherDAO.next_teacher_id(db) + db_teacher = Teacher(**data) + db.add(db_teacher) + db.commit() + db.refresh(db_teacher) + return db_teacher + + @staticmethod + def delete_light(db: Session, teacher_id: int) -> bool: + """ + 删除教师(逻辑删除) + :return: True 删除成功 / False 不存在或已被软删除 + """ + db_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher_id) + if (not db_teacher) or db_teacher.is_deleted == 1: + return False + db_teacher.is_deleted = 1 + db.commit() + return True + + @staticmethod + def update(db: Session, teacher_id: int, teacher_data: TeacherUpdate) -> Optional[Teacher]: + """更新教师信息(只更新传入的非 None 字段)""" + db_teacher = TeacherDAO.inspect_teacher_id_unq(db, teacher_id) + if (not db_teacher) or db_teacher.is_deleted == 1: + return None + for key, value in teacher_data.model_dump(exclude_unset=True, exclude_none=True).items(): + setattr(db_teacher, key, value) + db.commit() + db.refresh(db_teacher) + return db_teacher + + @staticmethod + def get_all(db: Session, skip: int = 0, limit: int = 100) -> Tuple[int, list]: + """分页获取所有教师(不含软删除),联表带出班级名称,返回 (总数, 列表)""" + query = ( + db.query(Teacher, Classinfo.class_name) + .join(Classinfo, Teacher.class_id == Classinfo.class_id, isouter=True) + .filter(Teacher.is_deleted == 0) + ) + total = query.count() + rows = query.order_by(Teacher.teacher_id).offset(skip).limit(limit).all() + # 把 class_name 附加到对象上,供响应模型 from_attributes 读取 + items = [] + for teacher, class_name in rows: + teacher.class_name = class_name + items.append(teacher) + return total, items + + @staticmethod + def get_by_id(db: Session, teacher_id: int) -> Optional[Teacher]: + """根据 ID 查询单个教师(不含软删除),联表带出班级名称""" + row = ( + db.query(Teacher, Classinfo.class_name) + .join(Classinfo, Teacher.class_id == Classinfo.class_id, isouter=True) + .filter(Teacher.teacher_id == teacher_id, Teacher.is_deleted == 0) + .first() + ) + if row is None: + return None + teacher, class_name = row + teacher.class_name = class_name + return teacher diff --git a/dao/users_dao.py b/dao/users_dao.py new file mode 100644 index 0000000..e5ced3e --- /dev/null +++ b/dao/users_dao.py @@ -0,0 +1,32 @@ +# dao/users_dao.py +# 用户表的数据访问层(认证模块) +from sqlalchemy.orm import Session + +from model.user import User +from scheme.users import UserAdd +from core.security import hash_password + + +class UserDAO: + @staticmethod + def get_by_username(db: Session, username: str): + """按用户名查询(含软删除检查由调用方处理)""" + return db.query(User).filter(User.username == username).first() + + @staticmethod + def add_user(db: Session, user_data: UserAdd) -> User: + db_user = User( + username=user_data.username, + password_hash=hash_password(user_data.password), + role=user_data.role, + teacher_id=user_data.teacher_id, + stu_id=user_data.stu_id, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + @staticmethod + def get_by_id(db: Session, user_id: int): + return db.query(User).filter(User.user_id == user_id, User.is_deleted == 0).first() diff --git a/database.py b/database.py new file mode 100644 index 0000000..56c74e6 --- /dev/null +++ b/database.py @@ -0,0 +1,36 @@ +# database.py +# 本文件负责配置数据库连接、创建引擎、会话工厂,并提供依赖注入函数(同步模式) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +from config import settings + +# 1. 配置 MySQL 数据库连接 URL(从 config 读取,支持环境变量覆盖) +SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL + +# 2. 创建数据库引擎 +# - pool_pre_ping=True:每次取连接前先 ping,防止使用已断开的连接 +# - pool_recycle=3600:连接最长复用 1 小时,避免 MySQL wait_timeout 断连 +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, + echo=settings.DB_ECHO, # True 会在控制台打印所有 SQL,便于调试 +) + +# 3. 创建会话工厂:不自动提交,需要手动 commit +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 4. 声明式基类,所有模型类都继承自它 +Base = declarative_base() + + +# 5. 依赖注入函数:用于 FastAPI 路由中获取数据库会话 +def get_db(): + """每次请求创建一个数据库会话,请求结束后关闭""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f3dba46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +services: + mysql: + image: mysql:8.0 + container_name: walin-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: walin_db + TZ: Asia/Shanghai + volumes: + - mysql_data:/var/lib/mysql + # 容器首次启动时自动执行建表 + 种子数据脚本 + - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123456"] + interval: 5s + timeout: 3s + retries: 20 + networks: + - walin-net + + api: + build: . + container_name: walin-api + restart: unless-stopped + environment: + # 注意:api 容器内通过服务名 mysql 访问数据库 + DATABASE_URL: mysql+pymysql://root:root123456@mysql:3306/walin_db?charset=utf8mb4 + SECRET_KEY: change_me_to_a_long_random_string_in_production + DB_ECHO: "false" + DEBUG: "false" + depends_on: + mysql: + condition: service_healthy + networks: + - walin-net + + nginx: + image: nginx:1.25-alpine + container_name: walin-nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - api + networks: + - walin-net + +volumes: + mysql_data: + +networks: + walin-net: + driver: bridge diff --git a/main.py b/main.py new file mode 100644 index 0000000..a061b5b --- /dev/null +++ b/main.py @@ -0,0 +1,77 @@ +# main.py +# 项目入口文件:创建 FastAPI 应用、注册路由、建表、挂载前端页面 + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from config import settings +from database import engine, Base +# 导入 model 包触发全部模型注册到 Base.metadata +import model # noqa: F401 + +from api import auth +from api import classes as classes_api +from api import teachers as teachers_api +from api import students as students_api +from api import scores as scores_api +from api import employment as employment_api +from api import statistics as statistics_api + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期:启动时自动建表(不使用 Alembic,开发/演示环境够用)""" + Base.metadata.create_all(bind=engine) + yield + + +# 1. 创建 FastAPI 实例 +app = FastAPI( + title=settings.APP_NAME, + description="基于 FastAPI + SQLAlchemy 的学生管理系统:学生信息、考核成绩、就业管理、班级/老师管理、统计分析", + version=settings.APP_VERSION, + lifespan=lifespan, +) + +# 2. 添加跨域中间件(生产环境应指定具体域名) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 3. 注册子路由 +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +app.include_router(classes_api.router, prefix="/api/classes", tags=["班级管理"]) +app.include_router(teachers_api.router, prefix="/api/teachers", tags=["老师管理"]) +app.include_router(students_api.router, prefix="/api/students", tags=["学生管理"]) +app.include_router(scores_api.router, prefix="/api/scores", tags=["成绩管理"]) +app.include_router(employment_api.router, prefix="/api/employment", tags=["就业管理"]) +app.include_router(statistics_api.router, prefix="/api/statistics", tags=["统计分析"]) + +# 4. 挂载前端静态页面 +app.mount("/static", StaticFiles(directory="static", html=True), name="static") + + +@app.get("/", include_in_schema=False) +async def root(): + """根路径重定向到前端管理页面""" + return RedirectResponse(url="/static/index.html") + + +# 5. 直接运行时启动 uvicorn +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG, + ) diff --git a/model/__init__.py b/model/__init__.py new file mode 100644 index 0000000..761a5be --- /dev/null +++ b/model/__init__.py @@ -0,0 +1,11 @@ +# model/__init__.py +# 汇总导入所有模型,保证 Base.metadata 能扫描到全部表 +from .user import User +from .classes import Classinfo +from .teachers import Teacher +from .advisor import Advisor +from .students import Student +from .scores import Score +from .employment import EmploymentBase, EmploymentOffer + +__all__ = ["User", "Classinfo", "Teacher", "Advisor", "Student", "Score", "EmploymentBase", "EmploymentOffer"] diff --git a/model/__pycache__/__init__.cpython-310.pyc b/model/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..74416bb Binary files /dev/null and b/model/__pycache__/__init__.cpython-310.pyc differ diff --git a/model/__pycache__/__init__.cpython-313.pyc b/model/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..544739f Binary files /dev/null and b/model/__pycache__/__init__.cpython-313.pyc differ diff --git a/model/__pycache__/advisor.cpython-310.pyc b/model/__pycache__/advisor.cpython-310.pyc new file mode 100644 index 0000000..a6b6629 Binary files /dev/null and b/model/__pycache__/advisor.cpython-310.pyc differ diff --git a/model/__pycache__/advisor.cpython-313.pyc b/model/__pycache__/advisor.cpython-313.pyc new file mode 100644 index 0000000..70f0198 Binary files /dev/null and b/model/__pycache__/advisor.cpython-313.pyc differ diff --git a/model/__pycache__/classes.cpython-310.pyc b/model/__pycache__/classes.cpython-310.pyc new file mode 100644 index 0000000..b1e0e17 Binary files /dev/null and b/model/__pycache__/classes.cpython-310.pyc differ diff --git a/model/__pycache__/classes.cpython-313.pyc b/model/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000..0c972f6 Binary files /dev/null and b/model/__pycache__/classes.cpython-313.pyc differ diff --git a/model/__pycache__/employment.cpython-310.pyc b/model/__pycache__/employment.cpython-310.pyc new file mode 100644 index 0000000..82b2663 Binary files /dev/null and b/model/__pycache__/employment.cpython-310.pyc differ diff --git a/model/__pycache__/employment.cpython-313.pyc b/model/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000..1b37ae8 Binary files /dev/null and b/model/__pycache__/employment.cpython-313.pyc differ diff --git a/model/__pycache__/scores.cpython-310.pyc b/model/__pycache__/scores.cpython-310.pyc new file mode 100644 index 0000000..7f0eb45 Binary files /dev/null and b/model/__pycache__/scores.cpython-310.pyc differ diff --git a/model/__pycache__/scores.cpython-313.pyc b/model/__pycache__/scores.cpython-313.pyc new file mode 100644 index 0000000..ebdcc0a Binary files /dev/null and b/model/__pycache__/scores.cpython-313.pyc differ diff --git a/model/__pycache__/students.cpython-310.pyc b/model/__pycache__/students.cpython-310.pyc new file mode 100644 index 0000000..43ad007 Binary files /dev/null and b/model/__pycache__/students.cpython-310.pyc differ diff --git a/model/__pycache__/students.cpython-313.pyc b/model/__pycache__/students.cpython-313.pyc new file mode 100644 index 0000000..75396b1 Binary files /dev/null and b/model/__pycache__/students.cpython-313.pyc differ diff --git a/model/__pycache__/teachers.cpython-310.pyc b/model/__pycache__/teachers.cpython-310.pyc new file mode 100644 index 0000000..a067f43 Binary files /dev/null and b/model/__pycache__/teachers.cpython-310.pyc differ diff --git a/model/__pycache__/teachers.cpython-313.pyc b/model/__pycache__/teachers.cpython-313.pyc new file mode 100644 index 0000000..6969209 Binary files /dev/null and b/model/__pycache__/teachers.cpython-313.pyc differ diff --git a/model/__pycache__/user.cpython-310.pyc b/model/__pycache__/user.cpython-310.pyc new file mode 100644 index 0000000..a569710 Binary files /dev/null and b/model/__pycache__/user.cpython-310.pyc differ diff --git a/model/__pycache__/user.cpython-313.pyc b/model/__pycache__/user.cpython-313.pyc new file mode 100644 index 0000000..527a2a6 Binary files /dev/null and b/model/__pycache__/user.cpython-313.pyc differ diff --git a/model/advisor.py b/model/advisor.py new file mode 100644 index 0000000..f529edb --- /dev/null +++ b/model/advisor.py @@ -0,0 +1,16 @@ +# model/advisor.py +# 顾问表:学生表的外键关联表 +from sqlalchemy import Column, Integer, String + +from database import Base + + +class Advisor(Base): + __tablename__ = "advisor" + + advisor_id = Column(Integer, primary_key=True, autoincrement=True) + advisor_name = Column(String(50), nullable=False, comment="顾问姓名") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + def __repr__(self): + return f"" diff --git a/model/classes.py b/model/classes.py new file mode 100644 index 0000000..5a6f79b --- /dev/null +++ b/model/classes.py @@ -0,0 +1,22 @@ +# model/classes.py +# 班级表:对应建表语句中的 c_lass(补充 class_name 字段,便于展示与统计) +from sqlalchemy import Column, Integer, String, Date +from sqlalchemy.orm import relationship + +from database import Base + + +class Classinfo(Base): + __tablename__ = "c_lass" + + class_id = Column(Integer, primary_key=True, autoincrement=True) + class_name = Column(String(50), nullable=False, comment="班级名称,如 Java2301") + start_time = Column(Date, nullable=False, comment="开课时间") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + # 反向关联 + teachers = relationship("Teacher", back_populates="classes") + students = relationship("Student", back_populates="classes") + + def __repr__(self): + return f"" diff --git a/model/employment.py b/model/employment.py new file mode 100644 index 0000000..f4353e8 --- /dev/null +++ b/model/employment.py @@ -0,0 +1,51 @@ +# model/employment.py +# 就业表(两张): +# EmploymentBase 基础信息表:每个开放简历的学生 1 条数据(补充冗余字段 stu_name/class_name) +# EmploymentOffer offer 表:拿到 offer 后才插入数据,一个学生可有多条 +from sqlalchemy import Column, Integer, String, Date, Float, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class EmploymentBase(Base): + __tablename__ = "employment_base" + + stu_id = Column(Integer, ForeignKey("student.stu_id"), primary_key=True, comment="学生编号") + employment_open_time = Column(Date, nullable=False, comment="就业开放时间(开放简历)") + job_time = Column(Date, default=None, comment="offer 下发时间(未拿到 offer 为 NULL)") + company_name = Column(String(100), default="未就业", comment="就业公司名称") + salary = Column(Float, default=0, comment="就业薪资") + # ---------- 冗余字段(需求 2.3 设计提示:优化查询与一致性,登记时从 student/class 同步写入) ---------- + stu_name = Column(String(10), nullable=False, comment="冗余:学生姓名") + class_name = Column(String(50), nullable=False, comment="冗余:学生班级名称") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + student = relationship("Student", back_populates="employment") + # 一个学生可有多条 offer;viewonly=True:offer 由 offer 表接口单独维护,不走本关系写入 + offers = relationship( + "EmploymentOffer", + primaryjoin="EmploymentBase.stu_id == foreign(EmploymentOffer.stu_id)", + foreign_keys="EmploymentOffer.stu_id", + viewonly=True, + order_by="EmploymentOffer.offer_id", + ) + + def __repr__(self): + return f"" + + +class EmploymentOffer(Base): + __tablename__ = "employment_offer" + + stu_id = Column( + Integer, ForeignKey("employment_base.stu_id"), primary_key=True, comment="学生编号" + ) + offer_id = Column(Integer, primary_key=True, comment="offer 序号(同一学生内自增)") + offer_time = Column(Date, nullable=False, comment="offer 下发时间") + company_name = Column(String(100), default="", comment="offer 公司名") + salary = Column(Float, default=0, comment="offer 薪资") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + def __repr__(self): + return f"" diff --git a/model/scores.py b/model/scores.py new file mode 100644 index 0000000..5fc463d --- /dev/null +++ b/model/scores.py @@ -0,0 +1,20 @@ +# model/scores.py +# 成绩表:复合主键 (stu_id, exam_id),补充 is_deleted 以统一逻辑删除风格 +from sqlalchemy import Column, Integer, Float, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class Score(Base): + __tablename__ = "score" + + stu_id = Column(Integer, ForeignKey("student.stu_id"), primary_key=True, comment="学生编号") + exam_id = Column(Integer, primary_key=True, comment="考核序次:一个学生有多次考核") + score = Column(Float, default=None, comment="考核成绩") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + student = relationship("Student", back_populates="scores") + + def __repr__(self): + return f"" diff --git a/model/students.py b/model/students.py new file mode 100644 index 0000000..689d1df --- /dev/null +++ b/model/students.py @@ -0,0 +1,34 @@ +# model/students.py +# 学生表:在建表语句基础上补充 status 字段(需求 2.1 可选字段 + 就业模块状态联动依赖它) +from sqlalchemy import Column, Integer, String, Date, ForeignKey +from sqlalchemy.orm import relationship + +from database import Base + + +class Student(Base): + __tablename__ = "student" + + stu_id = Column(Integer, primary_key=True, comment="学号(支持按规则自动生成)") + stu_name = Column(String(10), nullable=False, comment="学生姓名") + native_place = Column(String(30), nullable=False, comment="籍贯") + graduate_school = Column(String(50), nullable=False, comment="毕业院校") + major = Column(String(20), nullable=False, comment="专业") + enroll_time = Column(Date, nullable=False, comment="入学时间") + graduate_time = Column(Date, nullable=False, comment="毕业时间") + education = Column(String(10), nullable=False, comment="学历:大专/本科/硕士等") + age = Column(Integer, nullable=False, comment="年龄") + gender = Column(String(10), nullable=False, comment="性别:男/女") + class_id = Column(Integer, ForeignKey("c_lass.class_id"), nullable=False, comment="所属班级") + advisor_id = Column(Integer, ForeignKey("advisor.advisor_id"), nullable=False, comment="顾问编号") + status = Column(String(10), default="在读", nullable=False, comment="状态:在读/进入就业/已就业") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + # 关联关系 + classes = relationship("Classinfo", back_populates="students") + advisor = relationship("Advisor") + scores = relationship("Score", back_populates="student") + employment = relationship("EmploymentBase", back_populates="student", uselist=False) + + def __repr__(self): + return f"" diff --git a/model/teachers.py b/model/teachers.py new file mode 100644 index 0000000..edfbd24 --- /dev/null +++ b/model/teachers.py @@ -0,0 +1,24 @@ +# model/teachers.py +# 老师表:沿用原 teacher 模块模型,统一命名与关系定义 +from sqlalchemy import Column, Integer, ForeignKey, String +from sqlalchemy.orm import relationship + +from database import Base + + +class Teacher(Base): + __tablename__ = "teacher" + + teacher_id = Column(Integer, primary_key=True, autoincrement=True) + class_id = Column(Integer, ForeignKey("c_lass.class_id"), nullable=False, comment="所带班级") + teacher_name = Column(String(50), nullable=False, comment="教师姓名") + job_name = Column(String(50), nullable=False, comment="职务:主讲/班主任/助教") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + + classes = relationship("Classinfo", back_populates="teachers") + + def __repr__(self): + return ( + f"" + ) diff --git a/model/user.py b/model/user.py new file mode 100644 index 0000000..17157ca --- /dev/null +++ b/model/user.py @@ -0,0 +1,24 @@ +# model/user.py +# 用户表:用于 JWT 登录认证与 RBAC 角色控制(建表语句中缺失,此处补充) +from datetime import datetime + +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey + +from database import Base + + +class User(Base): + __tablename__ = "user" + + user_id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), nullable=False, unique=True, comment="登录名") + password_hash = Column(String(200), nullable=False, comment="密码哈希(PBKDF2)") + role = Column(String(20), nullable=False, comment="角色: admin/teacher/student") + # 关联业务身份:student 角色账号绑定学号,teacher 角色账号绑定教师工号(便于资源归属校验) + stu_id = Column(Integer, ForeignKey("student.stu_id"), nullable=True, comment="学生角色的学号") + teacher_id = Column(Integer, ForeignKey("teacher.teacher_id"), nullable=True, comment="教师角色的工号") + is_deleted = Column(Integer, default=0, nullable=False, comment="逻辑删除: 0正常 1删除") + create_time = Column(DateTime, default=datetime.now, comment="创建时间") + + def __repr__(self): + return f"" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..b65078f --- /dev/null +++ b/nginx.conf @@ -0,0 +1,23 @@ +# nginx.conf:反向代理 FastAPI 后端 +server { + listen 80; + server_name _; + + client_max_body_size 20m; + + # 健康检查 + location = /health { + proxy_pass http://api:8000/; + access_log off; + } + + # API 与文档 + location / { + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7108abf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +gunicorn>=21.2.0 +SQLAlchemy>=2.0.25,<2.1 +PyMySQL>=1.1.0 +cryptography>=42.0.0 +pydantic>=2.6.0 +PyJWT>=2.8.0 +python-multipart>=0.0.9 diff --git a/scheme/__init__.py b/scheme/__init__.py new file mode 100644 index 0000000..805e351 --- /dev/null +++ b/scheme/__init__.py @@ -0,0 +1,2 @@ +# scheme/__init__.py +# Schema 层包 diff --git a/scheme/__pycache__/__init__.cpython-310.pyc b/scheme/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0d3974c Binary files /dev/null and b/scheme/__pycache__/__init__.cpython-310.pyc differ diff --git a/scheme/__pycache__/classes.cpython-310.pyc b/scheme/__pycache__/classes.cpython-310.pyc new file mode 100644 index 0000000..4bef320 Binary files /dev/null and b/scheme/__pycache__/classes.cpython-310.pyc differ diff --git a/scheme/__pycache__/classes.cpython-313.pyc b/scheme/__pycache__/classes.cpython-313.pyc new file mode 100644 index 0000000..926cc07 Binary files /dev/null and b/scheme/__pycache__/classes.cpython-313.pyc differ diff --git a/scheme/__pycache__/employment.cpython-310.pyc b/scheme/__pycache__/employment.cpython-310.pyc new file mode 100644 index 0000000..3bde7c1 Binary files /dev/null and b/scheme/__pycache__/employment.cpython-310.pyc differ diff --git a/scheme/__pycache__/employment.cpython-313.pyc b/scheme/__pycache__/employment.cpython-313.pyc new file mode 100644 index 0000000..ac419a9 Binary files /dev/null and b/scheme/__pycache__/employment.cpython-313.pyc differ diff --git a/scheme/__pycache__/scores.cpython-310.pyc b/scheme/__pycache__/scores.cpython-310.pyc new file mode 100644 index 0000000..f1f7c94 Binary files /dev/null and b/scheme/__pycache__/scores.cpython-310.pyc differ diff --git a/scheme/__pycache__/scores.cpython-313.pyc b/scheme/__pycache__/scores.cpython-313.pyc new file mode 100644 index 0000000..2bd5926 Binary files /dev/null and b/scheme/__pycache__/scores.cpython-313.pyc differ diff --git a/scheme/__pycache__/statistics.cpython-310.pyc b/scheme/__pycache__/statistics.cpython-310.pyc new file mode 100644 index 0000000..f116957 Binary files /dev/null and b/scheme/__pycache__/statistics.cpython-310.pyc differ diff --git a/scheme/__pycache__/statistics.cpython-313.pyc b/scheme/__pycache__/statistics.cpython-313.pyc new file mode 100644 index 0000000..cfeb05d Binary files /dev/null and b/scheme/__pycache__/statistics.cpython-313.pyc differ diff --git a/scheme/__pycache__/students.cpython-310.pyc b/scheme/__pycache__/students.cpython-310.pyc new file mode 100644 index 0000000..a8cfebb Binary files /dev/null and b/scheme/__pycache__/students.cpython-310.pyc differ diff --git a/scheme/__pycache__/students.cpython-313.pyc b/scheme/__pycache__/students.cpython-313.pyc new file mode 100644 index 0000000..5bd2d9f Binary files /dev/null and b/scheme/__pycache__/students.cpython-313.pyc differ diff --git a/scheme/__pycache__/teachers.cpython-310.pyc b/scheme/__pycache__/teachers.cpython-310.pyc new file mode 100644 index 0000000..bd5b646 Binary files /dev/null and b/scheme/__pycache__/teachers.cpython-310.pyc differ diff --git a/scheme/__pycache__/teachers.cpython-313.pyc b/scheme/__pycache__/teachers.cpython-313.pyc new file mode 100644 index 0000000..bbbdd4a Binary files /dev/null and b/scheme/__pycache__/teachers.cpython-313.pyc differ diff --git a/scheme/__pycache__/users.cpython-310.pyc b/scheme/__pycache__/users.cpython-310.pyc new file mode 100644 index 0000000..1702da9 Binary files /dev/null and b/scheme/__pycache__/users.cpython-310.pyc differ diff --git a/scheme/__pycache__/users.cpython-313.pyc b/scheme/__pycache__/users.cpython-313.pyc new file mode 100644 index 0000000..d3065c3 Binary files /dev/null and b/scheme/__pycache__/users.cpython-313.pyc differ diff --git a/scheme/classes.py b/scheme/classes.py new file mode 100644 index 0000000..adee616 --- /dev/null +++ b/scheme/classes.py @@ -0,0 +1,42 @@ +# scheme/classes.py +# 班级模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# -------------------- 请求体模型 ----------------------------- +class ClassAdd(BaseModel): + class_id: Optional[int] = Field(None, ge=1, description="班级ID(不传则自增)") + class_name: str = Field(..., min_length=1, max_length=50, description="班级名称,如 Java2301") + start_time: date = Field(..., description="开课时间,格式 YYYY-MM-DD") + + +class ClassUpdate(BaseModel): + class_name: Optional[str] = Field(None, min_length=1, max_length=50, description="班级名称") + start_time: Optional[date] = Field(None, description="开课时间") + + +# -------------------- 响应模型 ---------------------------- +class ClassResponse(BaseModel): + class_id: int + class_name: str + start_time: date + + class Config: + from_attributes = True + + +class ClassListResponse(BaseModel): + total: int + items: List[ClassResponse] + + +class ClassStudentCountResponse(BaseModel): + """班级人数统计(多维度班级统计)""" + class_id: int + class_name: str + total: int = Field(..., description="总人数") + male: int = Field(..., description="男生人数") + female: int = Field(..., description="女生人数") diff --git a/scheme/employment.py b/scheme/employment.py new file mode 100644 index 0000000..dffb757 --- /dev/null +++ b/scheme/employment.py @@ -0,0 +1,67 @@ +# scheme/employment.py +# 就业模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# -------------------- 请求体模型 ----------------------------- +class EmploymentOpen(BaseModel): + """登记就业开放(开放简历):学生状态联动更新为『进入就业』""" + stu_id: int = Field(..., ge=1, description="学生编号") + employment_open_time: date = Field(..., description="就业开放时间") + + +class OfferAdd(BaseModel): + """登记 offer:插入 offer 记录 + 更新就业基础表 + 学生状态联动更新为『已就业』""" + stu_id: int = Field(..., ge=1, description="学生编号") + offer_time: date = Field(..., description="offer 下发时间") + company_name: str = Field(..., min_length=1, max_length=100, description="就业公司名称") + salary: float = Field(..., ge=0, description="就业薪资") + + +class EmploymentUpdate(BaseModel): + """修改就业基础信息""" + company_name: Optional[str] = Field(None, min_length=1, max_length=100, description="公司名称") + salary: Optional[float] = Field(None, ge=0, description="薪资") + job_time: Optional[date] = Field(None, description="offer 下发时间") + employment_open_time: Optional[date] = Field(None, description="就业开放时间") + + +class OfferUpdate(BaseModel): + """修改某条 offer""" + offer_time: Optional[date] = Field(None, description="offer 下发时间") + company_name: Optional[str] = Field(None, min_length=1, max_length=100, description="公司名") + salary: Optional[float] = Field(None, ge=0, description="薪资") + + +# -------------------- 响应模型 ---------------------------- +class OfferResponse(BaseModel): + stu_id: int + offer_id: int + offer_time: date + company_name: Optional[str] = None + salary: Optional[float] = None + + class Config: + from_attributes = True + + +class EmploymentResponse(BaseModel): + stu_id: int + stu_name: str = Field(..., description="学生姓名(冗余字段)") + class_name: str = Field(..., description="学生班级(冗余字段)") + employment_open_time: date + job_time: Optional[date] = None + company_name: Optional[str] = None + salary: Optional[float] = None + offers: List[OfferResponse] = Field(default_factory=list, description="该学生的全部 offer 记录") + + class Config: + from_attributes = True + + +class EmploymentListResponse(BaseModel): + total: int + items: List[EmploymentResponse] diff --git a/scheme/scores.py b/scheme/scores.py new file mode 100644 index 0000000..d339519 --- /dev/null +++ b/scheme/scores.py @@ -0,0 +1,46 @@ +# scheme/scores.py +# 成绩模块的请求/响应模型(含 60 分红线预警标记) +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class ScoreAdd(BaseModel): + """录入成绩:一个学生同一考核序次只有一条成绩(复合主键)""" + stu_id: int = Field(..., ge=1, description="学生编号") + exam_id: int = Field(..., ge=1, description="考核序次") + score: float = Field(..., ge=0, le=100, description="成绩(0-100)") + + +class ScoreUpdate(BaseModel): + score: float = Field(..., ge=0, le=100, description="新成绩(0-100)") + + +class ScoreDelete(BaseModel): + """删除指定学生的某次成绩(需求 4.2 用 POST /score/delete 风格)""" + stu_id: int = Field(..., ge=1, description="学生编号") + exam_id: int = Field(..., ge=1, description="考核序次") + + +# -------------------- 响应模型 ---------------------------- +class ScoreResponse(BaseModel): + stu_id: int + exam_id: int + score: float + stu_name: Optional[str] = Field(None, description="冗余展示:学生姓名") + is_warning: bool = Field(False, description="红线预警:成绩 < 60 为 True") + + class Config: + from_attributes = True + + +class ScoreListResponse(BaseModel): + total: int + items: List[ScoreResponse] + + +class ScoreAddResponse(BaseModel): + """录入成绩响应:附带预警提示(需求 2.2 可选扩展)""" + score: ScoreResponse + warning: Optional[str] = Field(None, description="成绩低于60分时的预警提示") diff --git a/scheme/statistics.py b/scheme/statistics.py new file mode 100644 index 0000000..2c4006a --- /dev/null +++ b/scheme/statistics.py @@ -0,0 +1,130 @@ +# scheme/statistics.py +# 统计分析模块的请求/响应模型(含 2.7 通用高级筛选器) +from typing import Any, List, Literal, Union + +from pydantic import BaseModel, Field + + +# ============================================================ +# 2.7.1 通用高级筛选器:支持 AND/OR 逻辑组合与嵌套 sub_rules +# ============================================================ +class FilterRule(BaseModel): + """单条筛选规则""" + field: str = Field(..., description="字段名,如 age/gender/salary/class_name") + operator: Literal[">", "<", "=", "!=", ">=", "<=", "like", "in"] = Field( + ..., description="比较操作符" + ) + value: Any = Field(..., description="比较值;operator=in 时必须是列表") + + +class FilterGroup(BaseModel): + """逻辑组合规则:可嵌套 sub_rules 实现任意深度的 AND/OR 组合""" + logic: Literal["AND", "OR"] = Field("AND", description="组合方式") + sub_rules: List[Union["FilterGroup", "FilterRule"]] = Field( + ..., min_length=1, description="子规则(可继续嵌套 FilterGroup)" + ) + + +# Pydantic v2 递归模型需要显式 rebuild +FilterGroup.model_rebuild() + + +class FilterRequest(BaseModel): + """高级筛选请求体""" + model: Literal["student"] = Field(..., description="目标模型(当前支持 student)") + rules: List[Union[FilterGroup, FilterRule]] = Field( + ..., min_length=1, description="筛选规则列表(顶层默认 AND 连接)" + ) + + +class FilterResponse(BaseModel): + total: int + items: List[dict] = Field(..., description="命中的学生记录") + + +# ============================================================ +# 2.6 统计分析响应模型 +# ============================================================ +class ClassGenderStat(BaseModel): + """2.6.1 多维度班级统计:总人数 + 性别分布""" + class_id: int + class_name: str + total: int + male: int + female: int + + +class AllAboveStudent(BaseModel): + """2.6.2 每次考试都在分数线以上的学生""" + stu_id: int + stu_name: str + class_name: str + exam_count: int = Field(..., description="参加考核次数") + min_score: float = Field(..., description="最低分(>= 分数线)") + scores: List[dict] = Field(..., description="各次成绩明细 [{exam_id, score}]") + + +class FailStudent(BaseModel): + """2.6.2 不及格次数 >= N 的学生""" + stu_id: int + stu_name: str + class_name: str + fail_count: int = Field(..., description="不及格次数") + fail_details: List[dict] = Field(..., description="不及格成绩明细 [{exam_id, score}]") + + +class ClassExamAvg(BaseModel): + """2.6.2 每次考试每个班级的平均分(支持动态排序)""" + exam_id: int + class_id: int + class_name: str + avg_score: float + + +class TopSalaryStudent(BaseModel): + """2.6.3 薪资 Top N""" + stu_id: int + stu_name: str + class_name: str + job_time: Any = Field(None, description="offer 下发时间") + company_name: str + salary: float + + +class EmploymentDuration(BaseModel): + """2.6.3 单个学生就业时长(天)""" + stu_id: int + stu_name: str + class_name: str + employment_open_time: Any + job_time: Any = Field(None, description="未拿到 offer 为 null") + duration_days: int = Field(..., description="offer下发时间 - 就业开放时间;未拿到 offer 为 -1") + + +class ClassAvgDuration(BaseModel): + """2.6.3 每个班级平均就业时长""" + class_id: int + class_name: str + opened_count: int = Field(..., description="进入就业阶段人数(有开放时间)") + offered_count: int = Field(..., description="已拿到 offer 人数") + avg_duration_days: float = Field(..., description="平均就业时长(仅统计已拿到 offer 的学生)") + + +class ScoreVolatility(BaseModel): + """2.7.2 成绩波动分析:最大分差 Top N""" + stu_id: int + stu_name: str + class_name: str + max_score: float + min_score: float + diff: float = Field(..., description="最大分差 = 最高分 - 最低分") + + +class EmploymentFunnel(BaseModel): + """2.7.2 班级就业漏斗:总人数 -> 已就业 -> 高薪(>10k) -> 就业率""" + class_id: int + class_name: str + total: int = Field(..., description="班级总人数") + employed: int = Field(..., description="已就业人数") + high_salary: int = Field(..., description="高薪人数(薪资 > 10000)") + employment_rate: float = Field(..., description="就业率(百分比,保留2位)") diff --git a/scheme/students.py b/scheme/students.py new file mode 100644 index 0000000..059340f --- /dev/null +++ b/scheme/students.py @@ -0,0 +1,95 @@ +# scheme/students.py +# 学生模块的请求/响应模型 +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class StudentAdd(BaseModel): + """创建学生:stu_id 不传则按规则自动生成(入学年份+班级号+序号)""" + stu_id: Optional[int] = Field(None, ge=1, description="学号(不传则自动生成)") + class_id: int = Field(..., ge=1, description="学生班级ID") + stu_name: str = Field(..., min_length=1, max_length=10, description="学生姓名") + native_place: str = Field(..., min_length=1, description="籍贯") + graduate_school: str = Field(..., min_length=1, description="毕业院校") + major: str = Field(..., min_length=1, description="专业") + enroll_time: date = Field(..., description="入学时间 YYYY-MM-DD") + graduate_time: date = Field(..., description="毕业时间 YYYY-MM-DD") + education: str = Field(..., description="学历:大专/本科/硕士等") + age: int = Field(..., ge=15, le=60, description="年龄") + gender: str = Field(..., description="性别:男/女") + advisor_id: int = Field(..., ge=1, description="顾问编号") + status: str = Field("在读", description="状态:在读/进入就业/已就业") + + @field_validator("gender") + @classmethod + def validate_gender(cls, v): + if v not in ("男", "女"): + raise ValueError(f"gender 必须是 男/女,当前值: {v}") + return v + + @field_validator("status") + @classmethod + def validate_status(cls, v): + allowed = ["在读", "进入就业", "已就业"] + if v not in allowed: + raise ValueError(f"status 必须是 {allowed} 之一,当前值: {v}") + return v + + +class StudentUpdate(BaseModel): + """更新学生:只更新显式传入的非 None 字段;class_id/advisor_id 不允许改(保持归属一致性)""" + stu_name: Optional[str] = Field(None, min_length=1, max_length=10, description="学生姓名") + native_place: Optional[str] = Field(None, min_length=1, description="籍贯") + graduate_school: Optional[str] = Field(None, min_length=1, description="毕业院校") + major: Optional[str] = Field(None, min_length=1, description="专业") + enroll_time: Optional[date] = Field(None, description="入学时间") + graduate_time: Optional[date] = Field(None, description="毕业时间") + education: Optional[str] = Field(None, description="学历") + age: Optional[int] = Field(None, ge=15, le=60, description="年龄") + gender: Optional[str] = Field(None, description="性别:男/女") + status: Optional[str] = Field(None, description="状态:在读/进入就业/已就业") + + @field_validator("gender") + @classmethod + def validate_gender(cls, v): + if v is not None and v not in ("男", "女"): + raise ValueError(f"gender 必须是 男/女,当前值: {v}") + return v + + @field_validator("status") + @classmethod + def validate_status(cls, v): + allowed = ["在读", "进入就业", "已就业"] + if v is not None and v not in allowed: + raise ValueError(f"status 必须是 {allowed} 之一,当前值: {v}") + return v + + +# -------------------- 响应模型 ---------------------------- +class StudentResponse(BaseModel): + stu_id: int + stu_name: str + native_place: str + graduate_school: str + major: str + enroll_time: date + graduate_time: date + education: str + age: int + gender: str + class_id: int + advisor_id: int + status: str + class_name: Optional[str] = Field(None, description="冗余展示:班级名称") + advisor_name: Optional[str] = Field(None, description="冗余展示:顾问姓名") + + class Config: + from_attributes = True + + +class StudentListResponse(BaseModel): + total: int + items: List[StudentResponse] diff --git a/scheme/teachers.py b/scheme/teachers.py new file mode 100644 index 0000000..1eb9773 --- /dev/null +++ b/scheme/teachers.py @@ -0,0 +1,54 @@ +# scheme/teachers.py +# 老师模块的请求/响应模型(沿用原 teacher 模块风格) +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class TeacherAdd(BaseModel): + teacher_id: Optional[int] = Field(None, ge=1, description="教师ID(不传则自增)") + class_id: int = Field(..., ge=1, description="所带班级ID") + teacher_name: str = Field(..., min_length=1, description="教师姓名") + job_name: str = Field(..., min_length=2, description="主讲、班主任、助教") + is_deleted: int = Field(0, ge=0, le=0, description="只能输入0,0代表未删除") + + @field_validator("job_name") + @classmethod + def validate_job_name(cls, v): + """校验 job_name 必须是 主讲/班主任/助教 之一""" + allowed = ["主讲", "班主任", "助教"] + if v not in allowed: + raise ValueError(f"job_name 必须是 {allowed} 之一,当前值: {v}") + return v + + +class TeacherUpdate(BaseModel): + class_id: Optional[int] = Field(None, ge=1, description="所带班级ID") + teacher_name: Optional[str] = Field(None, min_length=1, description="教师姓名") + job_name: Optional[str] = Field(None, min_length=2, description="主讲、班主任、助教") + + @field_validator("job_name") + @classmethod + def validate_job_name(cls, v): + allowed = ["主讲", "班主任", "助教"] + if v is not None and v not in allowed: + raise ValueError(f"job_name 必须是 {allowed} 之一,当前值: {v}") + return v + + +# -------------------- 响应模型 ---------------------------- +class TeacherResponse(BaseModel): + teacher_id: int + class_id: int + teacher_name: str + job_name: str + class_name: Optional[str] = Field(None, description="冗余展示:班级名称") + + class Config: + from_attributes = True + + +class TeacherListResponse(BaseModel): + total: int + items: List[TeacherResponse] diff --git a/scheme/users.py b/scheme/users.py new file mode 100644 index 0000000..9e43de2 --- /dev/null +++ b/scheme/users.py @@ -0,0 +1,53 @@ +# scheme/users.py +# 用户认证模块的请求/响应模型 +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + + +# -------------------- 请求体模型 ----------------------------- +class UserAdd(BaseModel): + """创建用户(仅管理员)""" + username: str = Field(..., min_length=3, max_length=50, description="登录名") + password: str = Field(..., min_length=6, max_length=64, description="密码(明文,服务端加密存储)") + role: str = Field(..., description="角色:admin/teacher/student") + teacher_id: Optional[int] = Field(None, ge=1, description="教师角色的工号") + stu_id: Optional[int] = Field(None, ge=1, description="学生角色的学号") + + @field_validator("role") + @classmethod + def validate_role(cls, v): + allowed = ["admin", "teacher", "student"] + if v not in allowed: + raise ValueError(f"role 必须是 {allowed} 之一,当前值: {v}") + return v + + +class UserLogin(BaseModel): + """登录""" + username: str = Field(..., description="登录名") + password: str = Field(..., description="密码") + + +# -------------------- 响应模型 ---------------------------- +class UserResponse(BaseModel): + user_id: int + username: str + role: str + teacher_id: Optional[int] = None + stu_id: Optional[int] = None + + class Config: + from_attributes = True + + +class TokenResponse(BaseModel): + """登录成功返回""" + access_token: str = Field(..., description="JWT token") + token_type: str = Field("bearer", description="token 类型") + user: UserResponse + + +class MessageResponse(BaseModel): + """通用消息响应""" + message: str diff --git a/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..6bb43bc --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,12 @@ +# smoke_test.py 冒烟测试:应用构建 + 路由清单(不需要连接数据库) +import main + +print("APP_OK:", main.app.title) +count = 0 +for x in main.app.routes: + methods = getattr(x, "methods", None) + if methods: + ms = sorted(methods - {"HEAD", "OPTIONS"}) + print(f"{'/'.join(ms):8} {x.path}") + count += 1 +print("TOTAL_ROUTES:", count) diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..f1b6d8b --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,177 @@ +-- ============================================================ +-- 沃林学生管理系统 数据库初始化脚本(修正版) +-- 基于原始建表语句修正,主要变更: +-- 1. c_lass 补充 class_name 字段(原表缺少班级名称) +-- 2. student 补充 status 字段(在读/进入就业/已就业,就业联动依赖) +-- 3. score 补充 is_deleted 字段(统一逻辑删除风格) +-- 4. employment_base 补充冗余字段 stu_name / class_name(需求 2.3 设计提示) +-- 5. user 新增用户表(JWT 登录 + RBAC 角色,原建表语句缺失) +-- 6. 修复原语句语法错误(c_lass 末尾多余逗号) +-- 7. 追加种子数据:初始管理员 admin/admin123 + 演示数据 +-- 使用:mysql -uroot -p < init.sql (或 docker-compose 自动执行) +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS walin_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE walin_db; + +-- ---------- 班级表 ---------- +CREATE TABLE IF NOT EXISTS c_lass( + class_id INT PRIMARY KEY AUTO_INCREMENT, + class_name VARCHAR(50) NOT NULL COMMENT '班级名称', + start_time DATE NOT NULL COMMENT '开课时间', + is_deleted INT NOT NULL DEFAULT 0 COMMENT '逻辑删除' +); + +-- ---------- 老师表 ---------- +CREATE TABLE IF NOT EXISTS teacher( + teacher_id INT PRIMARY KEY AUTO_INCREMENT, + teacher_name VARCHAR(50) NOT NULL, + job_name VARCHAR(50) NOT NULL COMMENT '主讲/班主任/助教', + class_id INT NOT NULL, + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (class_id) REFERENCES c_lass(class_id) +); + +-- ---------- 顾问表 ---------- +CREATE TABLE IF NOT EXISTS advisor( + advisor_id INT PRIMARY KEY AUTO_INCREMENT, + advisor_name VARCHAR(50) NOT NULL, + is_deleted INT NOT NULL DEFAULT 0 +); + +-- ---------- 学生表 ---------- +CREATE TABLE IF NOT EXISTS student( + stu_id INT PRIMARY KEY COMMENT '学号(支持规则生成:入学年份+班级号+序号)', + stu_name VARCHAR(10) NOT NULL, + native_place VARCHAR(30) NOT NULL, + graduate_school VARCHAR(50) NOT NULL, + major VARCHAR(20) NOT NULL, + enroll_time DATE NOT NULL, + graduate_time DATE NOT NULL, + education VARCHAR(10) NOT NULL, + age INT NOT NULL, + gender VARCHAR(10) NOT NULL, + class_id INT NOT NULL, + advisor_id INT NOT NULL, + status VARCHAR(10) NOT NULL DEFAULT '在读' COMMENT '在读/进入就业/已就业', + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (class_id) REFERENCES c_lass(class_id), + FOREIGN KEY (advisor_id) REFERENCES advisor(advisor_id) +); + +-- ---------- 成绩表 ---------- +CREATE TABLE IF NOT EXISTS score( + stu_id INT, + exam_id INT COMMENT '考核序次', + score FLOAT DEFAULT NULL, + is_deleted INT NOT NULL DEFAULT 0, + PRIMARY KEY (stu_id, exam_id), + FOREIGN KEY (stu_id) REFERENCES student(stu_id) +); + +-- ---------- 就业表-基础信息表 ---------- +CREATE TABLE IF NOT EXISTS employment_base( + stu_id INT PRIMARY KEY, + employment_open_time DATE NOT NULL COMMENT '就业开放时间', + job_time DATE DEFAULT NULL COMMENT 'offer下发时间', + company_name VARCHAR(100) DEFAULT '未就业', + salary FLOAT DEFAULT 0, + stu_name VARCHAR(10) NOT NULL COMMENT '冗余:学生姓名', + class_name VARCHAR(50) NOT NULL COMMENT '冗余:班级名称', + is_deleted INT NOT NULL DEFAULT 0, + FOREIGN KEY (stu_id) REFERENCES student(stu_id) +); + +-- ---------- 就业表-offer表 ---------- +CREATE TABLE IF NOT EXISTS employment_offer( + stu_id INT, + offer_id INT COMMENT '同一学生内自增', + offer_time DATE NOT NULL, + company_name VARCHAR(100) DEFAULT '', + salary FLOAT DEFAULT 0, + is_deleted INT NOT NULL DEFAULT 0, + PRIMARY KEY (stu_id, offer_id), + FOREIGN KEY (stu_id) REFERENCES employment_base(stu_id) +); + +-- ---------- 用户表(新增:JWT 登录 + RBAC) ---------- +CREATE TABLE IF NOT EXISTS user( + user_id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(200) NOT NULL COMMENT 'PBKDF2-SHA256', + role VARCHAR(20) NOT NULL COMMENT 'admin/teacher/student', + stu_id INT NULL COMMENT 'student 角色绑定的学号', + teacher_id INT NULL COMMENT 'teacher 角色绑定的工号', + is_deleted INT NOT NULL DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (stu_id) REFERENCES student(stu_id), + FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) +); + +-- ============================================================ +-- 种子数据(演示用,可按需删减) +-- 使用 INSERT IGNORE:重复执行本脚本不会报主键冲突(幂等) +-- ============================================================ + +-- 班级 +INSERT IGNORE INTO c_lass(class_id, class_name, start_time) VALUES +(1, 'Java2301', '2026-03-01'), +(2, 'Java2302', '2026-04-10'), +(3, 'Python2301', '2026-05-06'); + +-- 顾问 +INSERT IGNORE INTO advisor(advisor_id, advisor_name) VALUES +(1, '王顾问'), (2, '李顾问'); + +-- 老师 +INSERT IGNORE INTO teacher(teacher_id, teacher_name, job_name, class_id) VALUES +(1, '张主讲', '主讲', 1), +(2, '刘班主任', '班主任', 1), +(3, '陈主讲', '主讲', 2), +(4, '赵班主任', '班主任', 2), +(5, '孙助教', '助教', 3); + +-- 学生(学号规则:入学年份4位 + 班级号2位 + 序号4位) +INSERT IGNORE INTO student(stu_id, stu_name, native_place, graduate_school, major, enroll_time, graduate_time, education, age, gender, class_id, advisor_id, status) VALUES +(2026010001, '张伟', '湖北武汉', '武汉大学', '软件工程', '2026-03-01', '2026-09-01', '本科', 24, '男', 1, 1, '进入就业'), +(2026010002, '王芳', '湖南长沙', '中南大学', '计算机科学', '2026-03-01', '2026-09-01', '本科', 23, '女', 1, 1, '进入就业'), +(2026010003, '李娜', '河南郑州', '郑州大学', '软件工程', '2026-03-01', '2026-09-01', '大专', 22, '女', 1, 1, '在读'), +(2026010004, '刘强', '四川成都', '四川大学', '信息安全', '2026-03-01', '2026-09-01', '本科', 25, '男', 1, 1, '在读'), +(2026020001, '陈静', '江苏南京', '南京大学', '软件工程', '2026-04-10', '2026-10-10', '本科', 24, '女', 2, 2, '在读'), +(2026020002, '杨帆', '安徽合肥', '合肥工业大学', '计算机科学', '2026-04-10', '2026-10-10', '硕士', 27, '男', 2, 2, '在读'), +(2026020003, '黄磊', '广东广州', '华南理工', '软件工程', '2026-04-10', '2026-10-10', '本科', 23, '男', 2, 2, '在读'), +(2026030001, '周婷', '浙江杭州', '浙江大学', '人工智能', '2026-05-06', '2026-11-06', '硕士', 26, '女', 3, 2, '在读'), +(2026030002, '吴涛', '陕西西安', '西安电子科大', '大数据', '2026-05-06', '2026-11-06', '本科', 24, '男', 3, 2, '在读'); + +-- 成绩(部分低于 60 分用于演示红线预警) +INSERT IGNORE INTO score(stu_id, exam_id, score) VALUES +(2026010001, 1, 88), (2026010001, 2, 92), (2026010001, 3, 85), +(2026010002, 1, 76), (2026010002, 2, 58), (2026010002, 3, 81), +(2026010003, 1, 55), (2026010003, 2, 49), +(2026010004, 1, 67), (2026010004, 2, 72), +(2026020001, 1, 95), (2026020001, 2, 90), +(2026020002, 1, 82), (2026020002, 2, 60), +(2026020003, 1, 45), (2026020003, 2, 52), (2026020003, 3, 61), +(2026030001, 1, 89), (2026030001, 2, 94), +(2026030002, 1, 70), (2026030002, 2, 65); + +-- 就业基础表(张伟已拿 offer;王芳开放简历未拿 offer) +INSERT IGNORE INTO employment_base(stu_id, employment_open_time, job_time, company_name, salary, stu_name, class_name) VALUES +(2026010001, '2026-08-01', '2026-08-20', '腾讯科技', 18000, '张伟', 'Java2301'), +(2026010002, '2026-08-15', NULL, '未就业', 0, '王芳', 'Java2301'); + +-- offer 记录 +INSERT IGNORE INTO employment_offer(stu_id, offer_id, offer_time, company_name, salary) VALUES +(2026010001, 1, '2026-08-20', '腾讯科技', 18000); + +-- ============================================================ +-- 初始账号(密码均为 PBKDF2-SHA256 哈希,120000 次迭代): +-- admin / admin123 管理员 +-- teacher1 / teacher123 教师(工号1 张主讲,班级1) +-- s2026010003 / student123 学生(学号 2026010003 李娜) +-- 生产环境请立即修改密码! +-- ============================================================ +INSERT IGNORE INTO user(username, password_hash, role, teacher_id, stu_id) VALUES +('admin', 'pbkdf2_sha256$120000$897b4de0db4f0edf881627fc49df801e$02fc3387a341190448589f5569ef750f48ff3d9938fcea327ee2d5ee0f7258b9', 'admin', NULL, NULL), +('teacher1', 'pbkdf2_sha256$120000$03dd3da6882e0dd77bc71e5d7ca1bcb5$f02eb626afdc528eca67644f23a39d70d898ddea4adc9b7dccb30a8e1fb788b2', 'teacher', 1, NULL), +('s2026010003', 'pbkdf2_sha256$120000$5f9bd2dee6ff5fe7e145d3e2f9f8e11e$56b07cd8d35882547f378d62d3075e9f2eac38c47b6b917782c5199cb6864d6b', 'student', NULL, 2026010003); diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..6ca0c0c --- /dev/null +++ b/static/app.js @@ -0,0 +1,457 @@ +// app.js +// 沃林学生管理系统前端逻辑(Vue 3 Options API) +const { createApp } = Vue; + +const API = "/api"; + +// 统计接口配置:key -> { url, method, headers, rowKeys, headerMap } +const STAT_CONFIG = { + byAge: { + url: () => `/statistics/students/by-age?op=${S.params.op}` + + (S.params.op === "between" + ? `&min_value=${S.params.min_value}&max_value=${S.params.max_value}` + : `&value=${S.params.value}`), + pick: (s) => ({ 学号: s.stu_id, 姓名: s.stu_name, 班级: s.class_name, 性别: s.gender, 年龄: s.age, 状态: s.status }), + }, + genderStats: { + url: () => "/statistics/class/gender-stats", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 男: r.male, 女: r.female }), + }, + allAbove: { + url: () => `/statistics/score/all-above?line=${S.params.line}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 考核次数: r.exam_count, 最低分: r.min_score, 明细: JSON.stringify(r.scores) }), + }, + fail: { + url: () => `/statistics/score/fail?times=${S.params.times}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 不及格次数: r.fail_count, 明细: JSON.stringify(r.fail_details) }), + }, + classAvg: { + url: () => `/statistics/score/class-avg?order=${S.params.order}` + + (S.params.exam_id ? `&exam_id=${S.params.exam_id}` : ""), + pick: (r) => ({ 考核序次: `第${r.exam_id}次`, 班级ID: r.class_id, 班级: r.class_name, 平均分: r.avg_score }), + }, + topSalary: { + url: () => `/statistics/employment/top-salary?n=${S.params.n}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, offer时间: r.job_time, 公司: r.company_name, 薪资: r.salary }), + }, + duration: { + url: () => "/statistics/employment/duration", + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 开放时间: r.employment_open_time, offer时间: r.job_time, 就业时长: r.duration_days < 0 ? "未就业" : r.duration_days + " 天" }), + }, + classAvgDuration: { + url: () => "/statistics/employment/class-avg-duration", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 进入就业人数: r.opened_count, 已拿offer人数: r.offered_count, 平均就业时长: r.avg_duration_days + " 天" }), + }, + volatility: { + url: () => `/statistics/score/volatility?top_n=${S.params.top_n}`, + pick: (r) => ({ 学号: r.stu_id, 姓名: r.stu_name, 班级: r.class_name, 最高分: r.max_score, 最低分: r.min_score, 最大分差: r.diff }), + }, + funnel: { + url: () => "/statistics/employment/funnel", + pick: (r) => ({ 班级ID: r.class_id, 班级: r.class_name, 总人数: r.total, 已就业: r.employed, 高薪人数: r.high_salary, 就业率: r.employment_rate + "%" }), + }, +}; + +// 状态对象(非响应式共享,供 STAT_CONFIG 读取) +const S = { + params: { op: "gt", value: 25, min_value: 20, max_value: 30, line: 80, times: 2, n: 10, exam_id: "", order: "desc", top_n: 5 }, +}; + +const app = createApp({ + data() { + return { + token: localStorage.getItem("token") || "", + me: { username: "", role: "" }, + loading: false, + tip: "", + loginForm: { username: "admin", password: "admin123" }, + tabs: [ + { key: "students", label: "学生管理" }, + { key: "scores", label: "成绩管理" }, + { key: "employment", label: "就业管理" }, + { key: "classes", label: "班级管理" }, + { key: "teachers", label: "老师管理" }, + { key: "statistics", label: "统计分析" }, + { key: "filter", label: "高级筛选" }, + ], + tab: "students", + // 学生 + students: [], stuTotal: 0, stuPage: 1, + stuQ: { stu_id: "", stu_name: "", class_id: "", status: "" }, + showStu: false, stuForm: {}, + // 成绩 + scores: [], scoreQ: { stu_id: "", exam_id: "" }, scoreWarning: "", + showScore: false, scoreForm: {}, + // 就业 + employment: [], empTotal: 0, empPage: 1, + empQ: { stu_id: "", company_name: "", salary_min: "", salary_max: "" }, + showEmp: false, empFormMode: "open", empForm: {}, + // 班级 / 老师 + classes: [], showClass: false, classForm: {}, + teachers: [], teacherTotal: 0, teacherPage: 1, showTeacher: false, teacherForm: {}, + // 统计 + statKey: "", statRows: [], statParams: S.params, + // 高级筛选 + filterJson: "", filterRows: [], + }; + }, + computed: { + roleLabel() { + return { admin: "管理员", teacher: "教师", student: "学生" }[this.me.role] || this.me.role; + }, + statKeys() { + return this.statRows.length ? Object.keys(this.statRows[0]) : []; + }, + statHeaders() { + return this.statKeys; + }, + }, + methods: { + // ==================== 基础 ==================== + async api(path, method = "GET", body = null, raw = false) { + const opt = { + method, + headers: { Authorization: "Bearer " + this.token }, + }; + if (body !== null) { + opt.headers["Content-Type"] = "application/json"; + opt.body = JSON.stringify(body); + } + const resp = await fetch(API + path, opt); + if (resp.status === 401) { + this.logout(true); + throw new Error("登录已失效,请重新登录"); + } + if (!resp.ok) { + let detail = `请求失败(${resp.status})`; + try { detail = (await resp.json()).detail || detail; } catch (e) {} + // detail 可能是 pydantic 校验数组 + if (Array.isArray(detail)) detail = detail.map((d) => `${d.loc.join(".")}: ${d.msg}`).join("; "); + throw new Error(detail); + } + if (resp.status === 204) return null; + return raw ? resp : resp.json(); + }, + flash(msg, ok = true) { + this.tip = msg; + setTimeout(() => (this.tip = ""), ok ? 2500 : 5000); + }, + logout(silent = false) { + localStorage.removeItem("token"); + this.token = ""; + this.me = { username: "", role: "" }; + if (!silent) this.tip = ""; + }, + async login() { + this.loading = true; + this.tip = ""; + try { + const resp = await fetch(API + "/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.loginForm), + }); + if (!resp.ok) throw new Error((await resp.json()).detail || "登录失败"); + const data = await resp.json(); + this.token = data.access_token; + localStorage.setItem("token", this.token); + await this.afterLogin(); + } catch (e) { + this.tip = e.message; + } finally { + this.loading = false; + } + }, + async afterLogin() { + try { + this.me = await this.api("/auth/me"); + await Promise.all([this.loadClasses(), this.loadStudents(1), this.loadTeachers(1)]); + this.switchTab(this.me.role === "student" ? "students" : this.tab); + this.flash("登录成功"); + } catch (e) { + this.tip = e.message; + } + }, + switchTab(key) { + this.tab = key; + const loaders = { + students: () => this.loadStudents(1), + scores: () => this.loadScores(), + employment: () => this.loadEmployment(1), + classes: () => this.loadClasses(), + teachers: () => this.loadTeachers(1), + }; + if (loaders[key]) loaders[key](); + }, + closeAll() { + this.showStu = this.showScore = this.showEmp = this.showClass = this.showTeacher = false; + }, + + // ==================== 学生 ==================== + async loadStudents(page = 1) { + try { + const q = new URLSearchParams(); + if (this.stuQ.stu_id) q.set("stu_id", this.stuQ.stu_id); + if (this.stuQ.stu_name) q.set("stu_name", this.stuQ.stu_name); + if (this.stuQ.class_id) q.set("class_id", this.stuQ.class_id); + if (this.stuQ.status) q.set("status", this.stuQ.status); + q.set("skip", (page - 1) * 10); + q.set("limit", 10); + const data = await this.api("/students/total_query?" + q.toString()); + this.students = data.items; + this.stuTotal = data.total; + this.stuPage = page; + } catch (e) { this.flash(e.message, false); } + }, + resetStuQ() { this.stuQ = { stu_id: "", stu_name: "", class_id: "", status: "" }; this.loadStudents(1); }, + openStuForm(s = null) { + this.stuForm = s + ? { ...s, _editing: true } + : { stu_id: "", class_id: this.classes[0]?.class_id, stu_name: "", gender: "男", age: 20, education: "本科", major: "", native_place: "", graduate_school: "", advisor_id: 1, enroll_time: "", graduate_time: "", status: "在读" }; + this.showStu = true; + }, + async saveStudent() { + try { + if (this.stuForm._editing) { + await this.api(`/students/update?stu_id=${this.stuForm.stu_id}`, "PUT", { + stu_name: this.stuForm.stu_name, native_place: this.stuForm.native_place, + graduate_school: this.stuForm.graduate_school, major: this.stuForm.major, + education: this.stuForm.education, age: this.stuForm.age, gender: this.stuForm.gender, + status: this.stuForm.status, + }); + this.flash("学生更新成功"); + } else { + const body = { ...this.stuForm }; + if (body.stu_id) body.stu_id = Number(body.stu_id); else delete body.stu_id; + await this.api("/students/add", "POST", body); + this.flash("学生创建成功"); + } + this.closeAll(); + this.loadStudents(this.stuPage); + } catch (e) { this.flash(e.message, false); } + }, + async delStudent(s) { + if (!confirm(`确认删除学生 ${s.stu_name}(${s.stu_id})?`)) return; + try { await this.api(`/students/delete?stu_id=${s.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadStudents(this.stuPage); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 成绩 ==================== + async loadScores() { + try { + const q = new URLSearchParams(); + if (this.scoreQ.stu_id) q.set("stu_id", this.scoreQ.stu_id); + if (this.scoreQ.exam_id) q.set("exam_id", this.scoreQ.exam_id); + if (!q.toString()) { this.scores = []; this.flash("请输入学号或考核序次查询", false); return; } + const data = await this.api("/scores/query?" + q.toString()); + this.scores = data.items; + this.scoreWarning = ""; + } catch (e) { this.flash(e.message, false); } + }, + openScoreForm(s = null) { + this.scoreForm = s + ? { stu_id: s.stu_id, exam_id: s.exam_id, score: s.score, _editing: true } + : { stu_id: "", exam_id: 1, score: 80 }; + this.showScore = true; + }, + async saveScore() { + try { + if (this.scoreForm._editing) { + await this.api(`/scores/update?stu_id=${this.scoreForm.stu_id}&exam_id=${this.scoreForm.exam_id}`, "PUT", { score: this.scoreForm.score }); + this.flash("成绩修改成功"); + } else { + const data = await this.api("/scores/add", "POST", { stu_id: this.scoreForm.stu_id, exam_id: this.scoreForm.exam_id, score: this.scoreForm.score }); + if (data.warning) { this.scoreWarning = data.warning; this.flash(data.warning, false); } + else this.flash("成绩录入成功"); + } + this.closeAll(); + if (this.scoreQ.stu_id || this.scoreQ.exam_id) this.loadScores(); + } catch (e) { this.flash(e.message, false); } + }, + async delScore(s) { + if (!confirm(`确认删除第 ${s.exam_id} 次考核成绩?`)) return; + try { await this.api("/scores/delete", "POST", { stu_id: s.stu_id, exam_id: s.exam_id }); this.flash("删除成功"); this.loadScores(); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 就业 ==================== + async loadEmployment(page = 1) { + try { + const q = new URLSearchParams(); + if (this.empQ.stu_id) q.set("stu_id", this.empQ.stu_id); + if (this.empQ.company_name) q.set("company_name", this.empQ.company_name); + if (this.empQ.salary_min !== "" && this.empQ.salary_min !== null) q.set("salary_min", this.empQ.salary_min); + if (this.empQ.salary_max !== "" && this.empQ.salary_max !== null) q.set("salary_max", this.empQ.salary_max); + q.set("skip", (page - 1) * 10); + q.set("limit", 10); + const data = await this.api("/employment/total_query?" + q.toString()); + this.employment = data.items; + this.empTotal = data.total; + this.empPage = page; + } catch (e) { this.flash(e.message, false); } + }, + openEmpEdit(e) { + this.empFormMode = "edit"; + this.empForm = { stu_id: e.stu_id, company_name: e.company_name, salary: e.salary }; + this.showEmp = true; + }, + async saveEmployment() { + try { + if (this.empFormMode === "open") { + await this.api("/employment/open", "POST", { stu_id: this.empForm.stu_id, employment_open_time: this.empForm.employment_open_time }); + this.flash("就业开放登记成功,学生状态已更新为『进入就业』"); + } else if (this.empFormMode === "offer") { + await this.api("/employment/offer", "POST", this.empForm); + this.flash("offer 登记成功,学生状态已更新为『已就业』"); + } else { + await this.api(`/employment/update?stu_id=${this.empForm.stu_id}`, "PUT", { company_name: this.empForm.company_name, salary: this.empForm.salary }); + this.flash("就业信息更新成功"); + } + this.closeAll(); + this.loadEmployment(this.empPage); + } catch (e) { this.flash(e.message, false); } + }, + async delEmployment(e) { + if (!confirm(`确认删除 ${e.stu_name} 的就业信息?学生状态将回退为在读。`)) return; + try { await this.api(`/employment/delete?stu_id=${e.stu_id}`, "DELETE"); this.flash("删除成功"); this.loadEmployment(this.empPage); } + catch (err) { this.flash(err.message, false); } + }, + + // ==================== 班级 ==================== + async loadClasses() { + try { + const data = await this.api("/classes/total_query?limit=200"); + this.classes = data.items; + } catch (e) { this.flash(e.message, false); } + }, + openClassForm(c = null) { + this.classForm = c ? { ...c } : { class_id: "", class_name: "", start_time: "" }; + this.showClass = true; + }, + async saveClass() { + try { + if (this.classForm.class_id && !this.classForm._editing) { + await this.api("/classes/add", "POST", { class_id: Number(this.classForm.class_id), class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } else if (this.classForm._editing) { + await this.api(`/classes/update?class_id=${this.classForm.class_id}`, "PUT", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } else { + await this.api("/classes/add", "POST", { class_name: this.classForm.class_name, start_time: this.classForm.start_time }); + } + this.flash("班级保存成功"); + this.closeAll(); + this.loadClasses(); + } catch (e) { this.flash(e.message, false); } + }, + async delClass(c) { + if (!confirm(`确认删除班级 ${c.class_name}?`)) return; + try { await this.api(`/classes/delete?class_id=${c.class_id}`, "DELETE"); this.flash("删除成功"); this.loadClasses(); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 老师 ==================== + async loadTeachers(page = 1) { + try { + const data = await this.api(`/teachers/teacher/total_query?skip=${(page - 1) * 10}&limit=10`); + this.teachers = data.items; + this.teacherTotal = data.total; + this.teacherPage = page; + } catch (e) { this.flash(e.message, false); } + }, + openTeacherForm(t = null) { + this.teacherForm = t ? { ...t, _editing: true } : { teacher_name: "", job_name: "主讲", class_id: this.classes[0]?.class_id }; + this.showTeacher = true; + }, + async saveTeacher() { + try { + if (this.teacherForm._editing) { + await this.api(`/teachers/teacher/update?teacher_id=${this.teacherForm.teacher_id}`, "PUT", { + class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, + }); + } else { + await this.api("/teachers/teacher/add", "POST", { + class_id: this.teacherForm.class_id, teacher_name: this.teacherForm.teacher_name, job_name: this.teacherForm.job_name, + }); + } + this.flash("老师保存成功"); + this.closeAll(); + this.loadTeachers(this.teacherPage); + } catch (e) { this.flash(e.message, false); } + }, + async delTeacher(t) { + if (!confirm(`确认删除老师 ${t.teacher_name}?`)) return; + try { await this.api(`/teachers/teacher/delete?teacher_id=${t.teacher_id}`, "DELETE"); this.flash("删除成功"); this.loadTeachers(this.teacherPage); } + catch (e) { this.flash(e.message, false); } + }, + + // ==================== 统计 ==================== + runStat(key) { + this.statKey = key; + this.statRows = []; + // 只有需要参数的统计自动执行一次默认查询 + if (!["byAge", "allAbove", "fail", "topSalary", "classAvg", "volatility"].includes(key)) this.doStat(); + }, + async doStat() { + const cfg = STAT_CONFIG[this.statKey]; + if (!cfg) return; + try { + const data = await this.api(cfg.url()); + this.statRows = data.map(cfg.pick); + } catch (e) { this.flash(e.message, false); } + }, + + // ==================== 高级筛选 ==================== + resetFilterJson() { + this.filterJson = JSON.stringify( + { + model: "student", + rules: [ + { field: "age", operator: ">", value: 20 }, + { + logic: "OR", + sub_rules: [ + { field: "salary", operator: ">=", value: 10000 }, + { field: "class_name", operator: "like", value: "Java" }, + ], + }, + ], + }, + null, + 2 + ); + }, + async runFilter() { + try { + const body = JSON.parse(this.filterJson); + const data = await this.api("/statistics/filter", "POST", body); + this.filterRows = data.items; + this.flash(`筛选完成,共命中 ${data.total} 条记录`); + } catch (e) { + this.flash("筛选失败:" + e.message, false); + } + }, + }, + mounted() { + if (this.token) this.afterLogin(); + this.resetFilterJson(); + }, +}); + +// 分页组件 +app.component("pager", { + props: ["page", "total"], + emits: ["go"], + computed: { + pages() { + return Math.max(1, Math.ceil((this.total || 0) / 10)); + }, + }, + template: ` +
+ + 第 {{ page }} / {{ pages }} 页(共 {{ total }} 条) + +
+ `, +}); + +app.mount("#app"); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..43654c7 --- /dev/null +++ b/static/index.html @@ -0,0 +1,316 @@ + + + + + + 沃林学生管理系统 + + + + +
+ + + + + + + + + + +
{{ tip }}
+
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..19cad7b --- /dev/null +++ b/static/style.css @@ -0,0 +1,72 @@ +/* style.css 沃林学生管理系统前端样式(浅色主题) */ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f7fa; color: #2c3e50; font-size: 14px; } + +/* ---------- 登录页 ---------- */ +.login-wrap { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #4f7cff 0%, #6a5cff 100%); } +.login-card { background: #fff; padding: 40px; border-radius: 12px; width: 360px; box-shadow: 0 10px 40px rgba(0,0,0,.15); } +.login-card h2 { text-align: center; margin-bottom: 4px; } +.login-card .sub { text-align: center; color: #999; margin-bottom: 24px; font-size: 12px; } +.login-card label { display: block; margin: 12px 0 4px; color: #666; font-size: 13px; } +.login-card input { width: 100%; padding: 10px 12px; border: 1px solid #dcdfe6; border-radius: 6px; font-size: 14px; } +.login-card .block { width: 100%; margin-top: 20px; } +.login-card .tip { color: #e74c3c; margin-top: 12px; font-size: 13px; min-height: 18px; text-align: center; } + +/* ---------- 顶栏 / 标签页 ---------- */ +.topbar { display: flex; align-items: center; gap: 12px; background: #fff; padding: 12px 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); position: sticky; top: 0; z-index: 10; } +.topbar .logo { font-size: 17px; font-weight: 700; color: #4f7cff; } +.topbar .spacer { flex: 1; } +.role-tag { background: #eef3ff; color: #4f7cff; padding: 2px 10px; border-radius: 10px; font-size: 12px; } +.tabs { display: flex; gap: 4px; padding: 12px 24px 0; flex-wrap: wrap; } +.tab { border: none; background: transparent; padding: 9px 18px; cursor: pointer; border-radius: 8px 8px 0 0; color: #666; font-size: 14px; } +.tab.active { background: #fff; color: #4f7cff; font-weight: 600; box-shadow: 0 -2px 6px rgba(0,0,0,.04); } +.content { padding: 16px 24px 40px; } + +/* ---------- 工具栏 / 表格 ---------- */ +.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; } +.toolbar.wrap { flex-wrap: wrap; } +.toolbar input, .toolbar select { padding: 7px 10px; border: 1px solid #dcdfe6; border-radius: 6px; } +.w90 { width: 90px; } .w120 { width: 120px; } .w140 { width: 140px; } .w70 { width: 70px; } +table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.05); } +th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f2f5; white-space: nowrap; } +thead th { background: #fafbfc; color: #555; font-weight: 600; } +tbody tr:hover { background: #f7faff; } +.empty { text-align: center; color: #aaa; padding: 24px !important; } + +/* ---------- 按钮 / 徽标 ---------- */ +.btn { border: 1px solid #dcdfe6; background: #fff; padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #444; transition: .15s; } +.btn:hover { border-color: #4f7cff; color: #4f7cff; } +.btn.primary { background: #4f7cff; border-color: #4f7cff; color: #fff; } +.btn.primary:hover { background: #3f6cf0; } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.danger { color: #e74c3c; } +.btn.danger:hover { border-color: #e74c3c; } +.btn.mini { padding: 3px 8px; font-size: 12px; margin-right: 4px; } +.btn:disabled { opacity: .5; cursor: not-allowed; } +.badge { padding: 2px 8px; border-radius: 10px; font-size: 12px; background: #f0f2f5; } +.badge.ok { background: #e6f9ee; color: #18a058; } +.badge.warn { background: #fff5e6; color: #d48806; } +.badge.danger { background: #fdeaea; color: #e74c3c; } +.warn-tip { background: #fff5e6; color: #d48806; padding: 8px 12px; border-radius: 6px; margin-bottom: 10px; } + +/* ---------- 分页 ---------- */ +.pager { display: flex; align-items: center; gap: 12px; margin-top: 12px; color: #666; } + +/* ---------- 弹窗 ---------- */ +.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; justify-content: center; align-items: flex-start; padding-top: 8vh; z-index: 100; } +.modal { background: #fff; border-radius: 10px; padding: 24px; width: 560px; max-height: 82vh; overflow: auto; box-shadow: 0 10px 40px rgba(0,0,0,.2); } +.modal h3 { margin-bottom: 16px; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; } +.form-grid label { display: flex; flex-direction: column; gap: 4px; color: #666; font-size: 13px; } +.form-grid input, .form-grid select { padding: 8px 10px; border: 1px solid #dcdfe6; border-radius: 6px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +/* ---------- 统计 / 筛选 ---------- */ +.param-box { display: flex; gap: 10px; align-items: center; background: #fff; padding: 12px 16px; border-radius: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.param-box label { display: flex; align-items: center; gap: 6px; color: #555; } +.param-box input, .param-box select { padding: 6px 8px; border: 1px solid #dcdfe6; border-radius: 6px; } +.sub-tip { color: #888; font-size: 13px; margin-bottom: 10px; line-height: 1.8; } +.filter-json { width: 100%; font-family: Consolas, monospace; font-size: 13px; padding: 12px; border: 1px solid #dcdfe6; border-radius: 8px; margin-bottom: 10px; } + +/* ---------- 提示 ---------- */ +.toast { position: fixed; top: 70px; left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 10px 20px; border-radius: 8px; z-index: 200; box-shadow: 0 4px 14px rgba(0,0,0,.25); }