commit b9311bdb1efa8436f676cebfa3f7b61fe3a635d3 Author: jianqi Date: Fri Sep 18 16:53:21 2026 +0800 claude_sms_test diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..87fe10e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -rn \"pass$\\\\|TODO\\\\|NotImplemented\\\\|raise NotImplementedError\" api/ model/)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6a4dacf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# 构建镜像时不需要拷贝进容器的内容,可显著减小镜像体积、加快构建 +__pycache__/ +*.pyc +*.pyo +*.pyd + +venv/ +.venv/ +env/ + +.idea/ +.vscode/ +.git/ +.gitignore + +# 本地环境变量文件,绝不能打进镜像 +.env + +.pytest_cache/ +.mypy_cache/ +dist/ +build/ + +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2bacc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 忽略虚拟环境(依赖包很大,别人自己本地安装即可) +venv/ +.venv/ +env/ + +# 忽略 PyCharm 自身的配置文件 +.idea/ + +# 忽略 Python 自动生成的运行缓存 +__pycache__/ +*.pyc + +# 忽略本地环境变量文件(防止泄露云数据库密码!) +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2cbcddf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# ---------- 基础镜像 ---------- +# 使用精简版 Python 3.12 镜像,体积小 +FROM python:3.12-slim + +# 环境变量: +# PYTHONUNBUFFERED=1 让 print / 日志实时输出,不缓存 +# PYTHONDONTWRITEBYTECODE=1 不生成 .pyc,保持镜像干净 +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + TZ=Asia/Shanghai + +WORKDIR /app + +# 先只拷贝依赖清单并安装,利用 Docker 层缓存(代码改动时不必重装依赖) +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# 再拷贝项目源码 +COPY . . + +# 声明应用端口(与 main.py 保持一致) +EXPOSE 8002 + +# 启动命令:容器内必须监听 0.0.0.0,外部才能访问 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c4e781 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# 码力全开:学生管理系统(FastAPI 分层示例) + +基于 FastAPI + SQLAlchemy + MySQL 的学生管理系统后端,采用前后端分离的 RESTful API 设计。 + +## 模块一览 + +| 模块 | 路由前缀 | 说明 | +| --- | --- | --- | +| 学生信息管理 | `/api/student` | 学生基本信息的增删改查 | +| 学生成绩管理 | `/api/score` | 成绩增删改查,等级自动判定 | +| 班级管理 | `/api/classes` | 班级增删改查 | +| 学生就业管理 | `/api/employ` | 就业记录增删改查(与学生一对一) | +| 教师信息管理 | `/api/teacher` | 教师增删改查 | +| 顾问信息管理 | `/api/advisor` | 顾问增删改查 | +| 统计分析 | `/api/stats` | 总览、分布、成绩/就业综合统计 | + +## 项目结构(分层) + +``` +claude_sms_test/ +├── main.py # 应用入口:注册路由、中间件、DAO 异常处理器 +├── database.py # 数据库引擎 / 会话工厂 / 依赖注入 +├── init_db.py # 建表脚本 +├── model/ # 模型层:ORM 表映射 +├── schemas/ # 模式层:Pydantic 请求/响应校验 +├── dao/ # 数据访问层:查库、业务校验、事务 +├── api/ # 路由层:接口出入参 +└── static/ # 前端页面:原生 HTML/CSS/JS 单页应用(挂载在 /ui) +``` + +### 各层职责 + +| 层 | 干什么 | 不干什么 | +| --- | --- | --- | +| `api/` | 接收 HTTP 参数、调用 DAO、组装响应 | 不写 SQL、不 `import HTTPException` | +| `dao/` | 所有 SQLAlchemy 查询、唯一性/外键校验、软删除、事务提交 | 不感知 HTTP | +| `schemas/` | 请求体校验与响应序列化 | 不碰数据库 | +| `model/` | ORM 表结构映射 | 不含业务逻辑 | + +**异常流转**:DAO 层只抛 `NotFoundError`(404) / `ConflictError`(409), +在 `main.py` 注册的全局处理器统一转成 `{"detail": "..."}` 响应。 +因此接口层不需要写任何 `try/except`。 + +```python +# dao → 抛业务异常 +raise NotFoundError("所属班级不存在") + +# main.py → 统一映射为状态码 +@app.exception_handler(DaoError) +async def dao_error_handler(request: Request, exc: DaoError): + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + +# api → 干净的一行调用 +return stu_dao.create_student(db, payload.model_dump()) +``` + +## 快速开始 + +1. 安装依赖: + ```bash + pip install -r requirements.txt + ``` +2. 在 MySQL 中创建数据库(默认库名 `claude_sms_test`),并确认 `database.py` 中的连接信息正确。 +3. 建表: + ```bash + python init_db.py + ``` +4. 启动服务: + ```bash + python main.py + ``` +5. 打开接口文档:http://localhost:8002/docs +6. 打开可视化管理界面(含查询小助手):http://localhost:8002/ui + +## 前端界面 + +`static/` 下是配套的管理界面,原生 HTML/CSS/JS,**无构建、无第三方依赖**,由 `main.py` 挂载在 `/ui`。 + +- 九个视图:数据总览 / 统计分析 / 学生 / 班级 / 教师 / 顾问 / 成绩 / 就业 / 查询小助手 +- 表格支持分页与多条件筛选;新增、编辑、软删除均直连后端接口,删除为软删除并二次确认 +- 统计图表为纯 DOM 绘制,不依赖任何 CDN,离线可用 +- 右侧「查询小助手」直接用中文提问: + - 编号直达详情:`S0001` / `C001` / `T001` / `A001` + - 条件筛选:性别、学历、生源地、专业、班级、顾问、成绩等级、就业状态、就业公司 + - 统计问答:就业率、平均分、最高/最低分、及格率、薪资、生源地 Top、各类分布 + +## 说明 + +- 所有删除操作均为**软删除**(`is_deleted` 标记),数据不会真正从数据库中移除。 +- 成绩等级未显式指定时,按分数自动判定:`>=90 优秀`、`80-89 良好`、`60-79 普通`、`<60 不及格`。 \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..081c184 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,14 @@ +# api/__init__.py +# 聚合入口:导入所有路由模块,便于统一导出(与 main.py 的导入保持一致) + +from api import statistics_api, stu_score_api, cls_mgmt_api, employ_api, stu_api, teacher_api, advisor_api + +__all__ = [ + "statistics_api", + "stu_score_api", + "cls_mgmt_api", + "employ_api", + "stu_api", + "teacher_api", + "advisor_api", +] diff --git a/api/advisor_api.py b/api/advisor_api.py new file mode 100644 index 0000000..27e5959 --- /dev/null +++ b/api/advisor_api.py @@ -0,0 +1,59 @@ +# api/advisor_api.py +# 顾问信息管理模块:接口层只负责 HTTP 出入参,查库/校验/事务交给 dao.advisor_dao + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import advisor_dao +from database import get_db +from schemas.advisor_schema import AdvisorCreate, AdvisorUpdate, AdvisorOut + +router = APIRouter() + + +@router.get("", summary="分页查询顾问列表") +def list_advisors( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + name: Optional[str] = Query(None, description="按姓名模糊查询"), + db: Session = Depends(get_db), +): + total, items = advisor_dao.list_advisors(db, page=page, size=size, name=name) + return { + "total": total, + "page": page, + "size": size, + "items": [AdvisorOut.model_validate(x) for x in items], + } + + +@router.get("/{adv_id}", summary="查询单个顾问详情") +def get_advisor(adv_id: str, db: Session = Depends(get_db)): + """查询顾问详情,附带其负责的学生编号与人数。""" + advisor = advisor_dao.assert_advisor_alive(db, adv_id, "顾问不存在或已删除") + data = AdvisorOut.model_validate(advisor).model_dump() + student_ids = [s.id for s in advisor.students if not s.is_deleted] + data["student_ids"] = student_ids + data["student_count"] = len(student_ids) + return data + + +@router.post("", response_model=AdvisorOut, status_code=201, summary="新增顾问") +def create_advisor(payload: AdvisorCreate, db: Session = Depends(get_db)): + """新增顾问(编号唯一,由 DAO 校验)。""" + return advisor_dao.create_advisor(db, payload.model_dump()) + + +@router.put("/{adv_id}", response_model=AdvisorOut, summary="更新顾问信息") +def update_advisor(adv_id: str, payload: AdvisorUpdate, db: Session = Depends(get_db)): + """按传入字段局部更新顾问信息。""" + return advisor_dao.update_advisor(db, adv_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{adv_id}", summary="删除顾问(软删除)") +def delete_advisor(adv_id: str, db: Session = Depends(get_db)): + """软删除顾问。""" + advisor_dao.soft_delete_advisor(db, adv_id) + return {"message": "删除成功", "id": adv_id} \ No newline at end of file diff --git a/api/cls_mgmt_api.py b/api/cls_mgmt_api.py new file mode 100644 index 0000000..742bc3f --- /dev/null +++ b/api/cls_mgmt_api.py @@ -0,0 +1,66 @@ +# api/cls_mgmt_api.py +# 班级管理模块:接口层只负责 HTTP 出入参,查库/校验/事务交给 dao.cls_mgmt_dao + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import cls_mgmt_dao +from database import get_db +from schemas.cls_mgmt_schema import ClsMgmtCreate, ClsMgmtUpdate, ClsMgmtOut + +router = APIRouter() + + +def _to_detail(cls) -> dict: + """把班级实体组装成详情响应:基础字段 + 班主任/主讲老师姓名 + 在班人数。""" + data = ClsMgmtOut.model_validate(cls).model_dump() + data["head_teacher_name"] = cls.head_teacher.name if cls.head_teacher else None + data["lecturer_name"] = cls.lecturer.name if cls.lecturer else None + data["student_count"] = sum(1 for s in cls.stu if not s.is_deleted) + return data + + +@router.get("", summary="分页查询班级列表") +def list_classes( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + head_tea_id: Optional[str] = Query(None, description="按班主任id过滤"), + lecturer_id: Optional[str] = Query(None, description="按主讲老师id过滤"), + db: Session = Depends(get_db), +): + total, items = cls_mgmt_dao.list_classes( + db, page=page, size=size, head_tea_id=head_tea_id, lecturer_id=lecturer_id + ) + return { + "total": total, + "page": page, + "size": size, + "items": [ClsMgmtOut.model_validate(x) for x in items], + } + + +@router.get("/{cls_id}", summary="查询单个班级详情") +def get_class(cls_id: str, db: Session = Depends(get_db)): + """查询班级详情,附带班主任/主讲老师姓名与在班学生数。""" + return _to_detail(cls_mgmt_dao.assert_cls_alive(db, cls_id, "班级不存在或已删除")) + + +@router.post("", response_model=ClsMgmtOut, status_code=201, summary="新增班级") +def create_class(payload: ClsMgmtCreate, db: Session = Depends(get_db)): + """新增班级(编号唯一、两位教师必须存在,由 DAO 校验)。""" + return cls_mgmt_dao.create_class(db, payload.model_dump()) + + +@router.put("/{cls_id}", response_model=ClsMgmtOut, summary="更新班级信息") +def update_class(cls_id: str, payload: ClsMgmtUpdate, db: Session = Depends(get_db)): + """按传入字段局部更新班级信息。""" + return cls_mgmt_dao.update_class(db, cls_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{cls_id}", summary="删除班级(软删除)") +def delete_class(cls_id: str, db: Session = Depends(get_db)): + """软删除班级。""" + cls_mgmt_dao.soft_delete_class(db, cls_id) + return {"message": "删除成功", "id": cls_id} \ No newline at end of file diff --git a/api/employ_api.py b/api/employ_api.py new file mode 100644 index 0000000..0d7eece --- /dev/null +++ b/api/employ_api.py @@ -0,0 +1,60 @@ +# api/employ_api.py +# 学生就业管理模块:接口层只负责 HTTP 出入参,查库/校验/事务交给 dao.employ_dao + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import employ_dao +from database import get_db +from schemas.employ_schema import EmployCreate, EmployUpdate, EmployOut + +router = APIRouter() + + +@router.get("", summary="分页查询就业记录") +def list_employ( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + emp_company: Optional[str] = Query(None, description="按公司名称模糊查询"), + employed: Optional[bool] = Query(None, description="是否已就业(true/false)"), + db: Session = Depends(get_db), +): + total, items = employ_dao.list_employ( + db, page=page, size=size, emp_company=emp_company, employed=employed + ) + return { + "total": total, + "page": page, + "size": size, + "items": [EmployOut.model_validate(x) for x in items], + } + + +@router.get("/{stu_id}", summary="查询单个学生的就业信息") +def get_employ(stu_id: str, db: Session = Depends(get_db)): + """查询就业详情,附带学生姓名。""" + emp = employ_dao.assert_employ_alive(db, stu_id) + data = EmployOut.model_validate(emp).model_dump() + data["student_name"] = emp.stu.name if emp.stu else None + return data + + +@router.post("", response_model=EmployOut, status_code=201, summary="新增就业记录") +def create_employ(payload: EmployCreate, db: Session = Depends(get_db)): + """新增就业记录(学生必须存在,且一对一不可重复,由 DAO 校验)。""" + return employ_dao.create_employ(db, payload.model_dump()) + + +@router.put("/{stu_id}", response_model=EmployOut, summary="更新就业信息") +def update_employ(stu_id: str, payload: EmployUpdate, db: Session = Depends(get_db)): + """按传入字段局部更新就业信息。""" + return employ_dao.update_employ(db, stu_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{stu_id}", summary="删除就业记录(软删除)") +def delete_employ(stu_id: str, db: Session = Depends(get_db)): + """软删除就业记录。""" + employ_dao.soft_delete_employ(db, stu_id) + return {"message": "删除成功", "stu_id": stu_id} \ No newline at end of file diff --git a/api/statistics_api.py b/api/statistics_api.py new file mode 100644 index 0000000..243ccea --- /dev/null +++ b/api/statistics_api.py @@ -0,0 +1,61 @@ +# api/statistics_api.py +# 统计分析模块(动态查询与综合能力锻炼): +# 聚合 / 分组 / 关联 / 动态查询条件全部沉在 dao.statistics_dao,这里只暴露接口。 + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import statistics_dao +from database import get_db + +router = APIRouter() + + +@router.get("/overview", summary="系统总览统计") +def overview(db: Session = Depends(get_db)): + """统计学生、班级、教师、顾问、已就业人数及就业率。""" + return statistics_dao.overview(db) + + +@router.get("/student/gender", summary="学生性别分布") +def student_gender_distribution(db: Session = Depends(get_db)): + return statistics_dao.student_gender_distribution(db) + + +@router.get("/student/education", summary="学生学历分布") +def student_education_distribution(db: Session = Depends(get_db)): + return statistics_dao.student_education_distribution(db) + + +@router.get("/student/state", summary="学生就读状态分布") +def student_state_distribution(db: Session = Depends(get_db)): + return statistics_dao.student_state_distribution(db) + + +@router.get("/student/hometown", summary="学生生源地分布(Top N)") +def student_hometown_distribution( + limit: int = Query(10, ge=1, le=100, description="返回前 N 个生源地"), + db: Session = Depends(get_db), +): + return statistics_dao.student_hometown_distribution(db, limit=limit) + + +@router.get("/score/summary", summary="成绩综合统计(平均/最高/最低/及格率)") +def score_summary( + cls_id: Optional[str] = Query(None, description="按班级过滤(不传则统计全部)"), + db: Session = Depends(get_db), +): + """统计平均分、最高分、最低分、及格人数与及格率,可动态按班级过滤(关联 stu_info)。""" + return statistics_dao.score_summary(db, cls_id=cls_id) + + +@router.get("/score/distribution", summary="成绩等级分布") +def score_level_distribution(db: Session = Depends(get_db)): + return statistics_dao.score_level_distribution(db) + + +@router.get("/employ/summary", summary="就业综合统计(就业率/薪资)") +def employ_summary(db: Session = Depends(get_db)): + return statistics_dao.employ_summary(db) \ No newline at end of file diff --git a/api/stu_api.py b/api/stu_api.py new file mode 100644 index 0000000..eb67128 --- /dev/null +++ b/api/stu_api.py @@ -0,0 +1,100 @@ +# api/stu_api.py +# 学生信息管理模块:接口层只负责 HTTP 出入参与响应组装,查库/校验/事务交给 dao.stu_dao +# 业务异常(NotFoundError / ConflictError)由 main.py 注册的全局处理器统一转成状态码 + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import stu_dao +from database import get_db +from schemas.stu_schema import StuInfoCreate, StuInfoUpdate, StuInfoOut + +router = APIRouter() + + +def _to_detail(stu) -> dict: + """把学生实体组装成详情响应:基础字段 + 班级 / 顾问 / 成绩 / 就业信息。""" + data = StuInfoOut.model_validate(stu).model_dump() + + # 班级信息 + data["cls"] = None + if stu.cls: + data["cls"] = { + "id": stu.cls.id, + "cls_start_date": stu.cls.cls_start_date.isoformat() if stu.cls.cls_start_date else None, + } + + # 顾问信息 + data["advisor"] = {"id": stu.adv.id, "name": stu.adv.name} if stu.adv else None + + # 成绩列表(过滤已删除) + data["scores"] = [ + { + "exam_attempt": s.exam_attempt, + "exam_score": s.exam_score, + "score_level": s.score_level, + } + for s in stu.score + if not s.is_deleted + ] + + # 就业信息(一对一) + data["employ"] = None + if stu.stu_emp_mgmt and not stu.stu_emp_mgmt.is_deleted: + e = stu.stu_emp_mgmt + data["employ"] = { + "emp_company": e.emp_company, + "salary": e.salary, + "emp_open_time": e.emp_open_time.isoformat() if e.emp_open_time else None, + "send_offer_time": e.send_offer_time.isoformat() if e.send_offer_time else None, + } + + return data + + +@router.get("", summary="分页查询学生列表") +def list_students( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + name: Optional[str] = Query(None, description="按姓名模糊查询"), + cls_id: Optional[str] = Query(None, description="按班级编号过滤"), + state: Optional[str] = Query(None, description="按状态过滤"), + db: Session = Depends(get_db), +): + """查询未删除的学生,支持分页和按姓名/班级/状态过滤。""" + total, items = stu_dao.list_students( + db, page=page, size=size, name=name, cls_id=cls_id, state=state + ) + return { + "total": total, + "page": page, + "size": size, + "items": [StuInfoOut.model_validate(x) for x in items], + } + + +@router.get("/{stu_id}", summary="查询单个学生详情") +def get_student(stu_id: str, db: Session = Depends(get_db)): + """查询学生详情,并附带班级、顾问、成绩与就业信息。""" + return _to_detail(stu_dao.assert_stu_alive(db, stu_id, "学生不存在或已删除")) + + +@router.post("", response_model=StuInfoOut, status_code=201, summary="新增学生") +def create_student(payload: StuInfoCreate, db: Session = Depends(get_db)): + """新增学生(学号唯一、班级与顾问必须存在,由 DAO 校验)。""" + return stu_dao.create_student(db, payload.model_dump()) + + +@router.put("/{stu_id}", response_model=StuInfoOut, summary="更新学生信息") +def update_student(stu_id: str, payload: StuInfoUpdate, db: Session = Depends(get_db)): + """按传入字段局部更新学生信息。""" + return stu_dao.update_student(db, stu_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{stu_id}", summary="删除学生(软删除)") +def delete_student(stu_id: str, db: Session = Depends(get_db)): + """软删除学生:仅置 is_deleted 标记,数据保留。""" + stu_dao.soft_delete_student(db, stu_id) + return {"message": "删除成功", "id": stu_id} \ No newline at end of file diff --git a/api/stu_score_api.py b/api/stu_score_api.py new file mode 100644 index 0000000..7b8fa3c --- /dev/null +++ b/api/stu_score_api.py @@ -0,0 +1,62 @@ +# api/stu_score_api.py +# 学生成绩管理模块:接口层只负责 HTTP 出入参,等级判定与查库交给 dao.stu_score_dao + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import stu_score_dao +from database import get_db +from schemas.stu_score_schema import StuScoreCreate, StuScoreUpdate, StuScoreOut + +router = APIRouter() + + +@router.get("", summary="分页查询成绩列表") +def list_scores( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + stu_id: Optional[str] = Query(None, description="按学生编号过滤"), + score_level: Optional[str] = Query(None, description="按成绩等级过滤"), + db: Session = Depends(get_db), +): + total, items = stu_score_dao.list_scores( + db, page=page, size=size, stu_id=stu_id, score_level=score_level + ) + return { + "total": total, + "page": page, + "size": size, + "items": [StuScoreOut.model_validate(x) for x in items], + } + + +@router.get("/student/{stu_id}", summary="查询某学生的全部成绩") +def get_student_scores(stu_id: str, db: Session = Depends(get_db)): + scores = stu_score_dao.list_scores_by_student(db, stu_id) + return {"stu_id": stu_id, "items": [StuScoreOut.model_validate(s) for s in scores]} + + +@router.get("/{score_id}", response_model=StuScoreOut, summary="查询单条成绩记录") +def get_score(score_id: int, db: Session = Depends(get_db)): + return stu_score_dao.assert_score_alive(db, score_id) + + +@router.post("", response_model=StuScoreOut, status_code=201, summary="新增成绩记录") +def create_score(payload: StuScoreCreate, db: Session = Depends(get_db)): + """新增成绩:未显式指定等级时,由 DAO 按分数自动判定。""" + return stu_score_dao.create_score(db, payload.model_dump()) + + +@router.put("/{score_id}", response_model=StuScoreOut, summary="更新成绩记录") +def update_score(score_id: int, payload: StuScoreUpdate, db: Session = Depends(get_db)): + """更新成绩:只改分数没改等级时,由 DAO 自动重判等级。""" + return stu_score_dao.update_score(db, score_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{score_id}", summary="删除成绩记录(软删除)") +def delete_score(score_id: int, db: Session = Depends(get_db)): + """软删除成绩记录。""" + stu_score_dao.soft_delete_score(db, score_id) + return {"message": "删除成功", "id": score_id} \ No newline at end of file diff --git a/api/teacher_api.py b/api/teacher_api.py new file mode 100644 index 0000000..f40ffcb --- /dev/null +++ b/api/teacher_api.py @@ -0,0 +1,59 @@ +# api/teacher_api.py +# 教师信息管理模块:接口层只负责 HTTP 出入参,查库/校验/事务交给 dao.teacher_dao + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from dao import teacher_dao +from database import get_db +from schemas.teacher_schema import TeacherCreate, TeacherUpdate, TeacherOut + +router = APIRouter() + + +@router.get("", summary="分页查询教师列表") +def list_teachers( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + name: Optional[str] = Query(None, description="按姓名模糊查询"), + type: Optional[str] = Query(None, description="按职位过滤"), + db: Session = Depends(get_db), +): + total, items = teacher_dao.list_teachers(db, page=page, size=size, name=name, type=type) + return { + "total": total, + "page": page, + "size": size, + "items": [TeacherOut.model_validate(x) for x in items], + } + + +@router.get("/{tea_id}", summary="查询单个教师详情") +def get_teacher(tea_id: str, db: Session = Depends(get_db)): + """查询教师详情,附带其担任班主任/主讲老师的班级编号。""" + teacher = teacher_dao.assert_teacher_alive(db, tea_id, "教师不存在或已删除") + data = TeacherOut.model_validate(teacher).model_dump() + data["head_classes"] = [c.id for c in teacher.head_classes if not c.is_deleted] + data["lecturer_classes"] = [c.id for c in teacher.lecturer_classes if not c.is_deleted] + return data + + +@router.post("", response_model=TeacherOut, status_code=201, summary="新增教师") +def create_teacher(payload: TeacherCreate, db: Session = Depends(get_db)): + """新增教师(编号与电话唯一,由 DAO 校验)。""" + return teacher_dao.create_teacher(db, payload.model_dump()) + + +@router.put("/{tea_id}", response_model=TeacherOut, summary="更新教师信息") +def update_teacher(tea_id: str, payload: TeacherUpdate, db: Session = Depends(get_db)): + """按传入字段局部更新教师信息,改电话时由 DAO 重新校验唯一。""" + return teacher_dao.update_teacher(db, tea_id, payload.model_dump(exclude_unset=True)) + + +@router.delete("/{tea_id}", summary="删除教师(软删除)") +def delete_teacher(tea_id: str, db: Session = Depends(get_db)): + """软删除教师。""" + teacher_dao.soft_delete_teacher(db, tea_id) + return {"message": "删除成功", "id": tea_id} \ No newline at end of file diff --git a/dao/__init__.py b/dao/__init__.py new file mode 100644 index 0000000..fb2219f --- /dev/null +++ b/dao/__init__.py @@ -0,0 +1,74 @@ +# dao/__init__.py +# 聚合入口:统一导出各模块数据访问对象(与 model/、schemas/ 的写法保持一致) +# +# 导入顺序按依赖关系排列(先被依赖的放前面):exceptions / pagination 无依赖, +# teacher → advisor → cls_mgmt → stu → 成绩、就业 → 统计。 + +from dao.exceptions import ConflictError, DaoError, NotFoundError +from dao.pagination import paginate + +from dao.teacher_dao import ( + assert_teacher_alive, + list_teachers, + create_teacher, + update_teacher, + soft_delete_teacher, +) +from dao.advisor_dao import ( + assert_advisor_alive, + list_advisors, + create_advisor, + update_advisor, + soft_delete_advisor, +) +from dao.cls_mgmt_dao import ( + assert_cls_alive, + list_classes, + create_class, + update_class, + soft_delete_class, +) +from dao.stu_dao import ( + assert_stu_alive, + list_students, + create_student, + update_student, + soft_delete_student, +) +from dao.stu_score_dao import ( + compute_score_level, + assert_score_alive, + list_scores, + list_scores_by_student, + create_score, + update_score, + soft_delete_score, +) +from dao.employ_dao import ( + assert_employ_alive, + list_employ, + create_employ, + update_employ, + soft_delete_employ, +) +from dao import statistics_dao + +__all__ = [ + # 异常与工具 + "DaoError", "NotFoundError", "ConflictError", "paginate", + # 教师 + "assert_teacher_alive", "list_teachers", "create_teacher", "update_teacher", "soft_delete_teacher", + # 顾问 + "assert_advisor_alive", "list_advisors", "create_advisor", "update_advisor", "soft_delete_advisor", + # 班级 + "assert_cls_alive", "list_classes", "create_class", "update_class", "soft_delete_class", + # 学生 + "assert_stu_alive", "list_students", "create_student", "update_student", "soft_delete_student", + # 成绩 + "compute_score_level", "assert_score_alive", "list_scores", "list_scores_by_student", + "create_score", "update_score", "soft_delete_score", + # 就业 + "assert_employ_alive", "list_employ", "create_employ", "update_employ", "soft_delete_employ", + # 统计 + "statistics_dao", +] \ No newline at end of file diff --git a/dao/advisor_dao.py b/dao/advisor_dao.py new file mode 100644 index 0000000..92b0ac0 --- /dev/null +++ b/dao/advisor_dao.py @@ -0,0 +1,65 @@ +# dao/advisor_dao.py +# 顾问信息数据访问层 + +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from model.advisor_model import AdvisorInfo + + +def assert_advisor_alive(db: Session, adv_id: str, detail: Optional[str] = None) -> AdvisorInfo: + """ + 确认顾问存在且未删除,否则抛 NotFoundError。 + 供学生模块复用作外键校验。 + """ + advisor = db.query(AdvisorInfo).filter(AdvisorInfo.id == adv_id, AdvisorInfo.is_deleted == 0).first() + if not advisor: + raise NotFoundError(detail or f"顾问 {adv_id} 不存在") + return advisor + + +def list_advisors( + db: Session, + page: int, + size: int, + name: Optional[str] = None, +) -> Tuple[int, List[AdvisorInfo]]: + """分页查询顾问,支持按姓名模糊查询。""" + query = db.query(AdvisorInfo).filter(AdvisorInfo.is_deleted == 0) + if name: + query = query.filter(AdvisorInfo.name.like(f"%{name}%")) + return paginate(query, page, size, order_by=AdvisorInfo.id) + + +def create_advisor(db: Session, data: dict) -> AdvisorInfo: + """新增顾问:校验编号唯一(主键,含已删除记录)。""" + if db.query(AdvisorInfo).filter(AdvisorInfo.id == data["id"]).first(): + raise ConflictError("顾问编号已存在") + + advisor = AdvisorInfo(**data) + db.add(advisor) + db.commit() + db.refresh(advisor) + return advisor + + +def update_advisor(db: Session, adv_id: str, data: dict) -> AdvisorInfo: + """更新顾问信息。""" + advisor = assert_advisor_alive(db, adv_id, "顾问不存在或已删除") + + for field, value in data.items(): + setattr(advisor, field, value) + + db.commit() + db.refresh(advisor) + return advisor + + +def soft_delete_advisor(db: Session, adv_id: str) -> None: + """软删除顾问。""" + advisor = assert_advisor_alive(db, adv_id, "顾问不存在或已删除") + advisor.is_deleted = 1 + db.commit() \ No newline at end of file diff --git a/dao/cls_mgmt_dao.py b/dao/cls_mgmt_dao.py new file mode 100644 index 0000000..e635c84 --- /dev/null +++ b/dao/cls_mgmt_dao.py @@ -0,0 +1,77 @@ +# dao/cls_mgmt_dao.py +# 班级管理数据访问层 + +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from dao.teacher_dao import assert_teacher_alive +from model.cls_mgmt_model import ClsMgmt + + +def assert_cls_alive(db: Session, cls_id: str, detail: Optional[str] = None) -> ClsMgmt: + """ + 确认班级存在且未删除,否则抛 NotFoundError。 + 供学生模块复用作外键校验。 + """ + cls = db.query(ClsMgmt).filter(ClsMgmt.id == cls_id, ClsMgmt.is_deleted == 0).first() + if not cls: + raise NotFoundError(detail or f"班级 {cls_id} 不存在") + return cls + + +def list_classes( + db: Session, + page: int, + size: int, + head_tea_id: Optional[str] = None, + lecturer_id: Optional[str] = None, +) -> Tuple[int, List[ClsMgmt]]: + """分页查询班级,支持按班主任 / 主讲老师过滤。""" + query = db.query(ClsMgmt).filter(ClsMgmt.is_deleted == 0) + if head_tea_id: + query = query.filter(ClsMgmt.head_tea_id == head_tea_id) + if lecturer_id: + query = query.filter(ClsMgmt.lecturer_id == lecturer_id) + return paginate(query, page, size, order_by=ClsMgmt.id) + + +def create_class(db: Session, data: dict) -> ClsMgmt: + """新增班级:校验编号唯一,且班主任与主讲老师都存在。""" + # 编号是主键,已软删除的记录同样占用该编号,所以这里不加 is_deleted 过滤 + if db.query(ClsMgmt).filter(ClsMgmt.id == data["id"]).first(): + raise ConflictError("班级编号已存在") + + for tea_id in (data["head_tea_id"], data["lecturer_id"]): + assert_teacher_alive(db, tea_id) + + cls = ClsMgmt(**data) + db.add(cls) + db.commit() + db.refresh(cls) + return cls + + +def update_class(db: Session, cls_id: str, data: dict) -> ClsMgmt: + """更新班级:若更换教师需重新校验教师存在。""" + cls = assert_cls_alive(db, cls_id, "班级不存在或已删除") + + for field in ("head_tea_id", "lecturer_id"): + if data.get(field): + assert_teacher_alive(db, data[field]) + + for field, value in data.items(): + setattr(cls, field, value) + + db.commit() + db.refresh(cls) + return cls + + +def soft_delete_class(db: Session, cls_id: str) -> None: + """软删除班级。""" + cls = assert_cls_alive(db, cls_id, "班级不存在或已删除") + cls.is_deleted = 1 + db.commit() \ No newline at end of file diff --git a/dao/employ_dao.py b/dao/employ_dao.py new file mode 100644 index 0000000..85cb08a --- /dev/null +++ b/dao/employ_dao.py @@ -0,0 +1,79 @@ +# dao/employ_dao.py +# 学生就业数据访问层(与学生一对一) + +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from dao.stu_dao import assert_stu_alive +from model.employ_model import StudentEmployManage + + +def assert_employ_alive(db: Session, stu_id: str, detail: Optional[str] = None) -> StudentEmployManage: + """确认就业记录存在且未删除,否则抛 NotFoundError。""" + emp = ( + db.query(StudentEmployManage) + .filter(StudentEmployManage.stu_id == stu_id, StudentEmployManage.is_deleted == 0) + .first() + ) + if not emp: + raise NotFoundError(detail or "就业记录不存在或已删除") + return emp + + +def list_employ( + db: Session, + page: int, + size: int, + emp_company: Optional[str] = None, + employed: Optional[bool] = None, +) -> Tuple[int, List[StudentEmployManage]]: + """分页查询就业记录,支持按公司名模糊、按是否已就业过滤。""" + query = db.query(StudentEmployManage).filter(StudentEmployManage.is_deleted == 0) + if emp_company: + query = query.filter(StudentEmployManage.emp_company.like(f"%{emp_company}%")) + if employed is True: + query = query.filter(StudentEmployManage.emp_company.isnot(None)) + elif employed is False: + query = query.filter(StudentEmployManage.emp_company.is_(None)) + return paginate(query, page, size, order_by=StudentEmployManage.stu_id) + + +def create_employ(db: Session, data: dict) -> StudentEmployManage: + """新增就业记录:校验学生存在,且该学生尚无有效就业记录(一对一)。""" + assert_stu_alive(db, data["stu_id"]) + + exists = ( + db.query(StudentEmployManage) + .filter(StudentEmployManage.stu_id == data["stu_id"], StudentEmployManage.is_deleted == 0) + .first() + ) + if exists: + raise ConflictError("该学生已存在就业记录") + + emp = StudentEmployManage(**data) + db.add(emp) + db.commit() + db.refresh(emp) + return emp + + +def update_employ(db: Session, stu_id: str, data: dict) -> StudentEmployManage: + """更新就业信息。""" + emp = assert_employ_alive(db, stu_id) + + for field, value in data.items(): + setattr(emp, field, value) + + db.commit() + db.refresh(emp) + return emp + + +def soft_delete_employ(db: Session, stu_id: str) -> None: + """软删除就业记录。""" + emp = assert_employ_alive(db, stu_id) + emp.is_deleted = 1 + db.commit() \ No newline at end of file diff --git a/dao/exceptions.py b/dao/exceptions.py new file mode 100644 index 0000000..e478ce8 --- /dev/null +++ b/dao/exceptions.py @@ -0,0 +1,27 @@ +# dao/exceptions.py +# 数据访问层异常:不依赖 FastAPI,由 main.py 统一注册处理器映射成 HTTP 状态码 +# +# 这样 api 层就不需要 try/except,也不需要 import HTTPException, +# 只负责「调用 dao + 组装响应」两件事。 + + +class DaoError(Exception): + """DAO 层异常基类:携带 HTTP 状态码与前端可读的提示信息""" + + status_code = 500 + + def __init__(self, detail: str): + self.detail = detail + super().__init__(detail) + + +class NotFoundError(DaoError): + """目标记录不存在、已删除,或外键指向的记录不存在 → 404""" + + status_code = 404 + + +class ConflictError(DaoError): + """主键重复、唯一字段冲突、一对一关系重复 → 409""" + + status_code = 409 \ No newline at end of file diff --git a/dao/pagination.py b/dao/pagination.py new file mode 100644 index 0000000..c792b02 --- /dev/null +++ b/dao/pagination.py @@ -0,0 +1,29 @@ +# dao/pagination.py +# 通用分页工具:各 DAO 复用,避免每个模块重复写 count / offset / limit + +from typing import Any, List, Optional, Tuple + +from sqlalchemy.orm import Query + + +def paginate( + query: Query, + page: int, + size: int, + order_by: Optional[Any] = None, +) -> Tuple[int, List[Any]]: + """ + 对查询对象分页。 + + :param query: 已带好过滤条件的 Query 对象 + :param page: 页码,从 1 开始 + :param size: 每页数量 + :param order_by: 排序字段(一般传模型主键),不传则沿用 query 自带排序 + :return: (总记录数, 当前页记录列表) + """ + if order_by is not None: + query = query.order_by(order_by) + + total = query.count() + items = query.offset((page - 1) * size).limit(size).all() + return total, items \ No newline at end of file diff --git a/dao/statistics_dao.py b/dao/statistics_dao.py new file mode 100644 index 0000000..d50282d --- /dev/null +++ b/dao/statistics_dao.py @@ -0,0 +1,143 @@ +# dao/statistics_dao.py +# 统计分析数据访问层: +# 使用 SQLAlchemy 的聚合函数(func.count/avg/max/min)、分组(group_by)、关联(join) +# 以及动态查询参数,实现多维度统计分析。返回纯数据(dict/list),不含任何 HTTP 概念。 + +from typing import Any, Dict, List, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from model.advisor_model import AdvisorInfo +from model.cls_mgmt_model import ClsMgmt +from model.employ_model import StudentEmployManage +from model.stu_model import StuInfo +from model.stu_score_model import StuScore +from model.teacher_model import Teacher + + +def _count_alive(db: Session, model) -> int: + """统计某张表的有效(未删除)记录数。""" + return db.query(func.count(model.id)).filter(model.is_deleted == 0).scalar() or 0 + + +def _employed_query(db: Session): + """已就业 = 未删除且已填公司名。""" + return db.query(StudentEmployManage).filter( + StudentEmployManage.is_deleted == 0, + StudentEmployManage.emp_company.isnot(None), + ) + + +def overview(db: Session) -> Dict[str, Any]: + """统计学生、班级、教师、顾问、已就业人数及就业率。""" + student_count = _count_alive(db, StuInfo) + class_count = _count_alive(db, ClsMgmt) + teacher_count = _count_alive(db, Teacher) + advisor_count = _count_alive(db, AdvisorInfo) + employed_count = _employed_query(db).count() + + return { + "student_count": student_count, + "class_count": class_count, + "teacher_count": teacher_count, + "advisor_count": advisor_count, + "employed_count": employed_count, + "employment_rate": round(employed_count / student_count, 4) if student_count else 0, + } + + +def _group_count(db: Session, column, *filters) -> List[tuple]: + """按某一列分组计数,返回 [(分组值, 数量), ...]。""" + return ( + db.query(column, func.count(StuInfo.id)) + .filter(StuInfo.is_deleted == 0, *filters) + .group_by(column) + .all() + ) + + +def student_gender_distribution(db: Session) -> List[Dict[str, Any]]: + """学生性别分布。""" + return [{"gender": g or "未知", "count": c} for g, c in _group_count(db, StuInfo.gender)] + + +def student_education_distribution(db: Session) -> List[Dict[str, Any]]: + """学生学历分布。""" + return [{"education": e or "未知", "count": c} for e, c in _group_count(db, StuInfo.education)] + + +def student_state_distribution(db: Session) -> List[Dict[str, Any]]: + """学生就读状态分布。""" + return [{"state": s or "未知", "count": c} for s, c in _group_count(db, StuInfo.state)] + + +def student_hometown_distribution(db: Session, limit: int = 10) -> List[Dict[str, Any]]: + """学生生源地分布(Top N)。""" + rows = ( + db.query(StuInfo.hometown, func.count(StuInfo.id)) + .filter(StuInfo.is_deleted == 0, StuInfo.hometown.isnot(None)) + .group_by(StuInfo.hometown) + .order_by(func.count(StuInfo.id).desc()) + .limit(limit) + .all() + ) + return [{"hometown": h, "count": c} for h, c in rows] + + +def score_summary(db: Session, cls_id: Optional[str] = None) -> Dict[str, Any]: + """统计平均分、最高分、最低分、及格人数与及格率,可动态按班级过滤(关联 stu_info)。""" + base = ( + db.query(StuScore) + .join(StuInfo, StuScore.stu_id == StuInfo.id) + .filter(StuScore.is_deleted == False, StuInfo.is_deleted == 0) # noqa: E712 布尔列 + ) + if cls_id: + base = base.filter(StuInfo.cls_id == cls_id) + + total = base.count() + avg = base.with_entities(func.avg(StuScore.exam_score)).scalar() + max_score = base.with_entities(func.max(StuScore.exam_score)).scalar() + min_score = base.with_entities(func.min(StuScore.exam_score)).scalar() + pass_count = base.filter(StuScore.exam_score >= 60).count() + + return { + "cls_id": cls_id, + "total": total, + "avg_score": round(float(avg), 2) if avg is not None else None, + "max_score": float(max_score) if max_score is not None else None, + "min_score": float(min_score) if min_score is not None else None, + "pass_count": pass_count, + "pass_rate": round(pass_count / total, 4) if total else 0, + } + + +def score_level_distribution(db: Session) -> List[Dict[str, Any]]: + """成绩等级分布。""" + rows = ( + db.query(StuScore.score_level, func.count(StuScore.id)) + .filter(StuScore.is_deleted == False) # noqa: E712 布尔列 + .group_by(StuScore.score_level) + .all() + ) + return [{"score_level": s or "未知", "count": c} for s, c in rows] + + +def employ_summary(db: Session) -> Dict[str, Any]: + """就业综合统计:就业率与薪资(平均 / 最高 / 最低)。""" + student_count = _count_alive(db, StuInfo) + + employed = _employed_query(db) + employed_count = employed.count() + avg_salary = employed.with_entities(func.avg(StudentEmployManage.salary)).scalar() + max_salary = employed.with_entities(func.max(StudentEmployManage.salary)).scalar() + min_salary = employed.with_entities(func.min(StudentEmployManage.salary)).scalar() + + return { + "student_count": student_count, + "employed_count": employed_count, + "employment_rate": round(employed_count / student_count, 4) if student_count else 0, + "avg_salary": round(float(avg_salary), 2) if avg_salary is not None else None, + "max_salary": float(max_salary) if max_salary is not None else None, + "min_salary": float(min_salary) if min_salary is not None else None, + } \ No newline at end of file diff --git a/dao/stu_dao.py b/dao/stu_dao.py new file mode 100644 index 0000000..79accf2 --- /dev/null +++ b/dao/stu_dao.py @@ -0,0 +1,83 @@ +# dao/stu_dao.py +# 学生信息数据访问层 + +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.advisor_dao import assert_advisor_alive +from dao.cls_mgmt_dao import assert_cls_alive +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from model.stu_model import StuInfo + + +def assert_stu_alive(db: Session, stu_id: str, detail: Optional[str] = None) -> StuInfo: + """ + 确认学生存在且未删除,否则抛 NotFoundError。 + 供成绩、就业模块复用作外键校验。 + """ + stu = db.query(StuInfo).filter(StuInfo.id == stu_id, StuInfo.is_deleted == 0).first() + if not stu: + raise NotFoundError(detail or "学生不存在") + return stu + + +def list_students( + db: Session, + page: int, + size: int, + name: Optional[str] = None, + cls_id: Optional[str] = None, + state: Optional[str] = None, +) -> Tuple[int, List[StuInfo]]: + """分页查询未删除的学生,支持按姓名模糊 / 班级 / 状态过滤。""" + query = db.query(StuInfo).filter(StuInfo.is_deleted == 0) + if name: + query = query.filter(StuInfo.name.like(f"%{name}%")) + if cls_id: + query = query.filter(StuInfo.cls_id == cls_id) + if state: + query = query.filter(StuInfo.state == state) + return paginate(query, page, size, order_by=StuInfo.id) + + +def create_student(db: Session, data: dict) -> StuInfo: + """新增学生:校验学号唯一,且所属班级、顾问(若填写)都存在。""" + # 学号是主键,已软删除的记录同样占用该学号,所以这里不加 is_deleted 过滤 + if db.query(StuInfo).filter(StuInfo.id == data["id"]).first(): + raise ConflictError("学生编号已存在") + + assert_cls_alive(db, data["cls_id"], "所属班级不存在") + if data.get("advisor_id"): + assert_advisor_alive(db, data["advisor_id"], "顾问不存在") + + stu = StuInfo(**data) + db.add(stu) + db.commit() + db.refresh(stu) + return stu + + +def update_student(db: Session, stu_id: str, data: dict) -> StuInfo: + """更新学生:若更换班级 / 顾问需重新校验外键。""" + stu = assert_stu_alive(db, stu_id, "学生不存在或已删除") + + if data.get("cls_id"): + assert_cls_alive(db, data["cls_id"], "所属班级不存在") + if data.get("advisor_id"): + assert_advisor_alive(db, data["advisor_id"], "顾问不存在") + + for field, value in data.items(): + setattr(stu, field, value) + + db.commit() + db.refresh(stu) + return stu + + +def soft_delete_student(db: Session, stu_id: str) -> None: + """软删除学生。""" + stu = assert_stu_alive(db, stu_id, "学生不存在或已删除") + stu.is_deleted = 1 + db.commit() \ No newline at end of file diff --git a/dao/stu_score_dao.py b/dao/stu_score_dao.py new file mode 100644 index 0000000..47288b6 --- /dev/null +++ b/dao/stu_score_dao.py @@ -0,0 +1,113 @@ +# dao/stu_score_dao.py +# 学生成绩数据访问层:含成绩等级自动判定的业务规则 + +from decimal import Decimal +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from dao.stu_dao import assert_stu_alive +from model.stu_score_model import StuScore + + +def compute_score_level(score: Decimal) -> str: + """根据分数自动判定等级:>=90 优秀、80-89 良好、60-79 普通、<60 不及格""" + if score >= 90: + return "优秀" + if score >= 80: + return "良好" + if score >= 60: + return "普通" + return "不及格" + + +def assert_score_alive(db: Session, score_id: int, detail: Optional[str] = None) -> StuScore: + """确认成绩记录存在且未删除,否则抛 NotFoundError。""" + score = ( + db.query(StuScore) + .filter(StuScore.id == score_id, StuScore.is_deleted == False) # noqa: E712 布尔列 + .first() + ) + if not score: + raise NotFoundError(detail or "成绩记录不存在或已删除") + return score + + +def list_scores( + db: Session, + page: int, + size: int, + stu_id: Optional[str] = None, + score_level: Optional[str] = None, +) -> Tuple[int, List[StuScore]]: + """分页查询未删除的成绩,支持按学生 / 等级过滤。""" + query = db.query(StuScore).filter(StuScore.is_deleted == False) # noqa: E712 布尔列 + if stu_id: + query = query.filter(StuScore.stu_id == stu_id) + if score_level: + query = query.filter(StuScore.score_level == score_level) + return paginate(query, page, size, order_by=StuScore.id) + + +def list_scores_by_student(db: Session, stu_id: str) -> List[StuScore]: + """查询某学生的全部有效成绩,按考试轮次升序。""" + return ( + db.query(StuScore) + .filter(StuScore.stu_id == stu_id, StuScore.is_deleted == False) # noqa: E712 + .order_by(StuScore.exam_attempt) + .all() + ) + + +def create_score(db: Session, data: dict) -> StuScore: + """新增成绩:校验学生存在、同轮次不重复,并在未指定等级时自动判定。""" + assert_stu_alive(db, data["stu_id"]) + + exists = ( + db.query(StuScore) + .filter( + StuScore.stu_id == data["stu_id"], + StuScore.exam_attempt == data["exam_attempt"], + StuScore.is_deleted == False, # noqa: E712 + ) + .first() + ) + if exists: + raise ConflictError("该学生此轮次成绩已存在") + + # 未显式指定等级时,根据分数自动判定 + if not data.get("score_level"): + data["score_level"] = compute_score_level(data["exam_score"]) + + score = StuScore(**data) + db.add(score) + db.commit() + db.refresh(score) + return score + + +def update_score(db: Session, score_id: int, data: dict) -> StuScore: + """更新成绩:若更换学生需校验存在;只改分数没改等级时自动重判等级。""" + score = assert_score_alive(db, score_id) + + if data.get("stu_id"): + assert_stu_alive(db, data["stu_id"]) + + if "exam_score" in data and "score_level" not in data: + data["score_level"] = compute_score_level(data["exam_score"]) + + for field, value in data.items(): + setattr(score, field, value) + + db.commit() + db.refresh(score) + return score + + +def soft_delete_score(db: Session, score_id: int) -> None: + """软删除成绩记录。""" + score = assert_score_alive(db, score_id) + score.is_deleted = True + db.commit() \ No newline at end of file diff --git a/dao/teacher_dao.py b/dao/teacher_dao.py new file mode 100644 index 0000000..642c807 --- /dev/null +++ b/dao/teacher_dao.py @@ -0,0 +1,77 @@ +# dao/teacher_dao.py +# 教师信息数据访问层 + +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from dao.exceptions import ConflictError, NotFoundError +from dao.pagination import paginate +from model.teacher_model import Teacher + + +def assert_teacher_alive(db: Session, tea_id: str, detail: Optional[str] = None) -> Teacher: + """ + 确认教师存在且未删除,否则抛 NotFoundError。 + 供班级模块复用作外键校验。 + """ + teacher = db.query(Teacher).filter(Teacher.id == tea_id, Teacher.is_deleted == 0).first() + if not teacher: + raise NotFoundError(detail or f"教师 {tea_id} 不存在") + return teacher + + +def list_teachers( + db: Session, + page: int, + size: int, + name: Optional[str] = None, + type: Optional[str] = None, +) -> Tuple[int, List[Teacher]]: + """分页查询教师,支持按姓名模糊、按职位过滤。""" + query = db.query(Teacher).filter(Teacher.is_deleted == 0) + if name: + query = query.filter(Teacher.name.like(f"%{name}%")) + if type: + query = query.filter(Teacher.type == type) + return paginate(query, page, size, order_by=Teacher.id) + + +def create_teacher(db: Session, data: dict) -> Teacher: + """新增教师:校验编号与电话唯一。""" + # 编号是主键,已软删除的记录同样占用该编号,所以这里不加 is_deleted 过滤 + if db.query(Teacher).filter(Teacher.id == data["id"]).first(): + raise ConflictError("教师编号已存在") + + if db.query(Teacher).filter(Teacher.phone == data["phone"], Teacher.is_deleted == 0).first(): + raise ConflictError("电话已被使用") + + teacher = Teacher(**data) + db.add(teacher) + db.commit() + db.refresh(teacher) + return teacher + + +def update_teacher(db: Session, tea_id: str, data: dict) -> Teacher: + """更新教师:若改电话需重新校验唯一。""" + teacher = assert_teacher_alive(db, tea_id, "教师不存在或已删除") + + new_phone = data.get("phone") + if new_phone and new_phone != teacher.phone: + if db.query(Teacher).filter(Teacher.phone == new_phone, Teacher.is_deleted == 0).first(): + raise ConflictError("电话已被使用") + + for field, value in data.items(): + setattr(teacher, field, value) + + db.commit() + db.refresh(teacher) + return teacher + + +def soft_delete_teacher(db: Session, tea_id: str) -> None: + """软删除教师:仅置 is_deleted 标记,数据保留。""" + teacher = assert_teacher_alive(db, tea_id, "教师不存在或已删除") + teacher.is_deleted = 1 + db.commit() \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..53edf17 --- /dev/null +++ b/database.py @@ -0,0 +1,54 @@ +# database.py +# 本文件负责配置数据库连接、创建引擎、会话工厂,并提供依赖注入函数 + +import os + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +# 1. 配置 MySQL 数据库连接 URL +# 格式:mysql+pymysql://用户名:密码@主机:端口/数据库名 +# 优先读取环境变量(Docker / 生产环境注入),未设置时回退到本地默认值,方便本地开发。 +# 例:Docker 中 DB_HOST 应设为 compose 里的服务名 "db",而不是 localhost。 +DB_USER = os.getenv("DB_USER", "root") +DB_PASSWORD = os.getenv("DB_PASSWORD", "123456") +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "3306") +DB_NAME = os.getenv("DB_NAME", "claude_sms_test") + +# 也可以直接用一个完整 URL 覆盖上面的拼接结果:DATABASE_URL=mysql+pymysql://用户:密码@主机:端口/库名 +SQLALCHEMY_DATABASE_URL = os.getenv( + "DATABASE_URL", + f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}", +) + +# 2. 创建数据库引擎 +# - pool_pre_ping=True 表示每次从连接池取出连接前先 ping 一下,防止使用已断开的连接 +# - pool_recycle=3600 表示连接空闲超过 1 小时自动回收,避免被 MySQL 主动断开 +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + pool_recycle=3600, + echo=os.getenv("SQL_ECHO", "false").lower() == "true" # 生产环境默认关闭 SQL 日志 +) + +# 3. 创建会话工厂 +# autocommit=False:不自动提交,需要手动 commit +# autoflush=False:不自动 flush +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 4. 创建声明式基类,所有模型类都继承自它 +Base = declarative_base() + +# 5. 依赖注入函数:用于 FastAPI 路由中获取数据库会话 +def get_db(): + """ + 每次请求创建一个数据库会话,请求结束后关闭。 + 这个函数会作为 Depends 的参数注入到路由中。 + """ + db = SessionLocal() # 创建一个会话实例 + try: + yield db # 将会话交给路由函数使用 + finally: + db.close() # 无论是否发生异常,最后都会关闭会话,释放连接 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f834428 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +# 一条命令启动整套服务:MySQL 数据库 + FastAPI 应用 +# +# 构建并启动:docker compose up -d --build +# 初始化建表:docker compose exec app python init_db.py +# 导入测试数据:docker compose exec app python init_test_data.py +# 查看日志:docker compose logs -f app +# 停止:docker compose down (加 -v 会连数据库数据一起删除) +# +services: + # ---------- MySQL 数据库 ---------- + db: + image: mysql:8.0 + container_name: sms_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: "123456" + MYSQL_DATABASE: "claude_sms_test" # 容器首次启动时自动创建该库 + TZ: Asia/Shanghai + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + ports: + - "3307:3306" # 如需用本机客户端连接,可保留;不需要可删掉 + volumes: + - mysql_data:/var/lib/mysql # 数据持久化,容器删除数据也不丢 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD"] + interval: 5s + timeout: 5s + retries: 20 + + # ---------- FastAPI 应用 ---------- + app: + build: . + container_name: sms_app + restart: unless-stopped + depends_on: + db: + condition: service_healthy # 等 MySQL 健康后再启动,避免连接失败 + environment: + # 关键:容器之间用「服务名 db」通信,而不是 localhost + DB_HOST: db + DB_PORT: "3306" + DB_USER: root + DB_PASSWORD: "123456" + DB_NAME: claude_sms_test + SQL_ECHO: "false" # 生产环境关闭 SQL 日志 + ports: + - "8002:8002" + # 如需热更新调试,可挂载源码并开启 reload: + # volumes: ["./:/app"] + # command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"] + +volumes: + mysql_data: diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..f6f4971 --- /dev/null +++ b/init_db.py @@ -0,0 +1,18 @@ +# init_db.py +# 建表脚本:根据 model/ 中定义的 ORM 模型,在 MySQL 中创建对应的数据表。 +# 运行前请确保: +# 1. database.py 中的连接信息(用户名/密码/主机/端口)正确; +# 2. 目标数据库已手动创建(本项目默认库名 claude_sms_test)。 +# 用法:python init_db.py + +from database import engine, Base +import model # noqa: F401 触发 model/__init__.py 导入所有模型,从而注册到 Base.metadata + + +def main(): + Base.metadata.create_all(bind=engine) + print("所有数据表已创建完成!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/init_test_data.py b/init_test_data.py new file mode 100644 index 0000000..5948825 --- /dev/null +++ b/init_test_data.py @@ -0,0 +1,123 @@ +# init_test_data.py +# 一次性灌入测试数据,运行前确保已建表且数据库连接正常 +import random +from datetime import date, timedelta +from database import SessionLocal +from model.teacher_model import Teacher +from model.advisor_model import AdvisorInfo +from model.cls_mgmt_model import ClsMgmt +from model.stu_model import StuInfo +from model.stu_score_model import StuScore +from model.employ_model import StudentEmployManage + +random.seed(42) # 保证每次生成的数据一致 + +db = SessionLocal() + +# ---------- 1. 教师 6 人 ---------- +teachers = [ + Teacher(id="T001", name="张伟", phone="13800000001", type="班主任", is_deleted=0), + Teacher(id="T002", name="李娜", phone="13800000002", type="班主任", is_deleted=0), + Teacher(id="T003", name="王强", phone="13800000003", type="主讲", is_deleted=0), + Teacher(id="T004", name="赵敏", phone="13800000004", type="主讲", is_deleted=0), + Teacher(id="T005", name="刘洋", phone="13800000005", type="主讲", is_deleted=0), + Teacher(id="T006", name="陈静", phone="13800000006", type="班主任", is_deleted=0), +] +db.add_all(teachers) + +# ---------- 2. 顾问 8 人 ---------- +advisors = [ + AdvisorInfo(id="A001", name="孙悦", phone="13900000001", is_deleted=0), + AdvisorInfo(id="A002", name="周杰", phone="13900000002", is_deleted=0), + AdvisorInfo(id="A003", name="吴倩", phone="13900000003", is_deleted=0), + AdvisorInfo(id="A004", name="郑凯", phone="13900000004", is_deleted=0), + AdvisorInfo(id="A005", name="冯雪", phone="13900000005", is_deleted=0), + AdvisorInfo(id="A006", name="韩磊", phone="13900000006", is_deleted=0), + AdvisorInfo(id="A007", name="许晴", phone="13900000007", is_deleted=0), + AdvisorInfo(id="A008", name="何勇", phone="13900000008", is_deleted=0), +] +db.add_all(advisors) + +# ---------- 3. 班级 5 个 ---------- +classes = [ + ClsMgmt(id="C001", cls_start_date=date(2024, 3, 1), head_tea_id="T001", lecturer_id="T003", is_deleted=0), + ClsMgmt(id="C002", cls_start_date=date(2024, 4, 1), head_tea_id="T002", lecturer_id="T004", is_deleted=0), + ClsMgmt(id="C003", cls_start_date=date(2024, 5, 1), head_tea_id="T006", lecturer_id="T005", is_deleted=0), + ClsMgmt(id="C004", cls_start_date=date(2024, 6, 1), head_tea_id="T001", lecturer_id="T003", is_deleted=0), + ClsMgmt(id="C005", cls_start_date=date(2024, 7, 1), head_tea_id="T002", lecturer_id="T004", is_deleted=0), +] +db.add_all(classes) + +# ---------- 4. 学生 45 人 ---------- +surnames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜" +given_names = ["伟", "娜", "强", "敏", "洋", "静", "磊", "婷", "超", "丽", + "军", "梅", "杰", "雪", "鹏", "芳", "浩", "娟", "宇", "艳"] +grad_schools = ["北京大学", "清华大学", "复旦大学", "浙江大学", "武汉大学", + "四川大学", "中山大学", "南京大学", "华中科技大学", "西安交通大学"] +majors = ["计算机科学与技术", "软件工程", "人工智能", "数据科学", "信息管理"] +educations = ["本科", "硕士", "大专"] +hometowns = ["北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "西安"] + +students = [] +for i in range(1, 46): + name = random.choice(surnames) + random.choice(given_names) + stu = StuInfo( + id=f"S{i:04d}", + cls_id=random.choice(["C001", "C002", "C003", "C004", "C005"]), + name=name, + gender=random.choice(["男", "女"]), + age=random.randint(20, 28), + hometown=random.choice(hometowns), + grad_school=random.choice(grad_schools), + major=random.choice(majors), + education=random.choice(educations), + enr_date=date(2023, 9, 1) + timedelta(days=random.randint(0, 300)), + grad_date=date(2025, 6, 30) + timedelta(days=random.randint(0, 180)), + advisor_id=f"A{random.randint(1, 8):03d}", + state=random.choice(["在读", "在读", "在读", "毕业"]), # 在读占多数 + is_deleted=0, + ) + students.append(stu) +db.add_all(students) + +# ---------- 5. 成绩(每人 1~2 次考试) ---------- +levels = ["优秀", "良好", "普通", "及格", "不及格"] +scores = [] +for stu in students: + for attempt in range(1, random.randint(2, 3)): # 1 或 2 次 + scores.append(StuScore( + stu_id=stu.id, + exam_attempt=attempt, + exam_score=round(random.uniform(45, 100), 2), + score_level=random.choice(levels), + is_deleted=False, + )) +db.add_all(scores) + +# ---------- 6. 就业(约一半学生) ---------- +companies = ["字节跳动", "腾讯", "阿里巴巴", "美团", "京东", "百度", "网易", "小米", "华为", "滴滴"] +emp_list = [] +for stu in random.sample(students, 25): # 25 人已就业 + open_time = date(2025, 1, 1) + timedelta(days=random.randint(0, 120)) + offer_time = open_time + timedelta(days=random.randint(10, 60)) + emp_list.append(StudentEmployManage( + stu_id=stu.id, + emp_open_time=open_time, + send_offer_time=offer_time, + emp_company=random.choice(companies), + salary=round(random.uniform(6000, 25000), 2), + is_deleted=0, + )) +db.add_all(emp_list) + +# ---------- 提交 ---------- +try: + db.commit() + print("✅ 测试数据导入成功") + print(f"教师 {len(teachers)} 人, 顾问 {len(advisors)} 人, 班级 {len(classes)} 个, " + f"学生 {len(students)} 人, 成绩 {len(scores)} 条, 就业 {len(emp_list)} 条") +except Exception as e: + db.rollback() + print("❌ 导入失败:", e) +finally: + db.close() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..317c475 --- /dev/null +++ b/main.py @@ -0,0 +1,80 @@ +# main.py +# 项目入口文件:创建 FastAPI 应用、注册路由 + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from api import statistics_api, stu_score_api, cls_mgmt_api, employ_api, stu_api, teacher_api, advisor_api +from dao.exceptions import DaoError + +# 1. 创建 FastAPI 实例 +app = FastAPI( + title="码力全开: 学生管理系统", + description="本项目旨在开发一个基于 FastAPI 的学生管理系统," + "提供学生基本信息管理、考核成绩管理、就业管理、教师/顾问管理和统计分析等核心功能模块。" + "系统采用 RESTful API 设计,支持前后端分离架构。
", + version="0.1.0" +) + +# 2. 添加跨域中间件(允许前端跨域请求) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 允许所有来源,生产环境应指定具体域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 3. 注册 DAO 层异常处理器 +# dao 层只抛 NotFoundError / ConflictError 这类业务异常,不依赖 FastAPI; +# 在这里统一映射成 HTTP 状态码(404 / 409),接口层因此不需要写 try/except。 +@app.exception_handler(DaoError) +async def dao_error_handler(request: Request, exc: DaoError): + """把 DAO 层异常转成 {"detail": "..."} 的标准错误响应。""" + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + +# 4. 注册子路由 +app.include_router(statistics_api.router, prefix="/api/stats", tags=["统计分析模块(动态查询与综合能力锻炼)"]) + +app.include_router(stu_score_api.router, prefix="/api/score", tags=["学生成绩管理模块"]) + +app.include_router(cls_mgmt_api.router, prefix="/api/classes", tags=["班级管理模块"]) + +app.include_router(employ_api.router, prefix="/api/employ", tags=["学生就业管理模块"]) + +app.include_router(teacher_api.router, prefix="/api/teacher", tags=["教师信息管理模块"]) + +app.include_router(advisor_api.router, prefix="/api/advisor", tags=["顾问信息管理模块"]) + +app.include_router(stu_api.router, prefix="/api/student", tags=["学生信息管理模块"]) + + +# 5. 挂载前端静态页面(原生 HTML/CSS/JS 单页应用,无需构建) +# 访问 http://localhost:8002/ui 即可打开可视化管理界面 +STATIC_DIR = Path(__file__).resolve().parent / "static" +if STATIC_DIR.is_dir(): + app.mount("/ui", StaticFiles(directory=str(STATIC_DIR), html=True), name="ui") + + +# 6. 根路径 +@app.get("/") +async def root(): + return {"message": "欢迎访问 码力全开: 学生管理系统 分层示例!请访问 /docs 查看 API 文档。"} + + +# 7. 如果直接运行此文件,启动 uvicorn 服务器 +if __name__ == "__main__": + import os + + import uvicorn + uvicorn.run( + "main:app", # 指定应用位置(模块名:应用变量名) + host=os.getenv("HOST", "0.0.0.0"), # 0.0.0.0 才能在容器内被外部访问 + port=int(os.getenv("PORT", "8002")), # 端口(避开原项目的 8001) + reload=os.getenv("APP_RELOAD", "false").lower() == "true" # 开发模式可设为 true + ) diff --git a/model/__init__.py b/model/__init__.py new file mode 100644 index 0000000..d6d358d --- /dev/null +++ b/model/__init__.py @@ -0,0 +1,21 @@ +# model/__init__.py +# 这个文件不是「模型之间互相导入」,而是「聚合入口」,单向扇出,不成环 + +from database import Base + +from model.advisor_model import AdvisorInfo +from model.cls_mgmt_model import ClsMgmt +from model.employ_model import StudentEmployManage +from model.stu_model import StuInfo +from model.stu_score_model import StuScore +from model.teacher_model import Teacher + +__all__ = [ + "Base", + "AdvisorInfo", + "ClsMgmt", + "StudentEmployManage", + "StuInfo", + "StuScore", + "Teacher", +] \ No newline at end of file diff --git a/model/advisor_model.py b/model/advisor_model.py new file mode 100644 index 0000000..63af52c --- /dev/null +++ b/model/advisor_model.py @@ -0,0 +1,16 @@ +# model/advisor_model.py +# 顾问信息表模型 +from sqlalchemy import Column, String, Integer +from sqlalchemy.orm import relationship +from database import Base + + +class AdvisorInfo(Base): + __tablename__ = "advisor_info" + + id = Column(String(15), primary_key=True) + name = Column(String(20), nullable=False) + phone = Column(String(20), nullable=False) + is_deleted = Column(Integer, nullable=False, default=0) + + students = relationship("StuInfo", back_populates="adv") \ No newline at end of file diff --git a/model/cls_mgmt_model.py b/model/cls_mgmt_model.py new file mode 100644 index 0000000..2a8bcfe --- /dev/null +++ b/model/cls_mgmt_model.py @@ -0,0 +1,30 @@ +# model/cls_mgmt_model.py +# 班级管理表模型 +from sqlalchemy import Column, Integer, String, Date, ForeignKey +from sqlalchemy.orm import relationship +from database import Base + + +class ClsMgmt(Base): + """ + 班级管理表模型 + 对应 MySQL 中的 cls_mgmt 表 + """ + __tablename__ = "cls_mgmt" # 表名 + + # 字段定义 + id = Column(String(15), primary_key=True, nullable=False) # 班级编号,主键,非空 + cls_start_date = Column(Date, nullable=False) # 开课时间 + head_tea_id = Column(String(15), ForeignKey("tea_info.id"), nullable=False) # 班主任id + lecturer_id = Column(String(15), ForeignKey("tea_info.id"), nullable=False) # 主讲老师id + is_deleted = Column(Integer, nullable=False, default=0) + + stu = relationship("StuInfo", back_populates="cls") + + head_teacher = relationship("Teacher", + foreign_keys=[head_tea_id], + back_populates="head_classes") + + lecturer = relationship("Teacher", + foreign_keys=[lecturer_id], + back_populates="lecturer_classes") \ No newline at end of file diff --git a/model/employ_model.py b/model/employ_model.py new file mode 100644 index 0000000..5d56b16 --- /dev/null +++ b/model/employ_model.py @@ -0,0 +1,33 @@ +# model/employ_model.py +# 学生就业管理表模型 +from sqlalchemy import Column, String, Date, Integer, ForeignKey, DECIMAL +from sqlalchemy.orm import relationship +from database import Base + + +class StudentEmployManage(Base): + """ + 学生就业管理表 的映射模型 + 对应 MYSQL 中的 stu_emp_mgmt 表 + """ + __tablename__ = 'stu_emp_mgmt' + + stu_id = Column(String(15), ForeignKey('stu_info.id'), primary_key=True, nullable=False) # 主键 索引 + emp_open_time = Column(Date, nullable=True, default=None) # 就业开放时间 + send_offer_time = Column(Date, nullable=True, default=None) # 收到Offer时间 + emp_company = Column(String(50), nullable=True, default=None) # 就业公司名称 + salary = Column(DECIMAL(10, 2), nullable=True, default=None) # 薪资待遇(月薪/元),共10位数,最多两位小数 + is_deleted = Column(Integer, nullable=False, default=0) # 软删除: 0-正常 1-已删除 + + stu = relationship('StuInfo', back_populates="stu_emp_mgmt") # 与学生信息表一对一关联 + + def __repr__(self): + """ + 返回对象时,自定义返回的信息 + """ + return (f'') \ No newline at end of file diff --git a/model/stu_model.py b/model/stu_model.py new file mode 100644 index 0000000..c170770 --- /dev/null +++ b/model/stu_model.py @@ -0,0 +1,36 @@ +# model/stu_model.py +# 学生信息表模型 +from sqlalchemy import Column, Integer, String, Date, ForeignKey +from sqlalchemy.orm import relationship +from database import Base + + +class StuInfo(Base): + __tablename__ = "stu_info" + + id = Column(String(15), primary_key=True) + cls_id = Column(String(15), ForeignKey("cls_mgmt.id"), nullable=False) + name = Column(String(20), nullable=False) + gender = Column(String(1), default="男") + age = Column(Integer, default=None) + hometown = Column(String(50), default=None) + grad_school = Column(String(50), default=None) + major = Column(String(50), default=None) + education = Column(String(20), default=None) + enr_date = Column(Date, default=None) + grad_date = Column(Date, default=None) + advisor_id = Column(String(15), ForeignKey("advisor_info.id"), default=None) + state = Column(String(10), default="在读") + is_deleted = Column(Integer, nullable=False, default=0) + + cls = relationship("ClsMgmt", back_populates="stu") + + score = relationship("StuScore", back_populates="student") + + adv = relationship("AdvisorInfo", back_populates="students") + + stu_emp_mgmt = relationship( + "StudentEmployManage", + back_populates="stu", + uselist=False, + ) \ No newline at end of file diff --git a/model/stu_score_model.py b/model/stu_score_model.py new file mode 100644 index 0000000..68d66a6 --- /dev/null +++ b/model/stu_score_model.py @@ -0,0 +1,29 @@ +# model/stu_score_model.py +# 学生成绩表模型 +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from sqlalchemy.dialects.mysql import DECIMAL +from sqlalchemy.orm import relationship +from database import Base + + +class StuScore(Base): + """ + 学生成绩表模型 + 对应 MySQL 中的 stu_score 表 + """ + __tablename__ = "stu_score" # 表名 + + # 字段定义 + id = Column(Integer, primary_key=True, nullable=False, autoincrement=True) # 自增主键 + stu_id = Column(String(15), ForeignKey("stu_info.id"), nullable=False) # 学生id,非空 + exam_attempt = Column(Integer, nullable=False, default=1) # 考试轮次,非空 + exam_score = Column(DECIMAL(5, 2), nullable=False) # 成绩,非空 + score_level = Column(String(10), nullable=False, default="普通") # 成绩评级 + is_deleted = Column(Boolean, default=False, nullable=False) # 软删除: False-正常 True-已删除 + + student = relationship("StuInfo", back_populates="score") + + def __repr__(self): + return (f"") \ No newline at end of file diff --git a/model/teacher_model.py b/model/teacher_model.py new file mode 100644 index 0000000..9d841d9 --- /dev/null +++ b/model/teacher_model.py @@ -0,0 +1,25 @@ +# model/teacher_model.py +# 教师信息表模型 +from sqlalchemy import Column, String, Integer +from sqlalchemy.orm import relationship + +from database import Base + + +class Teacher(Base): + __tablename__ = "tea_info" # 表名 + + # 字段定义 + id = Column(String(15), primary_key=True, index=True) # 主键,索引 + name = Column(String(20), nullable=False) # 姓名,非空 + phone = Column(String(20), unique=True, nullable=False) # 电话,唯一,非空 + type = Column(String(20), nullable=False) # 教师职位,非空 + is_deleted = Column(Integer, nullable=False, default=0) # 逻辑删除(软删除),0是未删除,1是删除,默认为0 + + head_classes = relationship("ClsMgmt", + foreign_keys="ClsMgmt.head_tea_id", + back_populates="head_teacher") + + lecturer_classes = relationship("ClsMgmt", + foreign_keys="ClsMgmt.lecturer_id", + back_populates="lecturer") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f79f11d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.141.1 +uvicorn==0.52.4 +sqlalchemy==2.0.52 +pymysql==1.2.0 +pydantic==2.13.5 +# MySQL 8 默认使用 caching_sha2_password 认证,pymysql 需要 cryptography 才能连接 +cryptography>=42.0.0 diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..2c9fee5 --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,18 @@ +# schemas/__init__.py +# 聚合入口:统一导出所有 Pydantic 模式(请求体校验 & 响应序列化),方便外部导入 + +from schemas.stu_schema import StuInfoCreate, StuInfoUpdate, StuInfoOut +from schemas.stu_score_schema import StuScoreCreate, StuScoreUpdate, StuScoreOut +from schemas.cls_mgmt_schema import ClsMgmtCreate, ClsMgmtUpdate, ClsMgmtOut +from schemas.employ_schema import EmployCreate, EmployUpdate, EmployOut +from schemas.teacher_schema import TeacherCreate, TeacherUpdate, TeacherOut +from schemas.advisor_schema import AdvisorCreate, AdvisorUpdate, AdvisorOut + +__all__ = [ + "StuInfoCreate", "StuInfoUpdate", "StuInfoOut", + "StuScoreCreate", "StuScoreUpdate", "StuScoreOut", + "ClsMgmtCreate", "ClsMgmtUpdate", "ClsMgmtOut", + "EmployCreate", "EmployUpdate", "EmployOut", + "TeacherCreate", "TeacherUpdate", "TeacherOut", + "AdvisorCreate", "AdvisorUpdate", "AdvisorOut", +] \ No newline at end of file diff --git a/schemas/advisor_schema.py b/schemas/advisor_schema.py new file mode 100644 index 0000000..c8e1d0b --- /dev/null +++ b/schemas/advisor_schema.py @@ -0,0 +1,31 @@ +# schemas/advisor_schema.py +# 顾问信息模块的 Pydantic 模式 + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class AdvisorBase(BaseModel): + """顾问公共字段""" + name: str = Field(..., max_length=20, description="姓名") + phone: str = Field(..., max_length=20, description="电话") + + +class AdvisorCreate(AdvisorBase): + """新增顾问时,id 必填""" + id: str = Field(..., max_length=15, description="顾问编号(主键)") + + +class AdvisorUpdate(BaseModel): + """更新顾问信息:仅更新传入的字段""" + name: Optional[str] = Field(default=None, max_length=20, description="姓名") + phone: Optional[str] = Field(default=None, max_length=20, description="电话") + + +class AdvisorOut(AdvisorBase): + """顾问响应模型(含 id 与软删除标记)""" + id: str + is_deleted: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/schemas/cls_mgmt_schema.py b/schemas/cls_mgmt_schema.py new file mode 100644 index 0000000..2aaedbd --- /dev/null +++ b/schemas/cls_mgmt_schema.py @@ -0,0 +1,34 @@ +# schemas/cls_mgmt_schema.py +# 班级管理模块的 Pydantic 模式 + +from datetime import date +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ClsMgmtBase(BaseModel): + """班级公共字段""" + cls_start_date: date = Field(..., description="开课时间") + head_tea_id: str = Field(..., max_length=15, description="班主任id") + lecturer_id: str = Field(..., max_length=15, description="主讲老师id") + + +class ClsMgmtCreate(ClsMgmtBase): + """新增班级时,id(班级编号)必填""" + id: str = Field(..., max_length=15, description="班级编号(主键)") + + +class ClsMgmtUpdate(BaseModel): + """更新班级信息:仅更新传入的字段""" + cls_start_date: Optional[date] = Field(default=None, description="开课时间") + head_tea_id: Optional[str] = Field(default=None, max_length=15, description="班主任id") + lecturer_id: Optional[str] = Field(default=None, max_length=15, description="主讲老师id") + + +class ClsMgmtOut(ClsMgmtBase): + """班级响应模型(含 id 与软删除标记)""" + id: str + is_deleted: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/schemas/employ_schema.py b/schemas/employ_schema.py new file mode 100644 index 0000000..dbe5139 --- /dev/null +++ b/schemas/employ_schema.py @@ -0,0 +1,37 @@ +# schemas/employ_schema.py +# 学生就业管理模块的 Pydantic 模式 + +from datetime import date +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class EmployBase(BaseModel): + """就业信息公共字段""" + emp_open_time: Optional[date] = Field(default=None, description="就业开放时间") + send_offer_time: Optional[date] = Field(default=None, description="收到Offer时间") + emp_company: Optional[str] = Field(default=None, max_length=50, description="就业公司名称") + salary: Optional[Decimal] = Field(default=None, ge=0, description="薪资(月薪/元)") + + +class EmployCreate(EmployBase): + """新增就业记录时,stu_id 必填(与学生一对一)""" + stu_id: str = Field(..., max_length=15, description="学生编号(主键)") + + +class EmployUpdate(BaseModel): + """更新就业信息:仅更新传入的字段""" + emp_open_time: Optional[date] = Field(default=None, description="就业开放时间") + send_offer_time: Optional[date] = Field(default=None, description="收到Offer时间") + emp_company: Optional[str] = Field(default=None, max_length=50, description="就业公司名称") + salary: Optional[Decimal] = Field(default=None, ge=0, description="薪资(月薪/元)") + + +class EmployOut(EmployBase): + """就业响应模型(含 stu_id 与软删除标记)""" + stu_id: str + is_deleted: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/schemas/stu_schema.py b/schemas/stu_schema.py new file mode 100644 index 0000000..18b66ac --- /dev/null +++ b/schemas/stu_schema.py @@ -0,0 +1,52 @@ +# schemas/stu_schema.py +# 学生信息模块的 Pydantic 模式:请求体校验 & 响应序列化 + +from datetime import date +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class StuInfoBase(BaseModel): + """学生信息公共字段""" + cls_id: str = Field(..., max_length=15, description="所属班级编号") + name: str = Field(..., max_length=20, description="姓名") + gender: Optional[str] = Field(default="男", max_length=1, description="性别") + age: Optional[int] = Field(default=None, ge=0, description="年龄") + hometown: Optional[str] = Field(default=None, max_length=50, description="籍贯") + grad_school: Optional[str] = Field(default=None, max_length=50, description="毕业院校") + major: Optional[str] = Field(default=None, max_length=50, description="专业") + education: Optional[str] = Field(default=None, max_length=20, description="学历") + enr_date: Optional[date] = Field(default=None, description="入学日期") + grad_date: Optional[date] = Field(default=None, description="毕业日期") + advisor_id: Optional[str] = Field(default=None, max_length=15, description="顾问编号") + state: Optional[str] = Field(default="在读", max_length=10, description="状态") + + +class StuInfoCreate(StuInfoBase): + """新增学生时,id(学号)必填""" + id: str = Field(..., max_length=15, description="学生编号(主键)") + + +class StuInfoUpdate(BaseModel): + """更新学生信息:所有字段均可选,仅更新传入的字段""" + cls_id: Optional[str] = Field(default=None, max_length=15, description="所属班级编号") + name: Optional[str] = Field(default=None, max_length=20, description="姓名") + gender: Optional[str] = Field(default=None, max_length=1, description="性别") + age: Optional[int] = Field(default=None, ge=0, description="年龄") + hometown: Optional[str] = Field(default=None, max_length=50, description="籍贯") + grad_school: Optional[str] = Field(default=None, max_length=50, description="毕业院校") + major: Optional[str] = Field(default=None, max_length=50, description="专业") + education: Optional[str] = Field(default=None, max_length=20, description="学历") + enr_date: Optional[date] = Field(default=None, description="入学日期") + grad_date: Optional[date] = Field(default=None, description="毕业日期") + advisor_id: Optional[str] = Field(default=None, max_length=15, description="顾问编号") + state: Optional[str] = Field(default=None, max_length=10, description="状态") + + +class StuInfoOut(StuInfoBase): + """学生信息响应模型(含 id 与软删除标记)""" + id: str + is_deleted: int + + model_config = ConfigDict(from_attributes=True) diff --git a/schemas/stu_score_schema.py b/schemas/stu_score_schema.py new file mode 100644 index 0000000..dc75664 --- /dev/null +++ b/schemas/stu_score_schema.py @@ -0,0 +1,36 @@ +# schemas/stu_score_schema.py +# 学生成绩模块的 Pydantic 模式 + +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class StuScoreBase(BaseModel): + """成绩公共字段""" + stu_id: str = Field(..., max_length=15, description="学生编号") + exam_attempt: int = Field(default=1, ge=1, description="考试轮次") + exam_score: Decimal = Field(..., ge=0, le=100, description="成绩(0-100)") + score_level: Optional[str] = Field(default=None, max_length=10, description="成绩等级") + + +class StuScoreCreate(StuScoreBase): + """新增成绩记录""" + pass + + +class StuScoreUpdate(BaseModel): + """更新成绩记录:仅更新传入的字段""" + stu_id: Optional[str] = Field(default=None, max_length=15, description="学生编号") + exam_attempt: Optional[int] = Field(default=None, ge=1, description="考试轮次") + exam_score: Optional[Decimal] = Field(default=None, ge=0, le=100, description="成绩(0-100)") + score_level: Optional[str] = Field(default=None, max_length=10, description="成绩等级") + + +class StuScoreOut(StuScoreBase): + """成绩响应模型(含自增 id 与软删除标记)""" + id: int + is_deleted: bool + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/schemas/teacher_schema.py b/schemas/teacher_schema.py new file mode 100644 index 0000000..32f834f --- /dev/null +++ b/schemas/teacher_schema.py @@ -0,0 +1,33 @@ +# schemas/teacher_schema.py +# 教师信息模块的 Pydantic 模式 + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class TeacherBase(BaseModel): + """教师公共字段""" + name: str = Field(..., max_length=20, description="姓名") + phone: str = Field(..., max_length=20, description="电话(唯一)") + type: str = Field(..., max_length=20, description="教师职位") + + +class TeacherCreate(TeacherBase): + """新增教师时,id 必填""" + id: str = Field(..., max_length=15, description="教师编号(主键)") + + +class TeacherUpdate(BaseModel): + """更新教师信息:仅更新传入的字段""" + name: Optional[str] = Field(default=None, max_length=20, description="姓名") + phone: Optional[str] = Field(default=None, max_length=20, description="电话(唯一)") + type: Optional[str] = Field(default=None, max_length=20, description="教师职位") + + +class TeacherOut(TeacherBase): + """教师响应模型(含 id 与软删除标记)""" + id: str + is_deleted: int + + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..e5261e0 --- /dev/null +++ b/static/app.js @@ -0,0 +1,1556 @@ +/* ===================================================================== + * static/app.js —— 码力全开·学生管理系统 前端逻辑 + * + * 结构: + * 一、通用工具 二、API 层 三、通用 UI 组件 + * 四、资源视图工厂 五、总览与统计页 六、查询小助手 + * 七、路由与启动 + * + * 说明:本文件为原生 JS,无任何第三方依赖;后端接口全部走 /api 前缀, + * 与 index.html 同源,因此不存在跨域问题。 + * ===================================================================== */ + +'use strict'; + +/* ===================== 一、通用工具 ===================== */ + +const $ = (sel, root = document) => root.querySelector(sel); + +/** 转义 HTML,防止数据中的特殊字符破坏页面结构 */ +function esc(v) { + if (v === null || v === undefined) return ''; + return String(v).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +/** 空值显示为占位符 */ +function dash(v) { + return (v === null || v === undefined || v === '') ? '—' : esc(v); +} + +/** 小数比率转百分比:0.5556 → 55.6% */ +function pct(v) { + return (v === null || v === undefined) ? '—' : `${(Number(v) * 100).toFixed(1)}%`; +} + +/** 金额格式化 */ +function money(v) { + if (v === null || v === undefined) return '—'; + return `¥${Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`; +} + +/** 数值固定小数位 */ +function fixed(v, digits = 2) { + return (v === null || v === undefined) ? '—' : Number(v).toFixed(digits); +} + +/** 构造查询串,自动忽略空值 */ +function qs(params) { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(params || {})) { + if (v === '' || v === null || v === undefined) continue; + p.append(k, v); + } + const s = p.toString(); + return s ? `?${s}` : ''; +} + +/** 去除空值字段,契合后端"局部更新"语义(Pydantic exclude_unset) */ +function compact(obj) { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (v === '' || v === null || v === undefined) continue; + out[k] = v; + } + return out; +} + +/* ===================== 二、API 层 ===================== */ + +const API_BASE = '/api'; + +/** 统一请求封装:把后端的 {"detail": "..."} 异常转成可读的 Error */ +async function http(method, path, body) { + const opt = { method, headers: {} }; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + + let res; + try { + res = await fetch(API_BASE + path, opt); + } catch (e) { + throw new Error('无法连接后端服务,请确认已在项目目录下启动 main.py(默认端口 8002)'); + } + + const text = await res.text(); + let data = null; + if (text) { try { data = JSON.parse(text); } catch (e) { data = text; } } + + if (!res.ok) { + const detail = data && data.detail; + let msg; + if (typeof detail === 'string') msg = detail; + else if (Array.isArray(detail)) msg = detail.map((d) => d.msg || JSON.stringify(d)).join(';'); + else msg = `请求失败(HTTP ${res.status})`; + throw new Error(msg); + } + return data; +} + +const enc = encodeURIComponent; + +const api = { + /* 学生信息 */ + stu: { + list: (p) => http('GET', `/student${qs(p)}`), + get: (id) => http('GET', `/student/${enc(id)}`), + create: (b) => http('POST', '/student', b), + update: (id, b) => http('PUT', `/student/${enc(id)}`, b), + remove: (id) => http('DELETE', `/student/${enc(id)}`), + }, + /* 班级管理 */ + cls: { + list: (p) => http('GET', `/classes${qs(p)}`), + get: (id) => http('GET', `/classes/${enc(id)}`), + create: (b) => http('POST', '/classes', b), + update: (id, b) => http('PUT', `/classes/${enc(id)}`, b), + remove: (id) => http('DELETE', `/classes/${enc(id)}`), + }, + /* 教师信息 */ + tea: { + list: (p) => http('GET', `/teacher${qs(p)}`), + get: (id) => http('GET', `/teacher/${enc(id)}`), + create: (b) => http('POST', '/teacher', b), + update: (id, b) => http('PUT', `/teacher/${enc(id)}`, b), + remove: (id) => http('DELETE', `/teacher/${enc(id)}`), + }, + /* 顾问信息 */ + adv: { + list: (p) => http('GET', `/advisor${qs(p)}`), + get: (id) => http('GET', `/advisor/${enc(id)}`), + create: (b) => http('POST', '/advisor', b), + update: (id, b) => http('PUT', `/advisor/${enc(id)}`, b), + remove: (id) => http('DELETE', `/advisor/${enc(id)}`), + }, + /* 学生成绩 */ + score: { + list: (p) => http('GET', `/score${qs(p)}`), + get: (id) => http('GET', `/score/${enc(id)}`), + byStudent: (stuId) => http('GET', `/score/student/${enc(stuId)}`), + create: (b) => http('POST', '/score', b), + update: (id, b) => http('PUT', `/score/${enc(id)}`, b), + remove: (id) => http('DELETE', `/score/${enc(id)}`), + }, + /* 学生就业 */ + emp: { + list: (p) => http('GET', `/employ${qs(p)}`), + get: (stuId) => http('GET', `/employ/${enc(stuId)}`), + create: (b) => http('POST', '/employ', b), + update: (stuId, b) => http('PUT', `/employ/${enc(stuId)}`, b), + remove: (stuId) => http('DELETE', `/employ/${enc(stuId)}`), + }, + /* 统计分析 */ + stats: { + overview: () => http('GET', '/stats/overview'), + gender: () => http('GET', '/stats/student/gender'), + education: () => http('GET', '/stats/student/education'), + state: () => http('GET', '/stats/student/state'), + hometown: (limit = 8) => http('GET', `/stats/student/hometown${qs({ limit })}`), + scoreSummary: (clsId) => http('GET', `/stats/score/summary${qs({ cls_id: clsId })}`), + scoreDist: () => http('GET', '/stats/score/distribution'), + employSummary: () => http('GET', '/stats/employ/summary'), + }, +}; + +/* ===================== 三、通用 UI 组件 ===================== */ + +/** 轻提示 */ +function toast(msg, type = '') { + const box = $('#toasts'); + const t = document.createElement('div'); + t.className = `toast ${type}`; + t.textContent = msg; + box.appendChild(t); + setTimeout(() => t.remove(), 2800); +} + +/** 二次确认对话框,返回 Promise */ +function confirmDialog(title, message) { + return new Promise((resolve) => { + const mask = document.createElement('div'); + mask.className = 'mask'; + mask.innerHTML = ` + `; + const done = (v) => { mask.remove(); resolve(v); }; + mask.addEventListener('click', (e) => { + const act = e.target.dataset.act; + if (act === 'yes') done(true); + else if (act === 'no' || e.target === mask) done(false); + }); + $('#modalRoot').appendChild(mask); + }); +} + +/** 把字段定义的 options 解析成 [{value,label}] 形式 */ +function resolveOptions(options) { + const raw = typeof options === 'function' ? options() : (options || []); + return raw.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); +} + +/** 渲染单个表单字段 */ +function fieldHtml(f, value) { + const v = (value === null || value === undefined) ? '' : String(value); + const wrapCls = f.full ? 'field full' : 'field'; + const star = f.required ? ' *' : ''; + + if (f.type === 'select') { + const list = resolveOptions(f.options); + let inner = f.allowEmpty ? `` : ''; + for (const o of list) { + inner += ``; + } + return `
+
`; + } + + const attrs = [ + f.maxlength ? `maxlength="${f.maxlength}"` : '', + f.min !== undefined ? `min="${f.min}"` : '', + f.max !== undefined ? `max="${f.max}"` : '', + f.step !== undefined ? `step="${f.step}"` : '', + ].filter(Boolean).join(' '); + const type = ['number', 'date'].includes(f.type) ? f.type : 'text'; + return `
+
`; +} + +/** + * 打开新增 / 编辑表单弹窗 + * @param {object} cfg {title, fields, values, note, submitText, onSubmit(payload)} + */ +function openModal(cfg) { + const mask = document.createElement('div'); + mask.className = 'mask'; + const fieldsHtml = cfg.fields.map((f) => fieldHtml(f, cfg.values[f.key])).join(''); + mask.innerHTML = ` + `; + + const form = $('form', mask); + const errBox = $('.form-err', mask); + + const close = () => mask.remove(); + mask.addEventListener('click', (e) => { + if (e.target.dataset.act === 'cancel' || e.target === mask) close(); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errBox.hidden = true; + + const fd = new FormData(form); + const raw = {}; + for (const [k, v] of fd.entries()) raw[k] = typeof v === 'string' ? v.trim() : v; + + // 前端必填校验(与后端 Pydantic 约束保持一致) + const missing = cfg.fields.filter((f) => f.required && !raw[f.key]).map((f) => f.label); + if (missing.length) { + errBox.textContent = `请填写:${missing.join('、')}`; + errBox.hidden = false; + return; + } + + const btn = $('button[type="submit"]', form); + btn.disabled = true; + try { + await cfg.onSubmit(compact(raw)); + close(); + } catch (err) { + errBox.textContent = err.message; + errBox.hidden = false; + } finally { + btn.disabled = false; + } + }); + + $('#modalRoot').appendChild(mask); + const first = $('input,select', form); + if (first) first.focus(); +} + +/** 右侧详情抽屉 */ +function openDrawer(title, html) { + const mask = document.createElement('div'); + mask.className = 'mask right'; + mask.innerHTML = ` +
+ + +
`; + mask.addEventListener('click', (e) => { + if (e.target.dataset.act === 'close' || e.target === mask) mask.remove(); + }); + $('#modalRoot').appendChild(mask); +} + +/** 详情字段列表 */ +function detailList(pairs) { + return `
${pairs + .map(([k, v]) => `
${esc(k)}
${v}
`) + .join('')}
`; +} + +/** 状态徽标 */ +function stateTag(v) { + const map = { '在读': 'info', '毕业': 'ok' }; + return `${esc(v || '—')}`; +} + +/** 成绩等级徽标 */ +function levelTag(v) { + const map = { '优秀': 'ok', '良好': 'info', '普通': '', '及格': 'warn', '不及格': 'bad' }; + return `${esc(v || '—')}`; +} + +/** 横向条形图(纯 DOM 实现,无第三方图表库) */ +function barChart(rows, showPct = false) { + if (!rows.length) return `
暂无数据
`; + const max = Math.max(1, ...rows.map((r) => Number(r.value) || 0)); + const total = rows.reduce((s, r) => s + (Number(r.value) || 0), 0); + return rows.map((r) => { + const w = ((Number(r.value) || 0) / max * 100).toFixed(1); + const tail = showPct && total + ? `${r.value} · ${((r.value / total) * 100).toFixed(0)}%` + : `${r.value}`; + return `
+ ${esc(r.label)} +
+ ${esc(tail)} +
`; + }).join(''); +} + +/* ===================== 四、资源视图工厂 ===================== */ + +const STATE_OPTS = ['在读', '毕业']; +const GENDER_OPTS = ['男', '女']; +const EDU_OPTS = ['大专', '本科', '硕士', '博士']; +const LEVEL_OPTS = ['优秀', '良好', '普通', '及格', '不及格']; +const TEA_TYPE_OPTS = ['班主任', '主讲']; + +/** 下拉数据源缓存(页面初始化时加载一次) */ +const selects = { + classes: [], teachers: [], advisors: [], students: [], + studentName: {}, teacherName: {}, +}; + +async function loadSelects() { + const [c, t, a, s] = await Promise.all([ + api.cls.list({ page: 1, size: 100 }), + api.tea.list({ page: 1, size: 100 }), + api.adv.list({ page: 1, size: 100 }), + api.stu.list({ page: 1, size: 100 }), + ]); + selects.classes = (c.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.cls_start_date}` })); + selects.teachers = (t.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); + selects.advisors = (a.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); + selects.students = (s.items || []).map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` })); + selects.studentName = Object.fromEntries((s.items || []).map((x) => [x.id, x.name])); + selects.teacherName = Object.fromEntries((t.items || []).map((x) => [x.id, x.name])); +} + +/** 各资源的视图状态:分页与筛选条件 */ +const viewState = {}; +function stateOf(key) { + if (!viewState[key]) viewState[key] = { page: 1, size: 10, filters: {} }; + return viewState[key]; +} + +/* 资源定义:列 / 筛选 / 表单 / 详情,六个模块共用一套渲染流程 */ +const RESOURCES = { + students: { + key: 'students', + title: '学生管理', + desc: '学生基本信息,关联班级、顾问、成绩与就业', + idKey: 'id', + idLabel: '学号', + api: api.stu, + columns: [ + { key: 'id', label: '学号', cls: 'mono' }, + { key: 'name', label: '姓名' }, + { key: 'gender', label: '性别' }, + { key: 'cls_id', label: '班级', cls: 'mono' }, + { key: 'education', label: '学历', render: dash }, + { key: 'hometown', label: '籍贯', render: dash }, + { key: 'grad_school', label: '毕业院校', render: dash }, + { key: 'state', label: '状态', render: stateTag }, + ], + filters: [ + { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, + { key: 'cls_id', label: '班级', type: 'select', options: () => selects.classes, allowEmpty: true }, + { key: 'state', label: '状态', type: 'select', options: () => STATE_OPTS, allowEmpty: true }, + ], + form: [ + { key: 'id', label: '学号', type: 'text', required: true, maxlength: 15, createOnly: true }, + { key: 'cls_id', label: '所属班级', type: 'select', required: true, options: () => selects.classes }, + { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, + { key: 'gender', label: '性别', type: 'select', options: () => GENDER_OPTS }, + { key: 'age', label: '年龄', type: 'number', min: 0 }, + { key: 'hometown', label: '籍贯', type: 'text', maxlength: 50 }, + { key: 'grad_school', label: '毕业院校', type: 'text', maxlength: 50 }, + { key: 'major', label: '专业', type: 'text', maxlength: 50 }, + { key: 'education', label: '学历', type: 'select', options: () => EDU_OPTS, allowEmpty: true }, + { key: 'enr_date', label: '入学日期', type: 'date' }, + { key: 'grad_date', label: '毕业日期', type: 'date' }, + { key: 'advisor_id', label: '顾问', type: 'select', options: () => selects.advisors, allowEmpty: true }, + { key: 'state', label: '状态', type: 'select', options: () => STATE_OPTS }, + ], + }, + + classes: { + key: 'classes', + title: '班级管理', + desc: '班级开课信息与班主任 / 主讲老师配置', + idKey: 'id', + idLabel: '班级编号', + api: api.cls, + columns: [ + { key: 'id', label: '班级编号', cls: 'mono' }, + { key: 'cls_start_date', label: '开课时间' }, + { key: 'head_tea_id', label: '班主任', cls: 'mono', render: (v) => teacherCell(v) }, + { key: 'lecturer_id', label: '主讲老师', cls: 'mono', render: (v) => teacherCell(v) }, + ], + filters: [ + { key: 'head_tea_id', label: '班主任', type: 'select', options: () => selects.teachers, allowEmpty: true }, + { key: 'lecturer_id', label: '主讲老师', type: 'select', options: () => selects.teachers, allowEmpty: true }, + ], + form: [ + { key: 'id', label: '班级编号', type: 'text', required: true, maxlength: 15, createOnly: true }, + { key: 'cls_start_date', label: '开课时间', type: 'date', required: true }, + { key: 'head_tea_id', label: '班主任', type: 'select', required: true, options: () => selects.teachers }, + { key: 'lecturer_id', label: '主讲老师', type: 'select', required: true, options: () => selects.teachers }, + ], + }, + + teachers: { + key: 'teachers', + title: '教师管理', + desc: '教师基本信息与所带班级', + idKey: 'id', + idLabel: '教师编号', + api: api.tea, + columns: [ + { key: 'id', label: '教师编号', cls: 'mono' }, + { key: 'name', label: '姓名' }, + { key: 'phone', label: '电话', cls: 'mono' }, + { key: 'type', label: '职位' }, + ], + filters: [ + { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, + { key: 'type', label: '职位', type: 'select', options: () => TEA_TYPE_OPTS, allowEmpty: true }, + ], + form: [ + { key: 'id', label: '教师编号', type: 'text', required: true, maxlength: 15, createOnly: true }, + { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, + { key: 'phone', label: '电话', type: 'text', required: true, maxlength: 20 }, + { key: 'type', label: '职位', type: 'select', options: () => TEA_TYPE_OPTS }, + ], + }, + + advisors: { + key: 'advisors', + title: '顾问管理', + desc: '顾问基本信息与所负责学生', + idKey: 'id', + idLabel: '顾问编号', + api: api.adv, + columns: [ + { key: 'id', label: '顾问编号', cls: 'mono' }, + { key: 'name', label: '姓名' }, + { key: 'phone', label: '电话', cls: 'mono' }, + ], + filters: [ + { key: 'name', label: '姓名', type: 'text', placeholder: '模糊匹配' }, + ], + form: [ + { key: 'id', label: '顾问编号', type: 'text', required: true, maxlength: 15, createOnly: true }, + { key: 'name', label: '姓名', type: 'text', required: true, maxlength: 20 }, + { key: 'phone', label: '电话', type: 'text', required: true, maxlength: 20 }, + ], + }, + + scores: { + key: 'scores', + title: '成绩管理', + desc: '考试轮次与成绩,等级可留空由系统自动判定', + idKey: 'id', + idLabel: '记录 ID', + api: api.score, + columns: [ + { key: 'id', label: 'ID', cls: 'mono' }, + { key: 'stu_id', label: '学生', cls: 'mono', render: (v) => studentCell(v) }, + { key: 'exam_attempt', label: '考试轮次', render: (v) => `第 ${esc(v)} 次` }, + { key: 'exam_score', label: '成绩', render: (v) => fixed(v, 2) }, + { key: 'score_level', label: '等级', render: levelTag }, + ], + filters: [ + { key: 'stu_id', label: '学生编号', type: 'text', placeholder: '如 S0001' }, + { key: 'score_level', label: '成绩等级', type: 'select', options: () => LEVEL_OPTS, allowEmpty: true }, + ], + form: [ + { key: 'stu_id', label: '学生', type: 'select', required: true, options: () => selects.students }, + { key: 'exam_attempt', label: '考试轮次', type: 'number', min: 1, required: true }, + { key: 'exam_score', label: '成绩(0-100)', type: 'number', min: 0, max: 100, step: '0.01', required: true }, + { key: 'score_level', label: '成绩等级', type: 'select', options: () => LEVEL_OPTS, allowEmpty: true }, + ], + }, + + employ: { + key: 'employ', + title: '就业管理', + desc: '学生就业记录(与学生一对一)', + idKey: 'stu_id', + idLabel: '学号', + api: api.emp, + columns: [ + { key: 'stu_id', label: '学生', cls: 'mono', render: (v) => studentCell(v) }, + { key: 'emp_company', label: '就业公司', render: dash }, + { key: 'salary', label: '月薪', render: (v) => money(v) }, + { key: 'emp_open_time', label: '就业开放时间', render: dash }, + { key: 'send_offer_time', label: '收到 Offer 时间', render: dash }, + ], + filters: [ + { key: 'emp_company', label: '公司名称', type: 'text', placeholder: '模糊匹配' }, + { + key: 'employed', label: '就业状态', type: 'select', allowEmpty: true, + options: () => [{ value: 'true', label: '已就业(含公司)' }, { value: 'false', label: '未就业' }], + }, + ], + form: [ + { key: 'stu_id', label: '学生', type: 'select', required: true, options: () => selects.students, createOnly: true }, + { key: 'emp_company', label: '就业公司', type: 'text', maxlength: 50 }, + { key: 'salary', label: '月薪(元)', type: 'number', min: 0, step: '0.01' }, + { key: 'emp_open_time', label: '就业开放时间', type: 'date' }, + { key: 'send_offer_time', label: '收到 Offer 时间', type: 'date' }, + ], + }, +}; + +/** 学生单元格:编号 + 姓名 */ +function studentCell(id) { + const name = selects.studentName[id]; + return `${esc(id)}${name ? ` ${esc(name)}` : ''}`; +} + +/** 教师单元格:编号 + 姓名 */ +function teacherCell(id) { + const name = selects.teacherName[id]; + return `${esc(id)}${name ? ` ${esc(name)}` : ''}`; +} + +/** 渲染筛选栏 */ +function filtersHtml(r, st) { + const items = r.filters.map((f) => { + const v = st.filters[f.key] || ''; + if (f.type === 'select') { + const list = resolveOptions(f.options); + let inner = f.allowEmpty ? `` : ''; + for (const o of list) { + inner += ``; + } + return `
+
`; + } + return `
+
`; + }).join(''); + + return `
${items} + + +
`; +} + +/** 渲染表格主体 */ +function tableHtml(r, items) { + if (!items.length) return `
没有符合条件的数据
`; + const head = r.columns.map((c) => `${esc(c.label)}`).join('') + '操作'; + const body = items.map((row, idx) => { + const tds = r.columns.map((c) => { + const raw = row[c.key]; + const content = c.render ? c.render(raw, row) : (raw === null || raw === undefined || raw === '' ? '—' : esc(raw)); + return `${content}`; + }).join(''); + return `${tds} + · + · + + `; + }).join(''); + return `
${head}${body}
`; +} + +/** 渲染分页器 */ +function pagerHtml(total, st) { + const pages = Math.max(1, Math.ceil(total / st.size)); + const from = total === 0 ? 0 : (st.page - 1) * st.size + 1; + const to = Math.min(total, st.page * st.size); + return `
+
共 ${total} 条,当前 ${from}-${to} + +
+
+ + ${st.page} / ${pages} + +
+
`; +} + +/* 当前视图的数据快照,供操作(编辑 / 详情)取用 */ +let currentItems = []; + +/** 渲染资源视图 */ +async function renderResource(key) { + const r = RESOURCES[key]; + const st = stateOf(key); + const main = $('#main'); + main.innerHTML = `
加载中…
`; + + let data; + try { + data = await r.api.list({ page: st.page, size: st.size, ...st.filters }); + } catch (e) { + main.innerHTML = `
加载失败:${esc(e.message)}
`; + return; + } + currentItems = data.items || []; + + main.innerHTML = ` +
+

${esc(r.title)}${esc(r.desc)}

+
+ + +
+
+ ${filtersHtml(r, st)} +
+ ${tableHtml(r, currentItems)} + ${pagerHtml(data.total, st)} +
`; +} + +/* ---------- 资源视图的增删改查交互 ---------- */ + +/** 取表单字段定义(新增时全部,编辑时排除主键与 createOnly) */ +function formFields(r, isCreate) { + return r.form.filter((f) => isCreate || (!f.createOnly && f.key !== r.idKey)); +} + +function openCreateForm(key) { + const r = RESOURCES[key]; + openModal({ + title: `新增${r.title.replace('管理', '')}`, + fields: formFields(r, true), + values: {}, + submitText: '创建', + onSubmit: async (payload) => { + await r.api.create(payload); + toast('创建成功', 'ok'); + await loadSelects(); + await renderResource(key); + }, + }); +} + +function openEditForm(key, item) { + const r = RESOURCES[key]; + const values = {}; + for (const f of formFields(r, false)) values[f.key] = item[f.key]; + openModal({ + title: `编辑${r.title.replace('管理', '')} · ${item[r.idKey]}`, + fields: formFields(r, false), + values, + submitText: '保存', + onSubmit: async (payload) => { + await r.api.update(item[r.idKey], payload); + toast('保存成功', 'ok'); + await loadSelects(); + await renderResource(key); + }, + }); +} + +async function doDelete(key, item) { + const r = RESOURCES[key]; + const ok = await confirmDialog( + '确认删除', + `将软删除 ${r.idLabel} ${item[r.idKey]},数据仍保留在数据库中(is_deleted 置位)。是否继续?` + ); + if (!ok) return; + try { + await r.api.remove(item[r.idKey]); + toast('已删除', 'ok'); + await renderResource(key); + } catch (e) { + toast(e.message, 'err'); + } +} + +/* ---------- 详情:按模块组装抽屉内容 ---------- */ + +async function showDetail(key, item) { + try { + if (key === 'students') return openDrawer('学生详情', stuDetailHtml(await api.stu.get(item.id))); + if (key === 'classes') return openDrawer('班级详情', clsDetailHtml(await api.cls.get(item.id))); + if (key === 'teachers') return openDrawer('教师详情', teaDetailHtml(await api.tea.get(item.id))); + if (key === 'advisors') return openDrawer('顾问详情', advDetailHtml(await api.adv.get(item.id))); + if (key === 'scores') { + const s = await api.score.get(item.id); + return openDrawer('成绩详情', detailList([ + ['记录 ID', esc(s.id)], + ['学生', studentCell(s.stu_id)], + ['考试轮次', `第 ${esc(s.exam_attempt)} 次`], + ['成绩', fixed(s.exam_score, 2)], + ['等级', levelTag(s.score_level)], + ])); + } + if (key === 'employ') { + const e = await api.emp.get(item.stu_id); + return openDrawer('就业详情', detailList([ + ['学生', `${studentCell(e.stu_id)}`], + ['就业公司', dash(e.emp_company)], + ['月薪', money(e.salary)], + ['就业开放时间', dash(e.emp_open_time)], + ['收到 Offer 时间', dash(e.send_offer_time)], + ])); + } + } catch (e) { + toast(e.message, 'err'); + } +} + +function stuDetailHtml(d) { + const base = detailList([ + ['学号', `${esc(d.id)}`], + ['姓名', esc(d.name)], + ['性别', dash(d.gender)], + ['年龄', dash(d.age)], + ['状态', stateTag(d.state)], + ['所属班级', d.cls ? `${esc(d.cls.id)}(开课 ${esc(d.cls.cls_start_date)})` : '—'], + ['顾问', d.advisor ? `${esc(d.advisor.name)} ${esc(d.advisor.id)}` : '—'], + ['籍贯', dash(d.hometown)], + ['毕业院校', dash(d.grad_school)], + ['专业', dash(d.major)], + ['学历', dash(d.education)], + ['入学日期', dash(d.enr_date)], + ['毕业日期', dash(d.grad_date)], + ]); + + const scores = (d.scores || []).length + ? `
+ + ${d.scores.map((s) => ` + `).join('')} +
轮次成绩等级
第 ${esc(s.exam_attempt)} 次${fixed(s.exam_score, 2)}${levelTag(s.score_level)}
` + : `
暂无成绩记录
`; + + const emp = d.employ + ? detailList([ + ['就业公司', dash(d.employ.emp_company)], + ['月薪', money(d.employ.salary)], + ['就业开放时间', dash(d.employ.emp_open_time)], + ['收到 Offer 时间', dash(d.employ.send_offer_time)], + ]) + : `
暂无就业记录
`; + + return `${base} +

成绩记录

${scores}
+

就业信息

${emp}
`; +} + +function clsDetailHtml(d) { + return detailList([ + ['班级编号', `${esc(d.id)}`], + ['开课时间', dash(d.cls_start_date)], + ['班主任', d.head_teacher_name ? `${esc(d.head_teacher_name)} ${esc(d.head_tea_id)}` : '—'], + ['主讲老师', d.lecturer_name ? `${esc(d.lecturer_name)} ${esc(d.lecturer_id)}` : '—'], + ['在班学生数', `${esc(d.student_count)} 人`], + ]); +} + +function teaDetailHtml(d) { + const list = (arr) => (arr && arr.length + ? arr.map((x) => `${esc(x)}`).join(' ') + : '无'); + return `${detailList([ + ['教师编号', `${esc(d.id)}`], + ['姓名', esc(d.name)], + ['电话', `${esc(d.phone)}`], + ['职位', dash(d.type)], + ])} +

担任班主任的班级

${list(d.head_classes)}
+

担任主讲老师的班级

${list(d.lecturer_classes)}
`; +} + +function advDetailHtml(d) { + const ids = d.student_ids || []; + return `${detailList([ + ['顾问编号', `${esc(d.id)}`], + ['姓名', esc(d.name)], + ['电话', `${esc(d.phone)}`], + ['负责学生数', `${esc(d.student_count)} 人`], + ])} +

负责的学生

${ + ids.length + ? `
${ids.map((id) => `${esc(id)}`).join('')}
` + : '
暂无学生
' + }
`; +} + +/* ===================== 五、总览与统计页 ===================== */ + +/** 数据总览页 */ +async function renderOverview() { + const main = $('#main'); + main.innerHTML = `
加载中…
`; + + let ov, gender, score, emp; + try { + [ov, gender, score, emp] = await Promise.all([ + api.stats.overview(), api.stats.gender(), api.stats.scoreSummary(), api.stats.employSummary(), + ]); + } catch (e) { + main.innerHTML = `
加载失败:${esc(e.message)}
`; + return; + } + + const genderRows = gender.map((g) => ({ label: g.gender, value: g.count })); + + main.innerHTML = ` +
+

数据总览实时读取 MySQL,数据每次刷新更新

+
+
+ +
+
学生总数
${ov.student_count}
未删除记录
+
班级数
${ov.class_count}
在册班级
+
教师数
${ov.teacher_count}
班主任 / 主讲
+
顾问数
${ov.advisor_count}
在册顾问
+
就业率
${pct(ov.employment_rate)}
已就业 ${ov.employed_count} / ${ov.student_count} 人
+
+ +
+
+
学生性别分布/api/stats/student/gender
+ ${barChart(genderRows, true)} +
+
+
成绩概览全部班级
+ ${detailList([ + ['成绩记录', `${score.total} 条`], + ['平均分', `${fixed(score.avg_score)}`], + ['最高分', fixed(score.max_score)], + ['最低分', fixed(score.min_score)], + ['及格率', `${pct(score.pass_rate)}(${score.pass_count} 人)`], + ])} +
+
+
就业概览/api/stats/employ/summary
+ ${detailList([ + ['就业率', `${pct(emp.employment_rate)}`], + ['已就业', `${emp.employed_count} 人`], + ['平均月薪', money(emp.avg_salary)], + ['最高月薪', money(emp.max_salary)], + ['最低月薪', money(emp.min_salary)], + ])} +
+
`; +} + +/** 统计分析页 */ +let statsClsFilter = ''; + +async function renderStats() { + const main = $('#main'); + main.innerHTML = `
加载中…
`; + + let gender, edu, state, home, sd, emp, sc; + try { + [gender, edu, state, home, sd, emp, sc] = await Promise.all([ + api.stats.gender(), api.stats.education(), api.stats.state(), + api.stats.hometown(8), api.stats.scoreDist(), api.stats.employSummary(), + api.stats.scoreSummary(statsClsFilter), + ]); + } catch (e) { + main.innerHTML = `
加载失败:${esc(e.message)}
`; + return; + } + + const clsOpts = selects.classes + .map((c) => ``) + .join(''); + + main.innerHTML = ` +
+

统计分析聚合、分组与关联查询

+
+
+ +
+
+
性别分布student/gender
+ ${barChart(gender.map((x) => ({ label: x.gender, value: x.count })), true)} +
+
+
学历分布student/education
+ ${barChart(edu.map((x) => ({ label: x.education, value: x.count })), true)} +
+
+
就读状态分布student/state
+ ${barChart(state.map((x) => ({ label: x.state, value: x.count })), true)} +
+
+
生源地 Top 8student/hometown
+ ${barChart(home.map((x) => ({ label: x.hometown, value: x.count })), true)} +
+
+
成绩等级分布score/distribution
+ ${barChart(sd.map((x) => ({ label: x.score_level, value: x.count })), true)} +
+
+
就业薪资概览employ/summary
+ ${detailList([ + ['就业率', `${pct(emp.employment_rate)}`], + ['已就业人数', `${emp.employed_count} 人`], + ['平均月薪', money(emp.avg_salary)], + ['最高月薪', money(emp.max_salary)], + ['最低月薪', money(emp.min_salary)], + ])} +
+
+ +
+
+ 成绩综合统计score/summary,可按班级动态过滤 +
+
+
+ +
+
+
+
成绩记录
${sc.total}
条
+
平均分
${fixed(sc.avg_score)}
满分 100
+
最高分
${fixed(sc.max_score)}
—
+
最低分
${fixed(sc.min_score)}
—
+
及格率
${pct(sc.pass_rate)}
及格 ${sc.pass_count} 条
+
+
`; +} + +/* ===================== 六、查询小助手 ===================== */ + +const assistant = { + snap: null, + snapAt: 0, + + /** 拉取一份本地快照用于条件筛选(30 秒内复用) */ + async snapshot(force = false) { + if (!force && this.snap && Date.now() - this.snapAt < 30000) return this.snap; + const [stu, cls, tea, adv, emp] = await Promise.all([ + api.stu.list({ page: 1, size: 100 }), + api.cls.list({ page: 1, size: 100 }), + api.tea.list({ page: 1, size: 100 }), + api.adv.list({ page: 1, size: 100 }), + api.emp.list({ page: 1, size: 100 }), + ]); + this.snap = { + students: stu.items || [], + classes: cls.items || [], + teachers: tea.items || [], + advisors: adv.items || [], + employ: emp.items || [], + truncated: (stu.total || 0) > 100, + }; + this.snapAt = Date.now(); + return this.snap; + }, + + /** 从快照里推导出可识别的词表(城市 / 专业 / 院校 / 公司) */ + vocab(snap) { + const uniq = (arr) => [...new Set(arr.filter(Boolean))].sort((a, b) => b.length - a.length); + return { + hometown: uniq(snap.students.map((s) => s.hometown)), + major: uniq(snap.students.map((s) => s.major)), + gradSchool: uniq(snap.students.map((s) => s.grad_school)), + company: uniq(snap.employ.map((e) => e.emp_company)), + }; + }, +}; + +/** 统计类意图规则:按顺序匹配,越具体越靠前 */ +const STAT_RULES = [ + { + test: (q) => /就业率|就业情况|就业统计|多少人就业/.test(q), + run: async () => { + const d = await api.stats.employSummary(); + return { + html: `当前就业率 ${pct(d.employment_rate)},即 ${d.employed_count} / ${d.student_count} 人已就业。`, + rows: [ + ['已就业人数', `${d.employed_count} 人`], + ['学生总数', `${d.student_count} 人`], + ['平均月薪', money(d.avg_salary)], + ['最高月薪', money(d.max_salary)], + ['最低月薪', money(d.min_salary)], + ], + jump: { route: 'stats', label: '查看就业统计' }, + }; + }, + }, + { + test: (q) => /平均分|平均成绩|成绩平均|分数平均/.test(q), + run: async () => { + const d = await api.stats.scoreSummary(); + return { + html: `全部成绩记录的平均分为 ${fixed(d.avg_score)} 分。`, + rows: [['成绩记录', `${d.total} 条`], ['最高分', fixed(d.max_score)], ['最低分', fixed(d.min_score)], ['及格率', pct(d.pass_rate)]], + jump: { route: 'stats', label: '查看成绩统计' }, + }; + }, + }, + { + test: (q) => /最高分|最高成绩/.test(q), + run: async () => { + const d = await api.stats.scoreSummary(); + return { html: `最高分为 ${fixed(d.max_score)} 分。`, rows: [], jump: { route: 'scores', label: '去成绩管理' } }; + }, + }, + { + test: (q) => /最低分|最低成绩/.test(q), + run: async () => { + const d = await api.stats.scoreSummary(); + return { html: `最低分为 ${fixed(d.min_score)} 分。`, rows: [], jump: { route: 'scores', label: '去成绩管理' } }; + }, + }, + { + test: (q) => /及格率|通过率/.test(q), + run: async () => { + const d = await api.stats.scoreSummary(); + return { + html: `及格率为 ${pct(d.pass_rate)}(及格 ${d.pass_count} / ${d.total} 条)。`, + rows: [], jump: { route: 'stats', label: '查看成绩统计' }, + }; + }, + }, + { + test: (q) => /薪资|工资|月薪/.test(q), + run: async () => { + const d = await api.stats.employSummary(); + return { + html: `已就业学生的平均月薪为 ${money(d.avg_salary)}。`, + rows: [['最高月薪', money(d.max_salary)], ['最低月薪', money(d.min_salary)], ['就业人数', `${d.employed_count} 人`]], + jump: { route: 'stats', label: '查看就业统计' }, + }; + }, + }, + { + test: (q) => /生源地|籍贯|哪里人|哪个城市|哪个地方/.test(q), + run: async () => { + const d = await api.stats.hometown(5); + if (!d.length) return { html: '暂无生源地数据。', rows: [] }; + return { + html: `生源地人数最多的是 ${esc(d[0].hometown)},共 ${d[0].count} 人。`, + rows: d.map((x) => [x.hometown, `${x.count} 人`]), + jump: { route: 'stats', label: '查看统计页' }, + }; + }, + }, + { + test: (q) => /学历|本科|硕士|大专|博士/.test(q), + run: async () => { + const d = await api.stats.education(); + return { + html: '学生学历分布如下:', + rows: d.sort((a, b) => b.count - a.count).map((x) => [x.education, `${x.count} 人`]), + jump: { route: 'stats', label: '查看统计页' }, + }; + }, + }, + { + test: (q) => /性别|男女|男生女生/.test(q), + run: async () => { + const d = await api.stats.gender(); + return { + html: '学生性别分布如下:', + rows: d.map((x) => [x.gender, `${x.count} 人`]), + jump: { route: 'stats', label: '查看统计页' }, + }; + }, + }, + { + test: (q) => /成绩等级|等级分布|优良率/.test(q), + run: async () => { + const d = await api.stats.scoreDist(); + return { + html: '成绩等级分布如下:', + rows: d.sort((a, b) => b.count - a.count).map((x) => [x.score_level, `${x.count} 条`]), + jump: { route: 'stats', label: '查看统计页' }, + }; + }, + }, + { + test: (q) => /几个班|多少班|班级数|班级数量/.test(q), + run: async () => { + const d = await api.stats.overview(); + return { html: `系统中共有 ${d.class_count} 个班级。`, rows: [], jump: { route: 'classes', label: '去班级管理' } }; + }, + }, + { + test: (q) => /多少老师|几个老师|教师数|教师数量|多少教师/.test(q), + run: async () => { + const d = await api.stats.overview(); + return { html: `系统中共有 ${d.teacher_count} 名教师。`, rows: [], jump: { route: 'teachers', label: '去教师管理' } }; + }, + }, + { + test: (q) => /多少顾问|几个顾问|顾问数|顾问数量/.test(q), + run: async () => { + const d = await api.stats.overview(); + return { html: `系统中共有 ${d.advisor_count} 名顾问。`, rows: [], jump: { route: 'advisors', label: '去顾问管理' } }; + }, + }, + { + test: (q) => /多少学生|几个学生|学生总数|学生数量|有多少人|总人数/.test(q), + run: async () => { + const d = await api.stats.overview(); + return { + html: `系统中共有 ${d.student_count} 名学生,已就业 ${d.employed_count} 人。`, + rows: [['班级数', `${d.class_count} 个`], ['教师数', `${d.teacher_count} 人`], ['顾问数', `${d.advisor_count} 人`], ['就业率', pct(d.employment_rate)]], + jump: { route: 'students', label: '去学生管理' }, + }; + }, + }, +]; + +/** 从问句中提取筛选槽位 */ +function extractSlots(q, snap) { + const vocab = assistant.vocab(snap); + const hit = {}; + const label = {}; + + // 学生自身字段 + if (/在读|在校/.test(q)) { hit.state = '在读'; label.state = '在读'; } + else if (/已毕业|毕业的|毕业了/.test(q)) { hit.state = '毕业'; label.state = '毕业'; } + + if (/男生|男性|男孩|男的/.test(q)) { hit.gender = '男'; label.gender = '男'; } + else if (/女生|女性|女孩|女的/.test(q)) { hit.gender = '女'; label.gender = '女'; } + + for (const e of EDU_OPTS) { + if (q.includes(e)) { hit.education = e; label.education = e; break; } + } + if (!hit.education && /研究生/.test(q)) { hit.education = '硕士'; label.education = '硕士'; } + + // 词表匹配(长词优先,避免"北京大学"被"北京"截断) + for (const [field, words] of [['hometown', vocab.hometown], ['grad_school', vocab.gradSchool], ['major', vocab.major]]) { + for (const w of words) { + if (q.includes(w)) { hit[field] = w; label[field] = w; break; } + } + } + + // 班级 / 教师 / 顾问编号 + const clsM = q.match(/C\d{2,4}/i); + if (clsM) { hit.cls_id = clsM[0].toUpperCase(); label.cls_id = `班级 ${hit.cls_id}`; } + const advM = q.match(/A\d{2,4}/i); + if (advM) { hit.advisor_id = advM[0].toUpperCase(); label.advisor_id = `顾问 ${hit.advisor_id}`; } + + // 派生条件:成绩等级 / 就业 + const scoreLevel = LEVEL_OPTS.find((lv) => q.includes(lv)) || (/不及格/.test(q) ? '不及格' : null); + if (scoreLevel) { hit.score_level = scoreLevel; label.score_level = `成绩${scoreLevel}`; } + + if (/已就业|有工作|拿到offer|入职/.test(q)) { hit.employed = true; label.employed = '已就业'; } + else if (/未就业|没就业|还没就业|待业/.test(q)) { hit.employed = false; label.employed = '未就业'; } + + for (const c of vocab.company) { + if (q.includes(c)) { hit.company = c; label.company = `公司 ${c}`; break; } + } + + return { hit, label }; +} + +/** 按槽位过滤学生(派生条件会先查关联表) */ +async function filterStudents(hit) { + const snap = await assistant.snapshot(); + let pool = snap.students; + + // 成绩等级 → 先取符合条件的成绩记录,再映射到学生 + if (hit.score_level) { + const r = await api.score.list({ page: 1, size: 100, score_level: hit.score_level }); + const ids = new Set((r.items || []).map((s) => s.stu_id)); + pool = pool.filter((s) => ids.has(s.id)); + } + // 就业状态 / 公司 + if (hit.employed !== undefined || hit.company) { + const p = { page: 1, size: 100 }; + if (hit.company) p.emp_company = hit.company; + const r = await api.emp.list(p); + const ids = new Set((r.items || []) + .filter((e) => (hit.employed === undefined ? true : (hit.employed ? !!e.emp_company : !e.emp_company))) + .map((e) => e.stu_id)); + pool = pool.filter((s) => ids.has(s.id)); + } + + // 学生自身字段过滤 + const selfKeys = ['state', 'gender', 'education', 'hometown', 'grad_school', 'major', 'cls_id', 'advisor_id']; + for (const k of selfKeys) { + if (hit[k] !== undefined) pool = pool.filter((s) => String(s[k] || '') === String(hit[k])); + } + return pool; +} + +/** 解析并回答一个问题 */ +async function answer(q, pushUser = true) { + if (pushUser) pushMsg('user', esc(q)); + const snap = await assistant.snapshot(); + + // 1) 编号直达 + const idM = q.match(/([SCAT])[\s-]?(\d{2,4})/i); + if (idM) { + const id = (idM[1] + idM[2]).toUpperCase(); + const prefix = id[0]; + try { + if (prefix === 'S') { + const d = await api.stu.get(id); + pushMsg('bot', `找到学生 ${esc(d.name)}(${esc(d.id)})
+ 班级 ${esc(d.cls_id)} · ${esc(d.gender || '—')} · ${esc(d.state || '—')} · 籍贯 ${esc(d.hometown || '—')} +
    +
  • 成绩记录${(d.scores || []).length} 条
  • +
  • 就业情况${d.employ && d.employ.emp_company ? esc(d.employ.emp_company) : '未就业'}
  • +
+ 在学生列表中查看 →`); + } else if (prefix === 'C') { + const d = await api.cls.get(id); + pushMsg('bot', `班级 ${esc(d.id)}:开课 ${esc(d.cls_start_date)},班主任 ${esc(d.head_teacher_name || d.head_tea_id)},主讲 ${esc(d.lecturer_name || d.lecturer_id)},在班 ${d.student_count} 人。 +
去班级管理 →`); + } else if (prefix === 'T') { + const d = await api.tea.get(id); + pushMsg('bot', `教师 ${esc(d.name)}(${esc(d.id)}),电话 ${esc(d.phone)},职位 ${esc(d.type)}。
+ 班主任班级:${(d.head_classes || []).join('、') || '无'}|主讲班级:${(d.lecturer_classes || []).join('、') || '无'} +
去教师管理 →`); + } else { + const d = await api.adv.get(id); + pushMsg('bot', `顾问 ${esc(d.name)}(${esc(d.id)}),电话 ${esc(d.phone)},负责 ${d.student_count} 名学生。 +
去顾问管理 →`); + } + return; + } catch (e) { + pushMsg('bot', `没有查到编号为 ${esc(id)} 的记录(${esc(e.message)})。可试试学号 S0001、班级 C001、教师 T001、顾问 A001。`); + return; + } + } + + // 2) 条件筛选 + const { hit, label } = extractSlots(q, snap); + if (Object.keys(hit).length) { + const list = await filterStudents(hit); + const cond = Object.values(label).join(' · '); + if (!list.length) { + pushMsg('bot', `没有找到满足「${esc(cond)}」的学生。`); + return; + } + const show = list.slice(0, 6).map((s) => `
  • ${esc(s.id)}${esc(s.name)} · ${esc(s.cls_id)}
  • `).join(''); + const more = list.length > 6 ? `
  • 其余 ${list.length - 6} 人…
  • ` : ''; + pushMsg('bot', `筛选条件「${esc(cond)}」命中 ${list.length} 名学生。 +
      ${show}${more}
    + ${snap.truncated ? '注:数据超过 100 条,统计基于前 100 条。' : ''} + 在学生列表中查看 →`); + return; + } + + // 3) 统计类问答 + for (const rule of STAT_RULES) { + if (rule.test(q)) { + const r = await rule.run(); + const rows = (r.rows || []).length + ? `
      ${r.rows.map(([k, v]) => `
    • ${esc(k)}${esc(v)}
    • `).join('')}
    ` + : ''; + const jump = r.jump ? `${esc(r.jump.label)} →` : ''; + pushMsg('bot', `${r.html}${rows}${jump}`); + return; + } + } + + // 4) 姓名匹配 + const words = q.replace(/[的|学生|查一下|查询|查|找|是谁|信息|资料|情况|看看]/g, ' ').trim().split(/\s+/).filter((w) => w.length >= 2); + for (const w of words) { + const hitStu = snap.students.filter((s) => s.name.includes(w)); + if (hitStu.length) { + pushMsg('bot', `按姓名找到 ${hitStu.length} 人: +
      ${hitStu.slice(0, 6).map((s) => `
    • ${esc(s.id)}${esc(s.name)} · ${esc(s.cls_id)}
    • `).join('')}
    + 在学生列表中查看 →`); + return; + } + } + + // 5) 兜底 + pushMsg('bot', `暂时没听懂这句话。我目前支持:按学号/编号查详情、按条件筛学生(性别、学历、生源地、班级、成绩等级、就业状态)、以及就业率/平均分/分布类统计问题。`, true); +} + +/** 取学生自身可映射到列表接口的筛选键(列表接口仅支持 name / cls_id / state) */ +function supportedFilters(hit) { + const out = {}; + for (const k of ['name', 'cls_id', 'state']) if (hit[k] !== undefined) out[k] = hit[k]; + return out; +} + +/* ---------- 助手消息渲染 ---------- */ + +function pushMsg(role, html) { + const box = $('#assistantBody'); + const d = document.createElement('div'); + d.className = `msg ${role}`; + d.innerHTML = html; + box.appendChild(d); + box.scrollTop = box.scrollHeight; +} + +function suggestChips() { + const snap = assistant.snap; + const chips = [ + '在读的女生有多少人', + '就业率是多少', + '平均分是多少', + '哪个生源地人最多', + '成绩优秀的名单', + '已就业的学生', + ]; + if (snap && snap.students.length) chips.push(`查学生 ${snap.students[0].id}`); + if (snap && snap.classes.length) chips.push(`${snap.classes[0].id} 班有多少人`); + return `
    ${chips.map((c) => ``).join('')}
    `; +} + +async function initAssistant() { + const box = $('#assistantBody'); + box.innerHTML = `
    你好,我是查询小助手。
    + 直接用中文问我就行:查学号、筛学生、问统计。
    +
    试试这些:
    正在准备示例…
    `; + + try { + await assistant.snapshot(); + const slot = $('#suggestSlot'); + if (slot) slot.outerHTML = suggestChips(); + } catch (e) { + const slot = $('#suggestSlot'); + if (slot) slot.textContent = '(后端未就绪,示例问句加载失败)'; + } + + box.addEventListener('click', async (e) => { + const sug = e.target.closest('[data-suggest]'); + if (sug) { await answer(sug.dataset.suggest); return; } + + const jump = e.target.closest('[data-jump-route]'); + if (!jump) return; + // 助手内的跳转:切到目标视图,尽量带上筛选条件 + const route = jump.dataset.jumpRoute; + const fRaw = jump.dataset.jumpFilters; + if (fRaw) { + try { + const st = stateOf(route); + Object.assign(st.filters, JSON.parse(fRaw)); + st.page = 1; + } catch (err) { /* 忽略解析失败 */ } + } + const id = jump.dataset.jumpId; + if (id) { + // 带具体 ID 的跳转:切到目标视图后直接打开详情抽屉 + await navigate(route); + const r = RESOURCES[route]; + if (r) return showDetail(route, { [r.idKey]: id }); + } + await navigate(route); // navigate 内部会同步地址栏,无需手动改 hash + box.scrollTop = box.scrollHeight; + }); + + $('#assistantForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const input = $('#assistantInput'); + const q = input.value.trim(); + if (!q) return; + input.value = ''; + try { + await answer(q); + } catch (err) { + pushMsg('bot', `出错了:${esc(err.message)}`); + } + }); +} + +/* ===================== 七、路由与启动 ===================== */ + +const NAV = [ + { group: '概览' }, + { key: 'overview', label: '数据总览' }, + { key: 'stats', label: '统计分析' }, + { group: '基础数据' }, + { key: 'students', label: '学生管理' }, + { key: 'classes', label: '班级管理' }, + { key: 'teachers', label: '教师管理' }, + { key: 'advisors', label: '顾问管理' }, + { group: '教学与就业' }, + { key: 'scores', label: '成绩管理' }, + { key: 'employ', label: '就业管理' }, +]; + +function renderNav(active) { + $('#nav').innerHTML = NAV.map((n) => { + if (n.group) return ``; + return ``; + }).join(''); +} + +/** 当前激活的路由:作为单一数据源,避免与地址栏 hash 不同步 */ +let activeRoute = 'overview'; + +/** 从地址栏 hash 解析出合法路由 */ +function routeFromHash() { + const key = location.hash.replace(/^#\/?/, '') || 'overview'; + return (key === 'overview' || key === 'stats' || RESOURCES[key]) ? key : 'overview'; +} + +/** 当前路由(供各操作读取,保证与实际渲染的视图一致) */ +function currentRoute() { + return activeRoute; +} + +/** 按路由渲染主区,并同步地址栏 */ +async function navigate(route) { + activeRoute = route; + + // 同步地址栏;随后触发的 hashchange 会因路由相同而跳过,不会重复渲染 + const target = `#/${route}`; + if (location.hash !== target) location.hash = target; + + renderNav(route); + if (route === 'overview') return renderOverview(); + if (route === 'stats') return renderStats(); + if (RESOURCES[route]) return renderResource(route); + return renderOverview(); +} + +/** 全局事件委托:导航、筛选、增删改查、分页 */ +function bindEvents() { + $('#nav').addEventListener('click', (e) => { + const btn = e.target.closest('[data-route]'); + if (btn) navigate(btn.dataset.route); + }); + + $('#main').addEventListener('click', async (e) => { + const btn = e.target.closest('[data-act]'); + if (!btn) return; + const act = btn.dataset.act; + const route = currentRoute(); + const r = RESOURCES[route]; + const st = r ? stateOf(route) : null; + + if (act === 'refresh') { await loadSelects(); return navigate(route); } + if (!r) return; + + if (act === 'search') { + for (const el of document.querySelectorAll('[data-filter]')) st.filters[el.dataset.filter] = el.value.trim(); + st.page = 1; + return renderResource(route); + } + if (act === 'reset') { + st.filters = {}; + st.page = 1; + return renderResource(route); + } + if (act === 'create') return openCreateForm(route); + if (act === 'edit') return openEditForm(route, currentItems[Number(btn.dataset.idx)]); + if (act === 'delete') return doDelete(route, currentItems[Number(btn.dataset.idx)]); + if (act === 'detail') return showDetail(route, currentItems[Number(btn.dataset.idx)]); + if (act === 'page') { + const p = Number(btn.dataset.page); + if (p >= 1) { st.page = p; return renderResource(route); } + } + }); + + // 分页大小 / 统计页班级过滤 + $('#main').addEventListener('change', async (e) => { + const el = e.target; + if (el.dataset.act === 'size') { + const st = stateOf(currentRoute()); + st.size = Number(el.value); + st.page = 1; + return renderResource(currentRoute()); + } + if (el.dataset.act === 'stats-cls') { + statsClsFilter = el.value; + return renderStats(); + } + }); + + // 筛选栏输入框支持回车查询 + $('#main').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target.dataset.filter !== undefined) { + const route = currentRoute(); + const st = stateOf(route); + for (const el of document.querySelectorAll('[data-filter]')) st.filters[el.dataset.filter] = el.value.trim(); + st.page = 1; + renderResource(route); + } + }); + + // 助手栏收起 / 展开 + $('#assistantToggle').addEventListener('click', () => { + const layout = $('#layout'); + const hidden = layout.classList.toggle('assistant-hidden'); + layout.classList.toggle('assistant-open', !hidden); + $('#assistantToggle').textContent = hidden ? '展开' : '收起'; + }); + + // hash 变化(浏览器前进/后退或手改地址)时同步视图 + window.addEventListener('hashchange', () => { + const r = routeFromHash(); + if (r !== activeRoute) navigate(r); + }); +} + +/** 启动 */ +(async function boot() { + bindEvents(); + try { + await loadSelects(); + } catch (e) { + toast(e.message, 'err'); + } + await initAssistant(); + await navigate(currentRoute()); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..80450c1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,44 @@ + + + + + + 码力全开 · 学生管理系统 + + + +
    + + +
    + + +
    + +
    +
    + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..acf1e5a --- /dev/null +++ b/static/style.css @@ -0,0 +1,448 @@ +/* static/style.css —— 码力全开·学生管理系统 前端样式 + 设计约定:浅色主题、扁平化、0.5px 细边框、无渐变无阴影堆叠 */ + +:root { + --c-bg: #f4f5f7; + --c-panel: #ffffff; + --c-panel-2: #fafbfc; + --c-border: #e4e6ea; + --c-border-strong: #cfd3da; + --c-text: #1f2329; + --c-text-2: #61666d; + --c-text-3: #9096a0; + + --c-primary: #185fa5; + --c-primary-hover: #0c447c; + --c-primary-weak: #e6f1fb; + + --c-success: #3b6d11; + --c-success-weak: #eaf3de; + --c-warn: #854f0b; + --c-warn-weak: #faeeda; + --c-danger: #a32d2d; + --c-danger-weak: #fcebeb; + + --radius: 8px; + --radius-lg: 12px; + --sidebar-w: 196px; + --assistant-w: 330px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 14px; + line-height: 1.6; + color: var(--c-text); + background: var(--c-bg); +} + +button, input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; } +a { color: var(--c-primary); text-decoration: none; } + +/* ---------- 整体布局:侧栏 + 主区 + 助手栏 ---------- */ +.layout { + display: grid; + grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--assistant-w); + height: 100vh; + overflow: hidden; +} +.layout.assistant-hidden { grid-template-columns: var(--sidebar-w) minmax(0, 1fr) 0; } + +/* ---------- 左侧导航 ---------- */ +.sidebar { + background: var(--c-panel); + border-right: 0.5px solid var(--c-border); + padding: 14px 10px; + overflow-y: auto; +} +.brand { + font-size: 14px; + font-weight: 600; + padding: 2px 8px 14px; + letter-spacing: .2px; +} +.brand small { display: block; font-weight: 400; font-size: 11px; color: var(--c-text-3); letter-spacing: 0; } +.nav { display: flex; flex-direction: column; gap: 2px; } +.nav-group { + font-size: 11px; + color: var(--c-text-3); + padding: 12px 8px 4px; +} +.nav-item { + border: 0; + background: transparent; + text-align: left; + padding: 7px 10px; + border-radius: var(--radius); + cursor: pointer; + color: var(--c-text-2); + font-size: 13px; + transition: background .12s; +} +.nav-item:hover { background: var(--c-panel-2); } +.nav-item.active { background: var(--c-primary-weak); color: #0c447c; font-weight: 500; } +.nav-item .dot { + display: inline-block; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--c-border-strong); + margin-right: 8px; + vertical-align: middle; +} +.nav-item.active .dot { background: var(--c-primary); } + +/* ---------- 主区 ---------- */ +.main { overflow-y: auto; padding: 16px 20px 40px; } + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.topbar h1 { font-size: 18px; font-weight: 600; margin: 0; } +.topbar .sub { font-size: 12px; color: var(--c-text-3); font-weight: 400; margin-left: 8px; } +.topbar-actions { display: flex; gap: 8px; align-items: center; } + +/* ---------- 按钮 ---------- */ +.btn { + border: 0.5px solid var(--c-border-strong); + background: var(--c-panel); + border-radius: var(--radius); + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + color: var(--c-text); + white-space: nowrap; + transition: background .12s, border-color .12s; +} +.btn:hover { background: var(--c-panel-2); border-color: var(--c-text-3); } +.btn-primary { background: var(--c-primary); border-color: var(--c-primary); color: #fff; } +.btn-primary:hover { background: var(--c-primary-hover); border-color: var(--c-primary-hover); } +.btn-danger { color: var(--c-danger); border-color: #e8b8b8; } +.btn-danger:hover { background: var(--c-danger-weak); } +.btn-sm { padding: 3px 8px; font-size: 12px; } +.btn:disabled { opacity: .45; cursor: not-allowed; } + +.link-btn { + border: 0; background: none; padding: 0 6px 0 0; + color: var(--c-primary); cursor: pointer; font-size: 12px; +} +.link-btn:hover { text-decoration: underline; } +.link-btn.danger { color: var(--c-danger); } + +/* ---------- 筛选栏 ---------- */ +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + background: var(--c-panel); + border: 0.5px solid var(--c-border); + border-radius: var(--radius-lg); + padding: 12px; + margin-bottom: 12px; +} +.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.field label { font-size: 11px; color: var(--c-text-3); } +.field input, .field select { + border: 0.5px solid var(--c-border-strong); + border-radius: var(--radius); + padding: 6px 9px; + background: var(--c-panel); + min-width: 130px; + outline: none; +} +.field input:focus, .field select:focus { border-color: var(--c-primary); } +.field.grow { flex: 1; } +.field.grow input { width: 100%; } + +/* ---------- KPI 卡片 ---------- */ +.kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} +.kpi { + background: var(--c-panel); + border: 0.5px solid var(--c-border); + border-radius: var(--radius-lg); + padding: 12px 14px; +} +.kpi .kpi-label { font-size: 12px; color: var(--c-text-2); } +.kpi .kpi-value { font-size: 24px; font-weight: 600; line-height: 1.3; } +.kpi .kpi-extra { font-size: 11px; color: var(--c-text-3); } +.kpi.accent { background: var(--c-primary-weak); border-color: #c9dff7; } +.kpi.accent .kpi-value { color: #0c447c; } + +/* ---------- 卡片 / 表格 ---------- */ +.card { + background: var(--c-panel); + border: 0.5px solid var(--c-border); + border-radius: var(--radius-lg); + padding: 14px 16px; + margin-bottom: 14px; +} +.card-title { + font-size: 13px; + font-weight: 600; + margin: 0 0 10px; + display: flex; + align-items: center; + justify-content: space-between; +} +.card-title .hint { font-weight: 400; font-size: 11px; color: var(--c-text-3); } + +.table-wrap { overflow-x: auto; border: 0.5px solid var(--c-border); border-radius: var(--radius); } +table.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +table.data-table th { + background: var(--c-panel-2); + text-align: left; + font-weight: 600; + color: var(--c-text-2); + font-size: 12px; + padding: 8px 10px; + white-space: nowrap; + border-bottom: 0.5px solid var(--c-border); +} +table.data-table td { + padding: 8px 10px; + border-bottom: 0.5px solid var(--c-border); + white-space: nowrap; +} +table.data-table tbody tr:last-child td { border-bottom: 0; } +table.data-table tbody tr:hover { background: #fbfcfd; } +table.data-table .mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; } +table.data-table .muted { color: var(--c-text-3); } +table.data-table .actions { white-space: nowrap; } +table.data-table .actions .sep { color: var(--c-border-strong); padding: 0 4px; } + +.tag { + display: inline-block; + padding: 1px 7px; + border-radius: 999px; + font-size: 11px; + background: var(--c-panel-2); + border: 0.5px solid var(--c-border); + color: var(--c-text-2); +} +.tag.ok { background: var(--c-success-weak); border-color: #cde0ab; color: var(--c-success); } +.tag.warn { background: var(--c-warn-weak); border-color: #f0d9a8; color: var(--c-warn); } +.tag.bad { background: var(--c-danger-weak); border-color: #f0c2c2; color: var(--c-danger); } +.tag.info { background: var(--c-primary-weak); border-color: #c9dff7; color: #0c447c; } + +.pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 10px; + font-size: 12px; + color: var(--c-text-2); +} +.pager .pages { display: flex; gap: 6px; align-items: center; } + +.empty { padding: 28px; text-align: center; color: var(--c-text-3); font-size: 13px; } +.loading { padding: 28px; text-align: center; color: var(--c-text-3); font-size: 13px; } + +/* ---------- 图表 ---------- */ +.chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} +.chart-row { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + margin-bottom: 6px; +} +.chart-row .name { width: 74px; color: var(--c-text-2); text-align: right; flex: none; } +.chart-row .bar-wrap { flex: 1; background: var(--c-panel-2); border-radius: 4px; height: 16px; overflow: hidden; } +.chart-row .bar { height: 100%; background: var(--c-primary); border-radius: 4px; } +.chart-row .val { width: 60px; text-align: right; color: var(--c-text-2); flex: none; } + +/* ---------- 弹窗 / 抽屉 ---------- */ +.mask { + position: fixed; + inset: 0; + background: rgba(31, 35, 41, .38); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 20px; +} +.mask.right { justify-content: flex-end; padding: 0; } +.modal { + background: var(--c-panel); + border-radius: var(--radius-lg); + width: min(560px, 100%); + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.modal-head { + padding: 14px 18px; + border-bottom: 0.5px solid var(--c-border); + font-weight: 600; + display: flex; + justify-content: space-between; + align-items: center; +} +.modal-body { padding: 16px 18px; overflow-y: auto; } +.modal-foot { + padding: 12px 18px; + border-top: 0.5px solid var(--c-border); + display: flex; + justify-content: flex-end; + gap: 8px; +} +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.form-grid .full { grid-column: 1 / -1; } +.form-grid .field input, .form-grid .field select { width: 100%; min-width: 0; } +.form-err { + grid-column: 1 / -1; + background: var(--c-danger-weak); + color: var(--c-danger); + border-radius: var(--radius); + padding: 8px 10px; + font-size: 12px; +} + +.drawer { + background: var(--c-panel); + width: min(520px, 100%); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.detail-list { display: grid; grid-template-columns: 96px 1fr; gap: 6px 12px; font-size: 13px; } +.detail-list dt { color: var(--c-text-3); } +.detail-list dd { margin: 0; word-break: break-all; } +.detail-section { margin-top: 18px; } +.detail-section h4 { font-size: 13px; font-weight: 600; margin: 0 0 8px; } + +/* ---------- 轻提示 ---------- */ +.toasts { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + align-items: center; +} +.toast { + background: #2c2c2a; + color: #fff; + border-radius: var(--radius); + padding: 8px 16px; + font-size: 13px; + max-width: 460px; +} +.toast.ok { background: var(--c-success); } +.toast.err { background: var(--c-danger); } + +/* ---------- 右侧查询助手 ---------- */ +.assistant { + background: var(--c-panel); + border-left: 0.5px solid var(--c-border); + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} +.assistant-head { + padding: 14px 14px 10px; + border-bottom: 0.5px solid var(--c-border); + display: flex; + align-items: center; + justify-content: space-between; +} +.assistant-head h2 { font-size: 14px; font-weight: 600; margin: 0; } +.assistant-head .sub { font-size: 11px; color: var(--c-text-3); } +.assistant-body { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.msg { max-width: 100%; font-size: 13px; border-radius: var(--radius); padding: 8px 11px; line-height: 1.65; } +.msg.user { background: var(--c-primary-weak); color: #0c447c; align-self: flex-end; max-width: 85%; } +.msg.bot { background: var(--c-panel-2); border: 0.5px solid var(--c-border); align-self: flex-start; } +.msg.bot .ans-num { font-weight: 600; color: #0c447c; font-size: 16px; } +.msg.bot .rows { margin: 6px 0 0; padding: 0; list-style: none; } +.msg.bot .rows li { + font-size: 12px; + padding: 3px 0; + border-top: 0.5px dashed var(--c-border); + display: flex; + justify-content: space-between; + gap: 8px; +} +.msg.bot .rows li:first-child { border-top: 0; } +.msg .jump { + display: inline-block; + margin-top: 8px; + font-size: 12px; + color: var(--c-primary); + cursor: pointer; +} +.msg .jump:hover { text-decoration: underline; } +.msg .tip { color: var(--c-text-3); font-size: 12px; } +.msg .kv { font-size: 12px; color: var(--c-text-2); } + +.suggests { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } +.suggest { + border: 0.5px solid var(--c-border-strong); + background: var(--c-panel); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + color: var(--c-text-2); +} +.suggest:hover { border-color: var(--c-primary); color: var(--c-primary); } + +.assistant-foot { padding: 10px 14px 14px; border-top: 0.5px solid var(--c-border); } +.assistant-foot form { display: flex; gap: 8px; } +.assistant-foot input { + flex: 1; + border: 0.5px solid var(--c-border-strong); + border-radius: var(--radius); + padding: 8px 11px; + min-width: 0; + outline: none; +} +.assistant-foot input:focus { border-color: var(--c-primary); } + +/* ---------- 窄屏适配 ---------- */ +@media (max-width: 1100px) { + .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr) 0; } + .layout.assistant-open { grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--assistant-w); } + .assistant { display: none; } + .layout.assistant-open .assistant { display: flex; } +} +@media (max-width: 760px) { + .layout, .layout.assistant-open { grid-template-columns: 0 minmax(0, 1fr) 0; } + .sidebar { display: none; } + .form-grid { grid-template-columns: 1fr; } +}