From 124d7f645ec30898ec4f912fef3d06ec9fb12633 Mon Sep 17 00:00:00 2001 From: canglan Date: Mon, 22 Jun 2026 10:21:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E7=8F=AD=E7=BA=A7=E7=89=88?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=20v2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 主要功能: - 多班级完全隔离(class_id 贯穿全系统) - 后端从 Python FastAPI 重写为 Go Gin(端口 56789) - 超级管理员独立登录(env 配置路径,默认账密 admin/Admin123) - 科任老师/课代表新角色 - 课代表作业管理页面 - 排行榜分项排行(操行分/考勤/作业) - 角色加减分上下限由班主任配置 - 家长改密功能(可开关) - 班级角色按需开关 - 宿舍号格式:南0-000 - 周度/月度重置功能 - MySQL 5.7 兼容 - Nginx 反向代理部署 开发者: Canglan 版权归属: Sea Network Technology Studio 许可证: Apache License 2.0 --- .gitignore | 64 ++ INSTALL.md | 455 ++++++++ LICENSE | 199 ++++ README.md | 233 ++++ VERSION | 1 + backend-go/.env.example | 67 ++ backend-go/Makefile | 63 ++ backend-go/cmd/server/main.go | 210 ++++ backend-go/go.mod | 13 + backend-go/internal/config/config.go | 163 +++ backend-go/internal/handler/admin_handler.go | 602 +++++++++++ backend-go/internal/handler/auth_handler.go | 131 +++ backend-go/internal/handler/cadre_handler.go | 143 +++ backend-go/internal/handler/class_handler.go | 271 +++++ backend-go/internal/handler/config_handler.go | 44 + backend-go/internal/handler/handler_utils.go | 20 + backend-go/internal/handler/parent_handler.go | 115 ++ .../internal/handler/semester_handler.go | 230 ++++ .../internal/handler/student_handler.go | 192 ++++ .../internal/handler/subject_handler.go | 152 +++ .../internal/handler/super_admin_handler.go | 56 + backend-go/internal/middleware/access_log.go | 57 + backend-go/internal/middleware/auth.go | 227 ++++ backend-go/internal/middleware/sanitize.go | 131 +++ backend-go/internal/model/admin_role.go | 36 + backend-go/internal/model/assignment.go | 53 + backend-go/internal/model/attendance.go | 38 + backend-go/internal/model/class_model.go | 60 ++ backend-go/internal/model/conduct.go | 44 + backend-go/internal/model/log.go | 50 + backend-go/internal/model/semester.go | 88 ++ backend-go/internal/model/student.go | 37 + backend-go/internal/model/subject.go | 29 + backend-go/internal/model/super_admin.go | 32 + backend-go/internal/model/system_setting.go | 26 + backend-go/internal/model/user.go | 34 + .../internal/repository/admin_role_repo.go | 112 ++ .../internal/repository/assignment_repo.go | 168 +++ .../internal/repository/attendance_repo.go | 184 ++++ backend-go/internal/repository/class_repo.go | 184 ++++ .../internal/repository/conduct_repo.go | 294 ++++++ backend-go/internal/repository/log_repo.go | 91 ++ .../internal/repository/semester_repo.go | 291 +++++ .../internal/repository/student_repo.go | 230 ++++ .../internal/repository/subject_repo.go | 104 ++ .../internal/repository/super_admin_repo.go | 110 ++ .../repository/system_setting_repo.go | 100 ++ backend-go/internal/repository/user_repo.go | 166 +++ backend-go/internal/router/router.go | 206 ++++ backend-go/internal/schema/admin.go | 33 + backend-go/internal/schema/attendance.go | 30 + backend-go/internal/schema/auth.go | 26 + backend-go/internal/schema/class.go | 44 + backend-go/internal/schema/conduct.go | 43 + backend-go/internal/schema/ranking.go | 50 + backend-go/internal/schema/semester.go | 38 + backend-go/internal/schema/student.go | 54 + backend-go/internal/schema/subject.go | 27 + backend-go/internal/service/admin_service.go | 452 ++++++++ .../internal/service/attendance_service.go | 226 ++++ backend-go/internal/service/auth_service.go | 461 ++++++++ backend-go/internal/service/class_service.go | 224 ++++ .../internal/service/conduct_service.go | 384 +++++++ backend-go/internal/service/config_service.go | 49 + backend-go/internal/service/log_service.go | 70 ++ .../internal/service/ranking_service.go | 80 ++ .../internal/service/semester_service.go | 665 ++++++++++++ .../internal/service/student_service.go | 171 +++ .../internal/service/subject_service.go | 92 ++ .../internal/service/super_admin_service.go | 158 +++ backend-go/internal/service/utils.go | 25 + backend-go/pkg/crypto/password.go | 110 ++ backend-go/pkg/database/mysql.go | 71 ++ backend-go/pkg/database/redis.go | 80 ++ backend-go/pkg/jwt/jwt.go | 93 ++ backend-go/pkg/logger/logger.go | 64 ++ backend-go/pkg/response/response.go | 106 ++ frontend/.env.example | 40 + frontend/admin/admins.php | 154 +++ frontend/admin/attendance.php | 100 ++ frontend/admin/cadre_homework.php | 125 +++ frontend/admin/class_settings.php | 439 ++++++++ frontend/admin/classes.php | 214 ++++ frontend/admin/conduct.php | 154 +++ frontend/admin/dashboard.php | 219 ++++ frontend/admin/history.php | 120 +++ frontend/admin/homework.php | 255 +++++ frontend/admin/password.php | 81 ++ frontend/admin/rankings.php | 91 ++ frontend/admin/semesters.php | 264 +++++ frontend/admin/students.php | 218 ++++ frontend/admin/subjects.php | 14 + frontend/api/check_upgrade.php | 69 ++ frontend/api/clear_session.php | 64 ++ frontend/api/execute_upgrade.php | 104 ++ frontend/api/save_session.php | 219 ++++ frontend/assets/css/admin.css | 265 +++++ frontend/assets/css/style.css | 998 ++++++++++++++++++ frontend/assets/js/admin.js | 14 + frontend/assets/js/admins.js | 146 +++ frontend/assets/js/attendance-manage.js | 195 ++++ frontend/assets/js/cadre-homework.js | 159 +++ frontend/assets/js/common.js | 425 ++++++++ frontend/assets/js/conduct.js | 253 +++++ frontend/assets/js/dashboard.js | 120 +++ frontend/assets/js/history.js | 345 ++++++ frontend/assets/js/homework-manage.js | 266 +++++ frontend/assets/js/modules/admin-mgmt.js | 53 + frontend/assets/js/modules/modal-utils.js | 24 + frontend/assets/js/modules/points-mgmt.js | 102 ++ frontend/assets/js/modules/student-mgmt.js | 234 ++++ frontend/assets/js/modules/subject-mgmt.js | 47 + frontend/assets/js/modules/utils.js | 35 + frontend/assets/js/parent.js | 13 + frontend/assets/js/rankings.js | 59 ++ frontend/assets/js/semesters.js | 383 +++++++ frontend/assets/js/student-homework.js | 38 + frontend/assets/js/student.js | 13 + frontend/assets/js/students-manage.js | 100 ++ frontend/assets/uploads/sample_import.json | 23 + frontend/config.php | 97 ++ frontend/includes/footer.php | 6 + frontend/includes/header.php | 92 ++ frontend/includes/nav.php | 31 + frontend/index.php | 143 +++ frontend/parent/attendance.php | 84 ++ frontend/parent/dashboard.php | 101 ++ frontend/parent/history.php | 117 ++ frontend/parent/password.php | 106 ++ frontend/student/attendance.php | 83 ++ frontend/student/conduct_history.php | 11 + frontend/student/dashboard.php | 515 +++++++++ frontend/student/homework.php | 51 + frontend/student/password.php | 81 ++ frontend/student/semester_history.php | 206 ++++ frontend/super-admin/login.php | 134 +++ sql/init.sql | 423 ++++++++ sql/upgrades/v1.0.sql | 264 +++++ sql/upgrades/v2.1.sql | 53 + upgrade.php | 719 +++++++++++++ 140 files changed, 21103 insertions(+) create mode 100644 .gitignore create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 VERSION create mode 100644 backend-go/.env.example create mode 100644 backend-go/Makefile create mode 100644 backend-go/cmd/server/main.go create mode 100644 backend-go/go.mod create mode 100644 backend-go/internal/config/config.go create mode 100644 backend-go/internal/handler/admin_handler.go create mode 100644 backend-go/internal/handler/auth_handler.go create mode 100644 backend-go/internal/handler/cadre_handler.go create mode 100644 backend-go/internal/handler/class_handler.go create mode 100644 backend-go/internal/handler/config_handler.go create mode 100644 backend-go/internal/handler/handler_utils.go create mode 100644 backend-go/internal/handler/parent_handler.go create mode 100644 backend-go/internal/handler/semester_handler.go create mode 100644 backend-go/internal/handler/student_handler.go create mode 100644 backend-go/internal/handler/subject_handler.go create mode 100644 backend-go/internal/handler/super_admin_handler.go create mode 100644 backend-go/internal/middleware/access_log.go create mode 100644 backend-go/internal/middleware/auth.go create mode 100644 backend-go/internal/middleware/sanitize.go create mode 100644 backend-go/internal/model/admin_role.go create mode 100644 backend-go/internal/model/assignment.go create mode 100644 backend-go/internal/model/attendance.go create mode 100644 backend-go/internal/model/class_model.go create mode 100644 backend-go/internal/model/conduct.go create mode 100644 backend-go/internal/model/log.go create mode 100644 backend-go/internal/model/semester.go create mode 100644 backend-go/internal/model/student.go create mode 100644 backend-go/internal/model/subject.go create mode 100644 backend-go/internal/model/super_admin.go create mode 100644 backend-go/internal/model/system_setting.go create mode 100644 backend-go/internal/model/user.go create mode 100644 backend-go/internal/repository/admin_role_repo.go create mode 100644 backend-go/internal/repository/assignment_repo.go create mode 100644 backend-go/internal/repository/attendance_repo.go create mode 100644 backend-go/internal/repository/class_repo.go create mode 100644 backend-go/internal/repository/conduct_repo.go create mode 100644 backend-go/internal/repository/log_repo.go create mode 100644 backend-go/internal/repository/semester_repo.go create mode 100644 backend-go/internal/repository/student_repo.go create mode 100644 backend-go/internal/repository/subject_repo.go create mode 100644 backend-go/internal/repository/super_admin_repo.go create mode 100644 backend-go/internal/repository/system_setting_repo.go create mode 100644 backend-go/internal/repository/user_repo.go create mode 100644 backend-go/internal/router/router.go create mode 100644 backend-go/internal/schema/admin.go create mode 100644 backend-go/internal/schema/attendance.go create mode 100644 backend-go/internal/schema/auth.go create mode 100644 backend-go/internal/schema/class.go create mode 100644 backend-go/internal/schema/conduct.go create mode 100644 backend-go/internal/schema/ranking.go create mode 100644 backend-go/internal/schema/semester.go create mode 100644 backend-go/internal/schema/student.go create mode 100644 backend-go/internal/schema/subject.go create mode 100644 backend-go/internal/service/admin_service.go create mode 100644 backend-go/internal/service/attendance_service.go create mode 100644 backend-go/internal/service/auth_service.go create mode 100644 backend-go/internal/service/class_service.go create mode 100644 backend-go/internal/service/conduct_service.go create mode 100644 backend-go/internal/service/config_service.go create mode 100644 backend-go/internal/service/log_service.go create mode 100644 backend-go/internal/service/ranking_service.go create mode 100644 backend-go/internal/service/semester_service.go create mode 100644 backend-go/internal/service/student_service.go create mode 100644 backend-go/internal/service/subject_service.go create mode 100644 backend-go/internal/service/super_admin_service.go create mode 100644 backend-go/internal/service/utils.go create mode 100644 backend-go/pkg/crypto/password.go create mode 100644 backend-go/pkg/database/mysql.go create mode 100644 backend-go/pkg/database/redis.go create mode 100644 backend-go/pkg/jwt/jwt.go create mode 100644 backend-go/pkg/logger/logger.go create mode 100644 backend-go/pkg/response/response.go create mode 100644 frontend/.env.example create mode 100644 frontend/admin/admins.php create mode 100644 frontend/admin/attendance.php create mode 100644 frontend/admin/cadre_homework.php create mode 100644 frontend/admin/class_settings.php create mode 100644 frontend/admin/classes.php create mode 100644 frontend/admin/conduct.php create mode 100644 frontend/admin/dashboard.php create mode 100644 frontend/admin/history.php create mode 100644 frontend/admin/homework.php create mode 100644 frontend/admin/password.php create mode 100644 frontend/admin/rankings.php create mode 100644 frontend/admin/semesters.php create mode 100644 frontend/admin/students.php create mode 100644 frontend/admin/subjects.php create mode 100644 frontend/api/check_upgrade.php create mode 100644 frontend/api/clear_session.php create mode 100644 frontend/api/execute_upgrade.php create mode 100644 frontend/api/save_session.php create mode 100644 frontend/assets/css/admin.css create mode 100644 frontend/assets/css/style.css create mode 100644 frontend/assets/js/admin.js create mode 100644 frontend/assets/js/admins.js create mode 100644 frontend/assets/js/attendance-manage.js create mode 100644 frontend/assets/js/cadre-homework.js create mode 100644 frontend/assets/js/common.js create mode 100644 frontend/assets/js/conduct.js create mode 100644 frontend/assets/js/dashboard.js create mode 100644 frontend/assets/js/history.js create mode 100644 frontend/assets/js/homework-manage.js create mode 100644 frontend/assets/js/modules/admin-mgmt.js create mode 100644 frontend/assets/js/modules/modal-utils.js create mode 100644 frontend/assets/js/modules/points-mgmt.js create mode 100644 frontend/assets/js/modules/student-mgmt.js create mode 100644 frontend/assets/js/modules/subject-mgmt.js create mode 100644 frontend/assets/js/modules/utils.js create mode 100644 frontend/assets/js/parent.js create mode 100644 frontend/assets/js/rankings.js create mode 100644 frontend/assets/js/semesters.js create mode 100644 frontend/assets/js/student-homework.js create mode 100644 frontend/assets/js/student.js create mode 100644 frontend/assets/js/students-manage.js create mode 100644 frontend/assets/uploads/sample_import.json create mode 100644 frontend/config.php create mode 100644 frontend/includes/footer.php create mode 100644 frontend/includes/header.php create mode 100644 frontend/includes/nav.php create mode 100644 frontend/index.php create mode 100644 frontend/parent/attendance.php create mode 100644 frontend/parent/dashboard.php create mode 100644 frontend/parent/history.php create mode 100644 frontend/parent/password.php create mode 100644 frontend/student/attendance.php create mode 100644 frontend/student/conduct_history.php create mode 100644 frontend/student/dashboard.php create mode 100644 frontend/student/homework.php create mode 100644 frontend/student/password.php create mode 100644 frontend/student/semester_history.php create mode 100644 frontend/super-admin/login.php create mode 100644 sql/init.sql create mode 100644 sql/upgrades/v1.0.sql create mode 100644 sql/upgrades/v2.1.sql create mode 100644 upgrade.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ccded7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# 环境变量 +.env +backend-go/.env +frontend/.env + +# Go +backend-go/sharedclassmanager +backend-go/sharedclassmanager.exe +backend-go/logs/ + +# Python(旧后端残留) +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +.venv +pip-log.txt +pip-delete-this-directory.txt + +# 数据库 +*.db +*.sqlite +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# 日志 +*.log + +# 测试 +.pytest_cache/ +.coverage +htmlcov/ + +# 操作系统 +.DS_Store +Thumbs.db + +# 临时文件 +*.tmp +*.bak + +# CoStrict +.cospec/ +plans/ +.roo/ +code-review_result/ + +# PDF +docs/guide/cadre.pdf +docs/guide/parent.pdf +docs/guide/student.pdf +docs/guide/teacher.pdf +qrcode.png + +# example +example/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..dc5dadd --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,455 @@ +# 多班级版班级管理系统 - 安装部署指南 + +## 环境要求 + +### 服务器配置 +- **操作系统**: Linux (Ubuntu 20.04+ / CentOS 7+) +- **CPU**: 2核+ +- **内存**: 4GB+ +- **磁盘**: 20GB+ + +### 软件依赖 +| 软件 | 版本 | 用途 | +|------|------|------| +| Go | 1.21+ | 后端运行环境 | +| MySQL | 5.7+ | 数据存储 | +| Redis | 6.0+ | 缓存、会话 | +| Nginx | 1.18+ | Web服务器、反向代理 | +| PHP | 8.0+ | 前端页面处理 | + +--- + +## 宝塔面板部署(推荐) + +### 1. 安装宝塔面板 + +```bash +# 通用安装脚本(免登录版) +url=https://download.bt.cn/install/installStable.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable.sh $url;fi;bash installStable.sh ed8484bec +``` + +安装完成后,根据提示访问宝塔面板地址,完成初始化设置。 + +### 2. 安装运行环境 + +在宝塔面板的"软件商店"中安装以下软件: + +| 软件名称 | 版本要求 | 用途 | +|---------|---------|------| +| Nginx | 1.18+ | Web服务器 | +| MySQL | 5.7+ | 数据库 | +| Redis | 6.0+ | 缓存服务 | +| PHP | 8.0+ | 前端处理 | + +### 3. 安装 Go 环境 + +在服务器上安装 Go 1.21+: + +```bash +# 下载 Go(以 1.21.0 为例,请替换为最新稳定版) +wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz + +# 解压到 /usr/local +sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz + +# 配置环境变量 +echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc +source ~/.bashrc + +# 验证安装 +go version +``` + +### 4. 创建数据库 + +在宝塔面板中: +1. 进入"数据库"菜单 +2. 点击"添加数据库" +3. 填写数据库信息: + - 数据库名:`classmanagerdb` + - 用户名:`class_admin` + - 密码:生成强密码并保存 +4. 点击"导入",选择 `sql/init.sql` 文件导入 + +### 5. 部署 Go 后端 + +#### 5.1 上传代码 + +1. 进入宝塔面板"文件"菜单 +2. 进入 `/www/wwwroot/` 目录 +3. 上传或克隆代码到 `/www/wwwroot/SharedClassManager` + +```bash +git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git /www/wwwroot/SharedClassManager +``` + +#### 5.2 配置环境变量 + +```bash +cd /www/wwwroot/SharedClassManager/backend-go +cp .env.example .env +vim .env # 根据实际环境修改配置 +``` + +**必须修改的配置项**: +- `DB_USER` - 数据库用户名 +- `DB_PASSWORD` - 数据库密码 +- `JWT_SECRET_KEY` - JWT密钥(32位以上随机字符串) +- `PASSWORD_SALT` - 密码加密盐值 + +#### 5.3 编译并运行 + +```bash +cd /www/wwwroot/SharedClassManager/backend-go +go mod tidy +go build -o sharedclassmanager ./cmd/server +``` + +#### 5.4 使用 Systemd 管理服务 + +创建 systemd 服务文件: + +```bash +sudo vim /etc/systemd/system/sharedclassmanager.service +``` + +写入以下内容: + +```ini +[Unit] +Description=SharedClassManager Go Backend +After=network.target mysql.service redis.service + +[Service] +Type=simple +User=www-data +WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go +ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +启动服务: + +```bash +sudo systemctl daemon-reload +sudo systemctl start sharedclassmanager +sudo systemctl enable sharedclassmanager +``` + +> 也可使用宝塔面板的"Python项目"管理器管理 Go 进程,将启动命令指向编译后的二进制文件即可。 + +### 6. 部署前端 + +#### 6.1 创建网站 + +1. 进入宝塔面板"网站"菜单 +2. 点击"添加站点": + - 域名:填写您的域名 + - 根目录:`/www/wwwroot/SharedClassManager/frontend` + - PHP版本:8.0 + +#### 6.2 配置 Nginx 反向代理 + +在站点设置中,点击"配置文件",替换为以下内容: + +```nginx +server { + listen 80; + server_name your-domain.com; + root /www/wwwroot/SharedClassManager/frontend; + index index.php; + + # PHP 处理 + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; + } + + # Go API 反向代理 + # 前后端通过 Nginx 反代同域通信,无需 CORS + location /api/ { + proxy_pass http://127.0.0.1:56789/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +3. 前端 `.env` 配置: + ``` + API_BASE_URL=https://your-domain.com + ``` + +### 7. 配置 SSL 证书 + +1. 在站点设置中点击"SSL" +2. 选择"Let's Encrypt"免费证书 +3. 勾选"强制HTTPS" + +### 8. 初始化系统管理员 + +Go 后端首次启动时会**自动创建**超级管理员账号,登录信息从环境变量读取: + +- **登录路径**:由 `SUPER_ADMIN_LOGIN_PATH` 配置(默认 `/super-admin/login`) +- **默认用户名**:由 `SUPER_ADMIN_DEFAULT_USERNAME` 配置(默认 `admin`) +- **默认密码**:由 `SUPER_ADMIN_DEFAULT_PASSWORD` 配置(默认 `Admin123`) + +> **注意**:首次登录后请立即修改密码。 + +--- + +## 手动部署(无宝塔面板) + +### 1. 安装系统依赖 + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install -y golang-go mysql-server redis-server nginx php8.0 php8.0-fpm php8.0-mysql + +# CentOS +sudo yum install -y golang mysql-server redis nginx php php-fpm php-mysql +``` + +### 2. 数据库配置 + +```bash +# 启动MySQL +sudo systemctl start mysqld +sudo systemctl enable mysqld + +# 登录MySQL创建数据库 +mysql -u root -p +``` + +```sql +CREATE DATABASE classmanagerdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'class_admin'@'localhost' IDENTIFIED BY 'YourStrongPassword'; +GRANT ALL PRIVILEGES ON classmanagerdb.* TO 'class_admin'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +导入初始化数据: +```bash +mysql -u class_admin -p classmanagerdb < sql/init.sql +``` + +### 3. Go 后端部署 + +```bash +# 创建项目目录 +sudo mkdir -p /www/wwwroot/SharedClassManager +sudo chown -R $USER:$USER /www/wwwroot/SharedClassManager + +# 上传代码 +cd /www/wwwroot/SharedClassManager/backend-go + +# 配置环境变量 +cp .env.example .env +vim .env # 根据实际情况修改配置 + +# 编译 +go mod tidy +go build -o sharedclassmanager ./cmd/server + +# 使用 Systemd 管理服务 +sudo vim /etc/systemd/system/sharedclassmanager.service +``` + +Systemd 服务文件内容: +```ini +[Unit] +Description=SharedClassManager Go Backend +After=network.target mysql.service redis.service + +[Service] +Type=simple +User=www-data +WorkingDirectory=/www/wwwroot/SharedClassManager/backend-go +ExecStart=/www/wwwroot/SharedClassManager/backend-go/sharedclassmanager +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +启动服务: +```bash +sudo systemctl daemon-reload +sudo systemctl start sharedclassmanager +sudo systemctl enable sharedclassmanager +``` + +### 4. 前端部署 + +Nginx 配置示例: +```nginx +server { + listen 80; + server_name your-domain.com; + root /www/wwwroot/SharedClassManager/frontend; + index index.php; + + # PHP 处理 + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php8.0-fpm.sock; + } + + # Go API 反向代理 + # 前后端通过 Nginx 反代同域通信,无需 CORS + location /api/ { + proxy_pass http://127.0.0.1:56789/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +启用站点: +```bash +sudo ln -s /etc/nginx/sites-available/sharedclassmanager /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl restart nginx +``` + +--- + +## 环境变量说明 + +Go 后端 `.env` 文件全部配置项(参考 `backend-go/.env.example`): + +### 应用配置 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `APP_NAME` | 应用名称 | 多班级版班级管理系统 | +| `APP_ENV` | 运行环境 | production / development | +| `DEBUG` | 调试模式 | false(生产环境) | +| `APP_PORT` | 服务端口 | 56789 | + +### MySQL 数据库 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `DB_HOST` | 数据库地址 | localhost | +| `DB_PORT` | 数据库端口 | 3306 | +| `DB_USER` | 数据库用户名 | class_admin | +| `DB_PASSWORD` | 数据库密码 | YourPassword | +| `DB_NAME` | 数据库名 | classmanagerdb | +| `DB_MAX_OPEN_CONNS` | 最大打开连接数 | 25 | +| `DB_MAX_IDLE_CONNS` | 最大空闲连接数 | 10 | +| `DB_CONN_MAX_LIFETIME` | 连接最大生命周期(秒) | 300 | + +### Redis 缓存 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `REDIS_HOST` | Redis地址 | localhost | +| `REDIS_PORT` | Redis端口 | 6379 | +| `REDIS_PASSWORD` | Redis密码 | 可选 | +| `REDIS_DB` | Redis数据库编号 | 0 | +| `REDIS_MAX_CONNECTIONS` | 最大连接数 | 500 | + +### JWT 认证 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `JWT_SECRET_KEY` | JWT密钥 | 32位以上随机字符串 | +| `JWT_ALGORITHM` | JWT算法 | HS256 | +| `JWT_EXPIRE_MINUTES` | Token过期时间(分钟) | 60 | +| `JWT_IDLE_TIMEOUT_MINUTES` | 空闲超时时间(分钟) | 10 | + +### 密码加密 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `PASSWORD_SALT` | 密码加密盐值 | your-fixed-salt-string | + +### 系统管理员 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `SUPER_ADMIN_LOGIN_PATH` | 超级管理员登录路径 | /super-admin/login | +| `SUPER_ADMIN_DEFAULT_USERNAME` | 默认用户名 | admin | +| `SUPER_ADMIN_DEFAULT_PASSWORD` | 默认密码 | Admin123 | + +### 日志配置 +| 配置项 | 说明 | 示例 | +|-------|------|------| +| `LOG_LEVEL` | 日志级别 | info | +| `LOG_FILE` | 日志文件路径 | logs/app.log | + +> **注意**:扣分规则等班级级配置已迁移到数据库 `class_settings` 表中,班主任可在管理端"班级设置"页面修改,无需修改环境变量。 + +--- + +## 初始化系统管理员 + +Go 后端首次启动时会**自动创建**超级管理员账号,无需手动操作: + +1. 确认 `.env` 中以下配置项已正确设置: + - `SUPER_ADMIN_LOGIN_PATH` — 登录页面路径 + - `SUPER_ADMIN_DEFAULT_USERNAME` — 默认用户名 + - `SUPER_ADMIN_DEFAULT_PASSWORD` — 默认密码 +2. 启动 Go 后端服务 +3. 访问 `https://your-domain.com/{SUPER_ADMIN_LOGIN_PATH}` 登录 +4. 首次登录后请**立即修改密码** +5. 创建班级,然后为班级指定班主任 + +--- + +## 多班级使用流程 + +1. 系统管理员登录 → 创建班级 +2. 为班级添加班主任(管理员管理) +3. 班主任登录 → 导入学生 → 开始使用 +4. 班主任可在"班级设置"中自定义本班扣分规则和功能开关 + +--- + +## 常见问题 + +### Q1: 后端启动失败 +- 检查端口 56789 是否被占用:`sudo lsof -i :56789` +- 检查数据库和 Redis 连接配置 +- 查看日志:`sudo journalctl -u sharedclassmanager -f` + +### Q2: 前端页面空白或报错 +- 检查 Nginx 配置中的 root 路径 +- 检查 PHP-FPM 是否运行:`sudo systemctl status php8.0-fpm` +- 检查文件权限:`sudo chown -R www-data:www-data /www/wwwroot/SharedClassManager` + +### Q3: API 请求 404 +- 检查反向代理配置是否正确(`/api/` → `127.0.0.1:56789`) +- 确认 Go 后端服务已启动:`sudo systemctl status sharedclassmanager` +- 检查防火墙设置 + +### Q4: 数据库连接失败 +- 确认 MySQL 已启动 +- 检查 `.env` 中的数据库用户名、密码、数据库名 +- 确认用户有数据库权限 + +### Q5: Go 编译失败 +- 确认 Go 版本 >= 1.21:`go version` +- 执行 `go mod tidy` 拉取依赖 +- 检查网络连接(可能需要配置 Go 代理:`go env -w GOPROXY=https://goproxy.cn,direct`) + +--- + +## 技术支持 + +- 开发者: Canglan +- 联系方式: admin@sea-studio.top +- 版权归属: Sea Network Technology Studio +- 许可证: Apache License 2.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6cc5745 --- /dev/null +++ b/LICENSE @@ -0,0 +1,199 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + "Alarm or alarm" from your own alarm vendor. + + Copyright 2025 Sea Network Technology Studio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..36857df --- /dev/null +++ b/README.md @@ -0,0 +1,233 @@ +# 多班级版班级管理系统 v1.0 + +基于 Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 开发的多班级操行分管理系统,支持多班级完全隔离,包含系统管理员、学生、管理端、家长端四端访问。 + +## 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端 API | **Go (Gin + GORM)** | 高性能、编译型、并发友好,适合中小规模管理系统 | +| 数据库 | **MySQL 5.7+** | 成熟稳定、事务支持完善、utf8mb4 全字符集支持 | +| 缓存 | **Redis 6.0+** | Token 管理、登录限流、会话缓存 | +| 前端 | **PHP 8.0+ + JavaScript** | 零构建依赖、部署简单、与后端 API 解耦,适合校园网络环境 | + +## 功能特性 + +### 系统管理员(super_admin) +- 独立登录入口(路径可配置) +- 班级管理:创建/编辑/删除/启用禁用班级 +- 切换班级上下文:在不同班级间切换进行管理操作 +- 跨班级查看:查看所有班级的管理员和学生列表 +- 首次启动自动创建,无需手动初始化 + +### 管理端(班级内角色) + +**班主任权限:** +- 学生管理:新增/编辑/删除学生、批量导入学生(JSON) +- 操行分管理:对学生进行加减分(无限制)、撤销任何扣分记录、查看全班历史记录 +- 作业管理:发布作业、查看提交情况 +- 考勤管理:按时段(早上/中午/晚修)记录考勤 +- 科目管理:动态增删学科 +- 管理员管理:添加/编辑/删除班干部、科任老师、课代表 +- 学期管理:创建/编辑/删除/激活/归档学期 +- 班级设置:修改扣分规则、功能开关、角色权限、加减分限制 +- 排行榜:查看分项排行(操行分、作业、考勤) +- 数据导出:导出德育分记录、历史记录 + +**科任老师权限(需配置科目):** +- 对所教科目学生进行加减分(±5分以内,可在班级设置中配置) +- 查看所教科目的作业管理 +- 查看全班历史记录 + +**班长权限:** +- 对学生进行加减分(±5分以内,可在班级设置中配置) +- 撤销任何操行分记录 +- 查看全班历史记录 + +**学习委员权限:** +- 对学生进行加减分(±5分以内,可在班级设置中配置) +- 科目管理 +- 作业管理 + +**考勤委员权限:** +- 考勤管理 +- 考勤扣分(仅扣分,上限8分) +- 可撤销自己创建的记录 + +**劳动委员权限:** +- 对学生进行加减分(±1分以内) + +**志愿委员权限:** +- 仅可加分(上限5分) +- 查看全班历史记录 + +**课代表权限:** +- 管理所代表科目的作业(管理端页面) +- 由学习委员/班主任/科任老师设定 + +### 学生端 +- 查询个人当前操行总分和班级排名 +- 查看个人加减分历史明细 +- 查看个人作业提交情况 +- 查看个人考勤记录 +- 查看历史学期归档数据 +- 修改个人登录密码 + +### 家长端 +- 查询子女当前操行总分和班级排名 +- 查看子女操行分历史记录 +- 查看子女考勤记录 +- 修改密码(受班级功能开关控制) + +## 角色权限矩阵 + +| 功能 | 班主任 | 科任老师 | 班长 | 学习委员 | 考勤委员 | 劳动委员 | 志愿委员 | 课代表 | +|------|--------|---------|------|---------|---------|---------|---------|--------| +| 操行分管理 | ✓ 无限制 | ±5分 | ±5分 | ±5分 | 仅扣分 | ±1分 | 仅加分 | - | +| 历史记录 | 全部(可撤销) | 自己的 | 全部(可撤销) | 自己的 | 自己的 | 自己的 | 自己的 | - | +| 作业管理 | ✓ | 所教科目 | - | ✓ | - | - | - | 所教科目 | +| 考勤管理 | ✓ | - | - | - | ✓ | - | - | - | +| 科目管理 | ✓ | - | - | ✓ | - | - | - | - | +| 学生管理 | ✓ | - | - | - | - | - | - | - | +| 管理员管理 | ✓ | - | - | - | - | - | - | - | +| 学期管理 | ✓ | - | - | - | - | - | - | - | +| 班级设置 | ✓ | - | - | - | - | - | - | - | +| 排行榜 | ✓ | - | - | - | - | - | - | - | + +> 加减分上下限可在班级设置中由班主任自行配置。 + +## 多班级隔离机制 + +``` +系统管理员 (super_admin) +├── JWT 中 class_id 可变(通过 /api/class/switch 切换) +├── 可管理所有班级 +└── 权限检查自动放行 + +班级管理员 (admin) — 班主任/班长/科任老师/课代表等 +├── admin_roles 绑定 class_id +├── JWT 中 class_id 固定 +├── 所有查询自动过滤 class_id +└── 严格隔离在本班内 + +学生/家长 +├── 通过 student.class_id 确定所属班级 +└── 只能看到本班数据 +``` + +## 班级设置 + +每个班级可独立配置以下内容(班主任可在管理端修改): + +### 扣分规则 +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| student_initial_points | 学生初始操行分 | 60 | +| deduction_homework_not_submit | 作业未提交扣分 | 2 | +| deduction_homework_late | 作业迟交扣分 | 1 | +| deduction_attendance_absent | 缺勤扣分 | 3 | +| deduction_attendance_late | 迟到扣分 | 1 | +| deduction_attendance_leave | 请假扣分 | 0 | + +### 功能开关 +| 功能标识 | 说明 | 默认 | +|----------|------|------| +| homework | 作业管理 | 启用 | +| attendance | 考勤管理 | 启用 | +| ranking | 排行榜 | 启用 | +| dormitory | 宿舍管理 | 启用 | +| parent_password | 家长改密功能 | 启用 | + +### 角色开关 +班主任可在班级设置中启用或禁用各角色(班长、学习委员、考勤委员、劳动委员、志愿委员、科任老师、课代表),禁用后该角色不可被分配。 + +### 加减分限制 +班主任可在班级设置中配置各角色的加减分上下限,灵活控制权限范围。 + +## 排行榜分项排行 + +管理端排行榜支持以下分项查看: +- **操行分排行**:按当前操行分排名 +- **作业排行**:按作业完成情况排名 +- **考勤排行**:按出勤率排名 + +排行榜支持百分比筛选(如显示前 10% 的学生)。 + +## 超级管理员独立登录 + +超级管理员通过独立路径登录,与普通用户登录入口分离: +- 登录路径通过 `.env` 中的 `SUPER_ADMIN_LOGIN_PATH` 配置 +- 默认路径:`/super-admin/login` +- 首次启动自动创建,默认账号:`admin` / `Admin123` + +## 家长登录账号 + +学生导入时,`parent_account` 字段为家长登录账号,**推荐填写手机号**。系统会自动为该账号创建家长用户,初始密码与学生相同(默认 `123456`)。 + +示例导入 JSON 格式: +```json +{ + "students": [ + { + "student_no": "2025001", + "name": "张三", + "parent_account": "13800138001", + "dormitory_number": "A301", + "password": "123456" + } + ] +} +``` + +## 快速开始 + +详细部署指南请参阅 [INSTALL.md](INSTALL.md)。 + +### 环境要求 +- Go 1.21+ +- MySQL 5.7+ +- Redis 6.0+ +- Nginx 1.18+ +- PHP 8.0+ + +### 安装步骤 + +1. 克隆项目 +```bash +git clone https://hz-gitea.sea-studio.top/canglan/SharedClassManager.git +cd SharedClassManager +``` + +2. 初始化数据库 +```bash +mysql -u root -p < sql/init.sql +``` + +3. 配置并启动 Go 后端 +```bash +cd backend-go +cp .env.example .env +vim .env # 修改配置 +go mod tidy +go build -o sharedclassmanager ./cmd/server +./sharedclassmanager +``` + +4. 配置前端 +```bash +cd frontend +cp .env.example .env +# 编辑 .env 文件,配置 API 地址 +``` + +5. 配置 Nginx 反向代理(参考 [INSTALL.md](INSTALL.md)) + +## 许可证 + +本项目采用 [Apache License 2.0](LICENSE) 许可证。 + +Copyright 2025 Sea Network Technology Studio + +## 开发者 + +Canglan — admin@sea-studio.top diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..d3827e7 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0 diff --git a/backend-go/.env.example b/backend-go/.env.example new file mode 100644 index 0000000..9184370 --- /dev/null +++ b/backend-go/.env.example @@ -0,0 +1,67 @@ +# =========================================== +# 多班级版班级管理系统 - Go 后端配置 +# =========================================== + +# 应用名称 +APP_NAME=多班级版班级管理系统 +# 运行环境: production / development +APP_ENV=production +# 调试模式 +DEBUG=false +# 服务端口 +APP_PORT=56789 + +# =========================================== +# MySQL 数据库配置 +# =========================================== + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=class_admin +DB_PASSWORD=YourPassword +DB_NAME=classmanagerdb +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=10 +DB_CONN_MAX_LIFETIME=300 + +# =========================================== +# Redis 缓存配置 +# =========================================== + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_MAX_CONNECTIONS=500 + +# =========================================== +# JWT 认证配置 +# =========================================== + +JWT_SECRET_KEY=your-32-char-secret-key +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 +JWT_IDLE_TIMEOUT_MINUTES=10 + +# =========================================== +# 密码加密配置(与 Python 版兼容) +# 算法: MD5(SHA1(password) + SALT) +# =========================================== + +PASSWORD_SALT=your-fixed-salt-string + +# =========================================== +# 系统管理员配置 +# =========================================== + +SUPER_ADMIN_LOGIN_PATH=/super-admin +SUPER_ADMIN_DEFAULT_USERNAME=admin +# ⚠️ 部署时必须修改为强密码,否则存在安全风险 +SUPER_ADMIN_DEFAULT_PASSWORD=Admin123 + +# =========================================== +# 日志配置 +# =========================================== + +LOG_LEVEL=info +LOG_FILE=logs/app.log diff --git a/backend-go/Makefile b/backend-go/Makefile new file mode 100644 index 0000000..3d25edf --- /dev/null +++ b/backend-go/Makefile @@ -0,0 +1,63 @@ +.PHONY: build run clean test lint fmt vet tidy + +# 应用名称 +APP_NAME=scm-server +# 入口目录 +CMD_DIR=./cmd/server +# 输出目录 +BUILD_DIR=./build + +# 默认目标 +all: build + +# 编译 +build: + @echo "==> 编译 $(APP_NAME)..." + @mkdir -p $(BUILD_DIR) + go build -o $(BUILD_DIR)/$(APP_NAME) $(CMD_DIR) + +# 运行 +run: + go run $(CMD_DIR)/main.go + +# 清理 +clean: + @echo "==> 清理构建产物..." + @rm -rf $(BUILD_DIR) + +# 测试 +test: + go test -v -count=1 ./... + +# 代码检查 +lint: fmt vet + +# 格式化 +fmt: + go fmt ./... + +# 静态分析 +vet: + go vet ./... + +# 整理依赖 +tidy: + go mod tidy + +# 开发模式(热重载需要安装 air) +dev: + @which air > /dev/null 2>&1 || (echo "请先安装 air: go install github.com/air-verse/air@latest" && exit 1) + air + +# 帮助 +help: + @echo "可用命令:" + @echo " make build - 编译项目" + @echo " make run - 直接运行" + @echo " make clean - 清理构建产物" + @echo " make test - 运行测试" + @echo " make lint - 代码检查 (fmt + vet)" + @echo " make fmt - 格式化代码" + @echo " make vet - 静态分析" + @echo " make tidy - 整理依赖" + @echo " make dev - 开发模式(需要 air)" diff --git a/backend-go/cmd/server/main.go b/backend-go/cmd/server/main.go new file mode 100644 index 0000000..693eb88 --- /dev/null +++ b/backend-go/cmd/server/main.go @@ -0,0 +1,210 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/handler" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/router" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +func main() { + // ========== 1. 加载配置 ========== + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) + os.Exit(1) + } + + // ========== 2. 初始化日志 ========== + logger.Init(cfg.LogLevel, cfg.IsProduction()) + defer logger.Sync() + + logger.Sugared.Infof("应用启动: %s (env=%s, port=%s)", cfg.AppName, cfg.AppEnv, cfg.AppPort) + + // ========== 3. 初始化 MySQL ========== + mysqlDB, err := database.InitMySQL(cfg) + if err != nil { + logger.Sugared.Fatalf("初始化 MySQL 失败: %v", err) + } + logger.Sugared.Info("MySQL 连接成功") + + sqlDB, err := mysqlDB.DB() + if err != nil { + logger.Sugared.Fatalf("获取 sql.DB 失败: %v", err) + } + defer sqlDB.Close() + + // ========== 4. 初始化 Redis ========== + redisClient, err := database.InitRedis(cfg) + if err != nil { + logger.Sugared.Fatalf("初始化 Redis 失败: %v", err) + } + logger.Sugared.Info("Redis 连接成功") + defer redisClient.Close() + + // ========== 5. 初始化 Repository 层 ========== + userRepo := repository.NewUserRepo(mysqlDB) + studentRepo := repository.NewStudentRepo(mysqlDB) + adminRoleRepo := repository.NewAdminRoleRepo(mysqlDB) + classRepo := repository.NewClassRepo(mysqlDB) + conductRepo := repository.NewConductRepo(mysqlDB) + attendanceRepo := repository.NewAttendanceRepo(mysqlDB) + semesterRepo := repository.NewSemesterRepo(mysqlDB) + subjectRepo := repository.NewSubjectRepo(mysqlDB) + assignmentRepo := repository.NewAssignmentRepo(mysqlDB) + logRepo := repository.NewLogRepo(mysqlDB) + superAdminRepo := repository.NewSuperAdminRepo(mysqlDB) + settingRepo := repository.NewSystemSettingRepo(mysqlDB) + + // ========== 6. 初始化 Service 层 ========== + logService := service.NewLogService(logRepo) + + authService := service.NewAuthService( + userRepo, studentRepo, adminRoleRepo, classRepo, logService, + ) + adminService := service.NewAdminService( + userRepo, studentRepo, adminRoleRepo, classRepo, + ) + conductService := service.NewConductService( + conductRepo, studentRepo, adminRoleRepo, semesterRepo, classRepo, + ) + attendanceService := service.NewAttendanceService( + attendanceRepo, studentRepo, userRepo, conductRepo, semesterRepo, settingRepo, classRepo, + ) + semesterService := service.NewSemesterService( + semesterRepo, studentRepo, classRepo, attendanceRepo, assignmentRepo, logService, + ) + classService := service.NewClassService( + classRepo, userRepo, adminRoleRepo, + ) + subjectService := service.NewSubjectService(subjectRepo) + studentService := service.NewStudentService( + studentRepo, conductRepo, attendanceRepo, semesterRepo, + ) + parentService := service.NewParentService( + userRepo, studentRepo, conductRepo, attendanceRepo, + ) + rankingService := service.NewRankingService( + studentRepo, conductRepo, + ) + superAdminService := service.NewSuperAdminService(superAdminRepo, logService) + configService := service.NewConfigService(classRepo) + + // 确保默认超级管理员存在 + if err := superAdminService.EnsureDefaultAdmin(); err != nil { + logger.Sugared.Errorf("初始化默认超级管理员失败: %v", err) + } + + // ========== 7. 初始化 Handler 层 ========== + handlers := &router.Handlers{ + Auth: handler.NewAuthHandler(authService, superAdminService), + Admin: handler.NewAdminHandler(adminService, conductService, attendanceService, rankingService, logService), + Student: handler.NewStudentHandler(studentService, classRepo), + Parent: handler.NewParentHandler(parentService, authService, classService), + Subject: handler.NewSubjectHandler(subjectService), + Semester: handler.NewSemesterHandler(semesterService), + Class: handler.NewClassHandler(classService), + Config: handler.NewConfigHandler(configService), + SuperAdmin: handler.NewSuperAdminHandler(superAdminService), + Cadre: handler.NewCadreHandler(assignmentRepo, conductService, adminRoleRepo), + } + + // ========== 8. 初始化路由 ========== + r := router.SetupRouter(cfg, handlers) + + // ========== 9. 启动 HTTP 服务 ========== + addr := fmt.Sprintf(":%s", cfg.AppPort) + srv := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // 优雅关闭 + go func() { + logger.Sugared.Infof("HTTP 服务启动: http://0.0.0.0%s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Sugared.Fatalf("HTTP 服务异常: %v", err) + } + }() + + // ========== 10. 等待中断信号 ========== + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + // ========== 自动周期重置定时任务 ========== + // 每天凌晨 1:00 检查是否有班级需要执行周/月重置 + // 使用独立 done 通道避免与 quit 通道的竞态条件 + timerDone := make(chan struct{}) + go func() { + runAutoPeriodReset := func() { + defer func() { + if r := recover(); r != nil { + logger.Sugared.Errorf("自动周期重置 panic: %v", r) + } + }() + semesterService.AutoPeriodReset() + } + + // 计算距离下一个凌晨 1:00 的等待时间 + waitUntilNext1AM := func() time.Duration { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day(), 1, 0, 0, 0, now.Location()) + if now.After(next) { + next = next.Add(24 * time.Hour) + } + return next.Sub(now) + } + + timer := time.NewTimer(waitUntilNext1AM()) + defer timer.Stop() + + for { + select { + case <-timerDone: + logger.Sugared.Info("定时任务收到退出信号,停止") + return + case <-timer.C: + runAutoPeriodReset() + timer.Reset(24 * time.Hour) + } + } + }() + + sig := <-quit + close(timerDone) + logger.Sugared.Infof("收到信号 %v,正在关闭服务...", sig) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Sugared.Errorf("服务关闭异常: %v", err) + } + + logger.Sugared.Info("服务已安全停止") +} diff --git a/backend-go/go.mod b/backend-go/go.mod new file mode 100644 index 0000000..d748f33 --- /dev/null +++ b/backend-go/go.mod @@ -0,0 +1,13 @@ +module hz-gitea.sea-studio.top/canglan/SharedClassManager + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.7.0 + go.uber.org/zap v1.27.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 +) diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go new file mode 100644 index 0000000..b2ddcc2 --- /dev/null +++ b/backend-go/internal/config/config.go @@ -0,0 +1,163 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config 应用全局配置结构体 +type Config struct { + // 应用基础配置 + AppName string + AppEnv string + Debug bool + AppPort string + + // MySQL 数据库配置 + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLife int // 秒 + + // Redis 配置 + RedisHost string + RedisPort int + RedisPassword string + RedisDB int + RedisMaxConns int + + // JWT 配置 + JWTSecretKey string + JWTAlgorithm string + JWTExpireMinutes int + JWTIdleTimeoutMinutes int + + // 密码加密(兼容 Python 版) + PasswordSalt string + + // 系统管理员配置 + SuperAdminLoginPath string + SuperAdminDefaultUser string + SuperAdminDefaultPass string + + // 日志 + LogLevel string + LogFile string +} + +// AppConfig 全局配置实例 +var AppConfig *Config + +// Load 加载配置:先尝试加载 .env 文件,然后读取环境变量 +func Load() (*Config, error) { + // 尝试加载 .env 文件(不存在不报错) + _ = godotenv.Load() + + cfg := &Config{ + AppName: getEnv("APP_NAME", "多班级版班级管理系统"), + AppEnv: getEnv("APP_ENV", "production"), + Debug: getEnvBool("DEBUG", false), + AppPort: getEnv("APP_PORT", "56789"), + + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnvInt("DB_PORT", 3306), + DBUser: getEnv("DB_USER", "class_admin"), + DBPassword: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", "classmanagerdb"), + DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), + DBConnMaxLife: getEnvInt("DB_CONN_MAX_LIFETIME", 300), + + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: getEnvInt("REDIS_PORT", 6379), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: getEnvInt("REDIS_DB", 0), + RedisMaxConns: getEnvInt("REDIS_MAX_CONNECTIONS", 500), + + JWTSecretKey: getEnv("JWT_SECRET_KEY", ""), + JWTAlgorithm: getEnv("JWT_ALGORITHM", "HS256"), + JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60), + JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10), + + PasswordSalt: getEnv("PASSWORD_SALT", ""), + + SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"), + SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"), + // 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。 + // EnsureDefaultAdmin 通过 need_change_password=1 强制首次登录改密作为缓解措施。 + SuperAdminDefaultPass: getEnv("SUPER_ADMIN_DEFAULT_PASSWORD", "Admin123"), + + LogLevel: getEnv("LOG_LEVEL", "info"), + LogFile: getEnv("LOG_FILE", "logs/app.log"), + } + + // 校验必填项 + if cfg.JWTSecretKey == "" { + return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空") + } + if cfg.PasswordSalt == "" { + return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空") + } + + AppConfig = cfg + return cfg, nil +} + +// DSN 返回 MySQL 连接字符串 +func (c *Config) DSN() string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", + c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName) +} + +// RedisAddr 返回 Redis 地址 +func (c *Config) RedisAddr() string { + return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort) +} + +// IsProduction 判断是否为生产环境 +func (c *Config) IsProduction() bool { + return c.AppEnv == "production" +} + +// --- 辅助函数 --- + +func getEnv(key, fallback string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if val, ok := os.LookupEnv(key); ok { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return fallback +} +func getEnvBool(key string, fallback bool) bool { + if val, ok := os.LookupEnv(key); ok { + return strings.ToLower(val) == "true" + } + return fallback +} diff --git a/backend-go/internal/handler/admin_handler.go b/backend-go/internal/handler/admin_handler.go new file mode 100644 index 0000000..391ac83 --- /dev/null +++ b/backend-go/internal/handler/admin_handler.go @@ -0,0 +1,602 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "encoding/json" + "io" + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// AdminHandler 管理端处理器 +type AdminHandler struct { + adminService *service.AdminService + conductService *service.ConductService + attendanceSvc *service.AttendanceService + rankingService *service.RankingService + logService *service.LogService +} + +// NewAdminHandler 创建管理端处理器 +func NewAdminHandler( + adminService *service.AdminService, + conductService *service.ConductService, + attendanceSvc *service.AttendanceService, + rankingService *service.RankingService, + logService *service.LogService, +) *AdminHandler { + return &AdminHandler{ + adminService: adminService, + conductService: conductService, + attendanceSvc: attendanceSvc, + rankingService: rankingService, + logService: logService, + } +} + +// ========== 学生管理 ========== + +// GetDormitories 获取宿舍号列表 +func (h *AdminHandler) GetDormitories(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + dormitories, err := h.adminService.GetDormitories(classID) + if err != nil { + response.InternalError(c, "获取宿舍号列表失败") + return + } + response.Success(c, gin.H{"dormitories": dormitories}, "操作成功") +} + +// StudentList 获取学生列表 +func (h *AdminHandler) StudentList(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var query schema.StudentListQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.GetStudents(classID, query.Page, query.PageSize, query.Search, query.DormitoryNumber) + if err != nil { + response.InternalError(c, "获取学生列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// StudentImport 批量导入学生 +func (h *AdminHandler) StudentImport(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + file, _, err := c.Request.FormFile("file") + if err != nil { + response.BadRequest(c, "请上传文件") + return + } + defer file.Close() + + limitedReader := io.LimitReader(file, 5*1024*1024) + content, err := io.ReadAll(limitedReader) + if err != nil { + response.BadRequest(c, "读取文件失败") + return + } + + var data struct { + Students []map[string]interface{} `json:"students"` + } + if err := json.Unmarshal(content, &data); err != nil { + response.BadRequest(c, "JSON格式错误") + return + } + if len(data.Students) == 0 { + response.BadRequest(c, "文件中没有学生数据") + return + } + + result, err := h.adminService.ImportStudents(data.Students, classID) + if err != nil { + response.InternalError(c, "导入失败") + return + } + + response.Success(c, result, "操作成功") +} + +// StudentCreate 新增学生 +func (h *AdminHandler) StudentCreate(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var req schema.StudentCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.AddStudent(req.StudentNo, req.Name, req.ParentAccount, classID, req.DormitoryNumber) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + + response.Success(c, result, "学生添加成功") +} + +// StudentUpdate 编辑学生 +func (h *AdminHandler) StudentUpdate(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + + var req schema.StudentUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UpdateStudent(studentID, req.Name, req.ParentAccount, req.DormitoryNumber, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// StudentDelete 删除学生 +func (h *AdminHandler) StudentDelete(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + + if err := h.adminService.DeleteStudent(studentID, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// ResetStudentPassword 重置学生密码 +func (h *AdminHandler) ResetStudentPassword(c *gin.Context) { + studentID, ok := parseID(c, "student_id") + if !ok { + return + } + + var req schema.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.ResetStudentPassword(studentID, req.NewPassword); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码重置成功") +} + +// ========== 操行分管理 ========== + +// AddConductPoints 批量加减分 +func (h *AdminHandler) AddConductPoints(c *gin.Context) { + var req schema.ConductAddRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + + result, err := h.conductService.AddPoints( + req.StudentIDs, req.PointsChange, req.Reason, + userID, realName, classID, req.RelatedType, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "操作成功") +} + +// RevokeConductRecord 撤销记录 +func (h *AdminHandler) RevokeConductRecord(c *gin.Context) { + var req schema.RevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.conductService.RevokeRecord(req.RecordID, userID, classID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.SuccessWithMessage(c, "撤销成功") +} + +// RestoreConductRecord 反撤销记录 +func (h *AdminHandler) RestoreConductRecord(c *gin.Context) { + var req schema.RevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.conductService.RestoreRecord(req.RecordID, userID, classID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.SuccessWithMessage(c, "反撤销成功") +} + +// GetConductHistory 操行分历史 +func (h *AdminHandler) GetConductHistory(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.ConductHistoryQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.conductService.GetHistory( + classID, query.StudentID, query.Page, query.PageSize, + query.StartDate, query.EndDate, query.RelatedType, + query.ReasonPrefix, query.IsRevoked, query.ReasonSearch, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// BatchRevokeConductRecords 批量撤销 +func (h *AdminHandler) BatchRevokeConductRecords(c *gin.Context) { + var req schema.BatchRevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + successCount := 0 + failCount := 0 + var errors []map[string]interface{} + + for _, recordID := range req.RecordIDs { + result, _ := h.conductService.RevokeRecord(recordID, userID, classID) + if result != nil { + if success, _ := result["success"].(bool); success { + successCount++ + } else { + failCount++ + msg, _ := result["message"].(string) + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg}) + } + } else { + failCount++ + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "撤销失败"}) + } + } + + response.Success(c, gin.H{ + "success_count": successCount, + "fail_count": failCount, + "errors": errors, + }, "批量撤销完成") +} + +// BatchRestoreConductRecords 批量反撤销 +func (h *AdminHandler) BatchRestoreConductRecords(c *gin.Context) { + var req schema.BatchRevokeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + successCount := 0 + failCount := 0 + var errors []map[string]interface{} + + for _, recordID := range req.RecordIDs { + result, _ := h.conductService.RestoreRecord(recordID, userID, classID) + if result != nil { + if success, _ := result["success"].(bool); success { + successCount++ + } else { + failCount++ + msg, _ := result["message"].(string) + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg}) + } + } else { + failCount++ + errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "反撤销失败"}) + } + } + + response.Success(c, gin.H{ + "success_count": successCount, + "fail_count": failCount, + "errors": errors, + }, "批量反撤销完成") +} + +// ========== 考勤管理 ========== + +// CreateAttendanceRecord 添加考勤 +func (h *AdminHandler) CreateAttendanceRecord(c *gin.Context) { + var req schema.AttendanceCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + classID := middleware.GetClassID(c) + result, err := h.attendanceSvc.CreateRecord( + req.StudentID, req.Date, req.Slot, req.Status, + &req.Reason, req.ApplyDeduction, req.CustomDeduction, userID, classID, + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作成功" + } + response.SuccessWithMessage(c, msg) +} + +// GetAttendanceRecords 获取考勤记录 +func (h *AdminHandler) GetAttendanceRecords(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.AttendanceQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.attendanceSvc.GetRecords(classID, query.Date, query.StudentID, query.Slot) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// ========== 管理员管理 ========== + +// AdminList 管理员列表 +func (h *AdminHandler) AdminList(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.adminService.GetAdmins(classID) + if err != nil { + response.InternalError(c, "获取管理员列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// AdminCreate 添加管理员 +func (h *AdminHandler) AdminCreate(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + var req schema.AdminCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.adminService.AddAdmin(req.Username, req.RealName, req.Password, req.RoleType, classID, req.SubjectID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "管理员添加成功") +} + +// AdminUpdate 更新管理员 +func (h *AdminHandler) AdminUpdate(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + var req schema.AdminUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UpdateAdmin(userID, req.RealName, req.RoleType, classID, req.SubjectID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// AdminDelete 删除管理员 +func (h *AdminHandler) AdminDelete(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + classID := middleware.GetClassID(c) + if err := h.adminService.DeleteAdmin(userID, classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// AdminResetPassword 重置管理员密码 +func (h *AdminHandler) AdminResetPassword(c *gin.Context) { + userID, ok := parseID(c, "user_id") + if !ok { + return + } + + var req schema.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.ResetAdminPassword(userID, req.NewPassword); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码重置成功") +} + +// UnlockAccount 解除登录锁定 +func (h *AdminHandler) UnlockAccount(c *gin.Context) { + var req schema.UnlockUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.adminService.UnlockAccount(req.Username, c.ClientIP()); err != nil { + response.InternalError(c, "解锁失败") + return + } + response.SuccessWithMessage(c, "解锁成功") +} + + +// GetRankings 分项排行榜 +func (h *AdminHandler) GetRankings(c *gin.Context) { + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "请先选择班级") + return + } + + rankType := c.DefaultQuery("type", "all") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + result, err := h.rankingService.GetRankings(classID, rankType, limit) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/auth_handler.go b/backend-go/internal/handler/auth_handler.go new file mode 100644 index 0000000..5c42109 --- /dev/null +++ b/backend-go/internal/handler/auth_handler.go @@ -0,0 +1,131 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// AuthHandler 认证处理器 +type AuthHandler struct { + authService *service.AuthService + superAdminService *service.SuperAdminService +} + +// NewAuthHandler 创建认证处理器 +func NewAuthHandler(authService *service.AuthService, superAdminService *service.SuperAdminService) *AuthHandler { + return &AuthHandler{authService: authService, superAdminService: superAdminService} +} + +// Login 用户登录 +func (h *AuthHandler) Login(c *gin.Context) { + var req schema.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + result := h.authService.Login(req.Username, req.Password, ip, userAgent) + if !result.Success { + response.Unauthorized(c, result.Message) + return + } + + response.Success(c, result, "登录成功") +} + +// Logout 用户登出 +func (h *AuthHandler) Logout(c *gin.Context) { + userID := middleware.GetUserID(c) + if err := h.authService.Logout(userID); err != nil { + response.InternalError(c, "登出失败") + return + } + response.SuccessWithMessage(c, "登出成功") +} + +// ChangePassword 修改密码(超级管理员操作 super_admins 表,普通用户操作 users 表) +func (h *AuthHandler) ChangePassword(c *gin.Context) { + var req schema.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + userType := middleware.GetUserType(c) + + // force 参数仅在用户确实需要强制改密时才允许使用 + if req.Force { + if userType == "super_admin" { + // 超级管理员的 need_change_password 由 super_admin_service 处理 + // force 改密时直接允许(登录时已验证 need_change_password 标记) + } else { + userInfo, err := h.authService.GetUserInfo(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + needChange, _ := userInfo["need_change_password"].(bool) + if !needChange { + response.BadRequest(c, "当前状态不允许强制修改密码") + return + } + } + } + + if userType == "super_admin" { + if err := h.superAdminService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil { + response.BadRequest(c, err.Error()) + return + } + } else { + if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil { + response.BadRequest(c, err.Error()) + return + } + } + + response.SuccessWithMessage(c, "密码修改成功,请重新登录") +} + +// GetUserInfo 获取当前用户信息 +func (h *AuthHandler) GetUserInfo(c *gin.Context) { + userID := middleware.GetUserID(c) + userInfo, err := h.authService.GetUserInfo(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, userInfo, "操作成功") +} + +// parseID 解析路径参数中的 ID +func parseID(c *gin.Context, key string) (int, bool) { + idStr := c.Param(key) + id, err := strconv.Atoi(idStr) + if err != nil { + response.BadRequest(c, "无效的ID参数") + return 0, false + } + return id, true +} diff --git a/backend-go/internal/handler/cadre_handler.go b/backend-go/internal/handler/cadre_handler.go new file mode 100644 index 0000000..78e5584 --- /dev/null +++ b/backend-go/internal/handler/cadre_handler.go @@ -0,0 +1,143 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "time" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// CadreHandler 课代表处理器 +type CadreHandler struct { + assignmentRepo *repository.AssignmentRepo + conductService *service.ConductService + adminRoleRepo *repository.AdminRoleRepo +} + +// NewCadreHandler 创建课代表处理器 +func NewCadreHandler(assignmentRepo *repository.AssignmentRepo, conductService *service.ConductService, adminRoleRepo *repository.AdminRoleRepo) *CadreHandler { + return &CadreHandler{assignmentRepo: assignmentRepo, conductService: conductService, adminRoleRepo: adminRoleRepo} +} + +// HomeworkList 课代表查看作业列表 +func (h *CadreHandler) HomeworkList(c *gin.Context) { + classID := middleware.GetClassID(c) + + var query schema.CadreHomeworkQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + subjectID := 0 + if query.SubjectID != nil { + subjectID = *query.SubjectID + } + + assignments, total, err := h.assignmentRepo.GetAssignmentsByClass(classID, subjectID, query.Page, query.PageSize) + if err != nil { + response.InternalError(c, "获取作业列表失败") + return + } + + response.Paginated(c, assignments, total, query.Page, query.PageSize) +} + +// HomeworkSubmit 课代表发布作业 +func (h *CadreHandler) HomeworkSubmit(c *gin.Context) { + var req schema.CadreHomeworkSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + + // 从管理员角色中获取课代表关联的科目 ID + adminRole, err := h.adminRoleRepo.GetByUserID(userID) + if err != nil || adminRole == nil || adminRole.SubjectID == nil { + response.BadRequest(c, "无法获取课代表关联的科目信息") + return + } + + deadline, err := time.Parse("2006-01-02", req.Deadline) + if err != nil { + response.BadRequest(c, "日期格式错误") + return + } + + assignment := &model.Assignment{ + ClassID: classID, + SubjectID: *adminRole.SubjectID, + Title: req.Title, + Description: &req.Description, + Deadline: deadline, + CreatedBy: userID, + } + + assignmentID, err := h.assignmentRepo.CreateAssignment(assignment) + if err != nil { + response.InternalError(c, "发布作业失败") + return + } + + response.Success(c, gin.H{ + "assignment_id": assignmentID, + }, "发布成功") +} + +// AddConductPoints 课代表登记缺交(仅允许作业相关的扣分操作) +func (h *CadreHandler) AddConductPoints(c *gin.Context) { + var req schema.ConductAddRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + // 课代表只允许扣分操作 + if req.PointsChange >= 0 { + response.BadRequest(c, "课代表只能进行扣分操作") + return + } + + classID := middleware.GetClassID(c) + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + + result, err := h.conductService.CadreAddPoints( + req.StudentIDs, req.PointsChange, req.Reason, + userID, realName, classID, "homework", + ) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + msg, _ := result["message"].(string) + if msg == "" { + msg = "操作失败" + } + response.BadRequest(c, msg) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/class_handler.go b/backend-go/internal/handler/class_handler.go new file mode 100644 index 0000000..82ae5c5 --- /dev/null +++ b/backend-go/internal/handler/class_handler.go @@ -0,0 +1,271 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ClassHandler 班级管理处理器 +type ClassHandler struct { + classService *service.ClassService +} + +// NewClassHandler 创建班级管理处理器 +func NewClassHandler(classService *service.ClassService) *ClassHandler { + return &ClassHandler{classService: classService} +} + +// ClassList 班级列表 +func (h *ClassHandler) ClassList(c *gin.Context) { + includeDisabled := c.Query("include_disabled") == "true" + result, err := h.classService.ListClasses(includeDisabled) + if err != nil { + response.InternalError(c, "获取班级列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// ClassDetail 班级详情 +func (h *ClassHandler) ClassDetail(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + result, err := h.classService.GetClassDetail(classID) + if err != nil { + response.NotFound(c, "班级不存在") + return + } + response.Success(c, result, "操作成功") +} + +// ClassCreate 创建班级 +func (h *ClassHandler) ClassCreate(c *gin.Context) { + var req schema.ClassCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.classService.CreateClass(req.ClassName, req.Grade, req.Description) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "班级创建成功") +} + +// ClassUpdate 更新班级 +func (h *ClassHandler) ClassUpdate(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + var req schema.ClassUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.classService.UpdateClass(classID, req.ClassName, req.Grade, req.Description, req.Status); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// ClassDelete 删除班级 +func (h *ClassHandler) ClassDelete(c *gin.Context) { + classID, ok := parseID(c, "class_id") + if !ok { + return + } + + if err := h.classService.DeleteClass(classID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// SwitchClass 切换班级上下文 +func (h *ClassHandler) SwitchClass(c *gin.Context) { + var req schema.SwitchClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + result, err := h.classService.SwitchClass(userID, req.ClassID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "切换成功") +} + +// GetSettings 获取班级设置 +func (h *ClassHandler) GetSettings(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetSettings(classID) + if err != nil { + response.InternalError(c, "获取设置失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedSettingKeys 允许通过 SaveSetting 端点写入的配置键白名单 +var allowedSettingKeys = map[string]bool{ + "initial_password": true, + "initial_points": true, + "deduction_attendance_absent": true, + "deduction_attendance_late": true, + "deduction_attendance_leave": true, + "deduction_homework_not_submit": true, + "deduction_homework_late": true, + "reset_frequency": true, + "reset_day_of_week": true, + "reset_day_of_month": true, +} + +// SaveSetting 保存班级设置 +func (h *ClassHandler) SaveSetting(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req schema.SettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if !allowedSettingKeys[req.SettingKey] { + response.BadRequest(c, "不允许的配置项: "+req.SettingKey) + return + } + + if err := h.classService.SaveSetting(classID, req.SettingKey, req.SettingValue); err != nil { + response.InternalError(c, "保存设置失败") + return + } + response.SuccessWithMessage(c, "保存成功") +} + +// GetPointLimits 获取角色加减分配置 +func (h *ClassHandler) GetPointLimits(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetSettings(classID) + if err != nil { + response.InternalError(c, "获取配置失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedPointLimitKeys 允许的操行分限制配置键白名单(与 conduct_service 读取 key 一致) +var allowedPointLimitKeys = map[string]bool{ + "point_limit_班长_max": true, + "point_limit_班长_min": true, + "point_limit_学习委员_max": true, + "point_limit_学习委员_min": true, + "point_limit_考勤委员_max": true, + "point_limit_考勤委员_min": true, + "point_limit_劳动委员_max": true, + "point_limit_劳动委员_min": true, + "point_limit_志愿委员_max": true, + "point_limit_志愿委员_min": true, + "point_limit_科任老师_max": true, + "point_limit_科任老师_min": true, +} + +// SavePointLimits 保存角色加减分配置 +func (h *ClassHandler) SavePointLimits(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req map[string]string + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + for key, value := range req { + if !allowedPointLimitKeys[key] { + response.BadRequest(c, "不允许的配置项: "+key) + return + } + if err := h.classService.SaveSetting(classID, key, value); err != nil { + response.InternalError(c, "保存配置失败") + return + } + } + response.SuccessWithMessage(c, "保存成功") +} + +// GetFeatures 获取功能开关 +func (h *ClassHandler) GetFeatures(c *gin.Context) { + classID := middleware.GetClassID(c) + result, err := h.classService.GetFeatures(classID) + if err != nil { + response.InternalError(c, "获取功能开关失败") + return + } + response.Success(c, result, "操作成功") +} + +// allowedFeatureKeys 允许的功能开关键白名单 +var allowedFeatureKeys = map[string]bool{ + "parent_account_enabled": true, + "parent_password_change_enabled": true, + "parent_view_attendance": true, + "parent_view_ranking": true, + "student_view_ranking": true, + "homework_management": true, + "attendance_management": true, + "cadre_homework": true, +} + +// SaveFeature 保存功能开关 +func (h *ClassHandler) SaveFeature(c *gin.Context) { + classID := middleware.GetClassID(c) + + var req schema.FeatureToggleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if !allowedFeatureKeys[req.FeatureKey] { + response.BadRequest(c, "不允许的功能开关: "+req.FeatureKey) + return + } + + if err := h.classService.SaveFeature(classID, req.FeatureKey, req.Enabled); err != nil { + response.InternalError(c, "保存功能开关失败") + return + } + response.SuccessWithMessage(c, "保存成功") +} diff --git a/backend-go/internal/handler/config_handler.go b/backend-go/internal/handler/config_handler.go new file mode 100644 index 0000000..5b6bceb --- /dev/null +++ b/backend-go/internal/handler/config_handler.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ConfigHandler 配置处理器 +type ConfigHandler struct { + configService *service.ConfigService +} + +// NewConfigHandler 创建配置处理器 +func NewConfigHandler(configService *service.ConfigService) *ConfigHandler { + return &ConfigHandler{configService: configService} +} + +// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置) +func (h *ConfigHandler) GetDeductionRules(c *gin.Context) { + classID := 0 + if classIDStr := c.Query("class_id"); classIDStr != "" { + if id, err := strconv.Atoi(classIDStr); err == nil { + classID = id + } + } + + rules := h.configService.GetDeductionRules(classID) + response.Success(c, rules, "操作成功") +} diff --git a/backend-go/internal/handler/handler_utils.go b/backend-go/internal/handler/handler_utils.go new file mode 100644 index 0000000..4832166 --- /dev/null +++ b/backend-go/internal/handler/handler_utils.go @@ -0,0 +1,20 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// parseQueryParamInt 解析查询参数为 int +func parseQueryParamInt(c *gin.Context, key string, defaultVal int) int { + val := c.Query(key) + if val == "" { + return defaultVal + } + i, err := strconv.Atoi(val) + if err != nil { + return defaultVal + } + return i +} diff --git a/backend-go/internal/handler/parent_handler.go b/backend-go/internal/handler/parent_handler.go new file mode 100644 index 0000000..5d2bf92 --- /dev/null +++ b/backend-go/internal/handler/parent_handler.go @@ -0,0 +1,115 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// ParentHandler 家长端处理器 +type ParentHandler struct { + parentService *service.ParentService + authService *service.AuthService + classService *service.ClassService +} + +// NewParentHandler 创建家长端处理器 +func NewParentHandler( + parentService *service.ParentService, + authService *service.AuthService, + classService *service.ClassService, +) *ParentHandler { + return &ParentHandler{ + parentService: parentService, + authService: authService, + classService: classService, + } +} + +// Dashboard 子女操行分(家长仪表盘) +func (h *ParentHandler) Dashboard(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildConduct(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// History 子女历史记录 +func (h *ParentHandler) History(c *gin.Context) { + var query schema.ParentHistoryQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildHistory(userID, query.Page, query.PageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Attendance 子女考勤 +func (h *ParentHandler) Attendance(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildAttendance(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Ranking 子女排名 +func (h *ParentHandler) Ranking(c *gin.Context) { + userID := middleware.GetUserID(c) + result, err := h.parentService.GetChildRanking(userID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// ChangePassword 家长修改密码(受功能开关控制) +func (h *ParentHandler) ChangePassword(c *gin.Context) { + classID := middleware.GetClassID(c) + + // 检查功能开关 + if !h.classService.IsFeatureEnabled(classID, "parent_password_change_enabled") { + response.Forbidden(c, "该功能暂未开放") + return + } + + var req schema.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + userID := middleware.GetUserID(c) + if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, false); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "密码修改成功") +} diff --git a/backend-go/internal/handler/semester_handler.go b/backend-go/internal/handler/semester_handler.go new file mode 100644 index 0000000..26fe88e --- /dev/null +++ b/backend-go/internal/handler/semester_handler.go @@ -0,0 +1,230 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SemesterHandler 学期管理处理器 +type SemesterHandler struct { + semesterService *service.SemesterService +} + +// NewSemesterHandler 创建学期管理处理器 +func NewSemesterHandler(semesterService *service.SemesterService) *SemesterHandler { + return &SemesterHandler{semesterService: semesterService} +} + +// SemesterList 学期列表 +func (h *SemesterHandler) SemesterList(c *gin.Context) { + result, err := h.semesterService.ListSemesters() + if err != nil { + response.InternalError(c, "获取学期列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// ActiveSemester 当前学期 +func (h *SemesterHandler) ActiveSemester(c *gin.Context) { + semester, err := h.semesterService.GetActiveSemester() + if err != nil { + response.Success(c, nil, "无活跃学期") + return + } + response.Success(c, semester, "操作成功") +} + +// SemesterCreate 创建学期 +func (h *SemesterHandler) SemesterCreate(c *gin.Context) { + var req schema.SemesterCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.semesterService.CreateSemester(req.SemesterName, req.StartDate, req.EndDate) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// ActivateSemester 激活学期 +func (h *SemesterHandler) ActivateSemester(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + if err := h.semesterService.ActivateSemester(semesterID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "已设为当前学期") +} + +// SemesterUpdate 编辑学期 +func (h *SemesterHandler) SemesterUpdate(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + var req schema.SemesterUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + if err := h.semesterService.UpdateSemester(semesterID, req.SemesterName, req.StartDate, req.EndDate); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// SemesterDelete 删除学期 +func (h *SemesterHandler) SemesterDelete(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + if err := h.semesterService.DeleteSemester(semesterID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// AssociateRecords 关联记录 +func (h *SemesterHandler) AssociateRecords(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + result, err := h.semesterService.AssociateRecords(semesterID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// ArchiveSemester 归档学期 +func (h *SemesterHandler) ArchiveSemester(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + classID := parseQueryParamInt(c, "class_id", 0) + resetScores := c.Query("reset_scores") == "true" + + result, err := h.semesterService.ArchiveSemester(semesterID, classID, resetScores) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// GetArchiveData 归档数据 +func (h *SemesterHandler) GetArchiveData(c *gin.Context) { + semesterID, ok := parseID(c, "semester_id") + if !ok { + return + } + + classID := parseQueryParamInt(c, "class_id", 0) + page := parseQueryParamInt(c, "page", 1) + pageSize := parseQueryParamInt(c, "page_size", 20) + + result, err := h.semesterService.GetArchiveRecords(semesterID, classID, page, pageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// PeriodReset 手动触发周/月重置 +func (h *SemesterHandler) PeriodReset(c *gin.Context) { + var req schema.PeriodResetRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "未指定班级") + return + } + + userID := middleware.GetUserID(c) + realName := middleware.GetRealName(c) + ip := c.ClientIP() + + if err := h.semesterService.PeriodReset(classID, req.Period, userID, realName, ip); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, service.PeriodLabelCN(req.Period)+"重置成功") +} + +// GetPeriodArchives 查看周期归档数据 +func (h *SemesterHandler) GetPeriodArchives(c *gin.Context) { + var req schema.PeriodArchiveQuery + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + classID := middleware.GetClassID(c) + if classID == 0 { + response.BadRequest(c, "未指定班级") + return + } + + result, err := h.semesterService.GetPeriodArchives(classID, req.Period, req.Page, req.PageSize) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + diff --git a/backend-go/internal/handler/student_handler.go b/backend-go/internal/handler/student_handler.go new file mode 100644 index 0000000..eaf5d16 --- /dev/null +++ b/backend-go/internal/handler/student_handler.go @@ -0,0 +1,192 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// StudentHandler 学生端处理器 +type StudentHandler struct { + studentService *service.StudentService + classRepo *repository.ClassRepo +} + +// NewStudentHandler 创建学生端处理器 +func NewStudentHandler(studentService *service.StudentService, classRepo *repository.ClassRepo) *StudentHandler { + return &StudentHandler{studentService: studentService, classRepo: classRepo} +} + +// Dashboard 学生个人信息(仪表盘) +func (h *StudentHandler) Dashboard(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return + } + + result, err := h.studentService.GetStudentInfo(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// resolveStudentID 校验学生归属:学生只能查看自己的数据,家长只能查看关联子女,管理员可查看指定学生 +func (h *StudentHandler) resolveStudentID(c *gin.Context) (int, bool) { + userType := middleware.GetUserType(c) + if userType == "student" { + // 学生只能查看自己的数据,忽略 URL 参数中的 student_id + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return 0, false + } + return studentID, true + } + + requestedID, ok := parseID(c, "student_id") + if !ok { + return 0, false + } + + // 家长只能查看自己关联的子女数据 + if userType == "parent" { + parentStudentID := middleware.GetStudentID(c) + if parentStudentID == 0 || parentStudentID != requestedID { + response.Forbidden(c, "无权访问该学生数据") + return 0, false + } + return requestedID, true + } + + // 管理员/超级管理员允许查看(角色权限由路由中间件 RequireRole 控制) + return requestedID, true +} + +// ConductHistory 学生操行分历史 +func (h *StudentHandler) ConductHistory(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + var query schema.StudentConductQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.studentService.GetConductHistory(studentID, query.Limit, query.Offset) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Homework 学生作业情况 +func (h *StudentHandler) Homework(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + result, err := h.studentService.GetHomeworkStatus(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Attendance 学生考勤记录 +func (h *StudentHandler) Attendance(c *gin.Context) { + studentID, ok := h.resolveStudentID(c) + if !ok { + return + } + + month := c.Query("month") + result, err := h.studentService.GetAttendanceRecords(studentID, month) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// Ranking 操行分排行 +func (h *StudentHandler) Ranking(c *gin.Context) { + classID := middleware.GetClassID(c) + + // 检查班级功能开关:学生查看排行榜 + feature, err := h.classRepo.GetFeature(classID, "student_view_ranking") + if err == nil && feature != nil && feature.Enabled == 0 { + response.Forbidden(c, "该功能暂未开放") + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if limit > 500 { + limit = 500 + } + + result, err := h.studentService.GetRanking(classID, limit) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// MyInfo 学生个人信息 +func (h *StudentHandler) MyInfo(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID == 0 { + response.BadRequest(c, "非学生用户") + return + } + + result, err := h.studentService.GetStudentInfo(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} + +// SemesterRecords 学期归档记录 +func (h *StudentHandler) SemesterRecords(c *gin.Context) { + studentID := middleware.GetStudentID(c) + if studentID <= 0 { + response.BadRequest(c, "非学生用户") + return + } + result, err := h.studentService.GetSemesterRecords(studentID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + response.Success(c, result, "操作成功") +} diff --git a/backend-go/internal/handler/subject_handler.go b/backend-go/internal/handler/subject_handler.go new file mode 100644 index 0000000..5d7485f --- /dev/null +++ b/backend-go/internal/handler/subject_handler.go @@ -0,0 +1,152 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SubjectHandler 科目管理处理器 +type SubjectHandler struct { + subjectService *service.SubjectService +} + +// NewSubjectHandler 创建科目管理处理器 +func NewSubjectHandler(subjectService *service.SubjectService) *SubjectHandler { + return &SubjectHandler{subjectService: subjectService} +} + +// SubjectList 科目列表 +func (h *SubjectHandler) SubjectList(c *gin.Context) { + var isActive *bool + if v := c.Query("is_active"); v == "true" { + b := true + isActive = &b + } else if v == "false" { + b := false + isActive = &b + } + + result, err := h.subjectService.GetSubjects(isActive) + if err != nil { + response.InternalError(c, "获取科目列表失败") + return + } + response.Success(c, result, "操作成功") +} + +// SubjectCreate 创建科目 +func (h *SubjectHandler) SubjectCreate(c *gin.Context) { + var req schema.SubjectCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + result, err := h.subjectService.CreateSubject(req.SubjectName, req.SubjectCode, req.SortOrder) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if success, _ := result["success"].(bool); !success { + response.BadRequest(c, result["message"].(string)) + return + } + response.Success(c, result, "操作成功") +} + +// SubjectUpdate 更新科目 +func (h *SubjectHandler) SubjectUpdate(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + var req schema.SubjectUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + updates := make(map[string]interface{}) + if req.SubjectName != nil { + updates["subject_name"] = *req.SubjectName + } + if req.SubjectCode != nil { + updates["subject_code"] = *req.SubjectCode + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + if req.SortOrder != nil { + updates["sort_order"] = *req.SortOrder + } + + if err := h.subjectService.UpdateSubject(subjectID, updates); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "更新成功") +} + +// SubjectDelete 删除科目 +func (h *SubjectHandler) SubjectDelete(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + if err := h.subjectService.DeleteSubject(subjectID); err != nil { + response.InternalError(c, err.Error()) + return + } + response.SuccessWithMessage(c, "删除成功") +} + +// SubjectToggle 切换科目启用/禁用状态 +func (h *SubjectHandler) SubjectToggle(c *gin.Context) { + subjectID, ok := parseID(c, "subject_id") + if !ok { + return + } + + var req struct { + IsActive bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + var err error + if req.IsActive { + err = h.subjectService.EnableSubject(subjectID) + } else { + err = h.subjectService.DisableSubject(subjectID) + } + + if err != nil { + response.InternalError(c, err.Error()) + return + } + + if req.IsActive { + response.SuccessWithMessage(c, "科目已启用") + } else { + response.SuccessWithMessage(c, "科目已禁用") + } +} diff --git a/backend-go/internal/handler/super_admin_handler.go b/backend-go/internal/handler/super_admin_handler.go new file mode 100644 index 0000000..95e32e1 --- /dev/null +++ b/backend-go/internal/handler/super_admin_handler.go @@ -0,0 +1,56 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package handler + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// SuperAdminHandler 超级管理员处理器 +type SuperAdminHandler struct { + superAdminService *service.SuperAdminService +} + +// NewSuperAdminHandler 创建超级管理员处理器 +func NewSuperAdminHandler(superAdminService *service.SuperAdminService) *SuperAdminHandler { + return &SuperAdminHandler{superAdminService: superAdminService} +} + +// Login 超级管理员登录 +func (h *SuperAdminHandler) Login(c *gin.Context) { + var req schema.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + result, err := h.superAdminService.Login(req.Username, req.Password, ip, userAgent) + if err != nil { + response.InternalError(c, err.Error()) + return + } + + success, ok := result["success"].(bool) + if !ok || !success { + msg, _ := result["message"].(string) + response.Unauthorized(c, msg) + return + } + + response.Success(c, result, "登录成功") +} diff --git a/backend-go/internal/middleware/access_log.go b/backend-go/internal/middleware/access_log.go new file mode 100644 index 0000000..bf1b5bf --- /dev/null +++ b/backend-go/internal/middleware/access_log.go @@ -0,0 +1,57 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AccessLog 访问日志中间件 +func AccessLog() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + // 处理请求 + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + method := c.Request.Method + userAgent := c.Request.UserAgent() + + if query != "" { + path = path + "?" + query + } + + // 获取用户信息(如已认证) + userID, _ := c.Get(CtxUserID) + username, _ := c.Get(CtxUsername) + + logger.Sugared.Infow("请求日志", + "status", status, + "method", method, + "path", path, + "ip", clientIP, + "latency", latency.String(), + "user_agent", userAgent, + "user_id", userID, + "username", username, + ) + } +} diff --git a/backend-go/internal/middleware/auth.go b/backend-go/internal/middleware/auth.go new file mode 100644 index 0000000..04f424c --- /dev/null +++ b/backend-go/internal/middleware/auth.go @@ -0,0 +1,227 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "context" + "strings" + + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// 上下文 Key 常量 +const ( + CtxUserID = "user_id" + CtxUsername = "username" + CtxUserType = "user_type" + CtxStudentID = "student_id" + CtxRole = "role" + CtxRealName = "real_name" + CtxClassID = "class_id" +) + +// 公开路径(不需要认证) +var publicPaths = map[string]bool{ + "/": true, + "/health": true, + "/api/auth/login": true, +} + +// RegisterPublicPath 注册额外的公开路径(需在路由初始化阶段调用) +func RegisterPublicPath(path string) { + publicPaths[path] = true +} + +// AuthRequired JWT 认证中间件 +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + + // 公开路径跳过 + if publicPaths[path] { + c.Next() + return + } + + cfg := config.AppConfig + + // 获取 Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.Unauthorized(c, "缺少认证令牌") + c.Abort() + return + } + + // 解析 Bearer Token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + tokenStr := parts[1] + + // 验证 JWT + claims, err := appJwt.VerifyToken(tokenStr) + if err != nil { + logger.Sugared.Warnf("JWT 验证失败: path=%s, err=%v", path, err) + response.Unauthorized(c, "令牌无效或已过期") + c.Abort() + return + } + + // 验证 Redis 中的 Token + ctx := context.Background() + storedToken, err := database.GetUserToken(ctx, claims.UserID) + if err != nil || storedToken != tokenStr { + logger.Sugared.Warnf("Redis Token 不匹配: path=%s, user_id=%d", path, claims.UserID) + // 主动清理 Redis 中的旧 Token,避免残留 + if err == nil && storedToken != "" && storedToken != tokenStr { + _ = database.DeleteUserToken(ctx, claims.UserID) + } + response.Unauthorized(c, "令牌已失效,请重新登录") + c.Abort() + return + } + // 刷新 Token 过期时间(空闲超时) + _ = database.ExpireToken(ctx, claims.UserID, cfg.JWTIdleTimeoutMinutes) + + // 将用户信息写入 Gin 上下文 + c.Set(CtxUserID, claims.UserID) + c.Set(CtxUsername, claims.Username) + c.Set(CtxUserType, claims.UserType) + c.Set(CtxRealName, claims.RealName) + if claims.StudentID != nil { + c.Set(CtxStudentID, *claims.StudentID) + } + c.Set(CtxRole, claims.Role) + if claims.ClassID != nil { + c.Set(CtxClassID, *claims.ClassID) + } + + logger.Sugared.Debugf("认证成功: %s %s, user_id=%d, username=%s", + c.Request.Method, path, claims.UserID, claims.Username) + + c.Next() + } +} + +// RequireRole 角色权限中间件 +func RequireRole(roles ...string) gin.HandlerFunc { + roleSet := make(map[string]bool, len(roles)) + for _, r := range roles { + roleSet[r] = true + } + + return func(c *gin.Context) { + userType, _ := c.Get(CtxUserType) + role, _ := c.Get(CtxRole) + + // 超级管理员直接通过 + if userType == "super_admin" { + c.Next() + return + } + + // 检查 user_type + if ut, ok := userType.(string); ok && roleSet[ut] { + c.Next() + return + } + + // 检查 role(admin_roles.role_type) + if r, ok := role.(string); ok && roleSet[r] { + c.Next() + return + } + + response.Forbidden(c, "权限不足") + c.Abort() + } +} + +// GetUserID 从上下文获取用户 ID +func GetUserID(c *gin.Context) int { + if v, exists := c.Get(CtxUserID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetUsername 从上下文获取用户名 +func GetUsername(c *gin.Context) string { + if v, exists := c.Get(CtxUsername); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetUserType 从上下文获取用户类型 +func GetUserType(c *gin.Context) string { + if v, exists := c.Get(CtxUserType); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetRole 从上下文获取角色 +func GetRole(c *gin.Context) string { + if v, exists := c.Get(CtxRole); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetClassID 从上下文获取班级 ID +func GetClassID(c *gin.Context) int { + if v, exists := c.Get(CtxClassID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetStudentID 从上下文获取学生 ID +func GetStudentID(c *gin.Context) int { + if v, exists := c.Get(CtxStudentID); exists { + if id, ok := v.(int); ok { + return id + } + } + return 0 +} + +// GetRealName 从上下文获取真实姓名 +func GetRealName(c *gin.Context) string { + if v, exists := c.Get(CtxRealName); exists { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/backend-go/internal/middleware/sanitize.go b/backend-go/internal/middleware/sanitize.go new file mode 100644 index 0000000..681e620 --- /dev/null +++ b/backend-go/internal/middleware/sanitize.go @@ -0,0 +1,131 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// Sanitize 输入清理中间件(路径遍历防护 + 长度限制) +func Sanitize() gin.HandlerFunc { + return func(c *gin.Context) { + // 处理 POST、PUT、PATCH 请求体 + if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" { + body, err := io.ReadAll(c.Request.Body) + if err == nil && len(body) > 0 { + var data interface{} + if json.Unmarshal(body, &data) == nil { + cleaned := sanitizeData(data) + newBody, _ := json.Marshal(cleaned) + c.Request.Body = io.NopCloser(bytes.NewBuffer(newBody)) + c.Request.ContentLength = int64(len(newBody)) + } else { + // 非 JSON 请求体,恢复原始 body + c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) + } + } + } + + // 清理查询参数(GET 等请求的 URL query string) + if c.Request.URL.RawQuery != "" { + params := c.Request.URL.Query() + dirty := false + for key, values := range params { + for i, v := range values { + cleaned := sanitizeString(v) + if cleaned != v { + values[i] = cleaned + dirty = true + } + } + params[key] = values + } + if dirty { + c.Request.URL.RawQuery = params.Encode() + } + } + + c.Next() + } +} + +// sanitizeData 递归清理数据 +func sanitizeData(data interface{}) interface{} { + switch v := data.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(v)) + for key, val := range v { + result[key] = sanitizeData(val) + } + return result + case []interface{}: + result := make([]interface{}, len(v)) + for i, val := range v { + result[i] = sanitizeData(val) + } + return result + case string: + return sanitizeString(v) + default: + return v + } +} + +// sanitizeString 清理字符串 +func sanitizeString(value string) string { + if value == "" { + return "" + } + + value = strings.TrimSpace(value) + + // 路径遍历防护(循环解码直到稳定,防止多层编码绕过) + for { + decoded, err := url.PathUnescape(value) + if err != nil || decoded == value { + break + } + value = decoded + } + // 大小写无关的路径遍历模式清理(循环移除直到无匹配) + lower := strings.ToLower(value) + for strings.Contains(lower, "../") || strings.Contains(lower, "..\\") { + replaced := false + for _, pattern := range []string{"../", "..\\"} { + if idx := strings.Index(lower, pattern); idx >= 0 { + value = value[:idx] + value[idx+len(pattern):] + lower = lower[:idx] + lower[idx+len(pattern):] + replaced = true + break + } + } + if !replaced { + break + } + } + + // 限制长度(按 rune 截断,避免切断多字节 UTF-8 字符) + runes := []rune(value) + if len(runes) > 1000 { + value = string(runes[:1000]) + } + + // SQL 注入由 GORM 参数化查询防护,无需正则替换(避免破坏合法输入) + + return value +} diff --git a/backend-go/internal/model/admin_role.go b/backend-go/internal/model/admin_role.go new file mode 100644 index 0000000..120bf9a --- /dev/null +++ b/backend-go/internal/model/admin_role.go @@ -0,0 +1,36 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// AdminRole 管理员角色模型,对应 admin_roles 表 +type AdminRole struct { + AdminRoleID int `gorm:"column:admin_role_id;primaryKey;autoIncrement" json:"admin_role_id"` + UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_user_class" json:"user_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_user_class;index:idx_admin_role_class" json:"class_id"` + RoleType string `gorm:"column:role_type;type:enum('班主任','班长','学习委员','考勤委员','劳动委员','志愿委员','科任老师','课代表');not null" json:"role_type"` + SubjectID *int `gorm:"column:subject_id" json:"subject_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + RealName *string `gorm:"-" json:"real_name,omitempty"` + Username *string `gorm:"-" json:"username,omitempty"` + UserStatus *int8 `gorm:"-" json:"user_status,omitempty"` + SubjectName *string `gorm:"-" json:"subject_name,omitempty"` + ClassName *string `gorm:"-" json:"class_name,omitempty"` +} + +// TableName 指定表名 +func (AdminRole) TableName() string { + return "admin_roles" +} diff --git a/backend-go/internal/model/assignment.go b/backend-go/internal/model/assignment.go new file mode 100644 index 0000000..012fdcf --- /dev/null +++ b/backend-go/internal/model/assignment.go @@ -0,0 +1,53 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Assignment 作业模型,对应 assignments 表 +type Assignment struct { + AssignmentID int `gorm:"column:assignment_id;primaryKey;autoIncrement" json:"assignment_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_assignment_class" json:"class_id"` + SubjectID int `gorm:"column:subject_id;not null;index:idx_assignment_subject" json:"subject_id"` + Title string `gorm:"column:title;type:varchar(100);not null" json:"title"` + Description *string `gorm:"column:description;type:text" json:"description"` + Deadline time.Time `gorm:"column:deadline;type:date;not null" json:"deadline"` + CreatedBy int `gorm:"column:created_by;not null" json:"created_by"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + SubjectName *string `gorm:"-" json:"subject_name,omitempty"` +} + +// TableName 指定表名 +func (Assignment) TableName() string { + return "assignments" +} + +// AssignmentSubmission 作业提交记录模型,对应 homework_submissions 表 +type AssignmentSubmission struct { + SubmissionID int `gorm:"column:submission_id;primaryKey;autoIncrement" json:"submission_id"` + AssignmentID int `gorm:"column:assignment_id;not null;uniqueIndex:uk_assignment_student" json:"assignment_id"` + StudentID int `gorm:"column:student_id;not null;uniqueIndex:uk_assignment_student" json:"student_id"` + Status string `gorm:"column:status;type:enum('submitted','not_submitted','late');default:'not_submitted'" json:"status"` + SubmitTime *time.Time `gorm:"column:submit_time" json:"submit_time"` + Comments *string `gorm:"column:comments;type:text" json:"comments"` + DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"` + DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"` + UpdatedBy *int `gorm:"column:updated_by" json:"updated_by"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (AssignmentSubmission) TableName() string { + return "homework_submissions" +} diff --git a/backend-go/internal/model/attendance.go b/backend-go/internal/model/attendance.go new file mode 100644 index 0000000..1cc12e4 --- /dev/null +++ b/backend-go/internal/model/attendance.go @@ -0,0 +1,38 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// AttendanceRecord 考勤记录模型,对应 attendance_records 表 +type AttendanceRecord struct { + AttendanceID int `gorm:"column:attendance_id;primaryKey;autoIncrement" json:"attendance_id"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + Date time.Time `gorm:"column:date;type:date;not null;index:idx_date" json:"date"` + Slot string `gorm:"column:slot;type:enum('morning','afternoon','evening');default:'morning'" json:"slot"` + Status string `gorm:"column:status;type:enum('present','absent','late','leave');default:'present'" json:"status"` + Reason *string `gorm:"column:reason;type:varchar(255)" json:"reason"` + RecorderID int `gorm:"column:recorder_id;not null" json:"recorder_id"` + DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"` + DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"` + SemesterID *int `gorm:"column:semester_id;index:idx_attendance_semester" json:"semester_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + StudentName *string `gorm:"-" json:"student_name,omitempty"` + StudentNo *string `gorm:"-" json:"student_no,omitempty"` +} + +// TableName 指定表名 +func (AttendanceRecord) TableName() string { + return "attendance_records" +} diff --git a/backend-go/internal/model/class_model.go b/backend-go/internal/model/class_model.go new file mode 100644 index 0000000..3f00dcc --- /dev/null +++ b/backend-go/internal/model/class_model.go @@ -0,0 +1,60 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Class 班级模型,对应 classes 表 +type Class struct { + ClassID int `gorm:"column:class_id;primaryKey;autoIncrement" json:"class_id"` + ClassName string `gorm:"column:class_name;type:varchar(100);uniqueIndex:uk_class_name;not null" json:"class_name"` + Grade *string `gorm:"column:grade;type:varchar(50)" json:"grade"` + Description *string `gorm:"column:description;type:varchar(255)" json:"description"` + Status int8 `gorm:"column:status;default:1" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + StudentCount int64 `gorm:"-" json:"student_count,omitempty"` +} + +// TableName 指定表名 +func (Class) TableName() string { + return "classes" +} + +// ClassSetting 班级设置模型,对应 class_settings 表 +type ClassSetting struct { + SettingID int `gorm:"column:setting_id;primaryKey;autoIncrement" json:"setting_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_setting" json:"class_id"` + SettingKey string `gorm:"column:setting_key;type:varchar(50);not null;uniqueIndex:uk_class_setting" json:"setting_key"` + SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (ClassSetting) TableName() string { + return "class_settings" +} + +// ClassFeature 班级功能开关模型,对应 class_features 表 +type ClassFeature struct { + FeatureID int `gorm:"column:feature_id;primaryKey;autoIncrement" json:"feature_id"` + ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_feature" json:"class_id"` + FeatureKey string `gorm:"column:feature_key;type:varchar(50);not null;uniqueIndex:uk_class_feature" json:"feature_key"` + Enabled int8 `gorm:"column:enabled;default:1" json:"enabled"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (ClassFeature) TableName() string { + return "class_features" +} diff --git a/backend-go/internal/model/conduct.go b/backend-go/internal/model/conduct.go new file mode 100644 index 0000000..a6fb7e3 --- /dev/null +++ b/backend-go/internal/model/conduct.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// ConductRecord 操行分记录模型,对应 conduct_records 表 +type ConductRecord struct { + RecordID int64 `gorm:"column:record_id;primaryKey;autoIncrement" json:"record_id"` + StudentID int `gorm:"column:student_id;not null;index:idx_conduct_student;index:idx_student_created" json:"student_id"` + PointsChange int `gorm:"column:points_change;not null" json:"points_change"` + Reason string `gorm:"column:reason;type:varchar(255);not null" json:"reason"` + RecorderID int `gorm:"column:recorder_id;not null;index:idx_recorder_id" json:"recorder_id"` + RecorderName *string `gorm:"column:recorder_name;type:varchar(50)" json:"recorder_name"` + RelatedType string `gorm:"column:related_type;type:enum('manual','homework','attendance');default:'manual'" json:"related_type"` + RelatedID *int `gorm:"column:related_id" json:"related_id"` + IsRevoked int8 `gorm:"column:is_revoked;default:0" json:"is_revoked"` + RevokedBy *int `gorm:"column:revoked_by" json:"revoked_by"` + RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"` + SemesterID *int `gorm:"column:semester_id;index:idx_conduct_semester;index:idx_conduct_type_semester" json:"semester_id"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_student_created" json:"created_at"` + + // 虚拟字段(JOIN 查询时使用) + StudentName *string `gorm:"-" json:"student_name,omitempty"` + StudentNo *string `gorm:"-" json:"student_no,omitempty"` + RecorderReal *string `gorm:"-" json:"recorder_real,omitempty"` + RevokerName *string `gorm:"-" json:"revoker_name,omitempty"` + TotalPoints *int `gorm:"-" json:"total_points,omitempty"` + ClassID *int `gorm:"-" json:"class_id,omitempty"` +} + +// TableName 指定表名 +func (ConductRecord) TableName() string { + return "conduct_records" +} diff --git a/backend-go/internal/model/log.go b/backend-go/internal/model/log.go new file mode 100644 index 0000000..0506002 --- /dev/null +++ b/backend-go/internal/model/log.go @@ -0,0 +1,50 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// OperationLog 操作日志模型,对应 operation_logs 表 +type OperationLog struct { + LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"` + OperatorID int `gorm:"column:operator_id;not null;index:idx_operator_created" json:"operator_id"` + OperatorName *string `gorm:"column:operator_name;type:varchar(50)" json:"operator_name"` + OperatorRole *string `gorm:"column:operator_role;type:varchar(50)" json:"operator_role"` + ClassID *int `gorm:"column:class_id;index:idx_operation_class" json:"class_id"` + OperationType string `gorm:"column:operation_type;type:varchar(50);not null" json:"operation_type"` + TargetType *string `gorm:"column:target_type;type:varchar(50)" json:"target_type"` + TargetID *int `gorm:"column:target_id" json:"target_id"` + Details *string `gorm:"column:details;type:text" json:"details"` + IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_operator_created" json:"created_at"` +} + +// TableName 指定表名 +func (OperationLog) TableName() string { + return "operation_logs" +} + +// LoginLog 登录日志模型,对应 login_logs 表 +type LoginLog struct { + LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"` + Username string `gorm:"column:username;type:varchar(50);not null;index:idx_username_created" json:"username"` + LoginResult int8 `gorm:"column:login_result;not null" json:"login_result"` + FailReason *string `gorm:"column:fail_reason;type:varchar(100)" json:"fail_reason"` + IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"` + UserAgent *string `gorm:"column:user_agent;type:varchar(255)" json:"user_agent"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_username_created" json:"created_at"` +} + +// TableName 指定表名 +func (LoginLog) TableName() string { + return "login_logs" +} diff --git a/backend-go/internal/model/semester.go b/backend-go/internal/model/semester.go new file mode 100644 index 0000000..2f92988 --- /dev/null +++ b/backend-go/internal/model/semester.go @@ -0,0 +1,88 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Semester 学期模型,对应 semesters 表 +type Semester struct { + SemesterID int `gorm:"column:semester_id;primaryKey;autoIncrement" json:"semester_id"` + SemesterName string `gorm:"column:semester_name;type:varchar(100);not null" json:"semester_name"` + StartDate *time.Time `gorm:"column:start_date;type:date" json:"start_date"` + EndDate *time.Time `gorm:"column:end_date;type:date" json:"end_date"` + IsActive int8 `gorm:"column:is_active;default:0" json:"is_active"` + IsArchived int8 `gorm:"column:is_archived;default:0" json:"is_archived"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + + // 虚拟字段 + ConductCount int64 `gorm:"-" json:"conduct_count,omitempty"` + AttendanceCount int64 `gorm:"-" json:"attendance_count,omitempty"` + CurrentWeek *int `gorm:"-" json:"current_week,omitempty"` +} + +// TableName 指定表名 +func (Semester) TableName() string { + return "semesters" +} + +// SemesterArchive 学期归档快照模型,对应 semester_archives 表 +type SemesterArchive struct { + ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"` + SemesterID int `gorm:"column:semester_id;not null;index:idx_semester_id" json:"semester_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_archive_class" json:"class_id"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"` + FinalPoints int `gorm:"column:final_points;not null" json:"final_points"` + RankPosition *int `gorm:"column:rank_position" json:"rank_position"` + TotalStudents *int `gorm:"column:total_students" json:"total_students"` + AttendancePresent int `gorm:"column:attendance_present;default:0" json:"attendance_present"` + AttendanceAbsent int `gorm:"column:attendance_absent;default:0" json:"attendance_absent"` + AttendanceLate int `gorm:"column:attendance_late;default:0" json:"attendance_late"` + AttendanceLeave int `gorm:"column:attendance_leave;default:0" json:"attendance_leave"` + HomeworkSubmitted int `gorm:"column:homework_submitted;default:0" json:"homework_submitted"` + HomeworkNotSubmitted int `gorm:"column:homework_not_submitted;default:0" json:"homework_not_submitted"` + HomeworkLate int `gorm:"column:homework_late;default:0" json:"homework_late"` + ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"` + + // 虚拟字段 + SemesterName *string `gorm:"-" json:"semester_name,omitempty"` + SStartDate *time.Time `gorm:"-" json:"start_date,omitempty"` + SEndDate *time.Time `gorm:"-" json:"end_date,omitempty"` +} + +// TableName 指定表名 +func (SemesterArchive) TableName() string { + return "semester_archives" +} + +// PeriodArchive 周期归档快照模型,对应 period_archives 表 +type PeriodArchive struct { + ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"` + ClassID int `gorm:"column:class_id;not null;index:idx_period_archive_class" json:"class_id"` + PeriodType string `gorm:"column:period_type;type:enum('weekly','monthly');not null;index:idx_period_archive_type" json:"period_type"` + PeriodLabel string `gorm:"column:period_label;type:varchar(50);not null;index:idx_period_archive_type" json:"period_label"` + StudentID int `gorm:"column:student_id;not null" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"` + FinalPoints int `gorm:"column:final_points;not null" json:"final_points"` + RankPosition *int `gorm:"column:rank_position" json:"rank_position"` + TotalStudents *int `gorm:"column:total_students" json:"total_students"` + ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"` + ResetBy string `gorm:"column:reset_by;type:varchar(20);default:auto" json:"reset_by"` + OperatorID *int `gorm:"column:operator_id" json:"operator_id"` +} + +// TableName 指定表名 +func (PeriodArchive) TableName() string { + return "period_archives" +} diff --git a/backend-go/internal/model/student.go b/backend-go/internal/model/student.go new file mode 100644 index 0000000..b93068e --- /dev/null +++ b/backend-go/internal/model/student.go @@ -0,0 +1,37 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Student 学生模型,对应 students 表 +type Student struct { + StudentID int `gorm:"column:student_id;primaryKey;autoIncrement" json:"student_id"` + StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"` + ClassID int `gorm:"column:class_id;not null;index:idx_student_class" json:"class_id"` + Name string `gorm:"column:name;type:varchar(50);not null" json:"name"` + TotalPoints int `gorm:"column:total_points;default:60" json:"total_points"` + ParentAccount *string `gorm:"column:parent_account;type:varchar(50)" json:"parent_account"` + DormitoryNumber *string `gorm:"column:dormitory_number;type:varchar(20)" json:"dormitory_number"` // 格式:南0-000 + Status int8 `gorm:"column:status;default:1" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + PointsUpdatedAt time.Time `gorm:"column:points_updated_at;autoCreateTime" json:"points_updated_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + + // 虚拟字段(JOIN 查询时使用,不映射到数据库) + ClassName *string `gorm:"-" json:"class_name,omitempty"` +} + +// TableName 指定表名 +func (Student) TableName() string { + return "students" +} diff --git a/backend-go/internal/model/subject.go b/backend-go/internal/model/subject.go new file mode 100644 index 0000000..ba47061 --- /dev/null +++ b/backend-go/internal/model/subject.go @@ -0,0 +1,29 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// Subject 科目模型,对应 subjects 表 +type Subject struct { + SubjectID int `gorm:"column:subject_id;primaryKey;autoIncrement" json:"subject_id"` + SubjectName string `gorm:"column:subject_name;type:varchar(50);uniqueIndex:uk_subject_name;not null" json:"subject_name"` + SubjectCode *string `gorm:"column:subject_code;type:varchar(20)" json:"subject_code"` + IsActive int8 `gorm:"column:is_active;default:1" json:"is_active"` + SortOrder int `gorm:"column:sort_order;default:0" json:"sort_order"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +// TableName 指定表名 +func (Subject) TableName() string { + return "subjects" +} diff --git a/backend-go/internal/model/super_admin.go b/backend-go/internal/model/super_admin.go new file mode 100644 index 0000000..ec7c4b0 --- /dev/null +++ b/backend-go/internal/model/super_admin.go @@ -0,0 +1,32 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// SuperAdmin 超级管理员模型,对应 super_admins 表 +type SuperAdmin struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + Salt string `gorm:"column:salt;type:varchar(64);not null" json:"-"` + RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` + Status int8 `gorm:"column:status;default:1" json:"status"` + NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (SuperAdmin) TableName() string { + return "super_admins" +} diff --git a/backend-go/internal/model/system_setting.go b/backend-go/internal/model/system_setting.go new file mode 100644 index 0000000..a0959ff --- /dev/null +++ b/backend-go/internal/model/system_setting.go @@ -0,0 +1,26 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// SystemSetting 系统设置模型,对应 system_settings 表 +type SystemSetting struct { + SettingKey string `gorm:"column:setting_key;type:varchar(50);primaryKey;not null" json:"setting_key"` + SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定表名 +func (SystemSetting) TableName() string { + return "system_settings" +} diff --git a/backend-go/internal/model/user.go b/backend-go/internal/model/user.go new file mode 100644 index 0000000..7a28061 --- /dev/null +++ b/backend-go/internal/model/user.go @@ -0,0 +1,34 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package model + +import "time" + +// User 用户模型,对应 users 表 +type User struct { + UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"` + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` + RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"` + UserType string `gorm:"column:user_type;type:enum('student','parent','admin','super_admin');not null" json:"user_type"` + StudentID *int `gorm:"column:student_id" json:"student_id"` + Status int8 `gorm:"column:status;default:1" json:"status"` + NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"` + LastLoginTime *time.Time `gorm:"column:last_login_time" json:"last_login_time"` + LastLoginIP *string `gorm:"column:last_login_ip;type:varchar(45)" json:"last_login_ip"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` +} + +// TableName 指定表名 +func (User) TableName() string { + return "users" +} diff --git a/backend-go/internal/repository/admin_role_repo.go b/backend-go/internal/repository/admin_role_repo.go new file mode 100644 index 0000000..6a33039 --- /dev/null +++ b/backend-go/internal/repository/admin_role_repo.go @@ -0,0 +1,112 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AdminRoleRepo 管理员角色数据访问层 +type AdminRoleRepo struct { + db *gorm.DB +} + +// NewAdminRoleRepo 创建管理员角色 Repository +func NewAdminRoleRepo(db *gorm.DB) *AdminRoleRepo { + return &AdminRoleRepo{db: db} +} + +// GetByUserID 获取用户的管理员角色(取第一个,含科目名称) +func (r *AdminRoleRepo) GetByUserID(userID int) (*model.AdminRole, error) { + var role model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, s.subject_name"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.user_id = ?", userID). + Order("ar.admin_role_id ASC"). + Limit(1). + First(&role).Error; err != nil { + return nil, err + } + return &role, nil +} + +// GetByUserIDAndClass 获取用户在指定班级的管理员角色 +func (r *AdminRoleRepo) GetByUserIDAndClass(userID int, classID int) (*model.AdminRole, error) { + var role model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, s.subject_name"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.user_id = ? AND ar.class_id = ?", userID, classID). + Limit(1). + First(&role).Error; err != nil { + return nil, err + } + return &role, nil +} + +// GetAllByClass 获取指定班级的所有管理员列表(含用户和科目信息) +func (r *AdminRoleRepo) GetAllByClass(classID int) ([]model.AdminRole, error) { + var roles []model.AdminRole + if err := r.db.Table("admin_roles ar"). + Select("ar.*, u.real_name, u.username, u.status as user_status, s.subject_name"). + Joins("JOIN users u ON ar.user_id = u.user_id AND u.status = 1"). + Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id"). + Where("ar.class_id = ?", classID). + Order("ar.role_type"). + Find(&roles).Error; err != nil { + return nil, err + } + return roles, nil +} + +// Create 创建管理员角色 +func (r *AdminRoleRepo) Create(role *model.AdminRole) (int, error) { + if err := r.db.Create(role).Error; err != nil { + return 0, err + } + return role.AdminRoleID, nil +} + +// Delete 删除管理员角色(可指定班级) +func (r *AdminRoleRepo) Delete(userID int, classID int) error { + query := r.db.Where("user_id = ?", userID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + return query.Delete(&model.AdminRole{}).Error +} + +// UpdateRole 更新管理员角色类型和关联科目 +func (r *AdminRoleRepo) UpdateRole(userID int, roleType string, classID int, subjectID *int) error { + query := r.db.Model(&model.AdminRole{}).Where("user_id = ?", userID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + return query.Updates(map[string]interface{}{ + "role_type": roleType, + "subject_id": subjectID, + }).Error +} + +// GetUserRoleAndClassID 获取用户的角色类型和所属班级ID +func (r *AdminRoleRepo) GetUserRoleAndClassID(userID int) (string, int, error) { + var role model.AdminRole + if err := r.db.Where("user_id = ?", userID). + Limit(1). + First(&role).Error; err != nil { + return "", 0, err + } + return role.RoleType, role.ClassID, nil +} diff --git a/backend-go/internal/repository/assignment_repo.go b/backend-go/internal/repository/assignment_repo.go new file mode 100644 index 0000000..ac2db18 --- /dev/null +++ b/backend-go/internal/repository/assignment_repo.go @@ -0,0 +1,168 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AssignmentRepo 作业数据访问层 +type AssignmentRepo struct { + db *gorm.DB +} + +// NewAssignmentRepo 创建作业 Repository +func NewAssignmentRepo(db *gorm.DB) *AssignmentRepo { + return &AssignmentRepo{db: db} +} + +// ========== Assignment 操作 ========== + +// CreateAssignment 创建作业 +func (r *AssignmentRepo) CreateAssignment(assignment *model.Assignment) (int, error) { + if err := r.db.Create(assignment).Error; err != nil { + return 0, err + } + return assignment.AssignmentID, nil +} + +// GetAssignmentByID 根据ID获取作业 +func (r *AssignmentRepo) GetAssignmentByID(assignmentID int) (*model.Assignment, error) { + var assignment model.Assignment + if err := r.db.Where("assignment_id = ?", assignmentID).First(&assignment).Error; err != nil { + return nil, err + } + return &assignment, nil +} + +// GetAssignmentsByClass 获取班级作业列表 +func (r *AssignmentRepo) GetAssignmentsByClass(classID int, subjectID int, page, pageSize int) ([]model.Assignment, int64, error) { + var assignments []model.Assignment + var total int64 + + query := r.db.Model(&model.Assignment{}).Where("class_id = ?", classID) + if subjectID > 0 { + query = query.Where("subject_id = ?", subjectID) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&assignments).Error; err != nil { + return nil, 0, err + } + + return assignments, total, nil +} + +// GetAssignmentsBySubject 获取科目关联的作业列表 +func (r *AssignmentRepo) GetAssignmentsBySubject(subjectID int) ([]model.Assignment, error) { + var assignments []model.Assignment + if err := r.db.Where("subject_id = ?", subjectID). + Order("created_at DESC"). + Find(&assignments).Error; err != nil { + return nil, err + } + return assignments, nil +} + +// DeleteAssignment 删除作业 +func (r *AssignmentRepo) DeleteAssignment(assignmentID int) error { + return r.db.Where("assignment_id = ?", assignmentID).Delete(&model.Assignment{}).Error +} + +// GetHomeworkStatsByDateRange 通过作业截止日期范围查询学生作业提交统计 +func (r *AssignmentRepo) GetHomeworkStatsByDateRange(startDate, endDate time.Time) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + err := r.db.Table("homework_submissions hs"). + Select("hs.student_id, hs.status, COUNT(*) as count"). + Joins("JOIN assignments a ON hs.assignment_id = a.assignment_id"). + Where("a.deadline BETWEEN ? AND ?", startDate, endDate). + Group("hs.student_id, hs.status"). + Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// ========== AssignmentSubmission 操作 ========== + +// CreateSubmission 创建作业提交记录 +func (r *AssignmentRepo) CreateSubmission(submission *model.AssignmentSubmission) (int, error) { + if err := r.db.Create(submission).Error; err != nil { + return 0, err + } + return submission.SubmissionID, nil +} + +// GetSubmissionByAssignmentAndStudent 获取指定作业和学生的提交记录 +func (r *AssignmentRepo) GetSubmissionByAssignmentAndStudent(assignmentID, studentID int) (*model.AssignmentSubmission, error) { + var submission model.AssignmentSubmission + if err := r.db.Where("assignment_id = ? AND student_id = ?", assignmentID, studentID). + First(&submission).Error; err != nil { + return nil, err + } + return &submission, nil +} + +// GetSubmissionsByAssignment 获取作业的所有提交记录 +func (r *AssignmentRepo) GetSubmissionsByAssignment(assignmentID int) ([]model.AssignmentSubmission, error) { + var submissions []model.AssignmentSubmission + if err := r.db.Where("assignment_id = ?", assignmentID). + Find(&submissions).Error; err != nil { + return nil, err + } + return submissions, nil +} + +// GetSubmissionsByStudent 获取学生的所有提交记录 +func (r *AssignmentRepo) GetSubmissionsByStudent(studentID int) ([]model.AssignmentSubmission, error) { + var submissions []model.AssignmentSubmission + if err := r.db.Where("student_id = ?", studentID). + Find(&submissions).Error; err != nil { + return nil, err + } + return submissions, nil +} + +// UpdateSubmission 更新提交记录 +func (r *AssignmentRepo) UpdateSubmission(submissionID int, updates map[string]interface{}) error { + return r.db.Model(&model.AssignmentSubmission{}). + Where("submission_id = ?", submissionID). + Updates(updates).Error +} + +// BatchCreateSubmissions 批量创建提交记录 +func (r *AssignmentRepo) BatchCreateSubmissions(submissions []model.AssignmentSubmission) error { + if len(submissions) == 0 { + return nil + } + return r.db.Create(&submissions).Error +} diff --git a/backend-go/internal/repository/attendance_repo.go b/backend-go/internal/repository/attendance_repo.go new file mode 100644 index 0000000..dfc0185 --- /dev/null +++ b/backend-go/internal/repository/attendance_repo.go @@ -0,0 +1,184 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// AttendanceRepo 考勤数据访问层 +type AttendanceRepo struct { + db *gorm.DB +} + +// NewAttendanceRepo 创建考勤 Repository +func NewAttendanceRepo(db *gorm.DB) *AttendanceRepo { + return &AttendanceRepo{db: db} +} + +// GetStudentRecords 获取学生考勤记录 +func (r *AttendanceRepo) GetStudentRecords(studentID int, month string) ([]model.AttendanceRecord, error) { + var records []model.AttendanceRecord + query := r.db.Where("student_id = ?", studentID) + + if month != "" { + query = query.Where("DATE_FORMAT(date, '%Y-%m') = ?", month) + } + + if err := query.Order("date DESC").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetClassRecords 获取班级考勤记录(支持多种过滤条件) +func (r *AttendanceRepo) GetClassRecords(classID int, date string, studentID int, slot string) ([]model.AttendanceRecord, error) { + var records []model.AttendanceRecord + query := r.db.Table("attendance_records ar"). + Select("ar.*, s.name as student_name, s.student_no"). + Joins("JOIN students s ON ar.student_id = s.student_id"). + Where("1 = 1") + + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if date != "" { + query = query.Where("ar.date = ?", date) + } + if studentID > 0 { + query = query.Where("ar.student_id = ?", studentID) + } + if slot != "" { + query = query.Where("ar.slot = ?", slot) + } + + if err := query.Order("ar.date DESC, s.student_no").Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// CreateRecordResult 创建或更新考勤记录的结果 +type CreateRecordResult struct { + AttendanceID int + IsUpdate bool + OldDeductionApplied int8 + OldDeductionRecordID *int64 +} + +// CreateRecord 创建或更新考勤记录(存在则更新),使用事务+行锁防止并发竞态 +func (r *AttendanceRepo) CreateRecord(record *model.AttendanceRecord) (*CreateRecordResult, error) { + var result CreateRecordResult + err := r.db.Transaction(func(tx *gorm.DB) error { + // 使用 SELECT ... FOR UPDATE 锁定记录,防止并发请求同时判定为"不存在" + var existing model.AttendanceRecord + findErr := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("student_id = ? AND date = ? AND slot = ?", + record.StudentID, record.Date, record.Slot). + First(&existing).Error + + if findErr == nil { + // 更新已有记录 + if updateErr := tx.Model(&existing).Updates(map[string]interface{}{ + "status": record.Status, + "reason": record.Reason, + "recorder_id": record.RecorderID, + }).Error; updateErr != nil { + return updateErr + } + result = CreateRecordResult{ + AttendanceID: existing.AttendanceID, + IsUpdate: true, + OldDeductionApplied: existing.DeductionApplied, + OldDeductionRecordID: existing.DeductionRecordID, + } + return nil + } + + if findErr != gorm.ErrRecordNotFound { + return findErr + } + + // 插入新记录 + if createErr := tx.Create(record).Error; createErr != nil { + return createErr + } + result = CreateRecordResult{ + AttendanceID: record.AttendanceID, + IsUpdate: false, + } + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// GetAttendanceStatsBySemester 批量查询学期内所有学生的考勤统计 +func (r *AttendanceRepo) GetAttendanceStatsBySemester(semesterID int, startDate, endDate string) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + err := r.db.Model(&model.AttendanceRecord{}). + Select("student_id, status, COUNT(*) as count"). + Where("semester_id = ? OR (semester_id IS NULL AND `date` BETWEEN ? AND ?)", semesterID, startDate, endDate). + Group("student_id, status"). + Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// GetAttendanceStatsByDateRange 通过日期范围查询学生考勤统计 +func (r *AttendanceRepo) GetAttendanceStatsByDateRange(startDate, endDate time.Time, classID int) ([]struct { + StudentID int + Status string + Count int64 +}, error) { + var stats []struct { + StudentID int + Status string + Count int64 + } + query := r.db.Model(&model.AttendanceRecord{}). + Select("student_id, status, COUNT(*) as count"). + Where("date BETWEEN ? AND ?", startDate, endDate) + + if classID > 0 { + query = query.Where("student_id IN (SELECT student_id FROM students WHERE class_id = ?)", classID) + } + + err := query.Group("student_id, status").Find(&stats).Error + if err != nil { + return nil, err + } + return stats, nil +} + +// AssociateSemester 将考勤记录关联到学期 +func (r *AttendanceRepo) AssociateSemester(attendanceID int, semesterID int) error { + return r.db.Model(&model.AttendanceRecord{}). + Where("attendance_id = ? AND semester_id IS NULL", attendanceID). + Update("semester_id", semesterID).Error +} diff --git a/backend-go/internal/repository/class_repo.go b/backend-go/internal/repository/class_repo.go new file mode 100644 index 0000000..1fec2d9 --- /dev/null +++ b/backend-go/internal/repository/class_repo.go @@ -0,0 +1,184 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// ClassRepo 班级数据访问层 +type ClassRepo struct { + db *gorm.DB +} + +// NewClassRepo 创建班级 Repository +func NewClassRepo(db *gorm.DB) *ClassRepo { + return &ClassRepo{db: db} +} + +// GetDB 获取底层数据库连接 +func (r *ClassRepo) GetDB() *gorm.DB { + return r.db +} + +// GetByID 根据ID获取班级信息 +func (r *ClassRepo) GetByID(classID int) (*model.Class, error) { + var class model.Class + if err := r.db.Where("class_id = ?", classID).First(&class).Error; err != nil { + return nil, err + } + return &class, nil +} + +// GetAll 获取所有班级列表 +func (r *ClassRepo) GetAll(includeDisabled bool) ([]model.Class, error) { + var classes []model.Class + query := r.db.Where("1 = 1") + if !includeDisabled { + query = query.Where("status = 1") + } + if err := query.Order("class_id").Find(&classes).Error; err != nil { + return nil, err + } + return classes, nil +} + +// GetByName 根据班级名称获取班级 +func (r *ClassRepo) GetByName(className string) (*model.Class, error) { + var class model.Class + if err := r.db.Where("class_name = ?", className).First(&class).Error; err != nil { + return nil, err + } + return &class, nil +} + +// Create 创建班级 +func (r *ClassRepo) Create(class *model.Class) (int, error) { + if err := r.db.Create(class).Error; err != nil { + return 0, err + } + return class.ClassID, nil +} + +// Update 更新班级信息(仅更新非零值字段) +func (r *ClassRepo) Update(classID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Class{}). + Where("class_id = ?", classID). + Updates(updates).Error +} + +// Delete 删除班级(硬删除,需先确认无学生) +func (r *ClassRepo) Delete(classID int) error { + return r.db.Where("class_id = ?", classID).Delete(&model.Class{}).Error +} + +// GetStudentCount 获取班级活跃学生数量 +func (r *ClassRepo) GetStudentCount(classID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// HasActiveStudents 检查班级是否有活跃学生 +func (r *ClassRepo) HasActiveStudents(classID int) (bool, error) { + count, err := r.GetStudentCount(classID) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ========== 班级设置操作 ========== + +// GetSettings 获取班级的所有设置 +func (r *ClassRepo) GetSettings(classID int) ([]model.ClassSetting, error) { + var settings []model.ClassSetting + if err := r.db.Where("class_id = ?", classID).Find(&settings).Error; err != nil { + return nil, err + } + return settings, nil +} + +// GetSetting 获取班级单个设置项 +func (r *ClassRepo) GetSetting(classID int, key string) (*model.ClassSetting, error) { + var setting model.ClassSetting + if err := r.db.Where("class_id = ? AND setting_key = ?", classID, key).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +// SaveSetting 保存班级设置项(upsert) +func (r *ClassRepo) SaveSetting(classID int, key, value string) error { + setting := model.ClassSetting{ + ClassID: classID, + SettingKey: key, + SettingValue: value, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "class_id"}, {Name: "setting_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"setting_value"}), + }).Create(&setting).Error +} + +// BatchSaveSettings 批量保存班级设置项 +func (r *ClassRepo) BatchSaveSettings(classID int, settings map[string]string) error { + for key, value := range settings { + if err := r.SaveSetting(classID, key, value); err != nil { + return err + } + } + return nil +} + +// ========== 班级功能开关操作 ========== + +// GetFeatures 获取班级的所有功能开关 +func (r *ClassRepo) GetFeatures(classID int) ([]model.ClassFeature, error) { + var features []model.ClassFeature + if err := r.db.Where("class_id = ?", classID).Find(&features).Error; err != nil { + return nil, err + } + return features, nil +} + +// GetFeature 获取班级单个功能开关 +func (r *ClassRepo) GetFeature(classID int, featureKey string) (*model.ClassFeature, error) { + var feature model.ClassFeature + if err := r.db.Where("class_id = ? AND feature_key = ?", classID, featureKey).First(&feature).Error; err != nil { + return nil, err + } + return &feature, nil +} + +// SaveFeature 保存班级功能开关(upsert) +func (r *ClassRepo) SaveFeature(classID int, featureKey string, enabled int8) error { + feature := model.ClassFeature{ + ClassID: classID, + FeatureKey: featureKey, + Enabled: enabled, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "class_id"}, {Name: "feature_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"enabled"}), + }).Create(&feature).Error +} diff --git a/backend-go/internal/repository/conduct_repo.go b/backend-go/internal/repository/conduct_repo.go new file mode 100644 index 0000000..53a1380 --- /dev/null +++ b/backend-go/internal/repository/conduct_repo.go @@ -0,0 +1,294 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// ConductRepo 操行分记录数据访问层 +type ConductRepo struct { + db *gorm.DB +} + +// NewConductRepo 创建操行分 Repository +func NewConductRepo(db *gorm.DB) *ConductRepo { + return &ConductRepo{db: db} +} + +// CreateRecord 创建操行分记录 +func (r *ConductRepo) CreateRecord(record *model.ConductRecord) (int64, error) { + if err := r.db.Create(record).Error; err != nil { + return 0, err + } + return record.RecordID, nil +} + +// GetRecordByID 根据ID获取记录(含学生信息) +func (r *ConductRepo) GetRecordByID(recordID int64) (*model.ConductRecord, error) { + var record model.ConductRecord + if err := r.db.Table("conduct_records cr"). + Select("cr.*, s.name as student_name, s.total_points"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("cr.record_id = ?", recordID). + First(&record).Error; err != nil { + return nil, err + } + return &record, nil +} + +// CountStudentRecords 统计学生操行分记录总数 +func (r *ConductRepo) CountStudentRecords(studentID int, includeRevoked bool, startDate, endDate string, recorderID int) (int64, error) { + var count int64 + query := r.db.Model(&model.ConductRecord{}).Where("student_id = ?", studentID) + + if !includeRevoked { + query = query.Where("is_revoked = 0") + } + if startDate != "" { + query = query.Where("DATE(created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(created_at) <= ?", endDate) + } + if recorderID > 0 { + query = query.Where("recorder_id = ?", recorderID) + } + + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// GetStudentRecords 获取学生操行分记录 +func (r *ConductRepo) GetStudentRecords(studentID int, limit, offset int, includeRevoked bool, startDate, endDate string, recorderID int) ([]model.ConductRecord, error) { + var records []model.ConductRecord + query := r.db.Table("conduct_records cr"). + Select("cr.*, u.real_name as recorder_real"). + Joins("LEFT JOIN users u ON cr.recorder_id = u.user_id"). + Where("cr.student_id = ?", studentID) + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if recorderID > 0 { + query = query.Where("cr.recorder_id = ?", recorderID) + } + + if err := query.Order("cr.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetAllRecords 获取所有记录(管理员用,支持多种过滤条件) +func (r *ConductRepo) GetAllRecords(classID int, limit, offset int, startDate, endDate string, + studentID int, includeRevoked bool, relatedType, reasonPrefix string, + isRevoked *int, reasonSearch string) ([]model.ConductRecord, error) { + + var records []model.ConductRecord + query := r.db.Table("conduct_records cr"). + Select("cr.*, s.name as student_name, s.student_no, s.class_id, u.real_name as recorder_real, ru.real_name as revoker_name"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Joins("JOIN users u ON cr.recorder_id = u.user_id"). + Joins("LEFT JOIN users ru ON cr.revoked_by = ru.user_id"). + Where("1 = 1") + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if studentID > 0 { + query = query.Where("cr.student_id = ?", studentID) + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if relatedType != "" { + query = query.Where("cr.related_type = ?", relatedType) + } + if reasonPrefix != "" { + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix)) + } + if reasonSearch != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch) + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped)) + } + if isRevoked != nil { + query = query.Where("cr.is_revoked = ?", *isRevoked) + } + + if err := query.Order("cr.created_at DESC"). + Limit(limit). + Offset(offset). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// CountAllRecords 统计记录总数(与 GetAllRecords 使用相同过滤条件) +func (r *ConductRepo) CountAllRecords(classID int, startDate, endDate string, + studentID int, includeRevoked bool, relatedType, reasonPrefix string, + isRevoked *int, reasonSearch string) (int64, error) { + + var count int64 + query := r.db.Table("conduct_records cr"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("1 = 1") + + if !includeRevoked { + query = query.Where("cr.is_revoked = 0") + } + if classID > 0 { + query = query.Where("s.class_id = ?", classID) + } + if studentID > 0 { + query = query.Where("cr.student_id = ?", studentID) + } + if startDate != "" { + query = query.Where("DATE(cr.created_at) >= ?", startDate) + } + if endDate != "" { + query = query.Where("DATE(cr.created_at) <= ?", endDate) + } + if relatedType != "" { + query = query.Where("cr.related_type = ?", relatedType) + } + if reasonPrefix != "" { + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix)) + } + if reasonSearch != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch) + query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped)) + } + if isRevoked != nil { + query = query.Where("cr.is_revoked = ?", *isRevoked) + } + + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// RevokeRecord 撤销单条操行分记录 +func (r *ConductRepo) RevokeRecord(recordID int64, revokerID int) error { + return r.db.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 0", recordID). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + }).Error +} + +// BatchRevokeRecords 批量撤销记录 +func (r *ConductRepo) BatchRevokeRecords(recordIDs []int64, revokerID int) (int64, error) { + result := r.db.Model(&model.ConductRecord{}). + Where("record_id IN ? AND is_revoked = 0", recordIDs). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + "revoked_at": time.Now(), + }) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// BatchRestoreRecords 批量反撤销记录 +func (r *ConductRepo) BatchRestoreRecords(recordIDs []int64) (int64, error) { + result := r.db.Model(&model.ConductRecord{}). + Where("record_id IN ? AND is_revoked = 1", recordIDs). + Updates(map[string]interface{}{ + "is_revoked": 0, + "revoked_by": nil, + "revoked_at": nil, + }) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +// AssociateSemester 将记录关联到学期 +func (r *ConductRepo) AssociateSemester(recordID int64, semesterID int) error { + return r.db.Model(&model.ConductRecord{}). + Where("record_id = ? AND semester_id IS NULL", recordID). + Update("semester_id", semesterID).Error +} + +// GetHomeworkRecords 获取学生作业相关的操行分记录 +func (r *ConductRepo) GetHomeworkRecords(studentID int) ([]model.ConductRecord, error) { + var records []model.ConductRecord + if err := r.db.Where("student_id = ? AND related_type = 'homework' AND is_revoked = 0", studentID). + Order("created_at DESC"). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +// GetStudentPointsByType 按 related_type 在 SQL 层聚合学生分数(避免全量加载),支持 limit 限制返回数量 +func (r *ConductRepo) GetStudentPointsByType(classID int, relatedType string, limit int) ([]struct { + StudentID int + StudentNo string + Name string + TotalPoints int +}, error) { + var results []struct { + StudentID int + StudentNo string + Name string + TotalPoints int + } + err := r.db.Table("conduct_records cr"). + Select("cr.student_id, s.student_no, s.name, SUM(cr.points_change) as total_points"). + Joins("JOIN students s ON cr.student_id = s.student_id"). + Where("s.class_id = ? AND s.status = 1 AND cr.related_type = ? AND cr.is_revoked = 0", classID, relatedType). + Group("cr.student_id, s.student_no, s.name"). + Order("total_points DESC"). + Limit(limit). + Find(&results).Error + return results, err +} + +// GetStudentTotalPoints 获取学生当前总分 +func (r *ConductRepo) GetStudentTotalPoints(studentID int) (int, error) { + var student model.Student + if err := r.db.Where("student_id = ?", studentID).First(&student).Error; err != nil { + return 0, err + } + return student.TotalPoints, nil +} diff --git a/backend-go/internal/repository/log_repo.go b/backend-go/internal/repository/log_repo.go new file mode 100644 index 0000000..267813d --- /dev/null +++ b/backend-go/internal/repository/log_repo.go @@ -0,0 +1,91 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// LogRepo 日志数据访问层 +type LogRepo struct { + db *gorm.DB +} + +// NewLogRepo 创建日志 Repository +func NewLogRepo(db *gorm.DB) *LogRepo { + return &LogRepo{db: db} +} + +// ========== 操作日志 ========== + +// CreateOperationLog 写入操作日志 +func (r *LogRepo) CreateOperationLog(log *model.OperationLog) (int64, error) { + if err := r.db.Create(log).Error; err != nil { + return 0, err + } + return log.LogID, nil +} + +// GetOperationLogs 查询操作日志(支持按操作者和班级过滤) +func (r *LogRepo) GetOperationLogs(operatorID int, classID int, operationType string, page, pageSize int) ([]model.OperationLog, int64, error) { + var logs []model.OperationLog + var total int64 + + query := r.db.Model(&model.OperationLog{}).Where("1 = 1") + + if operatorID > 0 { + query = query.Where("operator_id = ?", operatorID) + } + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + if operationType != "" { + query = query.Where("operation_type = ?", operationType) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC"). + Limit(pageSize). + Offset(offset). + Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// ========== 登录日志 ========== + +// CreateLoginLog 写入登录日志 +func (r *LogRepo) CreateLoginLog(log *model.LoginLog) (int64, error) { + if err := r.db.Create(log).Error; err != nil { + return 0, err + } + return log.LogID, nil +} + +// GetRecentLoginFailCount 获取最近 5 分钟内的登录失败次数 +func (r *LogRepo) GetRecentLoginFailCount(username string) (int64, error) { + var count int64 + if err := r.db.Model(&model.LoginLog{}). + Where("username = ? AND login_result = 0 AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)", username). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/backend-go/internal/repository/semester_repo.go b/backend-go/internal/repository/semester_repo.go new file mode 100644 index 0000000..f4eec43 --- /dev/null +++ b/backend-go/internal/repository/semester_repo.go @@ -0,0 +1,291 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SemesterRepo 学期数据访问层 +type SemesterRepo struct { + db *gorm.DB +} + +// NewSemesterRepo 创建学期 Repository +func NewSemesterRepo(db *gorm.DB) *SemesterRepo { + return &SemesterRepo{db: db} +} + +// GetDB 获取底层数据库连接(用于事务操作) +func (r *SemesterRepo) GetDB() *gorm.DB { + return r.db +} + +// Create 创建学期 +func (r *SemesterRepo) Create(semester *model.Semester) (int, error) { + if err := r.db.Create(semester).Error; err != nil { + return 0, err + } + return semester.SemesterID, nil +} + +// GetByID 根据ID获取学期信息 +func (r *SemesterRepo) GetByID(semesterID int) (*model.Semester, error) { + var semester model.Semester + if err := r.db.Where("semester_id = ?", semesterID).First(&semester).Error; err != nil { + return nil, err + } + return &semester, nil +} + +// GetAll 获取所有学期列表 +func (r *SemesterRepo) GetAll() ([]model.Semester, error) { + var semesters []model.Semester + if err := r.db.Order("created_at DESC").Find(&semesters).Error; err != nil { + return nil, err + } + return semesters, nil +} + +// GetActive 获取当前活跃学期(优先 is_active 标记,降级为日期范围匹配) +func (r *SemesterRepo) GetActive() (*model.Semester, error) { + var semester model.Semester + + // 第一优先级:is_active 标记 + if err := r.db.Where("is_active = 1 AND is_archived = 0"). + Limit(1).First(&semester).Error; err == nil { + return &semester, nil + } + + // 第二优先级:日期范围匹配 + today := time.Now().Format("2006-01-02") + if err := r.db.Where("is_archived = 0 AND start_date <= ? AND (end_date IS NULL OR end_date >= ?)", today, today). + Limit(1).First(&semester).Error; err != nil { + return nil, err + } + return &semester, nil +} + +// DeactivateAll 将所有学期设为非活跃 +func (r *SemesterRepo) DeactivateAll() error { + return r.db.Model(&model.Semester{}). + Where("is_active = 1"). + Update("is_active", 0).Error +} + +// Activate 设为当前活跃学期 +func (r *SemesterRepo) Activate(semesterID int) error { + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Update("is_active", 1).Error +} + +// Archive 归档学期 +func (r *SemesterRepo) Archive(semesterID int) error { + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(map[string]interface{}{ + "is_archived": 1, + "is_active": 0, + }).Error +} + +// Update 编辑学期信息(仅未归档) +func (r *SemesterRepo) Update(semesterID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(updates).Error +} + +// Delete 删除学期 +func (r *SemesterRepo) Delete(semesterID int) error { + return r.db.Where("semester_id = ?", semesterID).Delete(&model.Semester{}).Error +} + +// CountArchives 统计学期归档数据数量 +func (r *SemesterRepo) CountArchives(semesterID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.SemesterArchive{}). + Where("semester_id = ?", semesterID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CountRecordsBySemester 统计学期关联的记录数 +func (r *SemesterRepo) CountRecordsBySemester(semesterID int) (conductCount, attendanceCount int64, err error) { + if err = r.db.Model(&model.ConductRecord{}). + Where("semester_id = ?", semesterID). + Count(&conductCount).Error; err != nil { + return 0, 0, err + } + if err = r.db.Model(&model.AttendanceRecord{}). + Where("semester_id = ?", semesterID). + Count(&attendanceCount).Error; err != nil { + return 0, 0, err + } + return conductCount, attendanceCount, nil +} + +// AssociateRecordsByDateRange 按日期范围关联记录到学期 +func (r *SemesterRepo) AssociateRecordsByDateRange(semesterID int, startDate, endDate string) (conductCount, attendanceCount int64, err error) { + if startDate == "" || endDate == "" { + return 0, 0, fmt.Errorf("日期范围不能为空") + } + + // 关联操行分记录 + result := r.db.Model(&model.ConductRecord{}). + Where("semester_id IS NULL AND created_at BETWEEN ? AND CONCAT(?, ' 23:59:59')", startDate, endDate). + Update("semester_id", semesterID) + if result.Error != nil { + return 0, 0, result.Error + } + conductCount = result.RowsAffected + + // 关联考勤记录 + result = r.db.Model(&model.AttendanceRecord{}). + Where("semester_id IS NULL AND `date` BETWEEN ? AND ?", startDate, endDate). + Update("semester_id", semesterID) + if result.Error != nil { + return conductCount, 0, result.Error + } + attendanceCount = result.RowsAffected + + return conductCount, attendanceCount, nil +} + +// GetConductRecordSemesterID 获取操行分记录所属的学期ID +func (r *SemesterRepo) GetConductRecordSemesterID(recordID int64) (*int, error) { + var record model.ConductRecord + if err := r.db.Where("record_id = ?", recordID).First(&record).Error; err != nil { + return nil, err + } + return record.SemesterID, nil +} + +// ========== 学期归档操作 ========== + +// BatchCreateArchives 批量创建归档快照 +func (r *SemesterRepo) BatchCreateArchives(archives []model.SemesterArchive) error { + if len(archives) == 0 { + return nil + } + return r.db.Create(&archives).Error +} + +// DeleteArchivesBySemester 删除指定学期的所有归档数据 +func (r *SemesterRepo) DeleteArchivesBySemester(semesterID int) error { + return r.db.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error +} + +// GetArchivesBySemester 获取学期的归档数据 +func (r *SemesterRepo) GetArchivesBySemester(semesterID int, classID int, page, pageSize int) ([]model.SemesterArchive, int64, error) { + var archives []model.SemesterArchive + var total int64 + + query := r.db.Model(&model.SemesterArchive{}).Where("semester_id = ?", semesterID) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("rank_position ASC"). + Limit(pageSize). + Offset(offset). + Find(&archives).Error; err != nil { + return nil, 0, err + } + + return archives, total, nil +} + +// GetArchivesByStudent 获取学生在所有已归档学期的数据 +func (r *SemesterRepo) GetArchivesByStudent(studentID int) ([]model.SemesterArchive, error) { + var archives []model.SemesterArchive + if err := r.db.Table("semester_archives sa"). + Select("sa.archive_id, sa.semester_id, sa.student_id, sa.student_no, "+ + "sa.student_name, sa.final_points, sa.rank_position, "+ + "sa.total_students, sa.attendance_present, sa.attendance_absent, "+ + "sa.attendance_late, sa.attendance_leave, "+ + "sa.homework_submitted, sa.homework_not_submitted, sa.homework_late, "+ + "sa.archived_at, s.semester_name, s.start_date, s.end_date"). + Joins("JOIN semesters s ON sa.semester_id = s.semester_id"). + Where("sa.student_id = ?", studentID). + Order("sa.archived_at DESC"). + Find(&archives).Error; err != nil { + return nil, err + } + return archives, nil +} + +// ========== 周期归档操作 ========== + +// GetPeriodArchives 获取周期归档列表 +func (r *SemesterRepo) GetPeriodArchives(classID int, periodType string, page, pageSize int) ([]model.PeriodArchive, int64, error) { + var archives []model.PeriodArchive + var total int64 + + query := r.db.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ?", classID, periodType) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := query.Order("archived_at DESC, period_label DESC, rank_position ASC"). + Limit(pageSize). + Offset(offset). + Find(&archives).Error; err != nil { + return nil, 0, err + } + + return archives, total, nil +} + +// GetPeriodArchiveLabels 获取班级的所有周期归档标签(按时间倒序去重) +func (r *SemesterRepo) GetPeriodArchiveLabels(classID int, periodType string) ([]string, error) { + var labels []string + if err := r.db.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ?", classID, periodType). + Distinct("period_label"). + Order("period_label DESC"). + Pluck("period_label", &labels).Error; err != nil { + return nil, err + } + return labels, nil +} + +// GetLatestPeriodArchiveLabel 获取指定班级最近一次周期归档的标签 +func (r *SemesterRepo) GetLatestPeriodArchiveLabel(classID int, periodType string) (string, error) { + var archive model.PeriodArchive + if err := r.db.Where("class_id = ? AND period_type = ?", classID, periodType). + Order("archived_at DESC"). + Limit(1). + First(&archive).Error; err != nil { + return "", err + } + return archive.PeriodLabel, nil +} diff --git a/backend-go/internal/repository/student_repo.go b/backend-go/internal/repository/student_repo.go new file mode 100644 index 0000000..7ae5a98 --- /dev/null +++ b/backend-go/internal/repository/student_repo.go @@ -0,0 +1,230 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// StudentRepo 学生数据访问层 +type StudentRepo struct { + db *gorm.DB +} + +// NewStudentRepo 创建学生 Repository +func NewStudentRepo(db *gorm.DB) *StudentRepo { + return &StudentRepo{db: db} +} + +// GetByID 根据ID获取学生信息(含班级名称) +func (r *StudentRepo) GetByID(studentID int) (*model.Student, error) { + var student model.Student + if err := r.db.Table("students s"). + Select("s.*, c.class_name"). + Joins("LEFT JOIN classes c ON s.class_id = c.class_id"). + Where("s.student_id = ?", studentID). + First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetByStudentNo 根据学号获取学生(可指定班级) +func (r *StudentRepo) GetByStudentNo(studentNo string, classID int) (*model.Student, error) { + var student model.Student + query := r.db.Where("student_no = ?", studentNo) + if classID > 0 { + query = query.Where("class_id = ?", classID) + } + if err := query.First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetAll 获取指定班级的学生列表 +func (r *StudentRepo) GetAll(classID int, includeDisabled bool) ([]model.Student, error) { + var students []model.Student + query := r.db.Where("class_id = ?", classID) + if !includeDisabled { + query = query.Where("status = 1") + } + if err := query.Order("student_no").Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} + +// GetDormitoryList 获取班级内所有不重复的宿舍号列表 +func (r *StudentRepo) GetDormitoryList(classID int) ([]string, error) { + var dormitories []string + err := r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1 AND dormitory_number IS NOT NULL AND dormitory_number != ''", classID). + Distinct("dormitory_number"). + Order("dormitory_number"). + Pluck("dormitory_number", &dormitories).Error + if err != nil { + return nil, err + } + return dormitories, nil +} + +// Create 创建学生记录 +func (r *StudentRepo) Create(student *model.Student) (int, error) { + if err := r.db.Create(student).Error; err != nil { + return 0, err + } + return student.StudentID, nil +} + +// Update 更新学生信息(仅更新非零值字段) +func (r *StudentRepo) Update(studentID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Updates(updates).Error +} + +// SoftDelete 软删除学生 +func (r *StudentRepo) SoftDelete(studentID int) error { + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("status", 0).Error +} + +// UpdateTotalPoints 更新学生总分(增量更新,下限保护为 0) +func (r *StudentRepo) UpdateTotalPoints(studentID int, pointsChange int) error { + return r.db.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error +} + +// GetRanking 获取班级内学生排行 +func (r *StudentRepo) GetRanking(classID int, limit int) ([]model.Student, error) { + var students []model.Student + if err := r.db.Where("status = 1 AND class_id = ?", classID). + Order("total_points DESC, student_id ASC"). + Limit(limit). + Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} + +// GetTotalCount 获取班级内活跃学生总数 +func (r *StudentRepo) GetTotalCount(classID int) (int64, error) { + var count int64 + if err := r.db.Model(&model.Student{}). + Where("status = 1 AND class_id = ?", classID). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// ListByClass 分页获取班级学生列表(支持搜索和宿舍号过滤) +func (r *StudentRepo) ListByClass(classID int, page, pageSize int, search, dormitoryNumber string) ([]model.Student, int64, error) { + var students []model.Student + var total int64 + + query := r.db.Model(&model.Student{}).Where("status = 1 AND class_id = ?", classID) + + if search != "" { + escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(search) + searchPattern := fmt.Sprintf("%%%s%%", escaped) + query = query.Where("student_no LIKE ? OR name LIKE ?", searchPattern, searchPattern) + } + + if dormitoryNumber != "" { + query = query.Where("dormitory_number = ?", dormitoryNumber) + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + offset := (page - 1) * pageSize + if err := query.Order("student_no"). + Limit(pageSize). + Offset(offset). + Find(&students).Error; err != nil { + return nil, 0, err + } + + return students, total, nil +} + +// BatchCreate 批量创建学生 +func (r *StudentRepo) BatchCreate(students []model.Student) error { + return r.db.Create(&students).Error +} + +// GetStudentNosByClass 获取指定班级所有学生学号(用于批量导入去重) +func (r *StudentRepo) GetStudentNosByClass(classID int) ([]string, error) { + var studentNos []string + if err := r.db.Model(&model.Student{}). + Where("class_id = ?", classID). + Pluck("student_no", &studentNos).Error; err != nil { + return nil, err + } + return studentNos, nil +} + +// ResetPoints 重置班级内所有学生的操行分为初始值 +func (r *StudentRepo) ResetPoints(classID int, initialPoints int) error { + return r.db.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error +} + +// GetByParentAccount 根据家长账号查找学生 +func (r *StudentRepo) GetByParentAccount(parentAccount string) (*model.Student, error) { + var student model.Student + if err := r.db.Where("parent_account = ? AND status = 1", parentAccount).First(&student).Error; err != nil { + return nil, err + } + return &student, nil +} + +// GetRankByStudentID 使用密集排名(dense rank)计算学生排名:相同分数同名次,后续名次不跳过 +func (r *StudentRepo) GetRankByStudentID(classID, studentID int) (int, error) { + var student model.Student + if err := r.db.Select("total_points").Where("student_id = ?", studentID).First(&student).Error; err != nil { + return 0, err + } + var distinctHigherCount int64 + if err := r.db.Raw("SELECT COUNT(DISTINCT total_points) FROM students WHERE status = 1 AND class_id = ? AND total_points > ?", + classID, student.TotalPoints).Scan(&distinctHigherCount).Error; err != nil { + return 0, err + } + return int(distinctHigherCount) + 1, nil +} + +// GetStudentsByClassID 获取班级内所有活跃学生(用于归档等批量操作) +func (r *StudentRepo) GetStudentsByClassID(classID int) ([]model.Student, error) { + var students []model.Student + if err := r.db.Where("class_id = ? AND status = 1", classID). + Order("total_points DESC, student_id ASC"). + Find(&students).Error; err != nil { + return nil, err + } + return students, nil +} diff --git a/backend-go/internal/repository/subject_repo.go b/backend-go/internal/repository/subject_repo.go new file mode 100644 index 0000000..6d3129d --- /dev/null +++ b/backend-go/internal/repository/subject_repo.go @@ -0,0 +1,104 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SubjectRepo 科目数据访问层 +type SubjectRepo struct { + db *gorm.DB +} + +// NewSubjectRepo 创建科目 Repository +func NewSubjectRepo(db *gorm.DB) *SubjectRepo { + return &SubjectRepo{db: db} +} + +// GetAll 获取所有科目列表 +func (r *SubjectRepo) GetAll(isActive *bool) ([]model.Subject, error) { + var subjects []model.Subject + query := r.db.Where("1 = 1") + if isActive != nil { + if *isActive { + query = query.Where("is_active = 1") + } else { + query = query.Where("is_active = 0") + } + } + if err := query.Order("sort_order, subject_id").Find(&subjects).Error; err != nil { + return nil, err + } + return subjects, nil +} + +// GetByID 根据ID获取科目 +func (r *SubjectRepo) GetByID(subjectID int) (*model.Subject, error) { + var subject model.Subject + if err := r.db.Where("subject_id = ?", subjectID).First(&subject).Error; err != nil { + return nil, err + } + return &subject, nil +} + +// GetByName 根据科目名称获取科目 +func (r *SubjectRepo) GetByName(subjectName string) (*model.Subject, error) { + var subject model.Subject + if err := r.db.Where("subject_name = ?", subjectName).First(&subject).Error; err != nil { + return nil, err + } + return &subject, nil +} + +// Create 创建科目 +func (r *SubjectRepo) Create(subject *model.Subject) (int, error) { + if err := r.db.Create(subject).Error; err != nil { + return 0, err + } + return subject.SubjectID, nil +} + +// Update 更新科目信息 +func (r *SubjectRepo) Update(subjectID int, updates map[string]interface{}) error { + if len(updates) == 0 { + return nil + } + return r.db.Model(&model.Subject{}). + Where("subject_id = ?", subjectID). + Updates(updates).Error +} + +// Delete 删除科目 +func (r *SubjectRepo) Delete(subjectID int) error { + return r.db.Where("subject_id = ?", subjectID).Delete(&model.Subject{}).Error +} + +// HasRelatedData 检查科目是否有关联的作业数据 +func (r *SubjectRepo) HasRelatedData(subjectID int) (bool, error) { + var count int64 + if err := r.db.Model(&model.Assignment{}). + Where("subject_id = ?", subjectID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// Activate 激活科目 +func (r *SubjectRepo) Activate(subjectID int) error { + return r.db.Model(&model.Subject{}). + Where("subject_id = ?", subjectID). + Update("is_active", 1).Error +} diff --git a/backend-go/internal/repository/super_admin_repo.go b/backend-go/internal/repository/super_admin_repo.go new file mode 100644 index 0000000..67e3855 --- /dev/null +++ b/backend-go/internal/repository/super_admin_repo.go @@ -0,0 +1,110 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SuperAdminRepo 超级管理员数据访问层 +type SuperAdminRepo struct { + db *gorm.DB +} + +// NewSuperAdminRepo 创建超级管理员 Repository +func NewSuperAdminRepo(db *gorm.DB) *SuperAdminRepo { + return &SuperAdminRepo{db: db} +} + +// GetByUsername 根据用户名获取超级管理员 +func (r *SuperAdminRepo) GetByUsername(username string) (*model.SuperAdmin, error) { + var admin model.SuperAdmin + if err := r.db.Where("username = ? AND status = 1", username).First(&admin).Error; err != nil { + return nil, err + } + return &admin, nil +} + +// GetByID 根据ID获取超级管理员 +func (r *SuperAdminRepo) GetByID(id int) (*model.SuperAdmin, error) { + var admin model.SuperAdmin + if err := r.db.Where("id = ?", id).First(&admin).Error; err != nil { + return nil, err + } + return &admin, nil +} + +// Create 创建超级管理员 +func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) { + if err := r.db.Create(admin).Error; err != nil { + return 0, err + } + return admin.ID, nil +} + +// UpdatePassword 更新超级管理员密码 +func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Update("password_hash", passwordHash).Error +} + +// UpdatePasswordWithSalt 更新超级管理员密码和盐值,并清除强制改密标记 +func (r *SuperAdminRepo) UpdatePasswordWithSalt(id int, passwordHash, salt string) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "password_hash": passwordHash, + "salt": salt, + "need_change_password": 0, + }).Error +} + +// CheckUsernameExists 检查用户名是否存在 +func (r *SuperAdminRepo) CheckUsernameExists(username string) (bool, error) { + var count int64 + if err := r.db.Model(&model.SuperAdmin{}).Where("username = ?", username).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// List 获取所有超级管理员 +func (r *SuperAdminRepo) List() ([]model.SuperAdmin, error) { + var admins []model.SuperAdmin + if err := r.db.Order("id").Find(&admins).Error; err != nil { + return nil, err + } + return admins, nil +} + +// UpdateStatus 更新超级管理员状态 +func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error { + return r.db.Model(&model.SuperAdmin{}). + Where("id = ?", id). + Update("status", status).Error +} + +// EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态) +func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, salt, realName string) error { + admin := model.SuperAdmin{ + Username: username, + PasswordHash: passwordHash, + Salt: salt, + RealName: realName, + Status: 1, + } + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&admin).Error +} diff --git a/backend-go/internal/repository/system_setting_repo.go b/backend-go/internal/repository/system_setting_repo.go new file mode 100644 index 0000000..1191494 --- /dev/null +++ b/backend-go/internal/repository/system_setting_repo.go @@ -0,0 +1,100 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// SystemSettingRepo 系统设置数据访问层 +type SystemSettingRepo struct { + db *gorm.DB +} + +// NewSystemSettingRepo 创建系统设置 Repository +func NewSystemSettingRepo(db *gorm.DB) *SystemSettingRepo { + return &SystemSettingRepo{db: db} +} + +// GetByKey 根据键名获取系统设置 +func (r *SystemSettingRepo) GetByKey(key string) (*model.SystemSetting, error) { + var setting model.SystemSetting + if err := r.db.Where("setting_key = ?", key).First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +// GetAll 获取所有系统设置 +func (r *SystemSettingRepo) GetAll() ([]model.SystemSetting, error) { + var settings []model.SystemSetting + if err := r.db.Find(&settings).Error; err != nil { + return nil, err + } + return settings, nil +} + +// GetByKeyMap 获取所有系统设置并转为 map +func (r *SystemSettingRepo) GetByKeyMap() (map[string]string, error) { + settings, err := r.GetAll() + if err != nil { + return nil, err + } + result := make(map[string]string, len(settings)) + for _, s := range settings { + result[s.SettingKey] = s.SettingValue + } + return result, nil +} + +// Save 保存系统设置(upsert) +func (r *SystemSettingRepo) Save(key, value string) error { + setting := model.SystemSetting{ + SettingKey: key, + SettingValue: value, + } + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "setting_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"setting_value"}), + }).Create(&setting).Error +} + +// BatchSave 批量保存系统设置 +func (r *SystemSettingRepo) BatchSave(settings map[string]string) error { + for key, value := range settings { + if err := r.Save(key, value); err != nil { + return err + } + } + return nil +} + +// GetValue 根据键名获取设置值 +func (r *SystemSettingRepo) GetValue(key string) (string, error) { + setting, err := r.GetByKey(key) + if err != nil { + return "", err + } + return setting.SettingValue, nil +} + +// GetValueWithDefault 根据键名获取设置值,不存在则返回默认值 +func (r *SystemSettingRepo) GetValueWithDefault(key, defaultValue string) string { + setting, err := r.GetByKey(key) + if err != nil { + return defaultValue + } + return setting.SettingValue +} diff --git a/backend-go/internal/repository/user_repo.go b/backend-go/internal/repository/user_repo.go new file mode 100644 index 0000000..5ea3636 --- /dev/null +++ b/backend-go/internal/repository/user_repo.go @@ -0,0 +1,166 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package repository + +import ( + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" +) + +// UserRepo 用户数据访问层 +type UserRepo struct { + db *gorm.DB +} + +// NewUserRepo 创建用户 Repository +func NewUserRepo(db *gorm.DB) *UserRepo { + return &UserRepo{db: db} +} + +// GetByUsername 根据用户名获取用户(含状态过滤) +func (r *UserRepo) GetByUsername(username string) (*model.User, error) { + var user model.User + if err := r.db.Where("username = ? AND status = 1", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetByUserID 根据用户ID获取用户 +func (r *UserRepo) GetByUserID(userID int) (*model.User, error) { + var user model.User + if err := r.db.Where("user_id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// CreateStudent 创建学生账号 +func (r *UserRepo) CreateStudent(username, passwordHash, realName string, studentID int) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "student", + StudentID: &studentID, + Status: 1, + NeedChangePassword: 1, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// CreateParent 创建家长账号 +func (r *UserRepo) CreateParent(username, passwordHash, realName string, studentID int) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "parent", + StudentID: &studentID, + Status: 1, + NeedChangePassword: 0, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// CreateAdmin 创建管理员账号 +func (r *UserRepo) CreateAdmin(username, passwordHash, realName string) (int, error) { + user := model.User{ + Username: username, + PasswordHash: passwordHash, + RealName: realName, + UserType: "admin", + Status: 1, + NeedChangePassword: 1, + } + if err := r.db.Create(&user).Error; err != nil { + return 0, err + } + return user.UserID, nil +} + +// UpdatePassword 更新密码并清除强制改密标记 +func (r *UserRepo) UpdatePassword(userID int, passwordHash string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Updates(map[string]interface{}{ + "password_hash": passwordHash, + "need_change_password": 0, + }).Error +} + +// UpdateLastLogin 更新最后登录信息 +func (r *UserRepo) UpdateLastLogin(userID int, ip string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Updates(map[string]interface{}{ + "last_login_time": time.Now(), + "last_login_ip": ip, + }).Error +} + +// CheckUsernameExists 检查用户名是否存在 +func (r *UserRepo) CheckUsernameExists(username string) (bool, error) { + var count int64 + if err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// UpdateStatus 更新用户状态 +func (r *UserRepo) UpdateStatus(userID int, status int8) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Update("status", status).Error +} + +// UpdateRealName 更新用户真实姓名 +func (r *UserRepo) UpdateRealName(userID int, realName string) error { + return r.db.Model(&model.User{}). + Where("user_id = ?", userID). + Update("real_name", realName).Error +} + +// GetByStudentID 根据学生ID获取关联的用户账号 +func (r *UserRepo) GetByStudentID(studentID int) (*model.User, error) { + var user model.User + if err := r.db.Where("student_id = ? AND status = 1", studentID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// DeleteUser 硬删除用户记录 +func (r *UserRepo) DeleteUser(userID int) error { + return r.db.Unscoped().Where("user_id = ?", userID).Delete(&model.User{}).Error +} + +// GetActiveUsernames 获取所有活跃用户的用户名列表(用于批量导入去重) +func (r *UserRepo) GetActiveUsernames() ([]string, error) { + var usernames []string + if err := r.db.Model(&model.User{}). + Where("status = 1"). + Pluck("username", &usernames).Error; err != nil { + return nil, err + } + return usernames, nil +} diff --git a/backend-go/internal/router/router.go b/backend-go/internal/router/router.go new file mode 100644 index 0000000..3002e0a --- /dev/null +++ b/backend-go/internal/router/router.go @@ -0,0 +1,206 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package router + +import ( + "github.com/gin-gonic/gin" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/handler" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response" +) + +// Handlers 聚合所有 HTTP 处理器 +type Handlers struct { + Auth *handler.AuthHandler + Admin *handler.AdminHandler + Student *handler.StudentHandler + Parent *handler.ParentHandler + Subject *handler.SubjectHandler + Semester *handler.SemesterHandler + Class *handler.ClassHandler + Config *handler.ConfigHandler + SuperAdmin *handler.SuperAdminHandler + Cadre *handler.CadreHandler +} + +// SetupRouter 注册所有路由,返回 Gin 引擎 +func SetupRouter(cfg *config.Config, h *Handlers) *gin.Engine { + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + + // ========== 全局中间件 ========== + // CORS 说明:生产环境通过 Nginx 反代实现同源策略,API 与前端同域,无需额外 CORS 配置。 + // 若需要直接访问 API(绕过 Nginx),需在此添加 CORS 中间件。 + r.Use(middleware.AccessLog()) + r.Use(gin.Recovery()) + r.Use(middleware.Sanitize()) + + // ========== 公开路由组(不需要认证) ========== + public := r.Group("/api") + { + public.POST("/auth/login", h.Auth.Login) + } + + // ========== 超级管理员独立登录(路径可配置) ========== + superAdminPath := "/api" + cfg.SuperAdminLoginPath + middleware.RegisterPublicPath(superAdminPath + "/login") + superAdmin := r.Group(superAdminPath) + { + superAdmin.POST("/login", h.SuperAdmin.Login) + } + + // ========== 需认证的路由组 ========== + authRequired := r.Group("/api") + authRequired.Use(middleware.AuthRequired()) + { + // 扣分规则(需认证) + authRequired.GET("/config/deduction-rules", h.Config.GetDeductionRules) + + // 认证相关 + authRequired.POST("/auth/logout", h.Auth.Logout) + authRequired.POST("/auth/change-password", h.Auth.ChangePassword) + authRequired.GET("/auth/me", h.Auth.GetUserInfo) + + // 学生端 + student := authRequired.Group("/student") + { + student.GET("/conduct/:student_id", h.Student.ConductHistory) + student.GET("/homework/:student_id", h.Student.Homework) + student.GET("/attendance/:student_id", h.Student.Attendance) + student.GET("/ranking", h.Student.Ranking) + student.GET("/my-info", h.Student.MyInfo) + student.GET("/semester-records", h.Student.SemesterRecords) + } + + // 家长端 + parent := authRequired.Group("/parent") + { + parent.GET("/child/conduct", h.Parent.Dashboard) + parent.GET("/child/attendance", h.Parent.Attendance) + parent.GET("/child/ranking", h.Parent.Ranking) + parent.GET("/child/history", h.Parent.History) + parent.POST("/password", h.Parent.ChangePassword) + } + + // 管理端 + admin := authRequired.Group("/admin") + admin.Use(middleware.RequireRole("admin", "super_admin")) + { + // 学生管理 + admin.GET("/students/dormitories", h.Admin.GetDormitories) + admin.GET("/students", h.Admin.StudentList) + admin.POST("/students/import", h.Admin.StudentImport) + admin.POST("/students", h.Admin.StudentCreate) + admin.PUT("/students/:student_id", h.Admin.StudentUpdate) + admin.DELETE("/students/:student_id", h.Admin.StudentDelete) + admin.POST("/students/reset-password/:student_id", h.Admin.ResetStudentPassword) + + // 操行分管理 + admin.POST("/conduct/add", h.Admin.AddConductPoints) + admin.POST("/conduct/revoke", h.Admin.RevokeConductRecord) + admin.POST("/conduct/restore", h.Admin.RestoreConductRecord) + admin.GET("/conduct/history", h.Admin.GetConductHistory) + admin.POST("/conduct/batch-revoke", h.Admin.BatchRevokeConductRecords) + admin.POST("/conduct/batch-restore", h.Admin.BatchRestoreConductRecords) + + // 考勤管理 + admin.POST("/attendance", h.Admin.CreateAttendanceRecord) + admin.GET("/attendance/records", h.Admin.GetAttendanceRecords) + + // 管理员管理 + admin.POST("/add", h.Admin.AdminCreate) + admin.GET("/list", h.Admin.AdminList) + admin.PUT("/update/:user_id", h.Admin.AdminUpdate) + admin.DELETE("/delete/:user_id", h.Admin.AdminDelete) + admin.POST("/reset-password/:user_id", h.Admin.AdminResetPassword) + admin.POST("/unlock-user", h.Admin.UnlockAccount) + + // 排行榜分项(新增) + admin.GET("/rankings", h.Admin.GetRankings) + } + + // 科目管理 + subject := authRequired.Group("/subject") + subject.Use(middleware.RequireRole("admin", "super_admin")) + { + subject.GET("/list", h.Subject.SubjectList) + subject.POST("/create", h.Subject.SubjectCreate) + subject.PUT("/update/:subject_id", h.Subject.SubjectUpdate) + subject.PUT("/toggle/:subject_id", h.Subject.SubjectToggle) + subject.DELETE("/delete/:subject_id", h.Subject.SubjectDelete) + } + + // 学期管理 + semester := authRequired.Group("/semester") + semester.Use(middleware.RequireRole("admin", "super_admin")) + { + semester.GET("/list", h.Semester.SemesterList) + semester.GET("/active", h.Semester.ActiveSemester) + semester.POST("/create", h.Semester.SemesterCreate) + semester.PUT("/activate/:semester_id", h.Semester.ActivateSemester) + semester.PUT("/update/:semester_id", h.Semester.SemesterUpdate) + semester.DELETE("/delete/:semester_id", h.Semester.SemesterDelete) + semester.POST("/:semester_id/associate", h.Semester.AssociateRecords) + semester.POST("/archive/:semester_id", h.Semester.ArchiveSemester) + semester.GET("/archive/:semester_id/records", h.Semester.GetArchiveData) + semester.POST("/period-reset", h.Semester.PeriodReset) + semester.GET("/period-archives", h.Semester.GetPeriodArchives) + } + + // 班级管理 + classGroup := authRequired.Group("/class") + classGroup.Use(middleware.RequireRole("admin", "super_admin")) + { + classGroup.GET("/list", h.Class.ClassList) + classGroup.GET("/:class_id", h.Class.ClassDetail) + classGroup.POST("/create", h.Class.ClassCreate) + classGroup.PUT("/update/:class_id", h.Class.ClassUpdate) + classGroup.DELETE("/delete/:class_id", h.Class.ClassDelete) + classGroup.POST("/switch", h.Class.SwitchClass) + classGroup.POST("/settings", h.Class.SaveSetting) + classGroup.GET("/settings", h.Class.GetSettings) + classGroup.GET("/point-limits", h.Class.GetPointLimits) + classGroup.POST("/point-limits", h.Class.SavePointLimits) + classGroup.GET("/features", h.Class.GetFeatures) + classGroup.POST("/features", h.Class.SaveFeature) + } + + // 课代表路由(新增) + cadre := authRequired.Group("/cadre") + cadre.Use(middleware.RequireRole("课代表")) + { + cadre.GET("/homework", h.Cadre.HomeworkList) + cadre.POST("/homework", h.Cadre.HomeworkSubmit) + cadre.POST("/conduct/add", h.Cadre.AddConductPoints) + } + } + + // ========== 系统路由 ========== + r.GET("/", func(c *gin.Context) { + response.Success(c, gin.H{ + "app": cfg.AppName, + "version": "2.0", + "status": "running", + }, "服务运行中") + }) + + r.GET("/health", func(c *gin.Context) { + response.Success(c, gin.H{"status": "ok"}, "健康检查通过") + }) + + return r +} diff --git a/backend-go/internal/schema/admin.go b/backend-go/internal/schema/admin.go new file mode 100644 index 0000000..d1ff6cb --- /dev/null +++ b/backend-go/internal/schema/admin.go @@ -0,0 +1,33 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// AdminCreateRequest 添加管理员请求 +type AdminCreateRequest struct { + Username string `json:"username" binding:"required"` + RealName string `json:"real_name" binding:"required"` + Password string `json:"password"` + RoleType string `json:"role_type" binding:"required"` + SubjectID *int `json:"subject_id"` +} + +// AdminUpdateRequest 更新管理员请求 +type AdminUpdateRequest struct { + RealName string `json:"real_name" binding:"required"` + RoleType string `json:"role_type" binding:"required"` + SubjectID *int `json:"subject_id"` +} + +// UnlockUserRequest 解锁用户请求 +type UnlockUserRequest struct { + Username string `json:"username" binding:"required"` +} diff --git a/backend-go/internal/schema/attendance.go b/backend-go/internal/schema/attendance.go new file mode 100644 index 0000000..0d45175 --- /dev/null +++ b/backend-go/internal/schema/attendance.go @@ -0,0 +1,30 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// AttendanceCreateRequest 创建考勤记录请求 +type AttendanceCreateRequest struct { + StudentID int `json:"student_id" binding:"required"` + Date string `json:"date" binding:"required"` + Slot string `json:"slot" binding:"required,oneof=morning afternoon evening"` + Status string `json:"status" binding:"required,oneof=present absent late leave"` + Reason string `json:"reason"` + ApplyDeduction bool `json:"apply_deduction"` + CustomDeduction *int `json:"custom_deduction"` +} + +// AttendanceQuery 考勤查询参数 +type AttendanceQuery struct { + Date string `form:"date"` + StudentID *int `form:"student_id"` + Slot string `form:"slot"` +} diff --git a/backend-go/internal/schema/auth.go b/backend-go/internal/schema/auth.go new file mode 100644 index 0000000..fdcf3d4 --- /dev/null +++ b/backend-go/internal/schema/auth.go @@ -0,0 +1,26 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` + Force bool `json:"force"` +} + diff --git a/backend-go/internal/schema/class.go b/backend-go/internal/schema/class.go new file mode 100644 index 0000000..24bfdb1 --- /dev/null +++ b/backend-go/internal/schema/class.go @@ -0,0 +1,44 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// ClassCreateRequest 创建班级请求 +type ClassCreateRequest struct { + ClassName string `json:"class_name" binding:"required"` + Grade *string `json:"grade"` + Description *string `json:"description"` +} + +// ClassUpdateRequest 更新班级请求 +type ClassUpdateRequest struct { + ClassName *string `json:"class_name"` + Grade *string `json:"grade"` + Description *string `json:"description"` + Status *int8 `json:"status"` +} + +// SwitchClassRequest 切换班级上下文请求 +type SwitchClassRequest struct { + ClassID int `json:"class_id" binding:"required"` +} + +// SettingRequest 保存班级设置请求 +type SettingRequest struct { + SettingKey string `json:"setting_key" binding:"required"` + SettingValue string `json:"setting_value" binding:"required"` +} + +// FeatureToggleRequest 功能开关请求 +type FeatureToggleRequest struct { + FeatureKey string `json:"feature_key" binding:"required"` + Enabled int8 `json:"enabled" binding:"oneof=0 1"` +} diff --git a/backend-go/internal/schema/conduct.go b/backend-go/internal/schema/conduct.go new file mode 100644 index 0000000..8ed1ef6 --- /dev/null +++ b/backend-go/internal/schema/conduct.go @@ -0,0 +1,43 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// ConductAddRequest 批量加减分请求 +type ConductAddRequest struct { + StudentIDs []int `json:"student_ids" binding:"required,min=1"` + PointsChange int `json:"points_change" binding:"required,ne=0"` + Reason string `json:"reason" binding:"required"` + RelatedType string `json:"related_type"` +} + +// RevokeRequest 撤销/反撤销请求 +type RevokeRequest struct { + RecordID int64 `json:"record_id" binding:"required"` +} + +// BatchRevokeRequest 批量撤销/反撤销请求 +type BatchRevokeRequest struct { + RecordIDs []int64 `json:"record_ids" binding:"required,min=1,max=100"` +} + +// ConductHistoryQuery 操行分历史查询参数 +type ConductHistoryQuery struct { + StudentID *int `form:"student_id"` + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"` + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` + RelatedType string `form:"related_type"` + ReasonPrefix string `form:"reason_prefix"` + IsRevoked *int `form:"is_revoked"` + ReasonSearch string `form:"reason_search"` +} diff --git a/backend-go/internal/schema/ranking.go b/backend-go/internal/schema/ranking.go new file mode 100644 index 0000000..39c7cff --- /dev/null +++ b/backend-go/internal/schema/ranking.go @@ -0,0 +1,50 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// RankingQuery 排行榜查询参数 +type RankingQuery struct { + Type string `form:"type" binding:"omitempty,oneof=attendance homework conduct all"` + Limit int `form:"limit,default=50" binding:"min=1,max=1000"` +} + +// ParentHistoryQuery 家长历史记录查询参数 +type ParentHistoryQuery struct { + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=100"` +} + +// StudentConductQuery 学生操行分查询参数 +type StudentConductQuery struct { + Limit int `form:"limit,default=50" binding:"min=1"` + Offset int `form:"offset,default=0" binding:"min=0"` +} + +// StudentAttendanceQuery 学生考勤查询参数 +type StudentAttendanceQuery struct { + Month string `form:"month"` +} + +// CadreHomeworkQuery 课代表作业查询参数 +type CadreHomeworkQuery struct { + SubjectID *int `form:"subject_id"` + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=100"` +} + +// CadreHomeworkSubmitRequest 课代表发布作业请求 +// SubjectID 由后端从管理员角色中自动获取,无需前端传递 +type CadreHomeworkSubmitRequest struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Deadline string `json:"deadline" binding:"required"` +} diff --git a/backend-go/internal/schema/semester.go b/backend-go/internal/schema/semester.go new file mode 100644 index 0000000..b378feb --- /dev/null +++ b/backend-go/internal/schema/semester.go @@ -0,0 +1,38 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// SemesterCreateRequest 创建学期请求 +type SemesterCreateRequest struct { + SemesterName string `json:"semester_name" binding:"required"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` +} + +// SemesterUpdateRequest 更新学期请求 +type SemesterUpdateRequest struct { + SemesterName *string `json:"semester_name"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` +} + +// PeriodResetRequest 周期重置请求 +type PeriodResetRequest struct { + Period string `json:"period" binding:"required,oneof=weekly monthly"` +} + +// PeriodArchiveQuery 周期归档查询参数 +type PeriodArchiveQuery struct { + Period string `form:"period" binding:"required,oneof=weekly monthly"` + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=20"` +} diff --git a/backend-go/internal/schema/student.go b/backend-go/internal/schema/student.go new file mode 100644 index 0000000..79e2809 --- /dev/null +++ b/backend-go/internal/schema/student.go @@ -0,0 +1,54 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// StudentCreateRequest 新增学生请求 +type StudentCreateRequest struct { + StudentNo string `json:"student_no" binding:"required"` + Name string `json:"name" binding:"required"` + ParentAccount *string `json:"parent_account"` + DormitoryNumber *string `json:"dormitory_number"` +} + +// StudentImportSingle 导入的单个学生数据 +type StudentImportSingle struct { + StudentNo string `json:"student_no"` + Name string `json:"name"` + ParentAccount string `json:"parent_account"` + DormitoryNumber string `json:"dormitory_number"` + Password string `json:"password"` +} + +// StudentImportRequest 批量导入学生请求 +type StudentImportRequest struct { + Students []StudentImportSingle `json:"students" binding:"required"` +} + +// StudentUpdateRequest 编辑学生请求 +type StudentUpdateRequest struct { + Name *string `json:"name"` + ParentAccount *string `json:"parent_account"` + DormitoryNumber *string `json:"dormitory_number"` +} + +// StudentListQuery 学生列表查询参数 +type StudentListQuery struct { + Page int `form:"page,default=1" binding:"min=1"` + PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"` + Search string `form:"search"` + DormitoryNumber string `form:"dormitory_number"` +} + +// ResetPasswordRequest 重置密码请求 +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" binding:"required"` +} diff --git a/backend-go/internal/schema/subject.go b/backend-go/internal/schema/subject.go new file mode 100644 index 0000000..a7da3de --- /dev/null +++ b/backend-go/internal/schema/subject.go @@ -0,0 +1,27 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package schema + +// SubjectCreateRequest 创建科目请求 +type SubjectCreateRequest struct { + SubjectName string `json:"subject_name" binding:"required"` + SubjectCode *string `json:"subject_code"` + SortOrder int `json:"sort_order"` +} + +// SubjectUpdateRequest 更新科目请求 +type SubjectUpdateRequest struct { + SubjectName *string `json:"subject_name"` + SubjectCode *string `json:"subject_code"` + IsActive *int8 `json:"is_active"` + SortOrder *int `json:"sort_order"` +} diff --git a/backend-go/internal/service/admin_service.go b/backend-go/internal/service/admin_service.go new file mode 100644 index 0000000..eb385c0 --- /dev/null +++ b/backend-go/internal/service/admin_service.go @@ -0,0 +1,452 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + "regexp" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AdminService 管理员服务 +type AdminService struct { + userRepo *repository.UserRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + classRepo *repository.ClassRepo +} + +// NewAdminService 创建管理员服务 +func NewAdminService( + userRepo *repository.UserRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + classRepo *repository.ClassRepo, +) *AdminService { + return &AdminService{ + userRepo: userRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + classRepo: classRepo, + } +} + +// dormitoryRegex 宿舍号格式校验:东南北西 + 1-2位楼号 - 3位房号 +var dormitoryRegex = regexp.MustCompile(`^[东南北西]\d{1,2}-\d{3}$`) + +// validateDormitoryNumber 校验宿舍号格式(允许空值或合法格式) +func validateDormitoryNumber(dn *string) bool { + if dn == nil || *dn == "" { + return true + } + return dormitoryRegex.MatchString(*dn) +} + +// GetStudents 获取指定班级的学生列表 +func (s *AdminService) GetStudents(classID int, page, pageSize int, search, dormitoryNumber string) (map[string]interface{}, error) { + students, total, err := s.studentRepo.ListByClass(classID, page, pageSize, search, dormitoryNumber) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "students": students, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// GetDormitories 获取宿舍号列表 +func (s *AdminService) GetDormitories(classID int) ([]string, error) { + return s.studentRepo.GetDormitoryList(classID) +} + +// getInitialPassword 从 class_settings 读取初始密码,若无则使用随机密码 +func (s *AdminService) getInitialPassword(classID int) (string, error) { + if s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, "initial_password") + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue, nil + } + } + pwd, err := crypto.GenerateRandomPassword(8) + if err != nil { + logger.Sugared.Errorf("生成随机密码失败: %v", err) + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + return pwd, nil +} + +// AddStudent 新增学生 +func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) { + cfg := config.AppConfig + + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil + } + + // 检查学号是否已存在 + existing, err := s.studentRepo.GetByStudentNo(studentNo, classID) + if err == nil && existing != nil { + return map[string]interface{}{"success": false, "message": "该班级中已存在此学号"}, nil + } + + // 创建学生记录 + student := &model.Student{ + StudentNo: studentNo, + ClassID: classID, + Name: name, + TotalPoints: 60, + ParentAccount: parentAccount, + DormitoryNumber: dormitoryNumber, + Status: 1, + } + studentID, err := s.studentRepo.Create(student) + if err != nil { + return nil, err + } + // 创建学生登录账号(从 class_settings 读取初始密码或使用随机密码) + defaultPassword, err := s.getInitialPassword(classID) + if err != nil { + return nil, err + } + passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + _, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID) + if err != nil { + logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) + // 回滚学生记录,避免存在无账号的孤儿学生 + _ = s.studentRepo.SoftDelete(studentID) + return nil, fmt.Errorf("创建学生登录账号失败") + } + + // 创建家长账号(失败时不回滚学生记录,仅记录日志;管理员可手动补建家长账号) + if parentAccount != nil && *parentAccount != "" { + exists, _ := s.userRepo.CheckUsernameExists(*parentAccount) + if !exists { + parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt) + parentRealName := fmt.Sprintf("%s家长", name) + if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { + logger.Sugared.Warnf("创建家长账号失败(学生记录已保留): parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) + } + } + } + + return map[string]interface{}{ + "success": true, + "student_id": studentID, + }, nil +} + +// ImportStudents 批量导入学生 +// 注意:当前实现为逐条创建,单条失败时回滚该条记录(SoftDelete),不影响其他记录。 +// 这是设计上有意为之——允许部分成功,避免一条失败导致整个导入作废。 +// 未使用数据库事务的原因:Repository 层未暴露事务接口,全量事务包裹需要较大重构; +// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。 +func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) { + cfg := config.AppConfig + successCount := 0 + failedCount := 0 + var details []map[string]interface{} + + // 预查重 + existingNos, _ := s.studentRepo.GetStudentNosByClass(classID) + existingSet := make(map[string]bool, len(existingNos)) + for _, no := range existingNos { + existingSet[no] = true + } + + existingUsernames, _ := s.userRepo.GetActiveUsernames() + usernameSet := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + usernameSet[u] = true + } + + for _, stu := range students { + studentNo, _ := stu["student_no"].(string) + name, _ := stu["name"].(string) + + if studentNo == "" || name == "" { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "学号或姓名不能为空", + }) + continue + } + + if existingSet[studentNo] { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "学号已存在", + }) + continue + } + + var parentAccount *string + if pa, ok := stu["parent_account"].(string); ok && pa != "" { + parentAccount = &pa + } + var dormitoryNumber *string + if dn, ok := stu["dormitory_number"].(string); ok && dn != "" { + dormitoryNumber = &dn + } + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "宿舍号格式不正确,应为如 东1-101 的格式", + }) + continue + } + + password, pwdErr := s.getInitialPassword(classID) + if pwdErr != nil { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "生成初始密码失败", + }) + continue + } + if pw, ok := stu["password"].(string); ok && pw != "" { + if valid, msg := crypto.ValidatePasswordStrength(pw); !valid { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": msg, + }) + continue + } + password = pw + } + + // 创建学生记录 + student := &model.Student{ + StudentNo: studentNo, + ClassID: classID, + Name: name, + TotalPoints: 60, + ParentAccount: parentAccount, + DormitoryNumber: dormitoryNumber, + Status: 1, + } + studentID, err := s.studentRepo.Create(student) + if err != nil { + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": err.Error(), + }) + continue + } + existingSet[studentNo] = true + + // 创建学生登录账号 + passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil { + logger.Sugared.Errorf("批量导入-创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err) + // 回滚学生记录 + _ = s.studentRepo.SoftDelete(studentID) + failedCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": false, "error": "创建登录账号失败", + }) + continue + } + usernameSet[studentNo] = true + + // 创建家长账号 + if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] { + parentHash := crypto.HashPassword(password, cfg.PasswordSalt) + parentRealName := fmt.Sprintf("%s家长", name) + if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil { + logger.Sugared.Errorf("批量导入-创建家长账号失败: parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err) + } + usernameSet[*parentAccount] = true + } + + successCount++ + details = append(details, map[string]interface{}{ + "student_no": studentNo, "success": true, "student_id": studentID, + }) + } + + return map[string]interface{}{ + "success": true, + "total": len(students), + "success_count": successCount, + "failed_count": failedCount, + "details": details, + }, nil +} + +// UpdateStudent 编辑学生信息 +func (s *AdminService) UpdateStudent(studentID int, name, parentAccount, dormitoryNumber *string, classID int) error { + // 校验学生是否属于当前班级 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + return fmt.Errorf("学生不存在") + } + if student.ClassID != classID { + return fmt.Errorf("无权操作该学生") + } + // 校验宿舍号格式 + if !validateDormitoryNumber(dormitoryNumber) { + return fmt.Errorf("宿舍号格式不正确,应为如 东1-101 的格式") + } + updates := make(map[string]interface{}) + if name != nil { + updates["name"] = *name + } + if parentAccount != nil { + updates["parent_account"] = *parentAccount + } + if dormitoryNumber != nil { + updates["dormitory_number"] = *dormitoryNumber + } + return s.studentRepo.Update(studentID, updates) +} + +// DeleteStudent 删除学生 +func (s *AdminService) DeleteStudent(studentID int, classID int) error { + // 校验学生是否属于当前班级 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + return fmt.Errorf("学生不存在") + } + if student.ClassID != classID { + return fmt.Errorf("无权操作该学生") + } + return s.studentRepo.SoftDelete(studentID) +} + +// ResetStudentPassword 重置学生密码 +func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) error { + // 验证新密码强度(#11) + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + cfg := config.AppConfig + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return fmt.Errorf("学生不存在") + } + // 通过学号查找关联的用户账号 + user, err := s.userRepo.GetByUsername(student.StudentNo) + if err != nil { + return fmt.Errorf("学生登录账号不存在") + } + passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + return s.userRepo.UpdatePassword(user.UserID, passwordHash) +} + +// AddAdmin 添加管理员 +func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) { + cfg := config.AppConfig + + exists, _ := s.userRepo.CheckUsernameExists(username) + if exists { + return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil + } + + if password == "" { + pwd, err := crypto.GenerateRandomPassword(8) + if err != nil { + return nil, fmt.Errorf("生成随机密码失败: %w", err) + } + password = pwd + } + + passwordHash := crypto.HashPassword(password, cfg.PasswordSalt) + userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName) + if err != nil { + return nil, err + } + + role := &model.AdminRole{ + UserID: userID, + ClassID: classID, + RoleType: roleType, + SubjectID: subjectID, + } + _, err = s.adminRoleRepo.Create(role) + if err != nil { + // 角色创建失败,回滚用户记录,避免孤儿数据 + _ = s.userRepo.DeleteUser(userID) + return nil, fmt.Errorf("创建管理员角色失败,已回滚用户记录: %w", err) + } + + return map[string]interface{}{ + "success": true, + "user_id": userID, + "username": username, + "role_type": roleType, + }, nil +} + +// GetAdmins 获取管理员列表 +func (s *AdminService) GetAdmins(classID int) (map[string]interface{}, error) { + admins, err := s.adminRoleRepo.GetAllByClass(classID) + if err != nil { + return nil, err + } + return map[string]interface{}{"admins": admins}, nil +} + +// UpdateAdmin 更新管理员 +func (s *AdminService) UpdateAdmin(userID int, realName, roleType string, classID int, subjectID *int) error { + if err := s.userRepo.UpdateRealName(userID, realName); err != nil { + return err + } + return s.adminRoleRepo.UpdateRole(userID, roleType, classID, subjectID) +} + +// DeleteAdmin 硬删除管理员(同时删除 users 和 admin_roles 记录) +func (s *AdminService) DeleteAdmin(userID int, classID int) error { + // 先删除关联的 admin_roles 记录 + if err := s.adminRoleRepo.Delete(userID, classID); err != nil { + return err + } + // 硬删除 users 表记录 + return s.userRepo.DeleteUser(userID) +} + +// ResetAdminPassword 重置管理员密码 +func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error { + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + cfg := config.AppConfig + passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + return s.userRepo.UpdatePassword(userID, passwordHash) +} + +// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数) +func (s *AdminService) UnlockAccount(username, ip string) error { + ctx := context.Background() + keys := []string{fmt.Sprintf("login_attempts:%s", username)} + if ip != "" { + keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip)) + } + return database.RDB.Del(ctx, keys...).Err() +} diff --git a/backend-go/internal/service/attendance_service.go b/backend-go/internal/service/attendance_service.go new file mode 100644 index 0000000..aab6f8f --- /dev/null +++ b/backend-go/internal/service/attendance_service.go @@ -0,0 +1,226 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AttendanceService 考勤服务 +type AttendanceService struct { + attendanceRepo *repository.AttendanceRepo + studentRepo *repository.StudentRepo + userRepo *repository.UserRepo + conductRepo *repository.ConductRepo + semesterRepo *repository.SemesterRepo + settingRepo *repository.SystemSettingRepo + classRepo *repository.ClassRepo +} + +// NewAttendanceService 创建考勤服务 +func NewAttendanceService( + attendanceRepo *repository.AttendanceRepo, + studentRepo *repository.StudentRepo, + userRepo *repository.UserRepo, + conductRepo *repository.ConductRepo, + semesterRepo *repository.SemesterRepo, + settingRepo *repository.SystemSettingRepo, + classRepo *repository.ClassRepo, +) *AttendanceService { + return &AttendanceService{ + attendanceRepo: attendanceRepo, + studentRepo: studentRepo, + userRepo: userRepo, + conductRepo: conductRepo, + semesterRepo: semesterRepo, + settingRepo: settingRepo, + classRepo: classRepo, + } +} + +// CreateRecord 创建考勤记录 +func (s *AttendanceService) CreateRecord(studentID int, dateStr, slot, status string, reason *string, + applyDeduction bool, customDeduction *int, recorderID int, classID int) (map[string]interface{}, error) { + + // 校验学生是否属于当前班级(#7) + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "学生不属于当前班级"}, nil + } + + // 解析日期 + parsedDate, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return map[string]interface{}{"success": false, "message": "日期格式错误"}, nil + } + + // 获取活跃学期 + var semesterID *int + activeSemester, _ := s.semesterRepo.GetActive() + if activeSemester != nil { + semesterID = &activeSemester.SemesterID + } + + record := &model.AttendanceRecord{ + StudentID: studentID, + Date: parsedDate, + Slot: slot, + Status: status, + Reason: reason, + RecorderID: recorderID, + SemesterID: semesterID, + } + + createResult, err := s.attendanceRepo.CreateRecord(record) + if err != nil { + return map[string]interface{}{"success": false, "message": "添加考勤记录失败"}, nil + } + attendanceID := createResult.AttendanceID + + // 更新已有记录时,先撤销旧扣分再应用新扣分 + if createResult.IsUpdate && createResult.OldDeductionApplied == 1 && createResult.OldDeductionRecordID != nil { + if err := s.conductRepo.RevokeRecord(*createResult.OldDeductionRecordID, recorderID); err != nil { + logger.Sugared.Errorf("撤销旧考勤扣分失败: attendance_id=%d, old_record_id=%d, err=%v", + attendanceID, *createResult.OldDeductionRecordID, err) + return nil, fmt.Errorf("撤销旧扣分失败,操作已中止以避免双重扣分: %w", err) + } + } + + // 应用扣分(事务保护,避免数据不一致) + if applyDeduction && (status == "absent" || status == "late" || status == "leave") { + // 校验自定义扣分值必须为非负数 + if customDeduction != nil && *customDeduction < 0 { + return map[string]interface{}{"success": false, "message": "自定义扣分值不能为负数"}, nil + } + + var pointsChange int + if customDeduction != nil { + pointsChange = -*customDeduction + } else { + pointsChange = s.getDeductionPoints(classID, status) + } + + if pointsChange == 0 { + return map[string]interface{}{"success": true, "message": "考勤记录添加成功(不扣分)"}, nil + } + + // 获取操作人姓名 + recorderName := "班主任" + user, err := s.userRepo.GetByUserID(recorderID) + if err == nil && user != nil { + recorderName = user.RealName + } + + statusText := map[string]string{ + "absent": "缺勤", "late": "迟到", "leave": "请假", + }[status] + + // 使用事务确保操行分记录创建、总分更新、考勤标记的原子性 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + conductRecord := &model.ConductRecord{ + StudentID: studentID, + PointsChange: pointsChange, + Reason: fmt.Sprintf("考勤:%s", statusText), + RecorderID: recorderID, + RecorderName: &recorderName, + RelatedType: "attendance", + RelatedID: &attendanceID, + SemesterID: semesterID, + } + if err := tx.Create(conductRecord).Error; err != nil { + return err + } + if err := tx.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil { + return err + } + if err := tx.Model(&model.AttendanceRecord{}). + Where("attendance_id = ?", attendanceID). + Updates(map[string]interface{}{ + "deduction_applied": 1, + "deduction_record_id": conductRecord.RecordID, + }).Error; err != nil { + return err + } + return nil + }) + if txErr != nil { + logger.Sugared.Errorf("考勤扣分事务失败: attendance_id=%d, student_id=%d, err=%v", attendanceID, studentID, txErr) + return map[string]interface{}{ + "success": false, + "message": "考勤记录添加成功,但扣分失败,请手动处理", + "attendance_id": attendanceID, + "deduction_failed": true, + }, nil + } + + logger.Sugared.Infof("用户[%d] 添加考勤记录[%d] -> %s (扣%d分)", recorderID, attendanceID, status, -pointsChange) + } + + return map[string]interface{}{"success": true, "message": "考勤记录添加成功"}, nil +} + +// GetRecords 获取考勤记录 +func (s *AttendanceService) GetRecords(classID int, date string, studentID *int, slot string) (map[string]interface{}, error) { + records, err := s.attendanceRepo.GetClassRecords(classID, date, derefInt(studentID), slot) + if err != nil { + return nil, err + } + return map[string]interface{}{"records": records}, nil +} + +// getClassSettingValue 从 class_settings 读取设置值,若无则返回默认值 +func (s *AttendanceService) getClassSettingValue(classID int, key, defaultVal string) string { + if classID > 0 && s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue + } + } + return defaultVal +} + +// getDeductionPoints 获取考勤扣分数值(优先从 class_settings 读取班级级配置) +func (s *AttendanceService) getDeductionPoints(classID int, status string) int { + switch status { + case "absent": + val := s.getClassSettingValue(classID, "deduction_attendance_absent", "3") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return -3 + case "late": + val := s.getClassSettingValue(classID, "deduction_attendance_late", "1") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return -1 + case "leave": + val := s.getClassSettingValue(classID, "deduction_attendance_leave", "0") + if v, err := strconv.Atoi(val); err == nil { + return -v + } + return 0 + default: + return 0 + } +} diff --git a/backend-go/internal/service/auth_service.go b/backend-go/internal/service/auth_service.go new file mode 100644 index 0000000..8d4a69a --- /dev/null +++ b/backend-go/internal/service/auth_service.go @@ -0,0 +1,461 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// AuthService 认证服务 +type AuthService struct { + userRepo *repository.UserRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + classRepo *repository.ClassRepo + logService *LogService +} + +// NewAuthService 创建认证服务 +func NewAuthService( + userRepo *repository.UserRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + classRepo *repository.ClassRepo, + logService *LogService, +) *AuthService { + return &AuthService{ + userRepo: userRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + classRepo: classRepo, + logService: logService, + } +} + +// LoginResult 登录结果 +type LoginResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Token string `json:"token,omitempty"` + UserID int `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + RealName string `json:"real_name,omitempty"` + UserType string `json:"user_type,omitempty"` + StudentID *int `json:"student_id,omitempty"` + Role *string `json:"role,omitempty"` + ClassID *int `json:"class_id,omitempty"` + ClassName *string `json:"class_name,omitempty"` + NeedChangePassword bool `json:"need_change_password,omitempty"` + Redirect string `json:"redirect,omitempty"` +} + +// incrWithExpireAtomic 原子递增并在首次设置过期时间(Lua 脚本保证原子性) +func incrWithExpireAtomic(ctx context.Context, key string, ttlSeconds int) (int64, error) { + script := redis.NewScript(` + local current = redis.call('INCR', KEYS[1]) + if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) + end + return current + `) + result, err := script.Run(ctx, database.RDB, []string{key}, ttlSeconds).Int64() + return result, err +} + +// Login 用户登录 +func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResult { + ctx := context.Background() + cfg := config.AppConfig + + // 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态) + attemptsKey := fmt.Sprintf("login_attempts:%s", username) + ipAttemptsKey := fmt.Sprintf("login_attempts:ip:%s", ip) + + // 用户名级限流:原子递增后检查 + userCount, err := incrWithExpireAtomic(ctx, attemptsKey, 300) + if err != nil { + logger.Sugared.Errorf("Redis 限流检查失败 (用户名级): %v", err) + return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"} + } + if userCount > 5 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "登录失败次数过多") + return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"} + } + // IP 级限流:原子递增后检查 + ipCount, err := incrWithExpireAtomic(ctx, ipAttemptsKey, 300) + if err != nil { + logger.Sugared.Errorf("Redis 限流检查失败 (IP级): %v", err) + return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"} + } + if ipCount > 20 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "IP登录失败次数过多") + return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"} + } + + // 获取用户 + user, err := s.userRepo.GetByUsername(username) + if err != nil { + // 尝试学生登录:username 匹配 student_no + student, stuErr := s.studentRepo.GetByStudentNo(username, 0) + if stuErr == nil && student != nil { + return s.loginAsStudent(student, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey) + } + // 尝试家长登录:username 匹配 parent_account + return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey) + } + + // 验证密码(使用全局 PASSWORD_SALT,与 Python 版兼容。 + // 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。 + // 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。) + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 检查账号状态 + if user.Status != 1 { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "账号已被禁用") + return &LoginResult{Success: false, Message: "账号已被禁用"} + } + + // 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(防止同 IP 其他用户限流被重置) + database.RDB.Del(ctx, attemptsKey) + + // 更新最后登录信息 + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + // 获取角色和班级信息 + var role *string + var classID *int + var className *string + + if user.UserType == "admin" { + adminRole, err := s.adminRoleRepo.GetByUserID(user.UserID) + if err == nil && adminRole != nil { + role = &adminRole.RoleType + classID = &adminRole.ClassID + } + } else if user.UserType == "super_admin" { + r := "系统管理员" + role = &r + } else if user.StudentID != nil { + student, err := s.studentRepo.GetByID(*user.StudentID) + if err == nil && student != nil { + cid := student.ClassID + classID = &cid + } + } + + // 获取班级名称 + if classID != nil { + cls, err := s.classRepo.GetByID(*classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + } + + // 生成 Token + token, err := appJwt.CreateToken( + user.UserID, user.Username, user.UserType, + user.StudentID, derefStr(role), user.RealName, classID, + user.NeedChangePassword == 1, + ) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + // 存储 Token 到 Redis(使用 IdleTimeout 与中间件空闲超时一致,避免 Token 在 Redis 中残留过久) + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + // 确定跳转路径 + redirect := getRedirectPath(user.UserType, role) + + // 需要强制改密时,跳转到密码修改页面 + needChangePassword := user.NeedChangePassword == 1 + if needChangePassword { + redirect = getPasswordChangePath(user.UserType) + } + + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + Role: role, + ClassID: classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// loginAsStudent 学生登录(通过学号) +func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult { + ctx := context.Background() + + user, err := s.userRepo.GetByUsername(student.StudentNo) + if err != nil { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if user.Status != 1 { + return &LoginResult{Success: false, Message: "账号已被禁用"} + } + + // 清除用户名级登录失败记录 + database.RDB.Del(ctx, attemptsKey) + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + classID := student.ClassID + var className *string + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + + token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + s.logService.WriteLoginLog(user.Username, 1, ip, userAgent, "") + + needChangePassword := user.NeedChangePassword == 1 + redirect := "/student/dashboard.php" + if needChangePassword { + redirect = "/student/password.php" + } + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + ClassID: &classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// tryParentLogin 尝试家长登录(通过 parent_account 查找学生,再获取关联的家长用户) +func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult { + ctx := context.Background() + + // 根据 parent_account 字段查找学生 + student, err := s.studentRepo.GetByParentAccount(username) + if err != nil || student == nil { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 根据学生ID获取关联的家长用户账号 + user, err := s.userRepo.GetByStudentID(student.StudentID) + if err != nil || user == nil || user.UserType != "parent" { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) { + return &LoginResult{Success: false, Message: "用户名或密码错误"} + } + + // 清除用户名级登录失败记录 + database.RDB.Del(ctx, attemptsKey) + _ = s.userRepo.UpdateLastLogin(user.UserID, ip) + + classID := student.ClassID + var className *string + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + className = &cls.ClassName + } + + token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1) + if err != nil { + return &LoginResult{Success: false, Message: "生成令牌失败"} + } + + _ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes) + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + needChangePassword := user.NeedChangePassword == 1 + redirect := "/parent/dashboard.php" + if needChangePassword { + redirect = "/parent/password.php" + } + + return &LoginResult{ + Success: true, + Token: token, + UserID: user.UserID, + Username: user.Username, + RealName: user.RealName, + UserType: user.UserType, + StudentID: user.StudentID, + ClassID: &classID, + ClassName: className, + NeedChangePassword: needChangePassword, + Redirect: redirect, + } +} + +// Logout 用户登出 +func (s *AuthService) Logout(userID int) error { + ctx := context.Background() + return database.DeleteUserToken(ctx, userID) +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error { + cfg := config.AppConfig + + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return fmt.Errorf("用户不存在") + } + + // 验证原密码(强制改密时跳过) + if !force { + if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) { + return fmt.Errorf("原密码错误") + } + } + + // 验证新密码强度 + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + + // 更新密码 + newHash := crypto.HashPassword(newPassword, cfg.PasswordSalt) + if err := s.userRepo.UpdatePassword(userID, newHash); err != nil { + return fmt.Errorf("密码修改失败") + } + + // 清除 Token + ctx := context.Background() + _ = database.DeleteUserToken(ctx, userID) + return nil +} + +// GetUserInfo 获取用户信息 +func (s *AuthService) GetUserInfo(userID int) (map[string]interface{}, error) { + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + + result := map[string]interface{}{ + "user_id": user.UserID, + "username": user.Username, + "real_name": user.RealName, + "user_type": user.UserType, + "need_change_password": user.NeedChangePassword == 1, + } + + var classID int + + if user.StudentID != nil { + student, err := s.studentRepo.GetByID(*user.StudentID) + if err == nil && student != nil { + result["student_no"] = student.StudentNo + result["student_name"] = student.Name + result["total_points"] = student.TotalPoints + classID = student.ClassID + } + } + + if user.UserType == "admin" { + adminRole, err := s.adminRoleRepo.GetByUserID(userID) + if err == nil && adminRole != nil { + result["role"] = adminRole.RoleType + classID = adminRole.ClassID + } + } + + if classID > 0 { + result["class_id"] = classID + cls, err := s.classRepo.GetByID(classID) + if err == nil && cls != nil { + result["class_name"] = cls.ClassName + } + } + + return result, nil +} + +// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数) +func (s *AuthService) UnlockAccount(username, ip string) error { + ctx := context.Background() + keys := []string{fmt.Sprintf("login_attempts:%s", username)} + if ip != "" { + keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip)) + } + return database.RDB.Del(ctx, keys...).Err() +} + +// getRedirectPath 根据用户类型和角色确定跳转路径 +func getRedirectPath(userType string, role *string) string { + switch userType { + case "super_admin": + return "/admin/dashboard.php" + case "admin": + return "/admin/dashboard.php" + case "student": + return "/student/dashboard.php" + case "parent": + return "/parent/dashboard.php" + default: + return "/" + } +} + +// getPasswordChangePath 根据用户类型返回密码修改页面路径 +func getPasswordChangePath(userType string) string { + switch userType { + case "super_admin": + return "/admin/password.php" + case "admin": + return "/admin/password.php" + case "student": + return "/student/password.php" + case "parent": + return "/parent/password.php" + default: + return "/" + } +} + diff --git a/backend-go/internal/service/class_service.go b/backend-go/internal/service/class_service.go new file mode 100644 index 0000000..9e7a981 --- /dev/null +++ b/backend-go/internal/service/class_service.go @@ -0,0 +1,224 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" +) + +// ClassService 班级服务 +type ClassService struct { + classRepo *repository.ClassRepo + userRepo *repository.UserRepo + adminRoleRepo *repository.AdminRoleRepo +} + +// NewClassService 创建班级服务 +func NewClassService( + classRepo *repository.ClassRepo, + userRepo *repository.UserRepo, + adminRoleRepo *repository.AdminRoleRepo, +) *ClassService { + return &ClassService{ + classRepo: classRepo, + userRepo: userRepo, + adminRoleRepo: adminRoleRepo, + } +} + +// ListClasses 获取班级列表 +func (s *ClassService) ListClasses(includeDisabled bool) (map[string]interface{}, error) { + classes, err := s.classRepo.GetAll(includeDisabled) + if err != nil { + return nil, err + } + + for i := range classes { + count, _ := s.classRepo.GetStudentCount(classes[i].ClassID) + classes[i].StudentCount = count + } + + return map[string]interface{}{ + "classes": classes, + "total": len(classes), + }, nil +} + +// GetClassDetail 获取班级详情 +func (s *ClassService) GetClassDetail(classID int) (map[string]interface{}, error) { + cls, err := s.classRepo.GetByID(classID) + if err != nil { + return nil, err + } + cls.StudentCount, _ = s.classRepo.GetStudentCount(classID) + return map[string]interface{}{"class": cls}, nil +} + +// CreateClass 创建班级 +func (s *ClassService) CreateClass(className string, grade, description *string) (map[string]interface{}, error) { + existing, _ := s.classRepo.GetByName(className) + if existing != nil { + return map[string]interface{}{"success": false, "message": "班级名称已存在"}, nil + } + + cls := &model.Class{ + ClassName: className, + Grade: grade, + Description: description, + Status: 1, + } + classID, err := s.classRepo.Create(cls) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "success": true, + "class_id": classID, + "message": "班级创建成功", + }, nil +} + +// UpdateClass 更新班级 +func (s *ClassService) UpdateClass(classID int, className, grade, description *string, status *int8) error { + existing, err := s.classRepo.GetByID(classID) + if err != nil { + return fmt.Errorf("班级不存在") + } + + updates := make(map[string]interface{}) + if className != nil && *className != existing.ClassName { + nameExists, _ := s.classRepo.GetByName(*className) + if nameExists != nil { + return fmt.Errorf("班级名称已存在") + } + updates["class_name"] = *className + } + if grade != nil { + updates["grade"] = *grade + } + if description != nil { + updates["description"] = *description + } + if status != nil { + updates["status"] = *status + } + + return s.classRepo.Update(classID, updates) +} + +// DeleteClass 删除班级 +func (s *ClassService) DeleteClass(classID int) error { + hasStudents, _ := s.classRepo.HasActiveStudents(classID) + if hasStudents { + return fmt.Errorf("该班级下还有学生,无法删除") + } + return s.classRepo.Delete(classID) +} + +// SwitchClass 切换班级上下文(超级管理员) +func (s *ClassService) SwitchClass(userID int, classID int) (map[string]interface{}, error) { + cfg := config.AppConfig + cls, err := s.classRepo.GetByID(classID) + if err != nil { + return nil, fmt.Errorf("班级不存在") + } + + user, err := s.userRepo.GetByUserID(userID) + if err != nil { + return nil, fmt.Errorf("用户不存在") + } + + // 查询目标班级中该用户的角色 + var role string + if user.UserType == "super_admin" { + role = "系统管理员" + } else { + adminRole, _ := s.adminRoleRepo.GetByUserIDAndClass(userID, classID) + if adminRole != nil { + role = adminRole.RoleType + } + } + + // 生成新 Token,更新 class_id + token, err := appJwt.CreateToken( + user.UserID, user.Username, user.UserType, + user.StudentID, role, user.RealName, &classID, + user.NeedChangePassword == 1, + ) + if err != nil { + return nil, fmt.Errorf("生成令牌失败") + } + + ctx := context.Background() + _ = database.SetUserToken(ctx, userID, token, cfg.JWTIdleTimeoutMinutes) + + return map[string]interface{}{ + "token": token, + "class_id": classID, + "class_name": cls.ClassName, + }, nil +} + +// GetSettings 获取班级设置 +func (s *ClassService) GetSettings(classID int) (map[string]interface{}, error) { + settings, err := s.classRepo.GetSettings(classID) + if err != nil { + return nil, err + } + + result := make(map[string]string) + for _, setting := range settings { + result[setting.SettingKey] = setting.SettingValue + } + return map[string]interface{}{"settings": result}, nil +} + +// SaveSetting 保存班级设置 +func (s *ClassService) SaveSetting(classID int, key, value string) error { + return s.classRepo.SaveSetting(classID, key, value) +} + +// GetFeatures 获取班级功能开关 +func (s *ClassService) GetFeatures(classID int) (map[string]interface{}, error) { + features, err := s.classRepo.GetFeatures(classID) + if err != nil { + return nil, err + } + + result := make(map[string]int8) + for _, f := range features { + result[f.FeatureKey] = f.Enabled + } + return map[string]interface{}{"features": result}, nil +} + +// SaveFeature 保存班级功能开关 +func (s *ClassService) SaveFeature(classID int, featureKey string, enabled int8) error { + return s.classRepo.SaveFeature(classID, featureKey, enabled) +} + +// IsFeatureEnabled 检查功能开关是否启用 +func (s *ClassService) IsFeatureEnabled(classID int, featureKey string) bool { + feature, err := s.classRepo.GetFeature(classID, featureKey) + if err != nil || feature == nil { + return true // 默认启用 + } + return feature.Enabled == 1 +} diff --git a/backend-go/internal/service/conduct_service.go b/backend-go/internal/service/conduct_service.go new file mode 100644 index 0000000..25ee71b --- /dev/null +++ b/backend-go/internal/service/conduct_service.go @@ -0,0 +1,384 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// ConductService 操行分服务 +type ConductService struct { + conductRepo *repository.ConductRepo + studentRepo *repository.StudentRepo + adminRoleRepo *repository.AdminRoleRepo + semesterRepo *repository.SemesterRepo + classRepo *repository.ClassRepo +} + +// NewConductService 创建操行分服务 +func NewConductService( + conductRepo *repository.ConductRepo, + studentRepo *repository.StudentRepo, + adminRoleRepo *repository.AdminRoleRepo, + semesterRepo *repository.SemesterRepo, + classRepo *repository.ClassRepo, +) *ConductService { + return &ConductService{ + conductRepo: conductRepo, + studentRepo: studentRepo, + adminRoleRepo: adminRoleRepo, + semesterRepo: semesterRepo, + classRepo: classRepo, + } +} + +// AddPoints 批量加减分 +func (s *ConductService) AddPoints(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 输入校验 + if len(studentIDs) == 0 || len(studentIDs) > 200 { + return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil + } + if reason == "" || len(reason) > 255 { + return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil + } + if pointsChange == 0 || absInt(pointsChange) > 100 { + return map[string]interface{}{"success": false, "message": "分值无效"}, nil + } + + // 获取操作人角色 + role, _, err := s.adminRoleRepo.GetUserRoleAndClassID(recorderID) + if err != nil { + return map[string]interface{}{"success": false, "message": "获取操作人角色失败"}, nil + } + + // 权限验证(从 class_settings 读取限制,这里使用默认值) + if err := s.validatePointsPermission(role, pointsChange, classID); err != nil { + return map[string]interface{}{"success": false, "message": err.Error()}, nil + } + + return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType) +} + +// CadreAddPoints 课代表专用加减分(跳过角色权限验证,仅限作业相关扣分) +func (s *ConductService) CadreAddPoints(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 输入校验 + if len(studentIDs) == 0 || len(studentIDs) > 200 { + return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil + } + if reason == "" || len(reason) > 255 { + return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil + } + if pointsChange >= 0 || absInt(pointsChange) > 100 { + return map[string]interface{}{"success": false, "message": "课代表只能进行扣分操作"}, nil + } + + // 强制设置为作业类型 + relatedType = "homework" + + return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType) +} + +// addPointsInternal 批量加减分内部实现 +func (s *ConductService) addPointsInternal(studentIDs []int, pointsChange int, reason string, + recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) { + + // 自动获取当前活跃学期 + activeSemester, semErr := s.semesterRepo.GetActive() + if semErr != nil { + logger.Sugared.Warnf("获取活跃学期失败,操行分将不关联学期: %v", semErr) + } + var semesterID *int + if activeSemester != nil { + semesterID = &activeSemester.SemesterID + } + + if relatedType == "" { + relatedType = "manual" + } + + successCount := 0 + failCount := 0 + var details []map[string]interface{} + db := s.semesterRepo.GetDB() + + for _, studentID := range studentIDs { + // 检查学生是否存在 + student, err := s.studentRepo.GetByID(studentID) + if err != nil || student == nil { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不存在"}) + continue + } + + // 校验学生是否属于当前班级 + if student.ClassID != classID { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不属于当前班级"}) + continue + } + + // 使用事务确保记录创建和总分更新的原子性(#3) + recordID, txErr := func() (int64, error) { + var rid int64 + txErr := db.Transaction(func(tx *gorm.DB) error { + record := &model.ConductRecord{ + StudentID: studentID, + PointsChange: pointsChange, + Reason: reason, + RecorderID: recorderID, + RecorderName: &recorderName, + RelatedType: relatedType, + SemesterID: semesterID, + } + if err := tx.Create(record).Error; err != nil { + return err + } + rid = record.RecordID + if err := tx.Model(&model.Student{}). + Where("student_id = ?", studentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil { + return err + } + return nil + }) + return rid, txErr + }() + + if txErr != nil { + failCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "error": txErr.Error()}) + continue + } + + successCount++ + details = append(details, map[string]interface{}{"student_id": studentID, "success": true, "record_id": recordID}) + logger.Sugared.Infof("用户[%d] 对学生[%d] 进行 %d 分操作", recorderID, studentID, pointsChange) + } + + return map[string]interface{}{ + "success": failCount == 0, + "success_count": successCount, + "fail_count": failCount, + "details": details, + }, nil +} + +// RevokeRecord 撤销记录(事务保护,避免并发重复撤销) +func (s *ConductService) RevokeRecord(recordID int64, revokerID int, classID int) (map[string]interface{}, error) { + record, err := s.conductRepo.GetRecordByID(recordID) + if err != nil || record == nil { + return map[string]interface{}{"success": false, "message": "记录不存在"}, nil + } + + // 校验记录所属学生是否在当前操作者的班级中 + student, _ := s.studentRepo.GetByID(record.StudentID) + if student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil + } + + if record.IsRevoked == 1 { + return map[string]interface{}{"success": false, "message": "该记录已被撤销"}, nil + } + + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 撤销记录 + if err := tx.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 0", recordID). + Updates(map[string]interface{}{ + "is_revoked": 1, + "revoked_by": revokerID, + }).Error; err != nil { + return err + } + // 反向恢复学生总分(下限保护) + return tx.Model(&model.Student{}). + Where("student_id = ?", record.StudentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", -record.PointsChange)).Error + }) + if txErr != nil { + return map[string]interface{}{"success": false, "message": "撤销失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "撤销成功", + "record": map[string]interface{}{ + "student_id": record.StudentID, + "recorder_name": derefStr(record.RecorderName), + "points_change": record.PointsChange, + }, + }, nil +} + +// RestoreRecord 反撤销记录(事务保护,避免并发重复恢复) +func (s *ConductService) RestoreRecord(recordID int64, restorerID int, classID int) (map[string]interface{}, error) { + record, err := s.conductRepo.GetRecordByID(recordID) + if err != nil || record == nil { + return map[string]interface{}{"success": false, "message": "记录不存在"}, nil + } + + // 校验记录所属学生是否在当前操作者的班级中 + student, _ := s.studentRepo.GetByID(record.StudentID) + if student == nil || student.ClassID != classID { + return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil + } + + if record.IsRevoked == 0 { + return map[string]interface{}{"success": false, "message": "该记录未被撤销,无需恢复"}, nil + } + + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 反撤销 + if err := tx.Model(&model.ConductRecord{}). + Where("record_id = ? AND is_revoked = 1", recordID). + Updates(map[string]interface{}{ + "is_revoked": 0, + "revoked_by": nil, + "revoked_at": nil, + }).Error; err != nil { + return err + } + // 恢复学生总分(下限保护) + return tx.Model(&model.Student{}). + Where("student_id = ?", record.StudentID). + Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", record.PointsChange)).Error + }) + if txErr != nil { + return map[string]interface{}{"success": false, "message": "反撤销失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "反撤销成功", + }, nil +} + +// GetHistory 获取操行分历史记录 +func (s *ConductService) GetHistory(classID int, studentID *int, page, pageSize int, + startDate, endDate, relatedType, reasonPrefix string, isRevoked *int, reasonSearch string) (map[string]interface{}, error) { + + includeRevoked := false + if isRevoked != nil && *isRevoked == 1 { + includeRevoked = true + } + + offset := (page - 1) * pageSize + + records, err := s.conductRepo.GetAllRecords(classID, pageSize, offset, startDate, endDate, + derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch) + if err != nil { + return nil, err + } + + total, err := s.conductRepo.CountAllRecords(classID, startDate, endDate, + derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "records": records, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// validatePointsPermission 验证角色加减分权限 +func (s *ConductService) validatePointsPermission(role string, pointsChange, classID int) error { + // 从 class_settings 读取配置,若无则使用默认值 + maxPoints := func(key string, defaultVal int) int { + if classID > 0 { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + return v + } + } + } + return defaultVal + } + + switch role { + case "班主任": + return nil // 无限制 + case "班长": + maxAdd := maxPoints("point_limit_班长_max", 5) + maxSub := maxPoints("point_limit_班长_min", -5) + if pointsChange > maxAdd || pointsChange < maxSub { + return fmt.Errorf("班长单次只能加%d至减%d分以内", maxAdd, absInt(maxSub)) + } + case "学习委员": + limit := maxPoints("point_limit_学习委员_max", 5) + if absInt(pointsChange) > limit { + return fmt.Errorf("学习委员单次只能加减%d分以内", limit) + } + case "科任老师": + limit := maxPoints("point_limit_科任老师_max", 5) + if absInt(pointsChange) > limit { + return fmt.Errorf("科任老师单次只能加减%d分以内", limit) + } + case "考勤委员": + if pointsChange > 0 { + return fmt.Errorf("考勤委员只能进行扣分操作") + } + limit := maxPoints("point_limit_考勤委员_max", 8) + if absInt(pointsChange) > limit { + return fmt.Errorf("考勤委员单次最多扣%d分", limit) + } + case "劳动委员": + limit := maxPoints("point_limit_劳动委员_max", 1) + if absInt(pointsChange) > limit { + return fmt.Errorf("劳动委员单次只能加减%d分以内", limit) + } + case "志愿委员": + if pointsChange < 0 { + return fmt.Errorf("志愿委员只能加分") + } + limit := maxPoints("point_limit_志愿委员_max", 5) + if pointsChange > limit { + return fmt.Errorf("志愿委员单次最多加%d分", limit) + } + case "课代表": + return fmt.Errorf("课代表无权进行此操作") + default: + return fmt.Errorf("无权进行此操作") + } + return nil +} + +// absInt 取绝对值 +func absInt(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/backend-go/internal/service/config_service.go b/backend-go/internal/service/config_service.go new file mode 100644 index 0000000..5c0daca --- /dev/null +++ b/backend-go/internal/service/config_service.go @@ -0,0 +1,49 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// ConfigService 配置服务 +type ConfigService struct { + classRepo *repository.ClassRepo +} + +// NewConfigService 创建配置服务 +func NewConfigService(classRepo *repository.ClassRepo) *ConfigService { + return &ConfigService{classRepo: classRepo} +} + +// GetClassSettingValue 从 class_settings 读取设置值,若无则返回默认值 +func (s *ConfigService) GetClassSettingValue(classID int, key, defaultVal string) string { + if classID > 0 && s.classRepo != nil { + setting, err := s.classRepo.GetSetting(classID, key) + if err == nil && setting != nil && setting.SettingValue != "" { + return setting.SettingValue + } + } + return defaultVal +} + +// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置) +func (s *ConfigService) GetDeductionRules(classID int) map[string]string { + return map[string]string{ + "DEDUCTION_ATTENDANCE_ABSENT": s.GetClassSettingValue(classID, "deduction_attendance_absent", "3"), + "DEDUCTION_ATTENDANCE_LATE": s.GetClassSettingValue(classID, "deduction_attendance_late", "1"), + "DEDUCTION_ATTENDANCE_LEAVE": s.GetClassSettingValue(classID, "deduction_attendance_leave", "0"), + "STUDENT_INITIAL_POINTS": s.GetClassSettingValue(classID, "initial_points", "60"), + "DEDUCTION_HOMEWORK_NOT_SUBMIT": s.GetClassSettingValue(classID, "deduction_homework_not_submit", "2"), + "DEDUCTION_HOMEWORK_LATE": s.GetClassSettingValue(classID, "deduction_homework_late", "1"), + } +} diff --git a/backend-go/internal/service/log_service.go b/backend-go/internal/service/log_service.go new file mode 100644 index 0000000..6924200 --- /dev/null +++ b/backend-go/internal/service/log_service.go @@ -0,0 +1,70 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// LogService 日志服务 +type LogService struct { + logRepo *repository.LogRepo +} + +// NewLogService 创建日志服务 +func NewLogService(logRepo *repository.LogRepo) *LogService { + return &LogService{logRepo: logRepo} +} + +// WriteLoginLog 写入登录日志 +func (s *LogService) WriteLoginLog(username string, loginResult int8, ip, userAgent, failReason string) { + log := &model.LoginLog{ + Username: username, + LoginResult: loginResult, + IPAddress: stringPtr(ip), + UserAgent: stringPtr(userAgent), + FailReason: stringPtr(failReason), + } + if _, err := s.logRepo.CreateLoginLog(log); err != nil { + logger.Sugared.Errorf("写入登录日志失败: %v", err) + } +} + +// WriteOperationLog 写入操作日志 +func (s *LogService) WriteOperationLog(operatorID int, operatorName, operatorRole, operationType string, + targetType *string, targetID *int, details *string, ip *string, classID *int) { + log := &model.OperationLog{ + OperatorID: operatorID, + OperatorName: stringPtr(operatorName), + OperatorRole: stringPtr(operatorRole), + OperationType: operationType, + TargetType: targetType, + TargetID: targetID, + Details: details, + IPAddress: ip, + ClassID: classID, + } + if _, err := s.logRepo.CreateOperationLog(log); err != nil { + logger.Sugared.Errorf("写入操作日志失败: %v", err) + } +} + +// stringPtr 辅助函数:字符串转指针(空字符串返回 nil) +func stringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + diff --git a/backend-go/internal/service/ranking_service.go b/backend-go/internal/service/ranking_service.go new file mode 100644 index 0000000..b1fbb13 --- /dev/null +++ b/backend-go/internal/service/ranking_service.go @@ -0,0 +1,80 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// RankingService 排行榜服务 +type RankingService struct { + studentRepo *repository.StudentRepo + conductRepo *repository.ConductRepo +} + +// NewRankingService 创建排行榜服务 +func NewRankingService( + studentRepo *repository.StudentRepo, + conductRepo *repository.ConductRepo, +) *RankingService { + return &RankingService{ + studentRepo: studentRepo, + conductRepo: conductRepo, + } +} + +// GetRankings 获取排行榜 +func (s *RankingService) GetRankings(classID int, rankType string, limit int) (map[string]interface{}, error) { + switch rankType { + case "attendance", "homework", "conduct": + return s.getTypedRanking(classID, rankType, limit) + default: + // 默认按操行分总分排行 + ranking, err := s.studentRepo.GetRanking(classID, limit) + if err != nil { + return nil, err + } + totalStudents, _ := s.studentRepo.GetTotalCount(classID) + return map[string]interface{}{ + "ranking": ranking, + "total_students": totalStudents, + "type": "all", + }, nil + } +} + +// getTypedRanking 获取分项排行榜(使用 SQL 层聚合,避免全量加载) +func (s *RankingService) getTypedRanking(classID int, relatedType string, limit int) (map[string]interface{}, error) { + dbType := relatedType + if relatedType == "conduct" { + dbType = "manual" + } + results, err := s.conductRepo.GetStudentPointsByType(classID, dbType, limit) + if err != nil { + return nil, err + } + + var rankings []map[string]interface{} + for _, r := range results { + rankings = append(rankings, map[string]interface{}{ + "student_id": r.StudentID, + "student_no": r.StudentNo, + "name": r.Name, + "points": r.TotalPoints, + }) + } + + return map[string]interface{}{ + "ranking": rankings, + "type": relatedType, + }, nil +} diff --git a/backend-go/internal/service/semester_service.go b/backend-go/internal/service/semester_service.go new file mode 100644 index 0000000..e3edb4a --- /dev/null +++ b/backend-go/internal/service/semester_service.go @@ -0,0 +1,665 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SemesterService 学期服务 +type SemesterService struct { + semesterRepo *repository.SemesterRepo + studentRepo *repository.StudentRepo + classRepo *repository.ClassRepo + attendanceRepo *repository.AttendanceRepo + assignmentRepo *repository.AssignmentRepo + logService *LogService +} + +// NewSemesterService 创建学期服务 +func NewSemesterService( + semesterRepo *repository.SemesterRepo, + studentRepo *repository.StudentRepo, + classRepo *repository.ClassRepo, + attendanceRepo *repository.AttendanceRepo, + assignmentRepo *repository.AssignmentRepo, + logService *LogService, +) *SemesterService { + return &SemesterService{ + semesterRepo: semesterRepo, + studentRepo: studentRepo, + classRepo: classRepo, + attendanceRepo: attendanceRepo, + assignmentRepo: assignmentRepo, + logService: logService, + } +} + +// ListSemesters 获取学期列表 +func (s *SemesterService) ListSemesters() (map[string]interface{}, error) { + semesters, err := s.semesterRepo.GetAll() + if err != nil { + return nil, err + } + + today := time.Now() + for i := range semesters { + conductCount, attendanceCount, _ := s.semesterRepo.CountRecordsBySemester(semesters[i].SemesterID) + semesters[i].ConductCount = conductCount + semesters[i].AttendanceCount = attendanceCount + + // 计算当前周数 + if semesters[i].IsActive == 1 && semesters[i].StartDate != nil { + delta := today.Sub(*semesters[i].StartDate).Hours() / (24 * 7) + if delta >= 0 { + week := int(delta) + 1 + semesters[i].CurrentWeek = &week + } + } + } + + return map[string]interface{}{ + "semesters": semesters, + }, nil +} + +// GetActiveSemester 获取当前活跃学期 +func (s *SemesterService) GetActiveSemester() (*model.Semester, error) { + return s.semesterRepo.GetActive() +} + +// CreateSemester 创建学期 +func (s *SemesterService) CreateSemester(semesterName string, startDate, endDate *string) (map[string]interface{}, error) { + semester := &model.Semester{ + SemesterName: semesterName, + IsActive: 0, + IsArchived: 0, + } + + if startDate != nil && *startDate != "" { + t, err := time.Parse("2006-01-02", *startDate) + if err == nil { + semester.StartDate = &t + } + } + if endDate != nil && *endDate != "" { + t, err := time.Parse("2006-01-02", *endDate) + if err == nil { + semester.EndDate = &t + } + } + + semesterID, err := s.semesterRepo.Create(semester) + if err != nil { + return map[string]interface{}{"success": false, "message": "创建学期失败"}, nil + } + + // 如果日期范围包含今天,自动激活 + if semester.StartDate != nil { + today := time.Now() + if semester.StartDate.Before(today) || sameDay(*semester.StartDate, today) { + if semester.EndDate == nil || semester.EndDate.After(today) || sameDay(*semester.EndDate, today) { + _ = s.semesterRepo.DeactivateAll() + _ = s.semesterRepo.Activate(semesterID) + } + } + } + + return map[string]interface{}{ + "success": true, + "message": "学期创建成功", + "semester_id": semesterID, + }, nil +} + +// ActivateSemester 激活学期 +func (s *SemesterService) ActivateSemester(semesterID int) error { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return fmt.Errorf("学期不存在") + } + if semester.IsArchived == 1 { + return fmt.Errorf("已归档的学期不能设为当前学期") + } + + _ = s.semesterRepo.DeactivateAll() + return s.semesterRepo.Activate(semesterID) +} + +// UpdateSemester 更新学期 +func (s *SemesterService) UpdateSemester(semesterID int, semesterName, startDate, endDate *string) error { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return fmt.Errorf("学期不存在") + } + if semester.IsArchived == 1 { + return fmt.Errorf("已归档的学期不能编辑") + } + + updates := make(map[string]interface{}) + if semesterName != nil { + updates["semester_name"] = *semesterName + } + if startDate != nil { + t, err := time.Parse("2006-01-02", *startDate) + if err == nil { + updates["start_date"] = t + } + } + if endDate != nil { + t, err := time.Parse("2006-01-02", *endDate) + if err == nil { + updates["end_date"] = t + } + } + + return s.semesterRepo.Update(semesterID, updates) +} + +// DeleteSemester 删除学期 +func (s *SemesterService) DeleteSemester(semesterID int) error { + archiveCount, err := s.semesterRepo.CountArchives(semesterID) + if err != nil { + return err + } + if archiveCount > 0 { + return fmt.Errorf("该学期有 %d 条归档数据,无法删除", archiveCount) + } + return s.semesterRepo.Delete(semesterID) +} + +// AssociateRecords 关联记录到学期 +func (s *SemesterService) AssociateRecords(semesterID int) (map[string]interface{}, error) { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return map[string]interface{}{"success": false, "message": "学期不存在"}, nil + } + if semester.IsArchived == 1 { + return map[string]interface{}{"success": false, "message": "已归档的学期不能关联数据"}, nil + } + if semester.StartDate == nil { + return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法关联数据"}, nil + } + + startDate := semester.StartDate.Format("2006-01-02") + endDate := time.Now().Format("2006-01-02") + if semester.EndDate != nil { + endDate = semester.EndDate.Format("2006-01-02") + } + + conductCount, attendanceCount, err := s.semesterRepo.AssociateRecordsByDateRange(semesterID, startDate, endDate) + if err != nil { + return map[string]interface{}{"success": false, "message": "关联记录失败"}, nil + } + + return map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("关联完成:操行分 %d 条,考勤 %d 条", conductCount, attendanceCount), + "data": map[string]interface{}{ + "conduct": conductCount, + "attendance": attendanceCount, + }, + }, nil +} + +// ArchiveSemester 归档学期 +func (s *SemesterService) ArchiveSemester(semesterID, classID int, resetScores bool) (map[string]interface{}, error) { + semester, err := s.semesterRepo.GetByID(semesterID) + if err != nil || semester == nil { + return map[string]interface{}{"success": false, "message": "学期不存在"}, nil + } + if semester.IsArchived == 1 { + return map[string]interface{}{"success": false, "message": "该学期已归档"}, nil + } + if semester.StartDate == nil { + return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法进行归档"}, nil + } + if classID == 0 { + return map[string]interface{}{"success": false, "message": "未指定班级"}, nil + } + + // 获取班级活跃学生 + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return map[string]interface{}{"success": false, "message": "没有可归档的学生数据"}, nil + } + totalStudents := len(students) + + // 查询考勤统计 + startDate := semester.StartDate.Format("2006-01-02") + endDate := time.Now().Format("2006-01-02") + if semester.EndDate != nil { + endDate = semester.EndDate.Format("2006-01-02") + } + attendanceStats, _ := s.attendanceRepo.GetAttendanceStatsBySemester(semesterID, startDate, endDate) + attendanceMap := make(map[int]map[string]int64) + for _, stat := range attendanceStats { + if attendanceMap[stat.StudentID] == nil { + attendanceMap[stat.StudentID] = make(map[string]int64) + } + attendanceMap[stat.StudentID][stat.Status] = stat.Count + } + + // 查询作业统计 + homeworkStats, err := s.assignmentRepo.GetHomeworkStatsByDateRange(*semester.StartDate, time.Now()) + if err != nil { + logger.Sugared.Warnf("查询作业统计失败,归档快照中作业数据可能不完整: %v", err) + } + homeworkMap := make(map[int]map[string]int64) + for _, stat := range homeworkStats { + if homeworkMap[stat.StudentID] == nil { + homeworkMap[stat.StudentID] = make(map[string]int64) + } + homeworkMap[stat.StudentID][stat.Status] = stat.Count + } + + // 使用事务确保归档操作的原子性,并通过行锁防止并发归档 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 使用 SELECT ... FOR UPDATE 锁定学期记录,防止并发归档 + var lockedSemester model.Semester + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("semester_id = ?", semesterID).First(&lockedSemester).Error; err != nil { + return fmt.Errorf("锁定学期记录失败: %w", err) + } + if lockedSemester.IsArchived == 1 { + return fmt.Errorf("该学期已被其他操作归档") + } + + // 删除旧的归档数据 + if err := tx.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error; err != nil { + return fmt.Errorf("删除旧归档数据失败: %w", err) + } + + // 创建归档快照(填充考勤和作业统计) + var archives []model.SemesterArchive + for rank, stu := range students { + stuAttendance := attendanceMap[stu.StudentID] + stuHomework := homeworkMap[stu.StudentID] + archive := model.SemesterArchive{ + SemesterID: semesterID, + ClassID: classID, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + AttendancePresent: int(stuAttendance["present"]), + AttendanceAbsent: int(stuAttendance["absent"]), + AttendanceLate: int(stuAttendance["late"]), + AttendanceLeave: int(stuAttendance["leave"]), + HomeworkSubmitted: int(stuHomework["submitted"]), + HomeworkNotSubmitted: int(stuHomework["not_submitted"]), + HomeworkLate: int(stuHomework["late"]), + } + archives = append(archives, archive) + } + + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建归档快照失败: %w", err) + } + } + + // 归档学期 + if err := tx.Model(&model.Semester{}). + Where("semester_id = ? AND is_archived = 0", semesterID). + Updates(map[string]interface{}{"is_archived": 1, "is_active": 0}).Error; err != nil { + return fmt.Errorf("归档学期失败: %w", err) + } + + // 重置分数(从 class_settings 读取初始分,若无则默认 60) + if resetScores { + initialPoints := 60 + var setting model.ClassSetting + if err := tx.Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + } + + return nil + }) + + if txErr != nil { + logger.Sugared.Errorf("归档事务失败: %v", txErr) + return map[string]interface{}{"success": false, "message": "归档失败: " + txErr.Error()}, nil + } + + return map[string]interface{}{ + "success": true, + "message": "归档成功", + }, nil +} + +// GetArchiveRecords 获取归档数据 +func (s *SemesterService) GetArchiveRecords(semesterID, classID, page, pageSize int) (map[string]interface{}, error) { + archives, total, err := s.semesterRepo.GetArchivesBySemester(semesterID, classID, page, pageSize) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "items": archives, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// sameDay 判断两个时间是否同一天 +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.YearDay() == b.YearDay() +} + +// ========== 周期重置功能 ========== + +// PeriodReset 周度/月度重置 +// 1. 创建当前操行分快照 +// 2. 将所有学生操行分重置为 class_settings.initial_points +// 3. 记录操作日志 +func (s *SemesterService) PeriodReset(classID int, period string, operatorID int, operatorName string, ip string) error { + periodLabel := generatePeriodLabel(period, time.Now()) + + // 读取初始分 + initialPoints := 60 + var setting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + + // 获取班级活跃学生 + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return fmt.Errorf("没有可重置的学生数据") + } + + totalStudents := len(students) + var archives []model.PeriodArchive + for rank, stu := range students { + archive := model.PeriodArchive{ + ClassID: classID, + PeriodType: period, + PeriodLabel: periodLabel, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + ResetBy: "manual", + OperatorID: &operatorID, + } + archives = append(archives, archive) + } + + // 使用事务确保原子性,并将存在检查移入事务内防止竞态条件 + db := s.semesterRepo.GetDB() + txErr := db.Transaction(func(tx *gorm.DB) error { + // 在事务内检查本期是否已有归档数据(防并发重复重置) + var existCount int64 + if err := tx.Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ? AND period_label = ?", classID, period, periodLabel). + Count(&existCount).Error; err != nil { + return fmt.Errorf("检查归档数据失败: %w", err) + } + if existCount > 0 { + return fmt.Errorf("当前周期(%s)已有归档数据,请勿重复重置", periodLabel) + } + + // 创建归档快照 + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建周期归档快照失败: %w", err) + } + } + + // 重置分数 + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + + return nil + }) + + if txErr != nil { + logger.Sugared.Errorf("周期重置事务失败: %v", txErr) + return txErr + } + + // 记录操作日志 + details := fmt.Sprintf("手动执行%s重置,周期标签: %s,影响学生数: %d", periodCN(period), periodLabel, totalStudents) + s.logService.WriteOperationLog( + operatorID, operatorName, "班主任", "period_reset", + nil, nil, &details, &ip, &classID, + ) + + return nil +} + +// AutoPeriodReset 自动周期重置检查(由定时任务调用) +func (s *SemesterService) AutoPeriodReset() { + logger.Sugared.Info("开始检查自动周期重置...") + + // 获取所有启用的班级 + classes, err := s.classRepo.GetAll(false) + if err != nil { + logger.Sugared.Errorf("获取班级列表失败: %v", err) + return + } + + now := time.Now() + for _, cls := range classes { + // 读取 reset_frequency + var freqSetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_frequency").First(&freqSetting).Error; err != nil { + continue // 无配置,跳过 + } + freq := freqSetting.SettingValue + if freq == "none" || freq == "" { + continue + } + + shouldReset := false + switch freq { + case "weekly": + // 读取 reset_day_of_week(默认1=周一) + resetDay := 1 + var daySetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_week").First(&daySetting).Error; err == nil { + if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 7 { + resetDay = v + } + } + // Go的Weekday: 0=Sunday, 1=Monday, ..., 6=Saturday + // 映射: 1=周一(1) ... 6=周六(6), 7=周日(0) + var targetWeekday time.Weekday + if resetDay == 7 { + targetWeekday = time.Sunday + } else { + targetWeekday = time.Weekday(resetDay) + } + if now.Weekday() == targetWeekday { + shouldReset = true + } + case "monthly": + // 读取 reset_day_of_month(默认1) + resetDay := 1 + var daySetting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_month").First(&daySetting).Error; err == nil { + if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 28 { + resetDay = v + } + } + if now.Day() == resetDay { + shouldReset = true + } + } + + if !shouldReset { + continue + } + + // 检查今天是否已经重置过 + periodLabel := generatePeriodLabel(freq, now) + var existCount int64 + if err := s.classRepo.GetDB().Model(&model.PeriodArchive{}). + Where("class_id = ? AND period_type = ? AND period_label = ? AND reset_by = ?", + cls.ClassID, freq, periodLabel, "auto"). + Count(&existCount).Error; err != nil { + logger.Sugared.Errorf("检查班级 %d 周期归档失败: %v", cls.ClassID, err) + continue + } + if existCount > 0 { + logger.Sugared.Infof("班级 %d 本期(%s)已自动重置,跳过", cls.ClassID, periodLabel) + continue + } + + // 执行自动重置 + logger.Sugared.Infof("自动重置班级 %d (%s, %s)", cls.ClassID, cls.ClassName, periodLabel) + if err := s.autoPeriodResetClass(cls.ClassID, freq, periodLabel); err != nil { + logger.Sugared.Errorf("自动重置班级 %d 失败: %v", cls.ClassID, err) + } + } +} + +// autoPeriodResetClass 单个班级的自动周期重置 +func (s *SemesterService) autoPeriodResetClass(classID int, period, periodLabel string) error { + initialPoints := 60 + var setting model.ClassSetting + if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil { + if v, e := strconv.Atoi(setting.SettingValue); e == nil { + initialPoints = v + } + } + + students, err := s.studentRepo.GetStudentsByClassID(classID) + if err != nil || len(students) == 0 { + return fmt.Errorf("没有可重置的学生数据") + } + + totalStudents := len(students) + var archives []model.PeriodArchive + for rank, stu := range students { + archive := model.PeriodArchive{ + ClassID: classID, + PeriodType: period, + PeriodLabel: periodLabel, + StudentID: stu.StudentID, + StudentNo: stu.StudentNo, + StudentName: stu.Name, + FinalPoints: stu.TotalPoints, + RankPosition: intPtr(rank + 1), + TotalStudents: &totalStudents, + ResetBy: "auto", + } + archives = append(archives, archive) + } + + db := s.semesterRepo.GetDB() + return db.Transaction(func(tx *gorm.DB) error { + if len(archives) > 0 { + if err := tx.Create(&archives).Error; err != nil { + return fmt.Errorf("创建周期归档快照失败: %w", err) + } + } + if err := tx.Model(&model.Student{}). + Where("class_id = ? AND status = 1", classID). + Update("total_points", initialPoints).Error; err != nil { + return fmt.Errorf("重置分数失败: %w", err) + } + return nil + }) +} + +// GetPeriodArchives 获取周期归档列表 +func (s *SemesterService) GetPeriodArchives(classID int, period string, page, pageSize int) (map[string]interface{}, error) { + archives, total, err := s.semesterRepo.GetPeriodArchives(classID, period, page, pageSize) + if err != nil { + return nil, err + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + return map[string]interface{}{ + "items": archives, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": totalPages, + }, nil +} + +// generatePeriodLabel 生成周期标签 +func generatePeriodLabel(period string, t time.Time) string { + switch period { + case "weekly": + year, week := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", year, week) + case "monthly": + return t.Format("2006-01") + default: + return t.Format("2006-01-02") + } +} + +// periodCN 周期类型的中文描述 +func periodCN(period string) string { + switch period { + case "weekly": + return "每周" + case "monthly": + return "每月" + default: + return period + } +} + +// PeriodLabelCN 周期类型的中文标签(当前周期) +func PeriodLabelCN(period string) string { + switch period { + case "weekly": + return "本周" + case "monthly": + return "本月" + default: + return period + } +} diff --git a/backend-go/internal/service/student_service.go b/backend-go/internal/service/student_service.go new file mode 100644 index 0000000..e43793d --- /dev/null +++ b/backend-go/internal/service/student_service.go @@ -0,0 +1,171 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" +) + +// StudentService 学生端服务 +type StudentService struct { + studentRepo *repository.StudentRepo + conductRepo *repository.ConductRepo + attendanceRepo *repository.AttendanceRepo + semesterRepo *repository.SemesterRepo +} + +// NewStudentService 创建学生端服务 +func NewStudentService( + studentRepo *repository.StudentRepo, + conductRepo *repository.ConductRepo, + attendanceRepo *repository.AttendanceRepo, + semesterRepo *repository.SemesterRepo, +) *StudentService { + return &StudentService{ + studentRepo: studentRepo, + conductRepo: conductRepo, + attendanceRepo: attendanceRepo, + semesterRepo: semesterRepo, + } +} + +// GetStudentInfo 获取学生个人信息 +func (s *StudentService) GetStudentInfo(studentID int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "student": student, + }, nil +} + +// GetConductHistory 获取学生操行分历史 +func (s *StudentService) GetConductHistory(studentID int, limit, offset int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.conductRepo.GetStudentRecords(studentID, limit, offset, false, "", "", 0) + if err != nil { + return nil, err + } + + // 扣分项的操作人统一显示为"班主任" + for i := range records { + if records[i].PointsChange < 0 { + name := "班主任" + records[i].RecorderReal = &name + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "total_points": student.TotalPoints, + "records": records, + }, nil +} + +// GetHomeworkStatus 获取学生作业情况 +func (s *StudentService) GetHomeworkStatus(studentID int) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.conductRepo.GetStudentRecords(studentID, 1000, 0, false, "", "", 0) + if err != nil { + return nil, err + } + + // 过滤出作业相关记录 + var homeworkRecords []interface{} + for _, r := range records { + if r.RelatedType == "homework" { + homeworkRecords = append(homeworkRecords, r) + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "homework": homeworkRecords, + }, nil +} + +// GetAttendanceRecords 获取学生考勤记录 +func (s *StudentService) GetAttendanceRecords(studentID int, month string) (map[string]interface{}, error) { + student, err := s.studentRepo.GetByID(studentID) + if err != nil { + return nil, err + } + + records, err := s.attendanceRepo.GetStudentRecords(studentID, month) + if err != nil { + return nil, err + } + + // 统计 + present, absent, late, leave := 0, 0, 0, 0 + for _, r := range records { + switch r.Status { + case "present": + present++ + case "absent": + absent++ + case "late": + late++ + case "leave": + leave++ + } + } + + return map[string]interface{}{ + "student_id": studentID, + "student_name": student.Name, + "statistics": map[string]interface{}{ + "present": present, + "absent": absent, + "late": late, + "leave": leave, + "total": len(records), + }, + "records": records, + }, nil +} + +// GetRanking 获取排行榜 +func (s *StudentService) GetRanking(classID int, limit int) (map[string]interface{}, error) { + ranking, err := s.studentRepo.GetRanking(classID, limit) + if err != nil { + return nil, err + } + totalStudents, _ := s.studentRepo.GetTotalCount(classID) + + return map[string]interface{}{ + "ranking": ranking, + "total_students": totalStudents, + }, nil +} + +// GetSemesterRecords 获取学生学期归档记录 +func (s *StudentService) GetSemesterRecords(studentID int) (map[string]interface{}, error) { + archives, err := s.semesterRepo.GetArchivesByStudent(studentID) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "records": archives, + }, nil +} diff --git a/backend-go/internal/service/subject_service.go b/backend-go/internal/service/subject_service.go new file mode 100644 index 0000000..a1ff115 --- /dev/null +++ b/backend-go/internal/service/subject_service.go @@ -0,0 +1,92 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SubjectService 科目服务 +type SubjectService struct { + subjectRepo *repository.SubjectRepo +} + +// NewSubjectService 创建科目服务 +func NewSubjectService(subjectRepo *repository.SubjectRepo) *SubjectService { + return &SubjectService{subjectRepo: subjectRepo} +} + +// GetSubjects 获取科目列表 +func (s *SubjectService) GetSubjects(isActive *bool) (map[string]interface{}, error) { + subjects, err := s.subjectRepo.GetAll(isActive) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "subjects": subjects, + "total": len(subjects), + }, nil +} + +// CreateSubject 创建科目 +func (s *SubjectService) CreateSubject(subjectName string, subjectCode *string, sortOrder int) (map[string]interface{}, error) { + existing, _ := s.subjectRepo.GetByName(subjectName) + if existing != nil { + return map[string]interface{}{"success": false, "message": "科目名称已存在"}, nil + } + + subject := &model.Subject{ + SubjectName: subjectName, + SubjectCode: subjectCode, + SortOrder: sortOrder, + IsActive: 1, + } + + subjectID, err := s.subjectRepo.Create(subject) + if err != nil { + return nil, err + } + + logger.Sugared.Infof("创建科目: %s", subjectName) + return map[string]interface{}{ + "success": true, + "subject_id": subjectID, + }, nil +} + +// UpdateSubject 更新科目 +func (s *SubjectService) UpdateSubject(subjectID int, updates map[string]interface{}) error { + return s.subjectRepo.Update(subjectID, updates) +} + +// DisableSubject 禁用科目(将 is_active 设为 0,保留数据) +func (s *SubjectService) DisableSubject(subjectID int) error { + return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 0}) +} + +// EnableSubject 启用科目(将 is_active 设为 1) +func (s *SubjectService) EnableSubject(subjectID int) error { + return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 1}) +} + +// DeleteSubject 物理删除科目(需先检查关联数据) +func (s *SubjectService) DeleteSubject(subjectID int) error { + hasData, _ := s.subjectRepo.HasRelatedData(subjectID) + if hasData { + return fmt.Errorf("该科目下已有作业数据,无法删除") + } + return s.subjectRepo.Delete(subjectID) +} diff --git a/backend-go/internal/service/super_admin_service.go b/backend-go/internal/service/super_admin_service.go new file mode 100644 index 0000000..aed8c2b --- /dev/null +++ b/backend-go/internal/service/super_admin_service.go @@ -0,0 +1,158 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package service + +import ( + "context" + "fmt" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database" + appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt" + "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger" +) + +// SuperAdminService 超级管理员服务 +type SuperAdminService struct { + superAdminRepo *repository.SuperAdminRepo + logService *LogService +} + +// NewSuperAdminService 创建超级管理员服务 +func NewSuperAdminService(superAdminRepo *repository.SuperAdminRepo, logService *LogService) *SuperAdminService { + return &SuperAdminService{superAdminRepo: superAdminRepo, logService: logService} +} + +// EnsureDefaultAdmin 确保默认超级管理员存在 +func (s *SuperAdminService) EnsureDefaultAdmin() error { + cfg := config.AppConfig + + logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务") + + // 为超级管理员生成独立的随机 Salt + salt, err := crypto.GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("生成随机盐值失败: %w", err) + } + passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt) + if err := s.superAdminRepo.EnsureDefaultAdmin( + cfg.SuperAdminDefaultUser, + passwordHash, + salt, + "系统管理员", + ); err != nil { + return fmt.Errorf("创建默认超级管理员失败: %w", err) + } + return nil +} + +// Login 超级管理员登录 +func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map[string]interface{}, error) { + ctx := context.Background() + cfg := config.AppConfig + + // 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态) + attemptsKey := fmt.Sprintf("login_attempts:sa:%s", username) + ipAttemptsKey := fmt.Sprintf("login_attempts:ip:super_admin:%s", ip) + + count, _ := incrWithExpireAtomic(ctx, attemptsKey, 300) + if count > 5 { + return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil + } + // IP 级限流 + ipCount, _ := incrWithExpireAtomic(ctx, ipAttemptsKey, 300) + if ipCount > 20 { + return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil + } + + admin, err := s.superAdminRepo.GetByUsername(username) + if err != nil { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil + } + + if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) { + s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误") + return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil + } + + // 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(与普通用户策略一致,防止同 IP 其他用户限流被重置) + database.RDB.Del(ctx, attemptsKey) + s.logService.WriteLoginLog(username, 1, ip, userAgent, "") + + // 生成 Token + token, err := appJwt.CreateToken( + admin.ID, admin.Username, "super_admin", + nil, "系统管理员", admin.RealName, nil, false, + ) + if err != nil { + return map[string]interface{}{"success": false, "message": "生成令牌失败"}, nil + } + + _ = database.SetUserToken(ctx, admin.ID, token, cfg.JWTIdleTimeoutMinutes) + + needChangePassword := admin.NeedChangePassword == 1 + redirect := "/admin/dashboard.php" + if needChangePassword { + redirect = "/admin/password.php" + } + + return map[string]interface{}{ + "success": true, + "token": token, + "user_id": admin.ID, + "username": admin.Username, + "real_name": admin.RealName, + "user_type": "super_admin", + "need_change_password": needChangePassword, + "redirect": redirect, + }, nil +} + +// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt) +func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error { + admin, err := s.superAdminRepo.GetByID(adminID) + if err != nil { + return fmt.Errorf("超级管理员不存在") + } + + // 验证原密码(强制改密时跳过) + if !force { + if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) { + return fmt.Errorf("原密码错误") + } + } + + // 验证新密码强度 + if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid { + return fmt.Errorf("%s", msg) + } + + // 生成新的独立 salt + newSalt, err := crypto.GenerateRandomPassword(16) + if err != nil { + return fmt.Errorf("生成随机盐值失败: %w", err) + } + newHash := crypto.HashPassword(newPassword, newSalt) + + if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil { + return fmt.Errorf("密码修改失败") + } + + // 清除旧 Token,强制重新登录 + ctx := context.Background() + _ = database.DeleteUserToken(ctx, adminID) + + return nil +} diff --git a/backend-go/internal/service/utils.go b/backend-go/internal/service/utils.go new file mode 100644 index 0000000..a4a3352 --- /dev/null +++ b/backend-go/internal/service/utils.go @@ -0,0 +1,25 @@ +package service + +// derefInt 安全解引用 int 指针 +func derefInt(i *int) int { + if i == nil { + return 0 + } + return *i +} + +// derefStr 安全解引用字符串指针 +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// intPtr 辅助函数:int 转指针(0 返回 nil) +func intPtr(i int) *int { + if i == 0 { + return nil + } + return &i +} diff --git a/backend-go/pkg/crypto/password.go b/backend-go/pkg/crypto/password.go new file mode 100644 index 0000000..ce05356 --- /dev/null +++ b/backend-go/pkg/crypto/password.go @@ -0,0 +1,110 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package crypto + +import ( + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/hex" + "fmt" + "math/big" +) + +// HashPassword 密码哈希(与 Python 版完全兼容) +// 算法: MD5(SHA1(password) + salt) +// Python 参考: backend/utils/security.py -> sha1_md5_password() +// 已知弱算法:MD5 和 SHA1 均不适合密码哈希场景,保留此实现仅为兼容 Python 版数据。 +// 后续迁移计划:迁移到 bcrypt/scrypt/argon2,并提供兼容层逐步过渡。 +func HashPassword(password string, salt string) string { + // 第一层: SHA1(password) + sha1Hash := sha1.Sum([]byte(password)) + sha1Hex := hex.EncodeToString(sha1Hash[:]) + + // 加盐: SHA1_hex + salt + salted := sha1Hex + salt + + // 第二层: MD5(salted) + md5Hash := md5.Sum([]byte(salted)) + return hex.EncodeToString(md5Hash[:]) +} + +// VerifyPassword 验证密码(使用常量时间比较,防止时序攻击) +func VerifyPassword(plainPassword, hashedPassword, salt string) bool { + computed := HashPassword(plainPassword, salt) + return subtle.ConstantTimeCompare([]byte(computed), []byte(hashedPassword)) == 1 +} + +// GenerateRandomPassword 生成随机密码 +// 与 Python 版 SecurityUtils.generate_random_password() 兼容 +func GenerateRandomPassword(length int) (string, error) { + alphabet := "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + result := make([]byte, length) + for i := range result { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + result[i] = alphabet[n.Int64()] + } + return string(result), nil +} + +// ValidatePasswordStrength 验证密码强度 +// 要求: 大小写字母、数字、特殊符号至少包含 3 种,长度 6-20 +func ValidatePasswordStrength(password string) (bool, string) { + if len(password) < 6 { + return false, "密码长度至少6位" + } + if len(password) > 20 { + return false, "密码长度不能超过20位" + } + + hasUpper := false + hasLower := false + hasDigit := false + hasSpecial := false + + for _, c := range password { + switch { + case c >= 'A' && c <= 'Z': + hasUpper = true + case c >= 'a' && c <= 'z': + hasLower = true + case c >= '0' && c <= '9': + hasDigit = true + default: + hasSpecial = true + } + } + + charTypes := 0 + if hasUpper { + charTypes++ + } + if hasLower { + charTypes++ + } + if hasDigit { + charTypes++ + } + if hasSpecial { + charTypes++ + } + + if charTypes < 3 { + return false, "密码必须包含大写字母、小写字母、数字、特殊符号中的至少3种" + } + + return true, "" +} diff --git a/backend-go/pkg/database/mysql.go b/backend-go/pkg/database/mysql.go new file mode 100644 index 0000000..3bceff9 --- /dev/null +++ b/backend-go/pkg/database/mysql.go @@ -0,0 +1,71 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package database + +import ( + "fmt" + "strings" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// DB 全局数据库实例 +var DB *gorm.DB + +// InitMySQL 初始化 MySQL 连接池 +func InitMySQL(cfg *config.Config) (*gorm.DB, error) { + dsn := cfg.DSN() + + // 根据 LogLevel 配置设置 GORM 日志级别 + gormLogLevel := logger.Info + switch strings.ToLower(cfg.LogLevel) { + case "silent": + gormLogLevel = logger.Silent + case "error": + gormLogLevel = logger.Error + case "warn", "warning": + gormLogLevel = logger.Warn + default: + gormLogLevel = logger.Info + } + gormCfg := &gorm.Config{ + Logger: logger.Default.LogMode(gormLogLevel), + } + + db, err := gorm.Open(mysql.Open(dsn), gormCfg) + if err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("获取底层 sql.DB 失败: %w", err) + } + + // 连接池配置 + sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLife) * time.Second) + + // 测试连接 + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("数据库 Ping 失败: %w", err) + } + + DB = db + return db, nil +} diff --git a/backend-go/pkg/database/redis.go b/backend-go/pkg/database/redis.go new file mode 100644 index 0000000..94160f3 --- /dev/null +++ b/backend-go/pkg/database/redis.go @@ -0,0 +1,80 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package database + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// RDB 全局 Redis 客户端实例 +var RDB *redis.Client + +// InitRedis 初始化 Redis 连接 +func InitRedis(cfg *config.Config) (*redis.Client, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr(), + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + PoolSize: cfg.RedisMaxConns, + MinIdleConns: 5, + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + }) + + // 测试连接 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("连接 Redis 失败: %w", err) + } + + RDB = rdb + return rdb, nil +} + +// --- Token 存储操作(兼容 Python 版 Redis Token 管理) --- + +const ( + tokenKeyPrefix = "user_token:" +) + +// SetUserToken 存储用户 Token +func SetUserToken(ctx context.Context, userID int, token string, expireMinutes int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Set(ctx, key, token, time.Duration(expireMinutes)*time.Minute).Err() +} + +// GetUserToken 获取用户 Token +func GetUserToken(ctx context.Context, userID int) (string, error) { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Get(ctx, key).Result() +} + +// DeleteUserToken 删除用户 Token +func DeleteUserToken(ctx context.Context, userID int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Del(ctx, key).Err() +} + +// ExpireToken 刷新 Token 过期时间(参数单位:分钟) +func ExpireToken(ctx context.Context, userID int, expireMinutes int) error { + key := fmt.Sprintf("%s%d", tokenKeyPrefix, userID) + return RDB.Expire(ctx, key, time.Duration(expireMinutes)*time.Minute).Err() +} diff --git a/backend-go/pkg/jwt/jwt.go b/backend-go/pkg/jwt/jwt.go new file mode 100644 index 0000000..2058c77 --- /dev/null +++ b/backend-go/pkg/jwt/jwt.go @@ -0,0 +1,93 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package jwt + +import ( + "fmt" + "time" + + goJwt "github.com/golang-jwt/jwt/v5" + + "hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config" +) + +// getSigningMethod 根据配置返回对应的签名算法 +func getSigningMethod(algorithm string) goJwt.SigningMethod { + switch algorithm { + case "HS384": + return goJwt.SigningMethodHS384 + case "HS512": + return goJwt.SigningMethodHS512 + default: + return goJwt.SigningMethodHS256 + } +} + +// Claims JWT 载荷结构(与 Python 版完全兼容) +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + UserType string `json:"user_type"` + StudentID *int `json:"student_id"` + Role string `json:"role"` + RealName string `json:"real_name"` + ClassID *int `json:"class_id"` + NeedChangePassword bool `json:"need_change_password"` + goJwt.RegisteredClaims +} + +// CreateToken 创建 JWT Token +func CreateToken(userID int, username, userType string, studentID *int, role, realName string, classID *int, needChangePassword bool) (string, error) { + now := time.Now() + cfg := config.AppConfig + + claims := Claims{ + UserID: userID, + Username: username, + UserType: userType, + StudentID: studentID, + Role: role, + RealName: realName, + ClassID: classID, + NeedChangePassword: needChangePassword, + RegisteredClaims: goJwt.RegisteredClaims{ + ExpiresAt: goJwt.NewNumericDate(now.Add(time.Duration(cfg.JWTExpireMinutes) * time.Minute)), + IssuedAt: goJwt.NewNumericDate(now), + Issuer: cfg.AppName, + }, + } + + token := goJwt.NewWithClaims(getSigningMethod(cfg.JWTAlgorithm), claims) + return token.SignedString([]byte(cfg.JWTSecretKey)) +} + +// VerifyToken 验证 JWT Token,返回解析后的载荷 +func VerifyToken(tokenStr string) (*Claims, error) { + cfg := config.AppConfig + + token, err := goJwt.ParseWithClaims(tokenStr, &Claims{}, func(t *goJwt.Token) (interface{}, error) { + if _, ok := t.Method.(*goJwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("不支持的签名算法: %v", t.Header["alg"]) + } + return []byte(cfg.JWTSecretKey), nil + }) + if err != nil { + return nil, fmt.Errorf("token 验证失败: %w", err) + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("token 无效") + } + + return claims, nil +} diff --git a/backend-go/pkg/logger/logger.go b/backend-go/pkg/logger/logger.go new file mode 100644 index 0000000..2693cee --- /dev/null +++ b/backend-go/pkg/logger/logger.go @@ -0,0 +1,64 @@ +package logger + +import ( + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Log 全局日志实例 +var Log *zap.Logger + +// Sugared 全局 SugaredLogger(便捷方法) +var Sugared *zap.SugaredLogger + +// Init 初始化日志 +func Init(level string, isProduction bool) { + var zapLevel zapcore.Level + switch level { + case "debug": + zapLevel = zapcore.DebugLevel + case "info": + zapLevel = zapcore.InfoLevel + case "warn": + zapLevel = zapcore.WarnLevel + case "error": + zapLevel = zapcore.ErrorLevel + default: + zapLevel = zapcore.InfoLevel + } + + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.TimeKey = "time" + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + encoderCfg.EncodeLevel = zapcore.CapitalLevelEncoder + + var core zapcore.Core + if isProduction { + // 生产环境:JSON 格式输出到 stdout + core = zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + zapLevel, + ) + } else { + // 开发环境:Console 格式输出 + encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder + core = zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + zapLevel, + ) + } + + Log = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) + Sugared = Log.Sugar() +} + +// Sync 刷新日志缓冲区 +func Sync() { + if Log != nil { + _ = Log.Sync() + } +} diff --git a/backend-go/pkg/response/response.go b/backend-go/pkg/response/response.go new file mode 100644 index 0000000..eab0828 --- /dev/null +++ b/backend-go/pkg/response/response.go @@ -0,0 +1,106 @@ +// =========================================== +// 多班级版班级管理系统 - Go 后端 +// +// 开发者: Canglan +// 联系方式: admin@sea-studio.top +// 版权归属: Sea Network Technology Studio +// 许可证: Apache License 2.0 +// +// 版权所有 © Sea Network Technology Studio +// =========================================== + +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response 统一响应结构体 +type Response struct { + Success bool `json:"success"` + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +// PageData 分页响应数据 +type PageData struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +// JSON 统一 JSON 响应 +func JSON(c *gin.Context, httpCode int, success bool, code int, message string, data interface{}) { + c.JSON(httpCode, Response{ + Success: success, + Code: code, + Message: message, + Data: data, + }) +} + +// Success 成功响应 (200) +func Success(c *gin.Context, data interface{}, message string) { + JSON(c, http.StatusOK, true, 200, message, data) +} + +// SuccessWithMessage 成功响应(仅消息) +func SuccessWithMessage(c *gin.Context, message string) { + JSON(c, http.StatusOK, true, 200, message, nil) +} + +// Created 创建成功响应 (201) +func Created(c *gin.Context, data interface{}, message string) { + JSON(c, http.StatusCreated, true, 201, message, data) +} + +// BadRequest 参数错误 (400) +func BadRequest(c *gin.Context, message string) { + JSON(c, http.StatusBadRequest, false, 400, message, nil) +} + +// Unauthorized 未授权 (401) +func Unauthorized(c *gin.Context, message string) { + JSON(c, http.StatusUnauthorized, false, 401, message, nil) +} + +// Forbidden 禁止访问 (403) +func Forbidden(c *gin.Context, message string) { + JSON(c, http.StatusForbidden, false, 403, message, nil) +} + +// NotFound 资源不存在 (404) +func NotFound(c *gin.Context, message string) { + JSON(c, http.StatusNotFound, false, 404, message, nil) +} + +// Conflict 冲突 (409) +func Conflict(c *gin.Context, message string) { + JSON(c, http.StatusConflict, false, 409, message, nil) +} + +// InternalError 服务器内部错误 (500) +func InternalError(c *gin.Context, message string) { + JSON(c, http.StatusInternalServerError, false, 500, message, nil) +} + +// Paginated 分页成功响应 +func Paginated(c *gin.Context, items interface{}, total int64, page, pageSize int) { + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + + Success(c, PageData{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + }, "操作成功") +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8b115b7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,40 @@ +# =========================================== +# 多班级版班级管理系统 - 前端配置 +# +# 开发者: Canglan +# 联系方式: admin@sea-studio.top +# 版权归属: Sea Network Technology Studio +# 许可证: Apache License 2.0 +# +# 版权所有 © Sea Network Technology Studio +# =========================================== + +# 后端API地址(Go 后端默认端口 56789,通过 Nginx 反代后可直接使用域名) +# 如果直接访问 Go 后端,格式为 http://your-server-ip:56789 +API_BASE_URL=https://your-api-domain.com + +# API超时时间(秒) +API_TIMEOUT=30 + +# JWT存储Key +JWT_STORAGE_KEY=class_system_token + +# 用户信息存储Key +USER_STORAGE_KEY=class_system_user + +# 站点名称 +SITE_NAME=多班级版班级管理系统 + +# 会话超时时间(分钟) +SESSION_TIMEOUT=30 + +# ICP备案号配置 +# 是否启用ICP备案号显示 - true/false +ICP_ENABLED=false +# ICP备案号 +ICP_NUMBER=京ICP备1234567890号-x + +# 超级管理员独立登录路径(不含 /api 前缀,代码会自动拼接) +SUPER_ADMIN_LOGIN_PATH=/super-admin + +STUDENT_INITIAL_POINTS=60 \ No newline at end of file diff --git a/frontend/admin/admins.php b/frontend/admin/admins.php new file mode 100644 index 0000000..db4bfed --- /dev/null +++ b/frontend/admin/admins.php @@ -0,0 +1,154 @@ + + + + +
+
+
+ +
+
+ + + + + +
用户名姓名角色操作
+
+
+
+ + + + + + + + + + + + + + + diff --git a/frontend/admin/attendance.php b/frontend/admin/attendance.php new file mode 100644 index 0000000..49c5b3e --- /dev/null +++ b/frontend/admin/attendance.php @@ -0,0 +1,100 @@ + + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + + +
+ +
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+
点击选择有考勤异常的学生
+
+ +
+
+ + +
+
考勤记录
+
+ + + + + +
学号姓名状态原因记录人扣分
+
+
+
+ + + + + + diff --git a/frontend/admin/cadre_homework.php b/frontend/admin/cadre_homework.php new file mode 100644 index 0000000..4512a68 --- /dev/null +++ b/frontend/admin/cadre_homework.php @@ -0,0 +1,125 @@ + + +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + +
作业标题科目截止日期描述操作
加载中...
+
+ +
+
+ + + + + + + + + + + + diff --git a/frontend/admin/class_settings.php b/frontend/admin/class_settings.php new file mode 100644 index 0000000..e6687e0 --- /dev/null +++ b/frontend/admin/class_settings.php @@ -0,0 +1,439 @@ + + +
+ + + +
+

扣分规则

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

角色加减分限制

+

配置各角色单次加减分的上下限

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

周期重置

+

按周或按月自动重置学生操行分(需配合定时任务或手动触发)

+
+
+ + +
+ + + +
+
+ + +
+

功能开关

+

控制各角色的功能启用状态

+
+
+ + + + + + + + +
+ +
+
+
+ + + + + + diff --git a/frontend/admin/classes.php b/frontend/admin/classes.php new file mode 100644 index 0000000..f9dfe6b --- /dev/null +++ b/frontend/admin/classes.php @@ -0,0 +1,214 @@ + + +
+ + +
+
加载中...
+
+
+ + + + + + + diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php new file mode 100644 index 0000000..6778782 --- /dev/null +++ b/frontend/admin/conduct.php @@ -0,0 +1,154 @@ + + + + +
+
+
+
+ + + + + +
+
+ +
+ + + + + + + + + + + +
学号姓名当前操行分操作
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/admin/dashboard.php b/frontend/admin/dashboard.php new file mode 100644 index 0000000..bc8a1c2 --- /dev/null +++ b/frontend/admin/dashboard.php @@ -0,0 +1,219 @@ + + + + +
+
+ +
+
快捷操作
+
+
+ +
+
操行分排行榜
+
+
+ 显示前 + + % 的学生 + + +
+ + + + + +
排名学号姓名操行分
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/admin/history.php b/frontend/admin/history.php new file mode 100644 index 0000000..c4fa1b5 --- /dev/null +++ b/frontend/admin/history.php @@ -0,0 +1,120 @@ + + + + +
+
+
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + +
类型分值原因学生操作人时间操作
+
+ +
+
+ + + + + + + + diff --git a/frontend/admin/homework.php b/frontend/admin/homework.php new file mode 100644 index 0000000..1833575 --- /dev/null +++ b/frontend/admin/homework.php @@ -0,0 +1,255 @@ + + + + +
+ +
+
+

科目管理

+ ▶ 展开 +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ + + + + + + + + + + +
学号姓名当前操行分操作
+
+
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/admin/password.php b/frontend/admin/password.php new file mode 100644 index 0000000..5ebaf23 --- /dev/null +++ b/frontend/admin/password.php @@ -0,0 +1,81 @@ + + + + +
+
+
修改密码
+
+
+ + +
+
+ + + 密码长度6-20位,需包含大写字母、小写字母、数字、特殊符号中的至少3种 +
+
+ + +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/frontend/admin/rankings.php b/frontend/admin/rankings.php new file mode 100644 index 0000000..cc3b9b9 --- /dev/null +++ b/frontend/admin/rankings.php @@ -0,0 +1,91 @@ + + +
+ + +
+ + + +
+ +
+
+ + + + + + + + + + + + +
排名学号姓名分值
加载中...
+
+
+
+ + + + + + diff --git a/frontend/admin/semesters.php b/frontend/admin/semesters.php new file mode 100644 index 0000000..b15bf24 --- /dev/null +++ b/frontend/admin/semesters.php @@ -0,0 +1,264 @@ + + + + +
+ +
+

周期重置

+

手动触发当前班级的周/月操行分重置(重置前会自动创建分数快照)

+
+ + + + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + +
学期名称开始日期结束日期当前周数状态记录数创建时间操作
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/admin/students.php b/frontend/admin/students.php new file mode 100644 index 0000000..a8cffe9 --- /dev/null +++ b/frontend/admin/students.php @@ -0,0 +1,218 @@ + + + + +
+
+
+
+ + + + +
+ +
+ +
+ + + + + + + + + + + + + +
学号姓名宿舍号操行分家长账号(推荐手机号)操作
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/admin/subjects.php b/frontend/admin/subjects.php new file mode 100644 index 0000000..1158e24 --- /dev/null +++ b/frontend/admin/subjects.php @@ -0,0 +1,14 @@ + '未授权']); + exit(); +} + +$role = $_SESSION['role'] ?? ''; +if ($role !== '班主任') { + echo json_encode(['needs_upgrade' => false]); + exit(); +} + +// 从 session 获取 JWT token +$token = $_SESSION['jwt_token'] ?? ''; +if (empty($token)) { + echo json_encode(['error' => '会话已过期,请重新登录']); + exit(); +} + +// 调用后端 API +$apiUrl = API_BASE_URL . '/api/upgrade/check'; + +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $apiUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => API_TIMEOUT, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 +]); + +$apiResponse = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if (empty($apiResponse)) { + echo json_encode(['error' => '无法连接升级服务']); + exit(); +} + +$result = json_decode($apiResponse, true); +if (!$result) { + echo json_encode(['error' => '升级服务返回数据格式错误']); + exit(); +} + +// 后端返回非200时,尝试解析实际错误信息 +if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) { + $errorMsg = $result['message'] ?? ($result['error'] ?? '升级检查失败'); + echo json_encode(['error' => $errorMsg]); + exit(); +} + +// 转发后端返回的升级数据 +$data = $result['data'] ?? []; +echo json_encode($data); diff --git a/frontend/api/clear_session.php b/frontend/api/clear_session.php new file mode 100644 index 0000000..e0c7eb3 --- /dev/null +++ b/frontend/api/clear_session.php @@ -0,0 +1,64 @@ + false, + 'message' => '仅支持 POST 请求' + ]); + exit(); +} + +// CSRF 风险说明:此接口仅清除 Session,无敏感数据操作。 +// 部署于同域 Nginx 反代下,浏览器同源策略已阻止跨域调用,实际风险较低。 +// 清除 Session +$_SESSION = array(); + +// 如果使用了 cookie,删除 cookie +if (ini_get("session.use_cookies")) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, + $params["path"], $params["domain"], + $params["secure"], $params["httponly"] + ); +} + +// 销毁 Session +session_destroy(); + +// 返回成功响应 +http_response_code(200); +echo json_encode([ + 'success' => true, + 'message' => 'Session 已清除' +]); +exit(); \ No newline at end of file diff --git a/frontend/api/execute_upgrade.php b/frontend/api/execute_upgrade.php new file mode 100644 index 0000000..d1d26e3 --- /dev/null +++ b/frontend/api/execute_upgrade.php @@ -0,0 +1,104 @@ + false, 'error' => '未授权']); + exit(); +} + +$userType = $_SESSION['user_type']; +$role = $_SESSION['role'] ?? ''; +if ($userType === 'admin' && $role !== '班主任') { + http_response_code(403); + echo json_encode(['success' => false, 'error' => '权限不足']); + exit(); +} + +// 只接受 POST +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => '无效请求']); + exit(); +} + +$input = json_decode(file_get_contents('php://input'), true); +$stepVersion = $input['version'] ?? ''; +if (empty($stepVersion)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => '缺少版本号参数']); + exit(); +} + +// 从 session 获取 JWT token +$token = $_SESSION['jwt_token'] ?? ''; +if (empty($token)) { + http_response_code(401); + echo json_encode(['success' => false, 'error' => '会话已过期,请重新登录']); + exit(); +} + +// 调用后端 API +$apiUrl = API_BASE_URL . '/api/upgrade/step'; + +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $apiUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode(['version' => $stepVersion]), + CURLOPT_TIMEOUT => API_TIMEOUT, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 +]); + +$apiResponse = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if (empty($apiResponse)) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'version' => $stepVersion, + 'error' => '无法连接升级服务' + ]); + exit(); +} + +$result = json_decode($apiResponse, true); +if (!$result) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'version' => $stepVersion, + 'error' => '升级服务返回数据格式错误' + ]); + exit(); +} + +// 后端返回非200或 success=false 时,提取实际错误信息 +if ($httpCode !== 200 || !isset($result['success']) || !$result['success']) { + $errorMsg = $result['message'] ?? ($result['error'] ?? '升级失败'); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'version' => $stepVersion, + 'error' => $errorMsg + ]); + exit(); +} + +// 转发后端返回的数据 +$data = $result['data'] ?? []; +echo json_encode($data); diff --git a/frontend/api/save_session.php b/frontend/api/save_session.php new file mode 100644 index 0000000..91c829b --- /dev/null +++ b/frontend/api/save_session.php @@ -0,0 +1,219 @@ + false, + 'message' => '仅支持 POST 请求' + ]); + exit(); +} + +// CSRF 防护:验证 Origin/Referer 头确保同源请求 +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +$referer = $_SERVER['HTTP_REFERER'] ?? ''; +$host = $_SERVER['HTTP_HOST'] ?? ''; +$serverName = $_SERVER['SERVER_NAME'] ?? ''; + +if (!empty($origin)) { + $parsedOrigin = parse_url($origin, PHP_URL_HOST); + if ($parsedOrigin !== $host && $parsedOrigin !== $serverName) { + http_response_code(403); + echo json_encode([ + 'success' => false, + 'message' => '跨域请求被拒绝' + ]); + exit(); + } +} elseif (!empty($referer)) { + $parsedReferer = parse_url($referer, PHP_URL_HOST); + if ($parsedReferer !== $host && $parsedReferer !== $serverName) { + http_response_code(403); + echo json_encode([ + 'success' => false, + 'message' => '跨域请求被拒绝' + ]); + exit(); + } +} + +// 获取原始输入 +$input = file_get_contents('php://input'); + +if (empty($input)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => '请求数据为空' + ]); + exit(); +} + +// 解析 JSON 数据 +$data = json_decode($input, true); + +if (json_last_error() !== JSON_ERROR_NONE) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => 'JSON 解析失败: ' . json_last_error_msg() + ]); + exit(); +} + +// 验证必要字段 +$requiredFields = ['user_id', 'user_type', 'username']; +$missingFields = []; + +foreach ($requiredFields as $field) { + if (!isset($data[$field]) || empty($data[$field])) { + $missingFields[] = $field; + } +} + +if (!empty($missingFields)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => '缺少必要字段: ' . implode(', ', $missingFields) + ]); + exit(); +} + +// 验证 user_type 是否合法 +$validUserTypes = ['student', 'parent', 'admin', 'super_admin']; +if (!in_array($data['user_type'], $validUserTypes)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => '无效的用户类型' + ]); + exit(); +} + +// 验证 JWT Token +$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; +if (empty($authHeader) || !preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'message' => '缺少认证令牌' + ]); + exit(); +} + +$token = $matches[1]; +$apiUrl = API_BASE_URL . '/api/auth/me'; + +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $apiUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Content-Type: application/json' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 +]); + +$apiResponse = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($httpCode !== 200 || empty($apiResponse)) { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'message' => '认证令牌无效或已过期' + ]); + exit(); +} + +$tokenData = json_decode($apiResponse, true); +if (!$tokenData || !isset($tokenData['success']) || !$tokenData['success']) { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'message' => '认证验证失败' + ]); + exit(); +} + +// 验证 token 中的 user_id 与请求数据中的 user_id 一致 +$tokenUserId = $tokenData['data']['user_id'] ?? null; +if ($tokenUserId === null || intval($tokenUserId) !== intval($data['user_id'])) { + http_response_code(403); + echo json_encode([ + 'success' => false, + 'message' => '身份验证不匹配' + ]); + exit(); +} + +// 从后端 JWT 解析权威数据(不信任客户端传入的 user_type/role) +$tokenData_user = $tokenData['data']; +// 登录成功后重新生成 Session ID,防止 Session 固定攻击 +session_regenerate_id(true); +$_SESSION['user_id'] = intval($tokenData_user['user_id']); +$_SESSION['user_type'] = $tokenData_user['user_type']; +$_SESSION['username'] = $tokenData_user['username']; +$_SESSION['real_name'] = $tokenData_user['real_name'] ?? ''; +$_SESSION['role'] = $tokenData_user['role'] ?? ''; +$_SESSION['class_id'] = $tokenData_user['class_id'] ?? null; +$_SESSION['class_name'] = $tokenData_user['class_name'] ?? ''; +$_SESSION['login_time'] = time(); +$_SESSION['jwt_token'] = $token; +// 如果是学生,额外设置 student_id(仅从 JWT 解析,不信任客户端传入值) +if ($_SESSION['user_type'] === 'student') { + $studentId = $tokenData_user['student_id'] ?? null; + if (empty($studentId)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => '学生类型必须提供 student_id' + ]); + exit(); + } + $_SESSION['student_id'] = $studentId; +} + +// 保存 Session +session_write_close(); + +// 返回成功响应 +http_response_code(200); +echo json_encode([ + 'success' => true, + 'message' => 'Session 保存成功' +]); +exit(); \ No newline at end of file diff --git a/frontend/assets/css/admin.css b/frontend/assets/css/admin.css new file mode 100644 index 0000000..497d5e8 --- /dev/null +++ b/frontend/assets/css/admin.css @@ -0,0 +1,265 @@ +/** + * 多班级版班级管理系统 - 管理端样式 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +/* 批量操作栏 */ +.batch-bar { + background: #f0f4ff; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.batch-info { + color: var(--color-primary); + font-weight: 500; +} + +/* 导入区域 */ +.import-area { + border: 2px dashed var(--color-border); + border-radius: 12px; + padding: 30px; + text-align: center; + margin-bottom: 20px; + transition: border-color 0.3s; + cursor: pointer; +} + +.import-area:hover { + border-color: var(--color-primary); +} + +.import-area input { + display: none; +} + +.import-label { + color: var(--color-primary); + text-decoration: underline; + cursor: pointer; +} + +/* 预览表格 */ +.preview-table { + max-height: 300px; + overflow-y: auto; + margin-top: 16px; +} + +/* 筛选栏 */ +.filter-bar { + background: var(--color-hover); + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-end; +} + +.filter-group { + flex: 1; + min-width: 150px; +} + +.filter-group label { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: var(--color-text-secondary); +} + +.filter-group input, +.filter-group select { + width: 100%; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; +} + +/* 作业卡片 */ +.assignment-card { + margin-bottom: 20px; +} + +.assignment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 10px; +} + +.assignment-title { + font-size: 16px; + font-weight: bold; + color: var(--color-text); +} + +.assignment-meta { + color: var(--color-text-muted); + font-size: 12px; +} + +/* 状态选择器 */ +.status-select { + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 12px; +} + +/* 复选框 */ +.student-checkbox { + width: 18px; + height: 18px; + cursor: pointer; +} +/* 扣分类型按钮组 */ +.deduction-types { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* 考勤学生方格网格 */ +.student-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 15px 0; +} + +.student-cell { + width: calc(100% / 7 - 10px); + min-height: 60px; + border: 2px solid #e5e7eb; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + padding: 8px 4px; + text-align: center; + word-break: break-all; + user-select: none; +} + +.student-cell:hover { + background: #f3f4f6; + border-color: #9ca3af; +} + +.student-cell.selected { + background: #fee2e2; + border-color: #ef4444; + color: #dc2626; +} + +.student-cell.has-record { + border: 2px dashed #9ca3af; + opacity: 0.7; +} + +.attendance-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin: 15px 0; + flex-wrap: wrap; +} + +.toolbar-field { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--color-hover); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 6px 10px; +} + +.toolbar-field .toolbar-label { + font-size: 12px; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.toolbar-field input, +.toolbar-field select { + border: none; + background: transparent; + outline: none; + font-size: 13px; + padding: 0; + min-width: 0; +} + +.attendance-toolbar .status-group { + display: flex; + gap: 8px; +} + +.attendance-toolbar .status-btn { + padding: 6px 16px; + border: 2px solid #e5e7eb; + border-radius: 6px; + background: white; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.attendance-toolbar .status-btn.active { + border-color: var(--color-primary); + background: var(--color-primary-light); + color: #4338ca; +} + +.student-tag { + display: inline-block; + padding: 2px 8px; + background: #e8f4f8; + border-radius: 12px; + font-size: 12px; + margin: 2px; + color: #2c3e50; +} +.student-tags-container { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +@media (max-width: 768px) { + .student-cell { + width: calc(100% / 4 - 10px); + } +} + +@media (max-width: 480px) { + .student-cell { + width: calc(100% / 3 - 10px); + } +} + +.preserve-newlines { + white-space: normal; + word-break: break-word; +} diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css new file mode 100644 index 0000000..624a044 --- /dev/null +++ b/frontend/assets/css/style.css @@ -0,0 +1,998 @@ +/** + * 多班级版班级管理系统 - 全局样式 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +:root { + /* 主色调 */ + --color-primary: #4361ee; + --color-primary-light: #eef0ff; + --color-primary-dark: #3651d4; + --color-primary-hover: #3a56d4; + + /* 语义色 */ + --color-danger: #e53e3e; + --color-danger-light: #fff5f5; + --color-danger-dark: #c53030; + --color-success: #38a169; + --color-success-light: #f0fff4; + --color-warning: #d69e2e; + --color-warning-light: #fffff0; + + /* 灰度 */ + --color-text: #1a202c; + --color-text-secondary: #4a5568; + --color-text-muted: #a0aec0; + --color-bg: #f5f7fb; + --color-card: #ffffff; + --color-border: #e2e8f0; + --color-border-light: #edf2f7; + --color-hover: #f7fafc; + + /* 按钮 */ + --btn-primary-bg: var(--color-primary); + --btn-primary-text: #ffffff; + --btn-outline-bg: transparent; + --btn-outline-border: var(--color-primary); + --btn-outline-text: var(--color-primary); + --btn-danger-bg: var(--color-danger); + --btn-danger-text: #ffffff; + --btn-ghost-text: var(--color-text-secondary); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--color-bg); + min-height: 100vh; + font-size: 14px; + color: var(--color-text); +} + +/* ========== 登录页面 ========== */ +.login-container { + background: white; + border-radius: 16px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); + padding: 40px; + width: 400px; + max-width: 90%; + margin: 100px auto; +} + +.login-header { + text-align: center; + margin-bottom: 30px; +} + +.login-header h1 { + font-size: 24px; + color: var(--color-text); + margin-bottom: 8px; +} + +.login-header p { + color: var(--color-text-secondary); + font-size: 14px; +} + +.login-form .form-group { + margin-bottom: 20px; +} + +.login-form label { + display: block; + margin-bottom: 6px; + color: var(--color-text-secondary); + font-weight: 500; +} + +.login-form input { + width: 100%; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 14px; + transition: border-color 0.3s; +} + +.login-form input:focus { + outline: none; + border-color: var(--color-primary); +} + +.btn-login { + width: 100%; + padding: 12px; + background: linear-gradient(135deg, var(--color-primary) 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: opacity 0.3s; +} + +.btn-login:hover { + opacity: 0.9; +} + +.error-msg { + background: var(--color-danger-light); + color: var(--color-danger); + padding: 10px; + border-radius: 8px; + margin-top: 15px; + text-align: center; + font-size: 13px; +} + +.login-footer { + text-align: center; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--color-border-light); + color: var(--color-text-muted); + font-size: 12px; +} + +/* ========== 公共头部 ========== */ +.header { + background: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + font-size: 18px; + color: var(--color-text); +} + +.header-info { + display: flex; + align-items: center; + gap: 16px; +} + +.user-name { + color: var(--color-text-secondary); + font-weight: 500; +} + +.user-role { + background: var(--color-primary); + color: white; + padding: 2px 8px; + border-radius: 20px; + font-size: 11px; +} + +.btn-logout { + background: var(--color-danger); + color: white; + border: none; + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: background 0.3s; +} + +.btn-logout:hover { + background: var(--color-danger-dark); +} + +/* ========== 导航菜单 ========== */ +.nav { + background: white; + padding: 0 24px; + border-bottom: 1px solid var(--color-border-light); + display: flex; + gap: 4px; + overflow-x: auto; +} + +.nav-item { + padding: 12px 20px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.3s; + border-bottom: 2px solid transparent; + text-decoration: none; + display: inline-block; +} + +.nav-item:hover { + color: var(--color-primary); +} + +.nav-item.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* ========== 容器 ========== */ +.container { + max-width: 1200px; + margin: 24px auto; + padding: 0 24px; +} + +/* ========== 卡片 ========== */ +.card { + background: white; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.card-title { + font-size: 18px; + font-weight: bold; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid var(--color-primary); + color: var(--color-text); +} + +/* ========== 统计卡片网格 ========== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: white; + border-radius: 12px; + padding: 20px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.stat-value { + font-size: 32px; + font-weight: bold; + color: var(--color-primary); + margin: 10px 0; +} + +.stat-label { + color: var(--color-text-secondary); + font-size: 13px; +} + +/* ========== 表格 ========== */ +.table-wrapper { + overflow-x: auto; + overflow-y: visible; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--color-border-light); +} + +th { + background: var(--color-hover); + font-weight: 600; + color: var(--color-text-secondary); +} + +tr:hover { + background: var(--color-hover); +} + +/* ========== 状态标签 ========== */ +.status-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; +} + +.status-submitted { + background: #c6f6d5; + color: #22543d; +} + +.status-not_submitted { + background: #fed7d7; + color: #742a2a; +} + +.status-late { + background: #feebc8; + color: #7c2d12; +} + +.status-present { + background: #c6f6d5; + color: #22543d; +} + +.status-absent { + background: #fed7d7; + color: #742a2a; +} + +.status-leave { + background: #e9d8fd; + color: #553c9a; +} + +/* ========== 按钮 ========== */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.3s; +} + +.btn-primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); + border: 1px solid transparent; +} + +.btn-primary:hover { + background: var(--color-primary-hover); +} + +.btn-danger { + background: var(--btn-danger-bg); + color: var(--btn-danger-text); + border: 1px solid transparent; +} + +.btn-danger:hover { + background: var(--color-danger-dark); +} + +.btn-success { + background: var(--color-success-light); + color: var(--color-success); + border: 1px solid #c6f6d5; +} + +.btn-success:hover { + background: #c6f6d5; +} + +.btn-warning { + background: var(--color-warning-light); + color: var(--color-warning); + border: 1px solid #fefcbf; +} + +.btn-warning:hover { + background: #fefcbf; +} + +.btn-info { + background: #e3f2fd; + color: #1565c0; + border: 1px solid #bbdefb; +} + +.btn-info:hover { + background: #bbdefb; +} + +.btn-secondary { + background: var(--color-hover); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover { + background: var(--color-border-light); +} + +.btn-outline { + background: transparent; + color: var(--color-primary); + border: 1px solid var(--color-primary); +} + +.btn-outline:hover { + background: var(--color-primary-light); +} + +.btn-ghost { + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-ghost:hover { + background: var(--color-hover); + border-color: var(--color-text-muted); +} + +.btn-outline-danger { + background: transparent; + color: var(--color-danger); + border: 1px solid var(--color-danger); +} + +.btn-outline-danger:hover { + background: var(--color-danger-light); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ========== 模态框 ========== */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + border-radius: 12px; + padding: 24px; + width: 500px; + max-width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border-light); +} + +.modal-header h3 { + font-size: 18px; + color: var(--color-text); +} + +.modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--color-text-muted); +} + +.modal-footer { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--color-border-light); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* ========== 表单 ========== */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.form-group small { + display: block; + color: var(--color-text-muted); + font-size: 12px; + margin-top: 4px; +} + +.form-group textarea { + min-height: 60px; + resize: vertical; +} + +/* ========== 复选框组 ========== */ +.checkbox-group { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.checkbox-group input { + width: auto; +} + +/* ========== 操作栏 ========== */ +.action-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} + +.action-buttons { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.search-bar { + display: flex; + gap: 10px; +} + +.search-bar input { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + width: 200px; +} + +/* ========== 分页 ========== */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + margin-top: 20px; + flex-wrap: wrap; +} + +.pagination a, .pagination span { + padding: 6px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + text-decoration: none; + color: var(--color-text-secondary); + cursor: pointer; + min-width: 36px; + text-align: center; + box-sizing: border-box; + transition: all 0.2s; +} + +.pagination a:hover { + background: var(--color-primary-light); + border-color: var(--color-primary); + color: var(--color-primary); +} + +.pagination .active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +.pagination .ellipsis { + border: none; + cursor: default; + padding: 6px 4px; + color: var(--color-text-muted); + min-width: auto; +} + +.pagination .page-jump { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; + font-size: 13px; + color: var(--color-text-secondary); +} + +.pagination .page-jump input { + width: 50px; + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + text-align: center; + font-size: 13px; + outline: none; +} + +.pagination .page-jump input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(67, 97, 238, 0.15); +} + +.pagination .page-nav { + padding: 6px 10px; + font-size: 13px; +} + +/* ========== 提示消息 ========== */ +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + border-radius: 8px; + color: white; + font-size: 14px; + z-index: 1100; + animation: fadeInUp 0.3s ease; +} + +.toast-success { + background: var(--color-success); +} + +.toast-error { + background: var(--color-danger); +} + +.toast-warning { + background: #ed8936; +} + +.toast-info { + background: var(--color-primary); +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* ========== 加载动画 ========== */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ========== 底部 ========== */ +.footer { + text-align: center; + padding: 20px; + color: var(--color-text-muted); + font-size: 12px; +} + +/* ========== 记录项 ========== */ +.record-item { + padding: 12px 0; + border-bottom: 1px solid var(--color-border-light); + display: flex; + justify-content: space-between; + align-items: center; +} + +.record-points { + font-weight: bold; +} + +.record-points.plus { + color: var(--color-success); +} + +.record-points.minus { + color: var(--color-danger); +} + +.record-reason { + flex: 1; + margin: 0 15px; + color: var(--color-text-secondary); +} + +.record-time { + font-size: 12px; + color: var(--color-text-muted); +} + +.view-more { + text-align: center; + margin-top: 15px; +} + +.view-more a { + color: var(--color-primary); + text-decoration: none; +} + +.conduct-score { + text-align: center; + padding: 20px; +} + +.score-number { + font-size: 64px; + font-weight: bold; + color: var(--color-primary); +} + +/* ========== 响应式 ========== */ +@media (max-width: 768px) { + .container { + padding: 0 16px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + th, td { + padding: 8px; + font-size: 12px; + } + + .card { + padding: 16px; + } + + .nav { + padding: 0 16px; + } + + .nav-item { + padding: 10px 14px; + font-size: 13px; + } + + .action-bar { + flex-direction: column; + align-items: stretch; + } + + .search-bar { + width: 100%; + } + + .search-bar input { + flex: 1; + } +} + +/* ========== 操作列下拉菜单 ========== */ +.action-dropdown { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.action-dropdown-toggle { + background: var(--color-hover); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + white-space: nowrap; +} + +.action-dropdown-toggle:hover { + background: var(--color-border-light); + border-color: #cbd5e0; +} + +.action-dropdown-toggle.open { + background: var(--color-border-light); + border-color: var(--color-text-muted); +} + +.action-dropdown-menu { + display: none; + background: white; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + border: 1px solid var(--color-border); + min-width: 120px; + z-index: 9999; + padding: 4px 0; +} + +.action-dropdown-menu.show { + display: block; +} + +.action-dropdown-menu a { + display: block; + padding: 8px 14px; + color: var(--color-text-secondary); + font-size: 13px; + cursor: pointer; + text-decoration: none; + transition: background 0.15s; + white-space: nowrap; +} + +.action-dropdown-menu a:hover { + background: var(--color-hover); + color: #2d3748; +} + +.action-dropdown-menu a.danger { + color: var(--color-danger); + border-top: 1px solid var(--color-border-light); + margin-top: 4px; + padding-top: 10px; +} + +.action-dropdown-menu a.danger:hover { + background: var(--color-danger-light); + color: var(--color-danger-dark); +} + + +/* ========== 链接 ========== */ +.link { + color: var(--color-primary); + text-decoration: none; +} + +.link:hover { + text-decoration: underline; +} + +/* ========== 文本工具类 ========== */ +.text-danger { color: var(--color-danger); } +.text-success { color: var(--color-success); } +.text-muted { color: var(--color-text-muted); } + +/* ========== 标签 ========== */ +.tag { padding: 2px 8px; border-radius: 10px; font-size: 12px; } +.tag-success { background: #e8f5e9; color: #2e7d32; } +.tag-danger { background: #ffebee; color: #c62828; } +.tag-warning { background: #fff3e0; color: #e65100; } +.tag-info { background: #e3f2fd; color: #1565c0; } + +/* ========== 历史记录页优化 ========== */ +/* 时间列:确保分两行显示(日期+时间) */ +.history-time { + white-space: nowrap; + min-width: 80px; + line-height: 1.5; + vertical-align: top; +} + +/* 原因列:每行最少7个字,自动换行(使用td前缀提升优先级,防止被preserve-newlines覆盖) */ +td.history-reason { + min-width: 7em; + max-width: 200px; + white-space: normal !important; + word-break: break-word; + line-height: 1.5; + vertical-align: top; +} + +/* 学生名列:允许换行 */ +.history-students { + white-space: normal; + word-break: break-word; + min-width: 60px; + max-width: 120px; + line-height: 1.5; + vertical-align: top; +} + +/* 合并记录复选框样式 */ +.history-grouped-label { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-hover); + transition: all 0.2s; + white-space: nowrap; + user-select: none; +} + +.history-grouped-label:hover { + border-color: var(--color-primary); + background: var(--color-primary-light); +} + +.history-grouped-label input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +/* 合并记录按钮样式 */ +.btn-outline-danger { + background: transparent; + color: var(--color-danger); + border: 1px solid var(--color-danger); + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-outline-danger:hover { + background: var(--color-danger-light); + color: var(--color-danger-dark); + border-color: var(--color-danger-dark); +} + diff --git a/frontend/assets/js/admin.js b/frontend/assets/js/admin.js new file mode 100644 index 0000000..9de749f --- /dev/null +++ b/frontend/assets/js/admin.js @@ -0,0 +1,14 @@ +/** + * admin.js - 管理端公共函数库 + * + * 此文件已拆分为独立模块,各模块文件位于 /assets/js/modules/ 目录 + * 各页面通过引用对应模块获取所需功能 + * + * 模块列表: + * - modules/modal-utils.js - 模态框工具函数 + * - modules/utils.js - 通用工具函数(escapeHtml, toggleSelectAll等) + * - modules/student-mgmt.js - 学生管理函数 + * - modules/admin-mgmt.js - 管理员管理函数 + * - modules/subject-mgmt.js - 科目管理函数 + * - modules/points-mgmt.js - 加减分管理函数 + */ diff --git a/frontend/assets/js/admins.js b/frontend/assets/js/admins.js new file mode 100644 index 0000000..d943d6d --- /dev/null +++ b/frontend/assets/js/admins.js @@ -0,0 +1,146 @@ +/** + * 多班级版班级管理系统 - 管理员管理页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +let currentEditUserId = null; +let currentResetUserId = null; + +async function loadAdmins() { + const res = await apiGet('/api/admin/list'); + if (res && res.success) { + let html = ''; + res.data.admins.forEach(admin => { + html += ` + ${escapeHtml(admin.username)} + ${escapeHtml(admin.real_name)} + ${escapeHtml(admin.role_type)} + +
+ + +
+ + `; + }); + if (res.data.admins.length === 0) { + html = '暂无管理员'; + } + document.getElementById('adminList').innerHTML = html; + } +} + +function showEditAdminModal(userId, username, realName, roleType) { + currentEditUserId = userId; + document.getElementById('editAdminUserId').value = userId; + document.getElementById('editAdminUsername').value = username; + document.getElementById('editAdminRealName').value = realName; + document.getElementById('editAdminRole').value = roleType; + document.getElementById('editAdminModal').style.display = 'flex'; +} + +async function submitEditAdmin() { + if (!currentEditUserId) return; + + const roleType = document.getElementById('editAdminRole').value; + if (!roleType) { + showToast('请选择角色', 'warning'); + return; + } + + const res = await apiPut(`/api/admin/update/${currentEditUserId}`, { + real_name: document.getElementById('editAdminRealName').value, + role_type: roleType + }); + + if (res && res.success) { + showToast('管理员更新成功'); + closeModal('editAdminModal'); + loadAdmins(); + } else { + showToast(res?.message || '更新失败', 'error'); + } +} + +async function deleteAdmin(userId, realName) { + if (!confirm(`确定要删除管理员 "${realName}" 吗?此操作不可恢复。`)) { + return; + } + + const res = await apiDelete(`/api/admin/delete/${userId}`); + if (res && res.success) { + showToast('管理员删除成功'); + loadAdmins(); + } else { + showToast(res?.message || '删除失败', 'error'); + } +} + +function resetAdminPassword(userId, realName) { + currentResetUserId = userId; + document.getElementById('resetPasswordUserId').value = userId; + document.getElementById('resetPasswordAdminName').value = realName; + document.getElementById('newPassword').value = ''; + document.getElementById('resetPasswordModal').style.display = 'flex'; +} + +async function unlockUser(username, realName) { + if (!confirm(`确定要解除用户 "${realName}" 的登录锁定吗?\n(适用于多次登录失败被禁止登录的情况)`)) { + return; + } + + const res = await apiPost('/api/admin/unlock-user', { + username: username + }); + + if (res && res.success) { + showToast(res.message || '解锁成功'); + } else { + showToast(res?.message || '解锁失败', 'error'); + } +} + +async function submitResetPassword() { + if (!currentResetUserId) return; + + const newPassword = document.getElementById('newPassword').value; + if (!newPassword || newPassword.length < 6) { + showToast('密码长度至少6位', 'warning'); + return; + } + + const res = await apiPost(`/api/admin/reset-password/${currentResetUserId}`, { + new_password: newPassword + }); + + if (res && res.success) { + showToast('密码重置成功'); + closeModal('resetPasswordModal'); + } else { + showToast(res?.message || '密码重置失败', 'error'); + } +} + +loadAdmins(); + +window.loadAdmins = loadAdmins; +window.showEditAdminModal = showEditAdminModal; +window.submitEditAdmin = submitEditAdmin; +window.deleteAdmin = deleteAdmin; +window.resetAdminPassword = resetAdminPassword; +window.unlockUser = unlockUser; +window.submitResetPassword = submitResetPassword; + +})(); diff --git a/frontend/assets/js/attendance-manage.js b/frontend/assets/js/attendance-manage.js new file mode 100644 index 0000000..6302f49 --- /dev/null +++ b/frontend/assets/js/attendance-manage.js @@ -0,0 +1,195 @@ +/** + * 多班级版班级管理系统 - 考勤管理页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +let currentStatus = 'absent'; +let studentsData = []; +let existingRecords = []; + +// 考勤扣分配置映射(从后端配置注入) +const attendanceDeductionMap = { + absent: window.DEDUCTION_ATTENDANCE_ABSENT || 3, + late: window.DEDUCTION_ATTENDANCE_LATE || 1, + leave: window.DEDUCTION_ATTENDANCE_LEAVE || 0 +}; + +// 初始化按钮文字 +function initAttendanceButtons() { + const btnAbsent = document.getElementById('btnAbsent'); + const btnLate = document.getElementById('btnLate'); + const btnLeave = document.getElementById('btnLeave'); + if (btnAbsent) btnAbsent.textContent = '缺勤(' + attendanceDeductionMap.absent + '分)'; + if (btnLate) btnLate.textContent = '迟到(' + attendanceDeductionMap.late + '分)'; + if (btnLeave) btnLeave.textContent = '请假(' + (attendanceDeductionMap.leave > 0 ? attendanceDeductionMap.leave + '分' : '不扣分') + ')'; + if (attendanceDeductionMap.absent > 0) { + document.getElementById('customDeduction').value = attendanceDeductionMap.absent; + } +} + +function selectStatus(btn) { + document.querySelectorAll('.status-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentStatus = btn.dataset.status; + const defaultDeduction = attendanceDeductionMap[currentStatus] || 0; + if (defaultDeduction > 0) { + document.getElementById('customDeduction').value = defaultDeduction; + } else { + document.getElementById('customDeduction').value = ''; + } +} + +async function loadStudents() { + const res = await apiGet('/api/admin/students', {page_size: 1000}); + if (res && res.success) { + studentsData = res.data.students; + renderStudentGrid(); + await loadExistingRecords(); + } else { + document.getElementById('studentGrid').innerHTML = '
加载学生列表失败
'; + } +} + +function renderStudentGrid() { + const currentSlot = document.getElementById('attendanceSlot').value; + let html = ''; + studentsData.forEach(student => { + const hasRecord = existingRecords.some(r => r.student_id === student.student_id && r.slot === currentSlot); + html += `
+ ${escapeHtml(student.name)} + ${escapeHtml(student.student_no)} +
`; + }); + if (studentsData.length === 0) { + html = '
暂无学生数据
'; + } + document.getElementById('studentGrid').innerHTML = html; +} + +function toggleStudent(cell) { + cell.classList.toggle('selected'); +} + +function selectAllStudents() { + document.querySelectorAll('.student-cell:not(.has-record)').forEach(cell => { + cell.classList.add('selected'); + }); +} + +function deselectAllStudents() { + document.querySelectorAll('.student-cell').forEach(cell => { + cell.classList.remove('selected'); + }); +} + +async function loadExistingRecords() { + const date = document.getElementById('attendanceDate').value; + const slot = document.getElementById('attendanceSlot').value; + const res = await apiGet('/api/admin/attendance/records', { date, slot }); + if (res && res.success) { + existingRecords = res.data.records || []; + renderStudentGrid(); + } +} + +async function submitAttendance() { + const selectedCells = document.querySelectorAll('.student-cell.selected'); + if (selectedCells.length === 0) { + showToast('请先选择有考勤异常的学生', 'warning'); + return; + } + + const date = document.getElementById('attendanceDate').value; + const slot = document.getElementById('attendanceSlot').value; + const reason = document.getElementById('attendanceReason').value; + const customDeduction = document.getElementById('customDeduction').value; + const customDeductionValue = customDeduction ? parseInt(customDeduction) : null; + + const promises = []; + selectedCells.forEach(cell => { + const studentId = parseInt(cell.dataset.id); + const payload = { + student_id: studentId, + date: date, + slot: slot, + status: currentStatus, + reason: reason, + apply_deduction: true + }; + if (customDeductionValue !== null && customDeductionValue > 0) { + payload.custom_deduction = customDeductionValue; + } + promises.push(apiPost('/api/admin/attendance', payload)); + }); + + const results = await Promise.allSettled(promises); + const succeeded = results.filter(r => r.status === 'fulfilled' && r.value?.success).length; + const failed = results.length - succeeded; + + if (failed === 0) { + showToast(`考勤提交成功(${succeeded}条)`); + } else { + showToast(`提交完成:成功${succeeded}条,失败${failed}条`, 'error'); + } + + deselectAllStudents(); + await loadExistingRecords(); + loadAttendanceRecords(); +} + +async function loadAttendanceRecords() { + const date = document.getElementById('attendanceDate').value; + const res = await apiGet('/api/admin/attendance/records', { date }); + if (res && res.success) { + let html = ''; + const records = res.data.records || []; + records.forEach(record => { + html += ` + ${escapeHtml(record.student_no)} + ${escapeHtml(record.student_name)} + ${getStatusBadge(record.status, 'attendance')} + ${escapeHtml(record.reason || '-')} + ${escapeHtml(record.recorder_name || '-')} + ${record.deduction_applied ? '已扣分' : '-'} + `; + }); + if (records.length === 0) { + html = '暂无考勤记录'; + } + document.getElementById('attendanceList').innerHTML = html; + } +} + +// 日期或时段变化时重新加载 +document.getElementById('attendanceDate').addEventListener('change', function() { + loadExistingRecords(); + loadAttendanceRecords(); +}); +document.getElementById('attendanceSlot').addEventListener('change', function() { + loadExistingRecords(); +}); + +// 页面初始化 +initAttendanceButtons(); +loadStudents(); +loadAttendanceRecords(); + +window.selectStatus = selectStatus; +window.loadStudents = loadStudents; +window.toggleStudent = toggleStudent; +window.selectAllStudents = selectAllStudents; +window.deselectAllStudents = deselectAllStudents; +window.submitAttendance = submitAttendance; +window.loadAttendanceRecords = loadAttendanceRecords; + +})(); diff --git a/frontend/assets/js/cadre-homework.js b/frontend/assets/js/cadre-homework.js new file mode 100644 index 0000000..688b5ee --- /dev/null +++ b/frontend/assets/js/cadre-homework.js @@ -0,0 +1,159 @@ +/** + * 多班级版班级管理系统 - 课代表作业管理JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +var currentPage = 1; +var pageSize = 20; +var currentAssignmentId = null; + +async function loadHomework(page) { + var res = await apiGet('/api/cadre/homework', { page: page, page_size: pageSize }); + if (res && res.success && res.data) { + var items = res.data.items || res.data.records || []; + var total = res.data.total || 0; + var html = ''; + if (items.length === 0) { + html = '暂无作业记录'; + } else { + items.forEach(function(item) { + html += '' + + '' + escapeHtml(item.title || '-') + '' + + '' + escapeHtml(item.subject_name || '-') + '' + + '' + formatDate(item.deadline) + '' + + '' + escapeHtml(item.description || '-') + '' + + '' + + ''; + }); + } + document.getElementById('homeworkList').innerHTML = html; + + var totalPages = Math.ceil(total / pageSize); + if (totalPages > 1) { + document.getElementById('pagination').style.display = 'flex'; + document.getElementById('pageInfo').textContent = page + ' / ' + totalPages; + document.getElementById('prevBtn').disabled = page <= 1; + document.getElementById('nextBtn').disabled = page >= totalPages; + } else { + document.getElementById('pagination').style.display = 'none'; + } + } +} + +window.changePage = function(delta) { + currentPage += delta; + loadHomework(currentPage); +}; + +window.showPublishModal = function() { + document.getElementById('publishForm').reset(); + document.getElementById('hwDeadline').value = new Date().toISOString().split('T')[0]; + document.getElementById('publishModal').style.display = 'flex'; +}; + +window.submitHomework = async function() { + var title = document.getElementById('hwTitle').value.trim(); + var deadline = document.getElementById('hwDeadline').value; + var description = document.getElementById('hwDescription').value.trim(); + + if (!title) { + showToast('请填写作业标题', 'error'); + return; + } + if (!deadline) { + showToast('请选择截止日期', 'error'); + return; + } + + var res = await apiPost('/api/cadre/homework', { + title: title, + deadline: deadline, + description: description + }); + + if (res && res.success) { + showToast('作业发布成功'); + closeModal('publishModal'); + loadHomework(currentPage); + } else { + showToast(res && res.message ? res.message : '发布失败', 'error'); + } +}; + +window.showAbsentModal = async function(assignmentId) { + currentAssignmentId = assignmentId; + var res = await apiGet('/api/admin/students', { page_size: 1000 }); + if (res && res.success && res.data) { + var students = res.data.students || res.data.items || []; + var html = '
'; + if (students.length === 0) { + html += '

暂无学生数据

'; + } else { + html += '
' + + '' + + ''; + students.forEach(function(s) { + html += '' + + '' + + '' + + '' + + ''; + }); + html += '
学号姓名
' + escapeHtml(s.student_no) + '' + escapeHtml(s.name) + '
'; + } + document.getElementById('absentStudentList').innerHTML = html; + document.getElementById('absentModal').style.display = 'flex'; + } else { + showToast('获取学生列表失败', 'error'); + } +}; + +window.toggleAllAbsent = function(el) { + var checkboxes = document.querySelectorAll('.absent-checkbox'); + checkboxes.forEach(function(cb) { cb.checked = el.checked; }); +}; + +window.submitAbsent = async function() { + var checkboxes = document.querySelectorAll('.absent-checkbox:checked'); + if (checkboxes.length === 0) { + showToast('请选择至少一名缺交学生', 'error'); + return; + } + + var studentIds = []; + checkboxes.forEach(function(cb) { + studentIds.push(parseInt(cb.getAttribute('data-id'))); + }); + + var hwDeduct = window.DEDUCTION_HOMEWORK_NOT_SUBMIT || 2; + var res = await apiPost('/api/cadre/conduct/add', { + student_ids: studentIds, + points_change: -hwDeduct, + reason: '作业未提交', + related_type: 'homework' + }); + + if (res && res.success) { + showToast('已登记 ' + studentIds.length + ' 名学生缺交'); + closeModal('absentModal'); + } else { + showToast(res && res.message ? res.message : '提交失败', 'error'); + } +}; + +window.closeModal = function(id) { + document.getElementById(id).style.display = 'none'; +}; + +document.addEventListener('DOMContentLoaded', function() { + loadHomework(currentPage); +}); + +})(); diff --git a/frontend/assets/js/common.js b/frontend/assets/js/common.js new file mode 100644 index 0000000..1f61775 --- /dev/null +++ b/frontend/assets/js/common.js @@ -0,0 +1,425 @@ +/** + * 多班级版班级管理系统 - 公共JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +function getToken() { + return localStorage.getItem(window.JWT_STORAGE_KEY || 'class_system_token'); +} + +function getUserInfo() { + const userStr = localStorage.getItem(window.USER_STORAGE_KEY || 'class_system_user'); + if (!userStr) return null; + try { + return JSON.parse(userStr); + } catch (e) { + return null; + } +} + +function setUserInfo(user) { + localStorage.setItem(window.USER_STORAGE_KEY || 'class_system_user', JSON.stringify(user)); +} + +function clearAuth() { + localStorage.removeItem(window.JWT_STORAGE_KEY || 'class_system_token'); + localStorage.removeItem(window.USER_STORAGE_KEY || 'class_system_user'); +} + +async function apiRequest(url, options = {}) { + const token = getToken(); + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const baseUrl = window.API_BASE_URL; + const fullUrl = `${baseUrl}${url}`; + + try { + const response = await fetch(fullUrl, { ...options, headers }); + const data = await response.json(); + + if (response.status === 401) { + clearAuth(); + + // 同步清除 PHP Session,防止 index.php 302 重定向循环 + try { + await fetch('/api/clear_session.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + } catch (e) { + console.warn('[Auth] 清除PHP Session失败:', e); + } + + // 防循环机制:检查是否已在登录页 + if (window.location.pathname === '/index.php' || window.location.pathname === '/') { + console.warn('[Auth] 已在登录页收到401,停止重定向'); + return null; + } + + // 防循环机制:5秒内重复401则停止重定向 + const now = Date.now(); + const lastRedirect = parseInt(sessionStorage.getItem('_last_401_redirect') || '0'); + if (now - lastRedirect < 5000) { + console.warn('[Auth] 5秒内重复401,停止重定向。请检查Token是否有效。'); + return null; + } + sessionStorage.setItem('_last_401_redirect', now.toString()); + + window.location.href = '/index.php'; + return null; + } + return data; + } catch (error) { + console.error('API请求错误:', error); + showToast('网络错误,请稍后重试', 'error'); + return null; + } +} + +function apiGet(url, params = {}) { + const queryString = new URLSearchParams(params).toString(); + const fullUrl = queryString ? `${url}?${queryString}` : url; + return apiRequest(fullUrl, { method: 'GET' }); +} + +function apiPost(url, data = {}) { + return apiRequest(url, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +function apiPut(url, data = {}) { + return apiRequest(url, { + method: 'PUT', + body: JSON.stringify(data) + }); +} + +function apiDelete(url) { + return apiRequest(url, { method: 'DELETE' }); +} + +function showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3000); +} + +function formatDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function formatDateTime(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +function getStatusBadge(status, type = 'attendance') { + const statusMap = { + attendance: { + 'present': '出勤', + 'absent': '缺勤', + 'late': '迟到', + 'leave': '请假' + } + }; + const texts = statusMap[type] || statusMap.attendance; + const text = texts[status] || status; + let className = 'status-badge '; + switch (status) { + case 'present': + className += 'status-submitted'; + break; + case 'absent': + className += 'status-not_submitted'; + break; + case 'late': + className += 'status-late'; + break; + case 'leave': + className += 'status-leave'; + break; + default: + className += 'status-not_submitted'; + } + return `${text}`; +} + +async function logout() { + // 清除 PHP Session + try { + await fetch('/api/clear_session.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + } catch (e) { + console.warn('清除Session失败', e); + } + + // 清除后端 Token + try { + await apiPost('/api/auth/logout'); + } catch (e) { + console.warn('后端登出失败', e); + } + + // 清除 localStorage + clearAuth(); + + // 跳转回登录页 + window.location.href = '/index.php'; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/`/g, '`') + .replace(/\//g, '/'); +} + +/** + * 智能分页渲染(最多显示7个页码 + 跳转输入框) + * @param {string|HTMLElement} container - 分页容器ID或DOM元素 + * @param {number} currentPage - 当前页码 + * @param {number} totalPages - 总页数 + * @param {function} onPageChange - 页码变化回调函数,参数为新的页码 + */ +function renderSmartPagination(container, currentPage, totalPages, onPageChange) { + if (typeof container === 'string') { + container = document.getElementById(container); + } + if (!container || totalPages <= 1) { + if (container) container.innerHTML = ''; + return; + } + + const MAX_VISIBLE = 7; + let html = ''; + + // 上一页按钮 + if (currentPage > 1) { + html += `« 上一页`; + } + + if (totalPages <= MAX_VISIBLE) { + // 总页数不超过最大显示数,全部显示 + for (let i = 1; i <= totalPages; i++) { + if (i === currentPage) { + html += `${i}`; + } else { + html += `${i}`; + } + } + } else { + // 需要省略号 + // 始终显示第1页 + if (currentPage === 1) { + html += `1`; + } else { + html += `1`; + } + + // 计算中间页码范围 + let start = Math.max(2, currentPage - 2); + let end = Math.min(totalPages - 1, currentPage + 2); + + // 调整确保中间至少有3个页码(加上首尾共5-7个) + if (currentPage <= 3) { + end = Math.min(5, totalPages - 1); + } + if (currentPage >= totalPages - 2) { + start = Math.max(2, totalPages - 4); + } + + // 前省略号 + if (start > 2) { + html += `...`; + } + + // 中间页码 + for (let i = start; i <= end; i++) { + if (i === currentPage) { + html += `${i}`; + } else { + html += `${i}`; + } + } + + // 后省略号 + if (end < totalPages - 1) { + html += `...`; + } + + // 始终显示最后一页 + if (currentPage === totalPages) { + html += `${totalPages}`; + } else { + html += `${totalPages}`; + } + } + + // 下一页按钮 + if (currentPage < totalPages) { + html += `下一页 »`; + } + + // 页码跳转 + html += `跳至 / ${totalPages}页`; + + container.innerHTML = html; + + // 绑定页码点击事件 + container.querySelectorAll('a[data-page]').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const page = parseInt(this.dataset.page); + if (page && page !== currentPage && page >= 1 && page <= totalPages) { + onPageChange(page); + } + }); + }); + + // 绑定跳转输入框事件 + const jumpInput = container.querySelector('.page-jump input'); + if (jumpInput) { + jumpInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + const page = parseInt(this.value); + if (page && page >= 1 && page <= totalPages) { + onPageChange(page); + } else { + showToast(`请输入1-${totalPages}之间的页码`, 'warning'); + } + } + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const user = getUserInfo(); + const userNameSpan = document.getElementById('userName'); + if (userNameSpan && user) { + userNameSpan.textContent = user.real_name || user.username; + } + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', logout); + } +}); + +function toggleActionDropdown(el) { + var dropdown = el.closest('.action-dropdown'); + if (!dropdown) return; + var menu = dropdown.querySelector('.action-dropdown-menu'); + if (!menu) return; + + var isOpen = menu.classList.contains('show'); + // 先关闭所有 + closeAllDropdowns(); + + if (!isOpen) { + // 使用 fixed 定位,避免被 overflow 容器裁剪 + var rect = el.getBoundingClientRect(); + // 先显示以便测量高度 + menu.classList.add('show'); + menu.style.position = 'fixed'; + menu.style.bottom = 'auto'; + menu.style.right = 'auto'; + menu.style.transform = 'none'; + var menuHeight = menu.offsetHeight; + // 智能判断:按钮在上半部分则菜单显示在下方,否则显示在上方 + if (rect.top < window.innerHeight / 2) { + menu.style.top = rect.bottom + 'px'; + } else { + menu.style.top = (rect.top - menuHeight) + 'px'; + } + menu.style.left = Math.min(rect.left, window.innerWidth - 130) + 'px'; + el.classList.add('open'); + } +} + +function closeAllDropdowns() { + document.querySelectorAll('.action-dropdown-menu.show').forEach(function(m) { + m.classList.remove('show'); + m.style.position = ''; + m.style.left = ''; + m.style.top = ''; + m.style.bottom = ''; + m.style.right = ''; + m.style.transform = ''; + var toggle = m.closest('.action-dropdown'); + if (toggle) { + var btn = toggle.querySelector('.action-dropdown-toggle'); + if (btn) btn.classList.remove('open'); + } + }); +} + +document.addEventListener('click', function(e) { + if (!e.target.closest('.action-dropdown')) { + closeAllDropdowns(); + } +}); + +// 全局textarea键盘事件:Ctrl+Enter提交表单,Enter换行(默认行为) +document.addEventListener('keydown', function(e) { + if (e.target.tagName !== 'TEXTAREA') return; + + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + // Ctrl+Enter / Cmd+Enter 提交表单 + e.preventDefault(); + var form = e.target.closest('form'); + if (form) { + var submitEvent = new Event('submit', { cancelable: true, bubbles: true }); + form.dispatchEvent(submitEvent); + } + } +}); + +window.selectDeductionType = function(points, reason) { + var pointsEl = document.getElementById('pointsChange'); + var reasonEl = document.getElementById('pointsReason'); + if (points === 0 && reason === '') { + // 自定义模式 - 清空分值和原因,聚焦原因输入框 + if (pointsEl) pointsEl.value = ''; + if (reasonEl) { + reasonEl.value = ''; + reasonEl.focus(); + } + } else if (points === null || points === undefined) { + // 类别模式 - 仅填充原因,聚焦分值输入框 + if (reasonEl) reasonEl.value = reason; + if (pointsEl) { + pointsEl.value = ''; + pointsEl.focus(); + } + } else { + // 预设模式 - 同时填充分值和原因 + if (pointsEl) pointsEl.value = points; + if (reasonEl) reasonEl.value = reason; + } +}; \ No newline at end of file diff --git a/frontend/assets/js/conduct.js b/frontend/assets/js/conduct.js new file mode 100644 index 0000000..5457493 --- /dev/null +++ b/frontend/assets/js/conduct.js @@ -0,0 +1,253 @@ +/** + * 多班级版班级管理系统 - 操行分管理页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +async function loadStudents() { + const res = await apiGet('/api/admin/students', {page_size: 1000}); + if (res && res.success) { + let html = ''; + res.data.students.forEach(student => { + html += ` + + ${escapeHtml(student.student_no)} + ${escapeHtml(student.name)} + ${student.total_points} + + `; + }); + if (res.data.students.length === 0) { + html = '暂无学生数据'; + } + document.getElementById('studentList').innerHTML = html; + } +} + +function showSinglePointsModal(studentId, studentName) { + window.selectedStudentIds = [studentId]; + document.getElementById('selectedStudentsCount').innerHTML = `${studentName} (1人)`; + document.getElementById('pointsChange').value = ''; + document.getElementById('pointsReason').value = ''; + document.getElementById('batchPointsModal').style.display = 'flex'; +} + +async function exportMoralityRecords() { + showToast('正在导出操行分记录...', 'info'); + + try { + const studentsRes = await apiGet('/api/admin/students', { page_size: 1000 }); + if (!studentsRes || !studentsRes.success) { + showToast('获取学生列表失败', 'error'); + return; + } + + const students = studentsRes.data.students; + if (students.length === 0) { + showToast('没有找到学生', 'warning'); + return; + } + + const allRecords = []; + let page = 1; + let totalPages = 1; + do { + const historyRes = await apiGet('/api/admin/conduct/history', { page: page, page_size: 500 }); + if (!historyRes || !historyRes.success) { + showToast('获取历史记录失败', 'error'); + return; + } + const records = historyRes.data.records || []; + allRecords.push(...records); + totalPages = historyRes.data.total_pages || 1; + page++; + } while (page <= totalPages); + + const recordsByStudent = {}; + allRecords.forEach(record => { + const sid = record.student_id; + if (!recordsByStudent[sid]) { + recordsByStudent[sid] = []; + } + recordsByStudent[sid].push(record); + }); + + const studentRecords = []; + for (const student of students) { + const studentRecords_list = recordsByStudent[student.student_id] || []; + const positiveRecords = studentRecords_list.filter(r => r.points_change > 0).map(r => `${r.reason}(+${r.points_change})`); + const negativeRecords = studentRecords_list.filter(r => r.points_change < 0).map(r => `${r.reason}(${r.points_change})`); + + studentRecords.push({ + student_no: student.student_no, + name: student.name, + total_points: student.total_points || 0, + positive_history: positiveRecords.join('; '), + negative_history: negativeRecords.join('; ') + }); + } + + function escapeCsvField(field) { + if (field === null || field === undefined) return ''; + let str = String(field).replace(/[\r\n]+/g, ' '); + str = str.replace(/"/g, '""'); + if (/[\,\"\s]/.test(str)) { + str = '"' + str + '"'; + } + return str; + } + + let csv = '\uFEFF'; + csv += '学号,姓名,分数,加分历史,减分记录\n'; + studentRecords.forEach(s => { + csv += `${escapeCsvField(s.student_no)},${escapeCsvField(s.name)},${escapeCsvField(s.total_points)},${escapeCsvField(s.positive_history)},${escapeCsvField(s.negative_history)}\n`; + }); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `操行分记录_${new Date().toISOString().slice(0,10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showToast(`导出成功,共${studentRecords.length}名学生`); + } catch (err) { + showToast('导出失败:' + err.message, 'error'); + console.error('导出失败:', err); + } +} +// 宿舍集体加分相关 +let dormitoryStudentIds = []; + +async function showDormitoryPointsModal() { + dormitoryStudentIds = []; + document.getElementById('dormitorySelect').innerHTML = ''; + document.getElementById('dormitoryStudentsGroup').style.display = 'none'; + document.getElementById('dormitoryStudentsList').innerHTML = ''; + document.getElementById('dormitoryPointsChange').value = ''; + document.getElementById('dormitoryPointsReason').value = ''; + + // 加载宿舍列表 + const res = await apiGet('/api/admin/students/dormitories'); + if (res && res.success && res.data.dormitories) { + const select = document.getElementById('dormitorySelect'); + res.data.dormitories.forEach(d => { + const option = document.createElement('option'); + option.value = d; + option.textContent = d; + select.appendChild(option); + }); + } + + document.getElementById('dormitoryPointsModal').style.display = 'flex'; +} + +async function onDormitorySelected() { + const dormitory = document.getElementById('dormitorySelect').value; + const studentsGroup = document.getElementById('dormitoryStudentsGroup'); + const studentsList = document.getElementById('dormitoryStudentsList'); + const studentsCount = document.getElementById('dormitoryStudentsCount'); + + dormitoryStudentIds = []; + studentsList.innerHTML = ''; + + if (!dormitory) { + studentsGroup.style.display = 'none'; + return; + } + + // 加载该宿舍的学生 + const res = await apiGet('/api/admin/students', { dormitory_number: dormitory, page_size: 1000 }); + if (res && res.success && res.data.students) { + const students = res.data.students; + if (students.length === 0) { + studentsList.innerHTML = '

该宿舍暂无学生

'; + studentsCount.textContent = ''; + } else { + students.forEach(s => { + dormitoryStudentIds.push(s.student_id); + const div = document.createElement('div'); + div.style.cssText = 'display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border-color);'; + div.innerHTML = `${escapeHtml(s.name)}${escapeHtml(s.student_no)}`; + studentsList.appendChild(div); + }); + studentsCount.textContent = `共 ${students.length} 人`; + } + studentsGroup.style.display = 'block'; + } else { + studentsList.innerHTML = '

加载失败

'; + studentsGroup.style.display = 'block'; + } +} + +async function submitDormitoryPoints() { + if (dormitoryStudentIds.length === 0) { + showToast('该宿舍没有学生', 'warning'); + return; + } + + const pointsChange = parseInt(document.getElementById('dormitoryPointsChange').value); + const reason = document.getElementById('dormitoryPointsReason').value; + + if (isNaN(pointsChange) || pointsChange === 0) { + showToast('分值不能为0', 'error'); + return; + } + + if (Math.abs(pointsChange) > 100) { + showToast('分值绝对值不能超过100', 'error'); + return; + } + + if (!reason.trim()) { + showToast('请填写原因', 'error'); + return; + } + + const data = { + student_ids: dormitoryStudentIds, + points_change: pointsChange, + reason: reason, + related_type: 'manual' + }; + + const res = await apiPost('/api/admin/conduct/add', data); + + if (res && res.success) { + showToast(`操作成功: ${res.data.success_count} 人成功`); + closeModal('dormitoryPointsModal'); + loadStudents(); + } else { + showToast(res?.message || '操作失败', 'error'); + } +} + +loadStudents(); + +document.getElementById('studentList').addEventListener('click', function(e) { + const btn = e.target.closest('.js-single-points'); + if (btn) { + showSinglePointsModal( + parseInt(btn.dataset.studentId, 10), + btn.dataset.studentName + ); + } +}); + +window.loadStudents = loadStudents; +window.showSinglePointsModal = showSinglePointsModal; +window.exportMoralityRecords = exportMoralityRecords; +window.showDormitoryPointsModal = showDormitoryPointsModal; +window.onDormitorySelected = onDormitorySelected; +window.submitDormitoryPoints = submitDormitoryPoints; + +})(); diff --git a/frontend/assets/js/dashboard.js b/frontend/assets/js/dashboard.js new file mode 100644 index 0000000..5c7b375 --- /dev/null +++ b/frontend/assets/js/dashboard.js @@ -0,0 +1,120 @@ +/** + * 多班级版班级管理系统 - 管理端首页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +const role = window.PAGE_CONFIG.role; +let totalStudents = 0; + +async function loadDashboard() { + // 并行加载学生数据和学期信息 + const [studentsRes, semesterRes] = await Promise.all([ + apiGet('/api/admin/students'), + apiGet('/api/semester/active') + ]); + + let statsHtml = ''; + if (studentsRes && studentsRes.success) { + statsHtml += ` +
+
学生总数
+
${studentsRes.data.total || 0}
+
+ `; + } + + // 显示学期信息和当前周数 + if (semesterRes && semesterRes.success && semesterRes.data) { + const sem = semesterRes.data; + const weekNum = sem.current_week; + let semesterInfo = escapeHtml(sem.semester_name); + if (weekNum && weekNum > 0) { + semesterInfo += ` · 第${weekNum}周`; + } + statsHtml += ` +
+
当前学期
+
${semesterInfo}
+
+ `; + } + + document.getElementById('dashboardStats').innerHTML = statsHtml; + + let quickActions = ''; + if (role === '班主任' || role === '班长' || role === '学习委员' || role === '劳动委员' || role === '志愿委员') { + quickActions += ''; + } + if (role === '班主任' || role === '学习委员') { + quickActions += ''; + } + if (role === '班主任' || role === '考勤委员') { + quickActions += ''; + } + if (role === '班主任') { + quickActions += ''; + quickActions += ''; + } + document.getElementById('quickActions').innerHTML = quickActions || '

暂无快捷操作

'; + + const rankingRes = await apiGet('/api/student/ranking', { limit: 100 }); + if (rankingRes && rankingRes.success) { + totalStudents = rankingRes.data.total_students || 0; + let html = ''; + rankingRes.data.ranking.forEach((student, index) => { + const rank = index + 1; + html += ` + ${rank} + ${escapeHtml(student.student_no)} + ${escapeHtml(student.name)} + ${student.total_points} + `; + }); + if (rankingRes.data.ranking.length === 0) { + html = '暂无数据'; + } + document.getElementById('rankingList').innerHTML = html; + } +} + +function applyPercentileFilter() { + const input = document.getElementById('percentileFilter'); + const percentile = parseInt(input.value); + if (isNaN(percentile) || percentile < 1 || percentile > 100) { + showToast('请输入 1-100 之间的整数', 'error'); + return; + } + const rows = document.getElementById('rankingList').querySelectorAll('tr'); + if (rows.length === 0) return; + const showCount = Math.max(1, Math.floor(totalStudents * (percentile / 100))); + rows.forEach(function(row, index) { + row.style.display = index < showCount ? '' : 'none'; + }); +} + +function resetPercentileFilter() { + document.getElementById('percentileFilter').value = 100; + const rows = document.getElementById('rankingList').querySelectorAll('tr'); + rows.forEach(function(row) { + row.style.display = ''; + }); +} + +document.getElementById('percentileFilter').addEventListener('keypress', function(e) { + if (e.key === 'Enter') applyPercentileFilter(); +}); + +loadDashboard(); + +window.loadDashboard = loadDashboard; +window.applyPercentileFilter = applyPercentileFilter; +window.resetPercentileFilter = resetPercentileFilter; + +})(); diff --git a/frontend/assets/js/history.js b/frontend/assets/js/history.js new file mode 100644 index 0000000..2a13bb9 --- /dev/null +++ b/frontend/assets/js/history.js @@ -0,0 +1,345 @@ +/** + * 多班级版班级管理系统 - 历史记录页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +const role = window.PAGE_CONFIG.role; +const currentUserId = window.PAGE_CONFIG.userId; +let currentHistoryPage = 1; +let totalHistoryPages = 1; + +function getTypeLabel(relatedType) { + if (!relatedType) return '操行'; + switch (relatedType) { + case 'conduct': return '操行'; + case 'homework': return '作业'; + case 'attendance': return '考勤'; + default: return relatedType; + } +} + +async function loadStudentsForSelect() { + const res = await apiGet('/api/admin/students', {page_size: 1000}); + if (res && res.success) { + let html = ''; + res.data.students.forEach(s => { + html += ''; + }); + document.getElementById('historyStudentId').innerHTML = html; + } +} + +// 加载科目下拉列表 +async function loadSubjectsForFilter() { + let subjectSelect = document.getElementById('historySubjectFilter'); + if (!subjectSelect) return; + let res = await apiGet('/api/subject/list', { is_active: true }); + if (res && res.success && res.data && res.data.subjects) { + let html = ''; + res.data.subjects.forEach(s => { + html += ''; + }); + subjectSelect.innerHTML = html; + } +} + +// 筛选学生时自动取消合并记录 +function onStudentFilterChange() { + let studentId = document.getElementById('historyStudentId').value; + if (studentId) { + let grouped = document.getElementById('historyGrouped'); + if (grouped) grouped.checked = false; + } +} + +// 科目筛选变化时,取消扣分类型筛选(互斥) +function onSubjectFilterChange() { + let subjectVal = document.getElementById('historySubjectFilter').value; + if (subjectVal) { + document.getElementById('historyReasonFilter').value = ''; + } +} + +// 折叠/展开筛选面板 +function toggleFilterPanel() { + let panel = document.getElementById('advancedFilters'); + let btn = document.getElementById('filterToggleBtn'); + if (!panel || !btn) return; + if (panel.style.display === 'none') { + panel.style.display = 'block'; + btn.textContent = '收起筛选 ▲'; + } else { + panel.style.display = 'none'; + btn.textContent = '展开筛选 ▼'; + } +} + +async function loadHistory(page) { + page = page || 1; + currentHistoryPage = page; + let startDate = document.getElementById('historyStartDate').value; + let endDate = document.getElementById('historyEndDate').value; + let studentId = document.getElementById('historyStudentId').value; + let reasonFilter = document.getElementById('historyReasonFilter').value; + let subjectFilter = document.getElementById('historySubjectFilter').value; + let reasonSearch = document.getElementById('historyReasonSearch').value.trim(); + let isGrouped = document.getElementById('historyGrouped').checked; + let statusFilter = document.getElementById('historyStatusFilter') ? document.getElementById('historyStatusFilter').value : ''; + + // 筛选学生时强制取消合并 + if (studentId) isGrouped = false; + + let params = { + page: page, page_size: 20, + start_date: startDate, + end_date: endDate + }; + if (studentId) params.student_id = studentId; + + // 科目筛选优先于扣分类型筛选 + if (subjectFilter) { + params.reason_prefix = '[' + subjectFilter + ']'; + } else if (reasonFilter) { + params.reason_prefix = reasonFilter; + } + + if (reasonSearch) params.reason_search = reasonSearch; + if (isGrouped) params.grouped = true; + if (statusFilter !== '') params.is_revoked = parseInt(statusFilter); + + let res = await apiGet('/api/admin/conduct/history', params); + + if (res && res.success) { + let nowrapStyle = ' style="white-space:nowrap;min-width:80px;"'; + let headHtml = ''; + if (isGrouped) { + headHtml = '类型分值原因学生名单操作人时间'; + if (role === '班主任' || role === '班长') { + headHtml += '操作'; + } + } else { + headHtml = '类型分值原因学生操作人时间'; + if (role === '班主任' || role === '班长' || role === '考勤委员') { + headHtml += '操作'; + } + } + document.getElementById('historyTableHead').innerHTML = headHtml; + + let html = ''; + if (isGrouped) { + res.data.records.forEach(function(record) { + let pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + let names = record.student_names || ''; + let allRevoked = record.all_revoked; + let revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; + html += '' + + '' + escapeHtml(getTypeLabel(record.related_type)) + '' + + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '×' + record.student_count + '' + + '' + escapeHtml(record.reason) + '' + + '' + escapeHtml(names) + '' + + '' + escapeHtml(record.recorder_name || '') + '' + + '' + formatDateTime(record.created_at) + ''; + if (role === '班主任' || role === '班长') { + if (allRevoked) { + html += '已撤销'; + } else { + html += ''; + } + } + html += ''; + }); + if (res.data.records.length === 0) { + let colSpan = (role === '班主任' || role === '班长') ? 7 : 6; + html = '暂无记录'; + } + } else { + res.data.records.forEach(function(record) { + let pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + let revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; + html += '' + + '' + escapeHtml(getTypeLabel(record.related_type)) + '' + + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '' + + '' + escapeHtml(record.reason) + '' + + '' + escapeHtml(record.student_name) + '' + + '' + escapeHtml(record.recorder_name) + '' + + '' + formatDateTime(record.created_at) + ''; + if (role === '班主任') { + if (record.is_revoked == 1) { + let revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; + html += '' + revokerInfo + ''; + } else { + html += ''; + } + } else if (role === '班长') { + if (record.is_revoked == 1) { + let revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; + html += '' + revokerInfo + ''; + } else { + html += ''; + } + } else if (role === '考勤委员') { + if (record.is_revoked == 1) { + html += '已撤销'; + } else if (record.recorder_id == currentUserId) { + html += ''; + } else { + html += '-'; + } + } + html += ''; + }); + + if (res.data.records.length === 0) { + let colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 7 : 6; + html = '暂无记录'; + } + } + + document.getElementById('historyList').innerHTML = html; + + totalHistoryPages = res.data.total_pages || 1; + renderHistoryPagination(); + } +} + +function renderHistoryPagination() { + renderSmartPagination('historyPagination', currentHistoryPage, totalHistoryPages, function(page) { + loadHistory(page); + }); +} + +async function exportHistoryRecords() { + let startDate = document.getElementById('historyStartDate').value; + let endDate = document.getElementById('historyEndDate').value; + let studentId = document.getElementById('historyStudentId').value; + + showToast('正在导出历史记录...', 'info'); + + try { + let reasonFilter = document.getElementById('historyReasonFilter').value; + let subjectFilter = document.getElementById('historySubjectFilter').value; + let reasonSearch = document.getElementById('historyReasonSearch').value.trim(); + let params = { page: 1, page_size: 1000 }; + if (startDate) params.start_date = startDate; + if (endDate) params.end_date = endDate; + if (studentId) params.student_id = studentId; + if (subjectFilter) { + params.reason_prefix = '[' + subjectFilter + ']'; + } else if (reasonFilter) { + params.reason_prefix = reasonFilter; + } + if (reasonSearch) params.reason_search = reasonSearch; + + let res = await apiGet('/api/admin/conduct/history', params); + if (res && res.success && res.data.records) { + let records = res.data.records; + if (records.length === 0) { + showToast('没有找到记录', 'warning'); + return; + } + + function csvField(val) { + let s = String(val == null ? '' : val); + if (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\n') >= 0) { + return '"' + s.replace(/"/g, '""') + '"'; + } + return s; + } + let csv = '\uFEFF'; + csv += '时间,学号,姓名,分数变动,原因,操作人\n'; + records.forEach(function(r) { + if (r.is_revoked == 1) return; + csv += csvField(r.created_at) + ',' + csvField(r.student_no) + ',' + csvField(r.student_name) + ',' + csvField((r.points_change > 0 ? '+' : '') + r.points_change) + ',' + csvField(r.reason) + ',' + csvField(r.recorder_name) + '\n'; + }); + + let blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + let url = URL.createObjectURL(blob); + let link = document.createElement('a'); + link.href = url; + link.download = '历史记录_' + new Date().toISOString().slice(0,10) + '.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showToast('导出成功,共' + records.length + '条记录'); + } else { + showToast('导出失败:' + (res && res.message || '未知错误'), 'error'); + } + } catch (err) { + showToast('导出失败:' + err.message, 'error'); + } +} + +// 批量撤销合并记录(按条件查找并撤销) +async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) { + if (!confirm('确定要撤销所有"' + reason + '"(' + (pointsChange > 0 ? '+' : '') + pointsChange + '分)的记录吗?')) return; + + showToast('正在批量撤销...', 'info'); + + try { + let params = { + page: 1, page_size: 1000, + start_date: document.getElementById('historyStartDate').value, + end_date: document.getElementById('historyEndDate').value, + reason_prefix: reason, + grouped: false + }; + + let res = await apiGet('/api/admin/conduct/history', params); + if (!res || !res.success || !res.data.records) { + showToast('查询记录失败', 'error'); + return; + } + + let matchedIds = []; + res.data.records.forEach(function(r) { + if (r.reason === reason && r.points_change === pointsChange && r.is_revoked == 0) { + matchedIds.push(r.record_id); + } + }); + + if (matchedIds.length === 0) { + showToast('没有找到可撤销的记录', 'warning'); + return; + } + + let revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds }); + if (revokeRes && revokeRes.success) { + showToast('批量撤销完成: ' + (revokeRes.data ? revokeRes.data.success_count : 0) + '条成功'); + loadHistory(currentHistoryPage); + } else { + showToast(revokeRes && revokeRes.message || '批量撤销失败', 'error'); + } + } catch (err) { + showToast('批量撤销失败: ' + err.message, 'error'); + } +} + +// 初始化:并行加载学生和科目列表,然后加载历史记录 +Promise.all([loadStudentsForSelect(), loadSubjectsForFilter()]).then(function() { + let urlParams = new URLSearchParams(window.location.search); + let preStudentId = urlParams.get('student_id'); + if (preStudentId) { + document.getElementById('historyStudentId').value = preStudentId; + onStudentFilterChange(); + } + loadHistory(); +}); + +window.loadHistory = loadHistory; +window.loadStudentsForSelect = loadStudentsForSelect; +window.exportHistoryRecords = exportHistoryRecords; +window.batchRevokeGrouped = batchRevokeGrouped; +window.onStudentFilterChange = onStudentFilterChange; +window.onSubjectFilterChange = onSubjectFilterChange; +window.toggleFilterPanel = toggleFilterPanel; + +})(); diff --git a/frontend/assets/js/homework-manage.js b/frontend/assets/js/homework-manage.js new file mode 100644 index 0000000..a7f6897 --- /dev/null +++ b/frontend/assets/js/homework-manage.js @@ -0,0 +1,266 @@ +/** + * 多班级版班级管理系统 - 作业扣分页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +const hwRole = window.PAGE_CONFIG.role; + +// 初始化扣分配置 +const hwMaxPoints = hwRole === '班主任' ? 100 : 5; +const hwNotSubmit = window.DEDUCTION_HOMEWORK_NOT_SUBMIT || 2; +const hwLate = window.DEDUCTION_HOMEWORK_LATE || 1; + +// 更新页面中的配置值显示 +document.querySelectorAll('.hw-not-submit').forEach(el => el.textContent = hwNotSubmit); +document.querySelectorAll('.hw-late').forEach(el => el.textContent = hwLate); +document.querySelectorAll('.hw-max').forEach(el => el.textContent = hwMaxPoints); + +// 更新输入框的 min/max +document.getElementById('pointsChange').setAttribute('min', -hwMaxPoints); +document.getElementById('pointsChange').setAttribute('max', hwMaxPoints); + +// 加载科目列表(学习委员) +async function loadSubjectsForHomework() { + const subjectSelect = document.getElementById('hwSubjectSelect'); + if (!subjectSelect) return; + const res = await apiGet('/api/subject/list'); + if (res && res.success && res.data && res.data.subjects) { + let html = ''; + res.data.subjects.forEach(s => { + html += ``; + }); + subjectSelect.innerHTML = html; + } +} + +async function loadStudents() { + const res = await apiGet('/api/admin/students', {page_size: 1000}); + if (res && res.success) { + let html = ''; + res.data.students.forEach(student => { + html += ` + + ${escapeHtml(student.student_no)} + ${escapeHtml(student.name)} + ${student.total_points} + + `; + }); + if (res.data.students.length === 0) { + html = '暂无学生数据'; + } + document.getElementById('studentList').innerHTML = html; + } +} + +function showSinglePointsModal(studentId, studentName) { + window.selectedStudentIds = [studentId]; + document.getElementById('selectedStudentsCount').innerHTML = `${studentName} (1人)`; + document.getElementById('pointsChange').value = ''; + document.getElementById('pointsReason').value = ''; + document.getElementById('batchPointsModal').style.display = 'flex'; +} + +function handleSubmitPoints() { + const pointsChange = parseInt(document.getElementById('pointsChange').value); + if (isNaN(pointsChange) || pointsChange === 0) { + showToast('请输入有效的加减分值', 'warning'); + return; + } + if (Math.abs(pointsChange) > hwMaxPoints) { + showToast(`每次加减分不超过${hwMaxPoints}分`, 'warning'); + return; + } + + // 学习委员附加科目前缀、具体作业和缴交时间 + if (hwRole === '学习委员' || hwRole === '班主任') { + const subjectSelect = document.getElementById('hwSubjectSelect'); + const subjectName = subjectSelect ? subjectSelect.value : ''; + const hwTitle = document.getElementById('hwTitle').value.trim(); + const hwDeadline = document.getElementById('hwDeadline').value; + const reasonEl = document.getElementById('pointsReason'); + + let prefix = ''; + if (subjectName) { + prefix = `[${subjectName}]`; + } + if (hwTitle) { + prefix += `[${hwTitle}]`; + } + if (hwDeadline) { + prefix += ` 缴交:${hwDeadline}`; + } + if (prefix) { + reasonEl.value = prefix + ' ' + reasonEl.value; + } + } + + submitBatchPoints({ related_type: 'homework' }); +} + +// ========== 科目管理功能 ========== + +function toggleSubjectPanel() { + const content = document.getElementById('subjectPanelContent'); + const toggle = document.getElementById('subjectPanelToggle'); + if (!content || !toggle) return; + + const isExpanded = content.classList.contains('expanded'); + if (isExpanded) { + content.classList.remove('expanded'); + toggle.classList.remove('expanded'); + toggle.textContent = '▶ 展开'; + } else { + content.classList.add('expanded'); + toggle.classList.add('expanded'); + toggle.textContent = '▼ 收起'; + loadSubjectList(); + } +} + +async function loadSubjectList() { + const res = await apiGet('/api/subject/list', { is_active: true }); + if (res && res.success && res.data) { + let html = ''; + const subjects = res.data.subjects || []; + subjects.forEach(sub => { + const safeName = escapeHtml(sub.subject_name || ''); + const safeCode = escapeHtml(sub.subject_code || ''); + const sortOrder = sub.sort_order || 0; + html += ` +
+ ${safeName} + ${safeCode} + + ${sub.is_active ? '启用' : '禁用'} + + + + +
+ `; + }); + if (subjects.length === 0) { + html = '

暂无科目,请点击"添加科目"

'; + } + document.getElementById('subjectList').innerHTML = html; + } +} + +function showAddSubjectModal() { + const form = document.getElementById('addSubjectFormInHw'); + if (form) form.reset(); + document.getElementById('addSubjectModal').style.display = 'flex'; +} + +async function submitAddSubject() { + const subjectName = document.getElementById('subjectName').value.trim(); + const subjectCode = document.getElementById('subjectCode').value.trim(); + + if (!subjectName) { + showToast('请填写科目名称', 'warning'); + return; + } + + const res = await apiPost('/api/subject/create', { + subject_name: subjectName, + subject_code: subjectCode + }); + + if (res && res.success) { + showToast('科目添加成功'); + closeModal('addSubjectModal'); + loadSubjectList(); + loadSubjectsForHomework(); + } else { + showToast(res?.message || '添加失败', 'error'); + } +} + +async function toggleSubjectStatus(subjectId, enable) { + const res = await apiPut(`/api/subject/toggle/${subjectId}`, { is_active: enable }); + if (res && res.success) { + showToast(enable ? '科目已启用' : '科目已禁用'); + loadSubjectList(); + loadSubjectsForHomework(); + } else { + showToast(res?.message || '操作失败', 'error'); + } +} + +async function deleteSubject(subjectId) { + if (!confirm('确定要删除该科目吗?如果科目下有作业数据将无法删除。')) return; + const res = await apiDelete('/api/subject/delete/' + subjectId); + if (res && res.success) { + showToast('科目删除成功'); + loadSubjectList(); + loadSubjectsForHomework(); + } else { + showToast(res?.message || '删除失败', 'error'); + } +} + +function showEditSubjectModal(subjectId, name, code, sortOrder) { + document.getElementById('editSubjectId').value = subjectId; + document.getElementById('editSubjectName').value = name; + document.getElementById('editSubjectCode').value = code; + document.getElementById('editSubjectSortOrder').value = sortOrder; + document.getElementById('editSubjectModal').style.display = 'flex'; +} + +async function submitEditSubject() { + const subjectId = document.getElementById('editSubjectId').value; + const subjectName = document.getElementById('editSubjectName').value.trim(); + const subjectCode = document.getElementById('editSubjectCode').value.trim(); + const sortOrder = document.getElementById('editSubjectSortOrder').value; + + if (!subjectName) { + showToast('请填写科目名称', 'warning'); + return; + } + + const data = { subject_name: subjectName }; + if (subjectCode) data.subject_code = subjectCode; + if (sortOrder !== '') data.sort_order = parseInt(sortOrder); + + const res = await apiPut(`/api/subject/update/${subjectId}`, data); + if (res && res.success) { + showToast('科目更新成功'); + closeModal('editSubjectModal'); + loadSubjectList(); + loadSubjectsForHomework(); + } else { + showToast(res?.message || '更新失败', 'error'); + } +} + +// 绑定科目管理折叠面板 +var subjectHeader = document.getElementById('subjectPanelHeader'); +if (subjectHeader) { + subjectHeader.addEventListener('click', toggleSubjectPanel); +} + +loadStudents(); +loadSubjectsForHomework(); + +window.loadStudents = loadStudents; +window.showSinglePointsModal = showSinglePointsModal; +window.handleSubmitPoints = handleSubmitPoints; +window.toggleSubjectPanel = toggleSubjectPanel; +window.showAddSubjectModal = showAddSubjectModal; +window.submitAddSubject = submitAddSubject; +window.toggleSubjectStatus = toggleSubjectStatus; +window.deleteSubject = deleteSubject; +window.showEditSubjectModal = showEditSubjectModal; +window.submitEditSubject = submitEditSubject; + +})(); diff --git a/frontend/assets/js/modules/admin-mgmt.js b/frontend/assets/js/modules/admin-mgmt.js new file mode 100644 index 0000000..a416837 --- /dev/null +++ b/frontend/assets/js/modules/admin-mgmt.js @@ -0,0 +1,53 @@ +/** + * 多班级版班级管理系统 - 管理员管理函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // 显示添加管理员模态框 + function showAddAdminModal() { + document.getElementById('addAdminModal').style.display = 'flex'; + document.getElementById('addAdminForm')?.reset(); + } + + // 提交添加管理员 + async function submitAddAdmin() { + const username = document.getElementById('adminUsername').value.trim(); + const realName = document.getElementById('adminRealName').value.trim(); + const password = document.getElementById('adminPassword').value; + const roleType = document.getElementById('adminRole').value; + + if (!username || !realName || !roleType) { + showToast('请填写完整信息', 'warning'); + return; + } + + const res = await apiPost('/api/admin/add', { + username: username, + real_name: realName, + password: password || undefined, + role_type: roleType + }); + + if (res && res.success) { + let msg = `管理员 ${res.data.username} 添加成功`; + if (res.data.password) msg += `,密码: ${res.data.password}`; + showToast(msg); + closeModal('addAdminModal'); + loadAdmins(); + } else { + showToast(res?.message || '添加失败', 'error'); + } + } + + window.showAddAdminModal = showAddAdminModal; + window.submitAddAdmin = submitAddAdmin; +})(); diff --git a/frontend/assets/js/modules/modal-utils.js b/frontend/assets/js/modules/modal-utils.js new file mode 100644 index 0000000..eeeb47f --- /dev/null +++ b/frontend/assets/js/modules/modal-utils.js @@ -0,0 +1,24 @@ +/** + * 多班级版班级管理系统 - 模态框工具函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // 关闭模态框 + function closeModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = 'none'; + } + } + + window.closeModal = closeModal; +})(); diff --git a/frontend/assets/js/modules/points-mgmt.js b/frontend/assets/js/modules/points-mgmt.js new file mode 100644 index 0000000..be5ccdb --- /dev/null +++ b/frontend/assets/js/modules/points-mgmt.js @@ -0,0 +1,102 @@ +/** + * 多班级版班级管理系统 - 加减分管理函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // 全局变量 + var selectedStudentIds = []; + var currentHistoryPage = 1; + + // 显示批量加减分模态框 + function showBatchPointsModal() { + selectedStudentIds = []; + document.querySelectorAll('.student-checkbox:checked').forEach(cb => { + selectedStudentIds.push(parseInt(cb.dataset.id)); + }); + + if (selectedStudentIds.length === 0) { + showToast('请先选择学生', 'warning'); + return; + } + + document.getElementById('selectedStudentsCount').innerHTML = `${selectedStudentIds.length} 人`; + document.getElementById('pointsChange').value = ''; + document.getElementById('pointsReason').value = ''; + document.getElementById('batchPointsModal').style.display = 'flex'; + } + + // 提交批量加减分 + async function submitBatchPoints(options = {}) { + const pointsChange = parseInt(document.getElementById('pointsChange').value); + const reason = document.getElementById('pointsReason').value; + + if (isNaN(pointsChange) || pointsChange === 0) { + showToast('分值不能为0', 'error'); + return; + } + + if (!reason.trim()) { + showToast('请填写原因', 'error'); + return; + } + + const data = { + student_ids: selectedStudentIds, + points_change: pointsChange, + reason: reason + }; + if (options.related_type) { + data.related_type = options.related_type; + } + const res = await apiPost('/api/admin/conduct/add', data); + + if (res && res.success) { + showToast(`操作成功: ${res.data.success_count} 人成功`); + closeModal('batchPointsModal'); + loadStudents(); + if (typeof loadConductStudents === 'function') loadConductStudents(); + } else { + showToast(res?.message || '操作失败', 'error'); + } + } + + // 撤销扣分记录 + async function revokeRecord(recordId) { + if (!confirm('确定要撤销这条记录吗?撤销后学生分数将恢复。')) return; + + const res = await apiPost('/api/admin/conduct/revoke', { record_id: recordId }); + if (res && res.success) { + showToast('撤销成功'); + loadHistory(currentHistoryPage); + } else { + showToast(res?.message || '撤销失败', 'error'); + } + } + + // 反撤销(恢复)记录 + async function restoreRecord(recordId) { + if (!confirm('确定要反撤销这条记录吗?分数变动将重新生效。')) return; + + const res = await apiPost('/api/admin/conduct/restore', { record_id: recordId }); + if (res && res.success) { + showToast('反撤销成功'); + loadHistory(currentHistoryPage); + } else { + showToast(res?.message || '反撤销失败', 'error'); + } + } + + window.showBatchPointsModal = showBatchPointsModal; + window.submitBatchPoints = submitBatchPoints; + window.revokeRecord = revokeRecord; + window.restoreRecord = restoreRecord; +})(); diff --git a/frontend/assets/js/modules/student-mgmt.js b/frontend/assets/js/modules/student-mgmt.js new file mode 100644 index 0000000..8b6f174 --- /dev/null +++ b/frontend/assets/js/modules/student-mgmt.js @@ -0,0 +1,234 @@ +/** + * 多班级版班级管理系统 - 学生管理函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // 显示新增学生模态框 + function showAddStudentModal() { + document.getElementById('addStudentModal').style.display = 'flex'; + document.getElementById('addStudentForm').reset(); + } + + // 提交新增学生 + async function submitAddStudent() { + const studentNo = document.getElementById('studentNo').value.trim(); + const name = document.getElementById('studentName').value.trim(); + const parentPhone = document.getElementById('parentPhone').value.trim(); + + if (!studentNo || !name) { + showToast('请填写学号和姓名', 'warning'); + return; + } + + const res = await apiPost('/api/admin/students', { + student_no: studentNo, + name: name, + parent_account: parentPhone, + dormitory_number: document.getElementById('addDormitoryNumber').value.trim() + }); + + if (res && res.success) { + showToast('学生添加成功'); + closeModal('addStudentModal'); + loadStudents(); + } else { + showToast(res?.message || '添加失败', 'error'); + } + } + + // 显示编辑学生模态框 + function showEditStudentModal(studentId, studentNo, name, phone, dormitoryNumber) { + document.getElementById('editStudentId').value = studentId; + document.getElementById('editStudentNo').value = studentNo; + document.getElementById('editStudentName').value = name; + document.getElementById('editStudentPhone').value = phone || ''; + document.getElementById('editDormitoryNumber').value = dormitoryNumber || ''; + document.getElementById('editStudentModal').style.display = 'flex'; + } + + // 提交编辑学生 + async function submitEditStudent() { + const studentId = document.getElementById('editStudentId').value; + const name = document.getElementById('editStudentName').value.trim(); + const phone = document.getElementById('editStudentPhone').value.trim(); + + if (!name) { + showToast('请输入姓名', 'warning'); + return; + } + + const res = await apiPut(`/api/admin/students/${studentId}`, { + name: name, + parent_account: phone || null, + dormitory_number: document.getElementById('editDormitoryNumber').value.trim() + }); + + if (res && res.success) { + showToast('学生信息更新成功'); + closeModal('editStudentModal'); + location.reload(); + } else { + showToast(res?.message || '更新失败', 'error'); + } + } + + // 显示重置学生密码模态框 + function showResetStudentPasswordModal(studentId, name) { + document.getElementById('resetStudentId').value = studentId; + document.getElementById('resetStudentInfo').textContent = `正在重置学生 "${name}" 的密码`; + document.getElementById('newStudentPassword').value = ''; + document.getElementById('resetStudentPasswordModal').style.display = 'flex'; + } + + // 提交重置学生密码 + async function submitResetStudentPassword() { + const studentId = document.getElementById('resetStudentId').value; + const newPassword = document.getElementById('newStudentPassword').value; + + if (!newPassword || newPassword.length < 6) { + showToast('密码至少6位', 'warning'); + return; + } + + const res = await apiPost(`/api/admin/students/reset-password/${studentId}`, { + new_password: newPassword + }); + + if (res && res.success) { + showToast('密码重置成功'); + closeModal('resetStudentPasswordModal'); + } else { + showToast(res?.message || '重置失败', 'error'); + } + } + + // 删除学生 + async function deleteStudent(studentId, name) { + if (!confirm(`确定要删除学生 "${name}" 吗?删除后学生账号将被禁用。`)) return; + + const res = await apiDelete(`/api/admin/students/${studentId}`); + + if (res && res.success) { + showToast('学生删除成功'); + location.reload(); + } else { + showToast(res?.message || '删除失败', 'error'); + } + } + + // 显示导入模态框 + function showImportModal() { + document.getElementById('importModal').style.display = 'flex'; + document.getElementById('importPreview').style.display = 'none'; + document.getElementById('importPreview').innerHTML = ''; + document.getElementById('importBtn').style.display = 'none'; + document.getElementById('importFile').value = ''; + } + + // 预览导入文件 + function previewImportFile() { + const file = document.getElementById('importFile').files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const data = JSON.parse(e.target.result); + const students = data.students || []; + + let html = '

预览数据

'; + html += ''; + html += ''; + + students.forEach(s => { + html += ` + + + + + + `; + }); + + html += `
学号姓名家长账号(推荐手机号)宿舍号初始密码
${escapeHtml(s.student_no || '')}${escapeHtml(s.name || '')}${escapeHtml(s.parent_account || '')}${escapeHtml(s.dormitory_number || '-')}${escapeHtml(s.password || '123456')}

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

`; + document.getElementById('importPreview').innerHTML = html; + document.getElementById('importPreview').style.display = 'block'; + document.getElementById('importBtn').style.display = 'inline-block'; + } catch (error) { + showToast('JSON格式错误', 'error'); + } + }; + reader.readAsText(file); + } + + // 执行导入 + async function doImport() { + const file = document.getElementById('importFile').files[0]; + if (!file) { + showToast('请选择文件', 'warning'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + const token = getToken(); + const response = await fetch(`${API_BASE_URL}/api/admin/students/import`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + const result = await response.json(); + + if (result.success) { + showToast(result.message); + closeModal('importModal'); + loadStudents(); + + // 显示详细导入结果 + if (result.data && result.data.results) { + const failedList = result.data.results.filter(r => !r.success); + if (failedList.length > 0) { + let detail = '失败详情:\n'; + failedList.forEach(r => { + detail += `${r.student_no || '未知'}: ${r.error}\n`; + }); + alert(detail); + } + } + } else { + showToast(result.message, 'error'); + } + } + + // 绑定文件选择事件 + document.addEventListener('DOMContentLoaded', () => { + const fileInput = document.getElementById('importFile'); + if (fileInput) { + fileInput.addEventListener('change', previewImportFile); + } + }); + + window.showAddStudentModal = showAddStudentModal; + window.submitAddStudent = submitAddStudent; + window.showEditStudentModal = showEditStudentModal; + window.submitEditStudent = submitEditStudent; + window.showResetStudentPasswordModal = showResetStudentPasswordModal; + window.submitResetStudentPassword = submitResetStudentPassword; + window.deleteStudent = deleteStudent; + window.showImportModal = showImportModal; + window.previewImportFile = previewImportFile; + window.doImport = doImport; +})(); diff --git a/frontend/assets/js/modules/subject-mgmt.js b/frontend/assets/js/modules/subject-mgmt.js new file mode 100644 index 0000000..a0b30b5 --- /dev/null +++ b/frontend/assets/js/modules/subject-mgmt.js @@ -0,0 +1,47 @@ +/** + * 多班级版班级管理系统 - 科目管理函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // 显示添加科目模态框 + function showAddSubjectModal() { + document.getElementById('addSubjectModal').style.display = 'flex'; + document.getElementById('addSubjectForm').reset(); + } + + // 提交添加科目 + async function submitAddSubject() { + const subjectName = document.getElementById('subjectName').value.trim(); + const subjectCode = document.getElementById('subjectCode').value.trim(); + + if (!subjectName) { + showToast('请填写科目名称', 'warning'); + return; + } + + const res = await apiPost('/api/subject/create', { + subject_name: subjectName, + subject_code: subjectCode + }); + + if (res && res.success) { + showToast('科目添加成功'); + closeModal('addSubjectModal'); + loadSubjects(); + } else { + showToast(res?.message || '添加失败', 'error'); + } + } + + window.showAddSubjectModal = showAddSubjectModal; + window.submitAddSubject = submitAddSubject; +})(); diff --git a/frontend/assets/js/modules/utils.js b/frontend/assets/js/modules/utils.js new file mode 100644 index 0000000..b50fedd --- /dev/null +++ b/frontend/assets/js/modules/utils.js @@ -0,0 +1,35 @@ +/** + * 多班级版班级管理系统 - 通用工具函数 + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { + 'use strict'; + + // HTML转义 + function escapeHtml(str) { + if (!str) return ''; + var el = document.createElement('span'); + el.appendChild(document.createTextNode(str)); + return el.innerHTML; + } + + // 全选功能 + function toggleSelectAll() { + const selectAll = document.getElementById('selectAll'); + if (selectAll) { + document.querySelectorAll('.student-checkbox').forEach(cb => { + cb.checked = selectAll.checked; + }); + } + } + + window.escapeHtml = escapeHtml; + window.toggleSelectAll = toggleSelectAll; +})(); diff --git a/frontend/assets/js/parent.js b/frontend/assets/js/parent.js new file mode 100644 index 0000000..4b225a1 --- /dev/null +++ b/frontend/assets/js/parent.js @@ -0,0 +1,13 @@ +/** + * 多班级版班级管理系统 - 家长端JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +// 家长端专用功能 +console.log('家长端已加载'); \ No newline at end of file diff --git a/frontend/assets/js/rankings.js b/frontend/assets/js/rankings.js new file mode 100644 index 0000000..00ffcdc --- /dev/null +++ b/frontend/assets/js/rankings.js @@ -0,0 +1,59 @@ +/** + * 多班级版班级管理系统 - 排行榜JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +let currentType = 'conduct'; + +async function loadRankings(type) { + const res = await apiGet('/api/admin/rankings', { type: type, limit: 50 }); + if (res && res.success && res.data) { + const rankings = res.data.ranking || []; + let html = ''; + if (rankings.length === 0) { + html = '暂无排行数据'; + } else { + rankings.forEach(function(item, index) { + let rankClass = ''; + if (index === 0) rankClass = 'rank-gold'; + else if (index === 1) rankClass = 'rank-silver'; + else if (index === 2) rankClass = 'rank-bronze'; + + let pointsText = Number(item.points !== undefined ? item.points : (item.total_points || 0)); + if (pointsText > 0) { + pointsText = '+' + pointsText; + } + + html += '' + + '' + (index + 1) + '' + + '' + escapeHtml(item.student_no || '-') + '' + + '' + escapeHtml(item.name || '-') + '' + + '' + pointsText + '' + + ''; + }); + } + document.getElementById('rankingList').innerHTML = html; + } else { + document.getElementById('rankingList').innerHTML = '加载失败'; + } +} + +window.switchTab = function(type, btn) { + currentType = type; + document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); + loadRankings(type); +}; + +document.addEventListener('DOMContentLoaded', function() { + loadRankings(currentType); +}); + +})(); diff --git a/frontend/assets/js/semesters.js b/frontend/assets/js/semesters.js new file mode 100644 index 0000000..05ddca2 --- /dev/null +++ b/frontend/assets/js/semesters.js @@ -0,0 +1,383 @@ +/** + * 多班级版班级管理系统 - 学期管理页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +let archiveSemesterId = null; +let archivePage = 1; +let archiveTotalPages = 1; +let associateSemesterId = null; + +function fillSemesterDates(type) { + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth() + 1; + const startDateInput = document.getElementById('semesterStartDate'); + const endDateInput = document.getElementById('semesterEndDate'); + + if (type === 'upper') { + const year = currentMonth >= 6 ? currentYear : currentYear - 1; + const endYear = year + 1; + let febDay = 28; + if ((endYear % 4 === 0 && endYear % 100 !== 0) || endYear % 400 === 0) { + febDay = 29; + } + startDateInput.value = year + '-09-01'; + endDateInput.value = endYear + '-02-' + febDay; + } else if (type === 'lower') { + startDateInput.value = currentYear + '-03-01'; + endDateInput.value = currentYear + '-07-15'; + } +} + +async function loadSemesters() { + const res = await apiGet('/api/semester/list'); + if (res && res.success) { + let html = ''; + const semesters = res.data.semesters || []; + semesters.forEach(sem => { + let statusText = ''; + let statusClass = ''; + if (sem.is_archived) { + statusText = '已归档'; + statusClass = 'status-badge status-not_submitted'; + } else if (sem.is_active) { + statusText = '当前学期'; + statusClass = 'status-badge status-submitted'; + } else { + statusText = '未激活'; + statusClass = 'status-badge status-late'; + } + + let actions = ''; + const startDate = sem.start_date || ''; + const endDate = sem.end_date || ''; + if (!sem.is_archived) { + actions += `
+ +
+ 编辑 + ${!sem.is_active ? `激活` : ''} + 关联数据 + 归档 +
+
`; + } + if (sem.is_archived) { + actions += ``; + } + + const conductCount = sem.conduct_count || 0; + const attendanceCount = sem.attendance_count || 0; + let recordText = '-'; + if (conductCount > 0 || attendanceCount > 0) { + recordText = `${conductCount}条操行分 / ${attendanceCount}条考勤`; + } + + const weekText = sem.current_week ? `第${sem.current_week}周` : '-'; + html += ` + ${escapeHtml(sem.semester_name)} + ${formatDate(sem.start_date)} + ${formatDate(sem.end_date)} + ${weekText} + ${statusText} + ${recordText} + ${formatDateTime(sem.created_at)} + ${actions} + `; + }); + if (semesters.length === 0) { + html = '暂无学期,请点击上方按钮创建新学期'; + } + document.getElementById('semesterList').innerHTML = html; + } +} + +function showCreateSemesterModal() { + document.getElementById('semesterName').value = ''; + document.getElementById('semesterStartDate').value = ''; + document.getElementById('semesterEndDate').value = ''; + document.getElementById('createSemesterModal').style.display = 'flex'; +} + +async function submitCreateSemester() { + const name = document.getElementById('semesterName').value.trim(); + const startDate = document.getElementById('semesterStartDate').value; + const endDate = document.getElementById('semesterEndDate').value; + + if (!name) { + showToast('请输入学期名称', 'warning'); + return; + } + + const res = await apiPost('/api/semester/create', { + semester_name: name, + start_date: startDate || null, + end_date: endDate || null + }); + + if (res && res.success) { + showToast(res.message || '学期创建成功'); + closeModal('createSemesterModal'); + loadSemesters(); + } else { + showToast(res?.message || '创建失败', 'error'); + } +} + +async function activateSemester(semesterId) { + if (!confirm('确认将此学期设为当前活跃学期?其他学期将被设为非活跃。')) { + return; + } + + const res = await apiPut(`/api/semester/activate/${semesterId}`); + if (res && res.success) { + showToast(res.message || '已设为当前学期'); + loadSemesters(); + } else { + showToast(res?.message || '操作失败', 'error'); + } +} + +function showEditSemesterModal(id, name, startDate, endDate) { + document.getElementById('editSemesterId').value = id; + document.getElementById('editSemesterName').value = name; + document.getElementById('editSemesterStartDate').value = startDate || ''; + document.getElementById('editSemesterEndDate').value = endDate || ''; + document.getElementById('editSemesterModal').style.display = 'flex'; +} + +async function submitEditSemester() { + const id = document.getElementById('editSemesterId').value; + const name = document.getElementById('editSemesterName').value.trim(); + const startDate = document.getElementById('editSemesterStartDate').value; + const endDate = document.getElementById('editSemesterEndDate').value; + + if (!name) { + showToast('请输入学期名称', 'warning'); + return; + } + + const data = { semester_name: name }; + if (startDate) data.start_date = startDate; + if (endDate) data.end_date = endDate; + + const res = await apiPut(`/api/semester/update/${id}`, data); + if (res && res.success) { + showToast(res.message || '更新成功'); + closeModal('editSemesterModal'); + loadSemesters(); + } else { + showToast(res?.message || '更新失败', 'error'); + } +} + +async function deleteSemester() { + const id = document.getElementById('editSemesterId').value; + if (!confirm('确定要删除该学期吗?如果学期已有归档数据则无法删除。')) { + return; + } + + const res = await apiDelete(`/api/semester/delete/${id}`); + if (res && res.success) { + showToast(res.message || '删除成功'); + closeModal('editSemesterModal'); + loadSemesters(); + } else { + showToast(res?.message || '删除失败', 'error'); + } +} + +function showAssociateConfirm(id, name, startDate, endDate) { + associateSemesterId = id; + const dateRange = startDate ? `${startDate} ~ ${endDate || '至今'}` : '未设置日期范围'; + document.getElementById('associateConfirmText').innerHTML = + `即将关联 ${dateRange} 内的所有未分配学期的操行分记录和考勤记录到学期 "${name}"。`; + document.getElementById('associateConfirmModal').style.display = 'flex'; +} + +async function confirmAssociate() { + if (!associateSemesterId) return; + + const res = await apiPost(`/api/semester/${associateSemesterId}/associate`); + if (res && res.success) { + showToast(res.message || '关联成功'); + closeModal('associateConfirmModal'); + associateSemesterId = null; + } else { + showToast(res?.message || '关联失败', 'error'); + } +} + +function showArchiveConfirm(semesterId, semesterName) { + archiveSemesterId = semesterId; + document.getElementById('archiveResetScores').checked = false; + document.getElementById('archiveConfirmText').innerHTML = + `确定要归档学期 "${semesterName}" 吗?
归档后将保存所有学生的当前操行分快照,该学期数据将变为只读。`; + document.getElementById('archiveConfirmModal').style.display = 'flex'; +} + +async function confirmArchive() { + if (!archiveSemesterId) return; + + const resetScores = document.getElementById('archiveResetScores').checked; + const url = `/api/semester/archive/${archiveSemesterId}?reset_scores=${resetScores}`; + + const res = await apiPost(url); + if (res && res.success) { + showToast(res.message || '归档成功'); + closeModal('archiveConfirmModal'); + archiveSemesterId = null; + loadSemesters(); + } else { + showToast(res?.message || '归档失败', 'error'); + } +} + +async function viewArchiveData(semesterId, semesterName, page) { + page = page || 1; + archivePage = page; + document.getElementById('archiveDataTitle').textContent = `归档数据 - ${semesterName}`; + + const res = await apiGet(`/api/semester/archive/${semesterId}/records`, { + page: page, page_size: 50 + }); + + if (res && res.success) { + const data = res.data || {}; + const archives = data.items || []; + let html = ''; + archives.forEach(a => { + html += ` + ${a.rank_position || '-'} + ${escapeHtml(a.student_no)} + ${escapeHtml(a.student_name)} + ${a.final_points} + ${a.attendance_present || 0} + ${a.attendance_absent || 0} + ${a.attendance_late || 0} + ${a.attendance_leave || 0} + ${a.homework_submitted || 0} + ${a.homework_not_submitted || 0} + ${a.homework_late || 0} + `; + }); + if (archives.length === 0) { + html = '暂无归档数据'; + } + document.getElementById('archiveDataList').innerHTML = html; + + archiveTotalPages = data.total_pages || 1; + renderArchivePagination(semesterId, semesterName); + document.getElementById('archiveDataModal').style.display = 'flex'; + } else { + showToast(res?.message || '获取归档数据失败', 'error'); + } +} + +function renderArchivePagination(semesterId, semesterName) { + renderSmartPagination('archivePagination', archivePage, archiveTotalPages, function(page) { + viewArchiveData(semesterId, semesterName, page); + }); +} + +// ========== 周期重置功能 ========== + +let pendingPeriodType = null; +let periodArchivesType = null; +let periodArchivesPage = 1; +let periodArchivesTotalPages = 1; + +function confirmPeriodReset(periodType) { + pendingPeriodType = periodType; + const label = periodType === 'weekly' ? '本周' : '本月'; + document.getElementById('periodResetText').innerHTML = + `确定要执行 ${label}重置 吗?
将保存当前所有学生的操行分快照,然后将所有学生操行分重置为初始值。`; + document.getElementById('periodResetModal').style.display = 'flex'; +} + +async function executePeriodReset() { + if (!pendingPeriodType) return; + + const res = await apiPost('/api/semester/period-reset', { period: pendingPeriodType }); + if (res && res.success) { + showToast(res.message || '重置成功'); + closeModal('periodResetModal'); + pendingPeriodType = null; + } else { + showToast(res?.message || '重置失败', 'error'); + } +} + +async function showPeriodArchives(type, page) { + var periodType = type || periodArchivesType; + page = page || 1; + periodArchivesType = periodType; + periodArchivesPage = page; + + const label = periodType === 'weekly' ? '周' : '月'; + document.getElementById('periodArchivesTitle').textContent = label + '归档数据'; + + const res = await apiGet('/api/semester/period-archives', { + period: periodType, + page: page, + page_size: 50 + }); + + if (res && res.success) { + const data = res.data || {}; + const archives = data.items || []; + let html = ''; + archives.forEach(function(a) { + const resetByLabel = a.reset_by === 'auto' ? '自动' : '手动'; + html += '' + + '' + escapeHtml(a.period_label) + '' + + '' + (a.rank_position || '-') + '' + + '' + escapeHtml(a.student_no) + '' + + '' + escapeHtml(a.student_name) + '' + + '' + a.final_points + '' + + '' + resetByLabel + '' + + '' + formatDateTime(a.archived_at) + '' + + ''; + }); + if (archives.length === 0) { + html = '暂无归档数据'; + } + document.getElementById('periodArchivesList').innerHTML = html; + + periodArchivesTotalPages = data.total_pages || 1; + renderSmartPagination('periodArchivePagination', periodArchivesPage, periodArchivesTotalPages, function(p) { + showPeriodArchives(periodArchivesType, p); + }); + document.getElementById('periodArchivesModal').style.display = 'flex'; + } else { + showToast(res?.message || '获取归档数据失败', 'error'); + } +} + +loadSemesters(); + +window.fillSemesterDates = fillSemesterDates; +window.showCreateSemesterModal = showCreateSemesterModal; +window.submitCreateSemester = submitCreateSemester; +window.activateSemester = activateSemester; +window.showEditSemesterModal = showEditSemesterModal; +window.submitEditSemester = submitEditSemester; +window.deleteSemester = deleteSemester; +window.showAssociateConfirm = showAssociateConfirm; +window.confirmAssociate = confirmAssociate; +window.showArchiveConfirm = showArchiveConfirm; +window.confirmArchive = confirmArchive; +window.viewArchiveData = viewArchiveData; +window.confirmPeriodReset = confirmPeriodReset; +window.executePeriodReset = executePeriodReset; +window.showPeriodArchives = showPeriodArchives; + +})(); diff --git a/frontend/assets/js/student-homework.js b/frontend/assets/js/student-homework.js new file mode 100644 index 0000000..626a5a3 --- /dev/null +++ b/frontend/assets/js/student-homework.js @@ -0,0 +1,38 @@ +/** + * 多班级版班级管理系统 - 学生端作业情况JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +const STUDENT_ID = window.PAGE_CONFIG.studentId; + +async function loadHomework() { + const res = await apiGet(`/api/student/homework/${STUDENT_ID}`); + if (res && res.success) { + let html = ''; + res.data.homework.forEach(record => { + const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + const pointsColor = record.points_change > 0 ? '#38a169' : '#e53e3e'; + html += ` + ${formatDateTime(record.created_at)} + ${record.points_change > 0 ? '+' : ''}${record.points_change} + ${escapeHtml(record.reason)} + ${escapeHtml(record.recorder_name || '-')} + `; + }); + if (res.data.homework.length === 0) { + html = '📝 暂无作业扣分记录'; + } + document.getElementById('homeworkList').innerHTML = html; + } +} + +loadHomework(); + +})(); diff --git a/frontend/assets/js/student.js b/frontend/assets/js/student.js new file mode 100644 index 0000000..43b676f --- /dev/null +++ b/frontend/assets/js/student.js @@ -0,0 +1,13 @@ +/** + * 多班级版班级管理系统 - 学生端JS + * + * 开发者: Canglan + * 联系方式: admin@sea-studio.top + * 版权归属: Sea Network Technology Studio + * 许可证: Apache License 2.0 + * + * 版权所有 © Sea Network Technology Studio + */ + +// 学生端专用功能 +console.log('学生端已加载'); \ No newline at end of file diff --git a/frontend/assets/js/students-manage.js b/frontend/assets/js/students-manage.js new file mode 100644 index 0000000..8bb0c7d --- /dev/null +++ b/frontend/assets/js/students-manage.js @@ -0,0 +1,100 @@ +/** + * 多班级版班级管理系统 - 学生管理页JS + * + * 开发者: Canglan + * 版权归属: Sea Network Technology Studio + * + * 版权所有 © Sea Network Technology Studio + */ + +(function() { +'use strict'; + +const userRole = window.PAGE_CONFIG.role; +let currentPage = 1; +let totalPages = 1; + +async function loadStudents(page = 1) { + currentPage = page; + const search = document.getElementById('searchInput').value; + const res = await apiGet('/api/admin/students', { page, page_size: 20, search }); + + if (res && res.success) { + let html = ''; + res.data.students.forEach(student => { + html += ` + + ${escapeHtml(student.student_no)} + ${escapeHtml(student.name)} + ${escapeHtml(student.dormitory_number || '-')} + ${student.total_points} + ${userRole === '班主任' ? `${student.parent_account ? student.parent_account.slice(0,3) + '******' + student.parent_account.slice(-2) : '-'}` : ''} + +
+ + ${userRole === '班主任' ? ` + ` : ''} +
+ + `; + }); + + if (res.data.students.length === 0) { + html = `暂无学生数据`; + } + + document.getElementById('studentList').innerHTML = html; + + totalPages = res.data.total_pages || 1; + renderPagination(); + } +} + +function renderPagination() { + renderSmartPagination('pagination', currentPage, totalPages, function(page) { + loadStudents(page); + }); +} + +function showSinglePointsModal(studentId, studentName) { + window.selectedStudentIds = [studentId]; + document.getElementById('selectedStudentsCount').innerHTML = `${studentName} (1人)`; + document.getElementById('pointsChange').value = ''; + document.getElementById('pointsReason').value = ''; + document.getElementById('batchPointsModal').style.display = 'flex'; +} + +async function unlockStudent(studentNo, studentName) { + if (!confirm(`确定要解除学生 "${studentName}" 的登录锁定吗?\n(适用于多次登录失败被禁止登录的情况)`)) { + return; + } + + const res = await apiPost('/api/admin/unlock-user', { + username: studentNo + }); + + if (res && res.success) { + showToast(res.message || '解锁成功'); + } else { + showToast(res?.message || '解锁失败', 'error'); + } +} + +loadStudents(); + +let searchTimeout; +document.getElementById('searchInput').addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => loadStudents(1), 500); +}); + +window.loadStudents = loadStudents; +window.showSinglePointsModal = showSinglePointsModal; +window.unlockStudent = unlockStudent; + +})(); diff --git a/frontend/assets/uploads/sample_import.json b/frontend/assets/uploads/sample_import.json new file mode 100644 index 0000000..5fa9b76 --- /dev/null +++ b/frontend/assets/uploads/sample_import.json @@ -0,0 +1,23 @@ +{ + "students": [ + { + "student_no": "2025001", + "name": "张三", + "parent_account": "13800138001", + "dormitory_number": "A301", + "password": "123456" + }, + { + "student_no": "2025002", + "name": "李四", + "parent_account": "13800138002", + "dormitory_number": "A302" + }, + { + "student_no": "2025003", + "name": "王五", + "parent_account": "", + "dormitory_number": "B101" + } + ] +} diff --git a/frontend/config.php b/frontend/config.php new file mode 100644 index 0000000..dba5510 --- /dev/null +++ b/frontend/config.php @@ -0,0 +1,97 @@ + SESSION_TIMEOUT)) { + session_unset(); + session_destroy(); + if (strpos($_SERVER['REQUEST_URI'] ?? '', '/api/') === false) { + header('Location: /index.php'); + exit(); + } + http_response_code(401); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => '会话已过期,请重新登录']); + exit(); +} + +// 时区设置 +date_default_timezone_set('Asia/Shanghai'); + +// 生产环境关闭错误显示(保留错误日志记录,仅隐藏页面输出) +error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED); +ini_set('display_errors', 0); +ini_set('log_errors', 1); \ No newline at end of file diff --git a/frontend/includes/footer.php b/frontend/includes/footer.php new file mode 100644 index 0000000..a605725 --- /dev/null +++ b/frontend/includes/footer.php @@ -0,0 +1,6 @@ +
+ + + diff --git a/frontend/includes/header.php b/frontend/includes/header.php new file mode 100644 index 0000000..7bdd891 --- /dev/null +++ b/frontend/includes/header.php @@ -0,0 +1,92 @@ + + + + + + + <?php echo htmlspecialchars(SITE_NAME, ENT_QUOTES, 'UTF-8'); ?> - <?php echo htmlspecialchars($page_title); ?> + + + + + + +
+

+
+ + + + + + () + + +
+
+ + + + +
\ No newline at end of file diff --git a/frontend/includes/nav.php b/frontend/includes/nav.php new file mode 100644 index 0000000..f22fdd1 --- /dev/null +++ b/frontend/includes/nav.php @@ -0,0 +1,31 @@ + diff --git a/frontend/index.php b/frontend/index.php new file mode 100644 index 0000000..6de1502 --- /dev/null +++ b/frontend/index.php @@ -0,0 +1,143 @@ + '/student/dashboard.php', + 'parent' => '/parent/dashboard.php', + 'admin' => '/admin/dashboard.php', + 'super_admin' => '/admin/dashboard.php' + ]; + header("Location: " . ($redirect[$_SESSION['user_type']] ?? '/index.php')); + exit(); +} +?> + + + + + + <?php echo htmlspecialchars(SITE_NAME, ENT_QUOTES, 'UTF-8'); ?> - 登录 + + + + + + + + \ No newline at end of file diff --git a/frontend/parent/attendance.php b/frontend/parent/attendance.php new file mode 100644 index 0000000..b47945b --- /dev/null +++ b/frontend/parent/attendance.php @@ -0,0 +1,84 @@ + + + + +
+
+
出勤
0
+
缺勤
0
+
迟到
0
+
请假
0
+
+ +
+
考勤记录明细
+
+ + + +
日期状态原因
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/parent/dashboard.php b/frontend/parent/dashboard.php new file mode 100644 index 0000000..440008c --- /dev/null +++ b/frontend/parent/dashboard.php @@ -0,0 +1,101 @@ + + + + +
+
+
--
+
--
+ +
+
+
+
当前操行分
+
--
+
+
+
班级排名
+
--
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/frontend/parent/history.php b/frontend/parent/history.php new file mode 100644 index 0000000..f798e14 --- /dev/null +++ b/frontend/parent/history.php @@ -0,0 +1,117 @@ + + + + +
+
+
操行分历史记录
+
+ + + + + + + + + + + + +
日期原因分值记录人
加载中...
+
+ +
+
+ + + + + + + diff --git a/frontend/parent/password.php b/frontend/parent/password.php new file mode 100644 index 0000000..3b937c0 --- /dev/null +++ b/frontend/parent/password.php @@ -0,0 +1,106 @@ + + + + +
+
+
修改密码
+ + +
+
+ + + + + diff --git a/frontend/student/attendance.php b/frontend/student/attendance.php new file mode 100644 index 0000000..a2e3887 --- /dev/null +++ b/frontend/student/attendance.php @@ -0,0 +1,83 @@ + + + + +
+
+
出勤
0
+
缺勤
0
+
迟到
0
+
请假
0
+
+ +
+
考勤记录明细
+
+ + + +
日期状态原因
+
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/student/conduct_history.php b/frontend/student/conduct_history.php new file mode 100644 index 0000000..c100904 --- /dev/null +++ b/frontend/student/conduct_history.php @@ -0,0 +1,11 @@ + + + + + + +
+ +
+
+
+
当前操行分
+
--
+
+
+
班级排名
+
--
+
+
+
作业扣分
+
--
+
+
+
本月出勤率
+
--%
+
+
+ + + +
+
最新操行分记录
+
+ +
+
+ + + + + + + + + + + + +
+ + + + + + + diff --git a/frontend/student/homework.php b/frontend/student/homework.php new file mode 100644 index 0000000..891dd7a --- /dev/null +++ b/frontend/student/homework.php @@ -0,0 +1,51 @@ + + + + +
+
+
作业列表
+
+ + + + + +
时间分值原因操作人
+
+
+
+ + + + + + \ No newline at end of file diff --git a/frontend/student/password.php b/frontend/student/password.php new file mode 100644 index 0000000..7b4fc7d --- /dev/null +++ b/frontend/student/password.php @@ -0,0 +1,81 @@ + + + + +
+
+
修改密码
+
+
+ + +
+
+ + + 密码长度6-20位,需包含大写字母、小写字母、数字、特殊符号中的至少3种 +
+
+ + +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/student/semester_history.php b/frontend/student/semester_history.php new file mode 100644 index 0000000..c974d49 --- /dev/null +++ b/frontend/student/semester_history.php @@ -0,0 +1,206 @@ + + + + + + +
+
+
历史学期记录
+
+
+
+ + + + + diff --git a/frontend/super-admin/login.php b/frontend/super-admin/login.php new file mode 100644 index 0000000..6c73800 --- /dev/null +++ b/frontend/super-admin/login.php @@ -0,0 +1,134 @@ + + + + + + + <?php echo htmlspecialchars(SITE_NAME, ENT_QUOTES, 'UTF-8'); ?> - 系统管理员登录 + + + + + + + + diff --git a/sql/init.sql b/sql/init.sql new file mode 100644 index 0000000..8718899 --- /dev/null +++ b/sql/init.sql @@ -0,0 +1,423 @@ +-- =========================================== +-- 多班级版班级管理系统 - 数据库初始化脚本 (v2.0 Go重写版) +-- 数据库: classmanagerdb +-- 字符集: utf8mb4 +-- MySQL 兼容: 5.7+(不使用 CHECK 约束、窗口函数等 8.0+ 特性) +-- +-- 开发者: Canglan +-- 联系方式: admin@sea-studio.top +-- 版权归属: Sea Network Technology Studio +-- 许可证: Apache License 2.0 +-- +-- 版权所有 © Sea Network Technology Studio +-- =========================================== + +CREATE DATABASE IF NOT EXISTS `classmanagerdb` +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE `classmanagerdb`; + +SET FOREIGN_KEY_CHECKS = 0; + +-- =========================================== +-- 1. 班级表(多班级核心) +-- =========================================== +CREATE TABLE IF NOT EXISTS `classes` ( + `class_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '班级ID', + `class_name` VARCHAR(100) NOT NULL COMMENT '班级名称,如 高一(1)班', + `grade` VARCHAR(50) DEFAULT NULL COMMENT '年级', + `description` VARCHAR(255) DEFAULT NULL COMMENT '班级描述', + `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_class_name` (`class_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 2. 学期表(系统级共享) +-- =========================================== +CREATE TABLE IF NOT EXISTS `semesters` ( + `semester_id` INT PRIMARY KEY AUTO_INCREMENT, + `semester_name` VARCHAR(100) NOT NULL COMMENT '学期名称,如 2025春季学期', + `start_date` DATE DEFAULT NULL COMMENT '学期开始日期', + `end_date` DATE DEFAULT NULL COMMENT '学期结束日期', + `is_active` TINYINT DEFAULT 0 COMMENT '是否为当前活跃学期', + `is_archived` TINYINT DEFAULT 0 COMMENT '是否已归档', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 3. 科目表(系统级共享) +-- =========================================== +CREATE TABLE IF NOT EXISTS `subjects` ( + `subject_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '科目ID', + `subject_name` VARCHAR(50) NOT NULL COMMENT '科目名称', + `subject_code` VARCHAR(20) DEFAULT NULL COMMENT '科目代码', + `is_active` TINYINT DEFAULT 1 COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序序号', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_subject_name` (`subject_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 4. 学生表(归属班级) +-- 宿舍号格式: 南0-000, 东12-345, 北1-001, 西5-222 +-- 正则校验: ^[东南北西]\d{1,2}-\d{3}$(应用层校验,MySQL 5.7 不执行 CHECK 约束) +-- =========================================== +CREATE TABLE IF NOT EXISTS `students` ( + `student_id` INT PRIMARY KEY AUTO_INCREMENT, + `student_no` VARCHAR(20) NOT NULL, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `name` VARCHAR(50) NOT NULL, + `total_points` INT DEFAULT 60, + `parent_account` VARCHAR(50) DEFAULT NULL COMMENT '家长登录账号(推荐手机号)', + `dormitory_number` VARCHAR(20) DEFAULT NULL COMMENT '宿舍号,格式:南0-000', + `status` TINYINT DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `points_updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '分数最后更新时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_student_no_class` (`student_no`, `class_id`), + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 5. 用户表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `users` ( + `user_id` INT PRIMARY KEY AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL UNIQUE, + `password_hash` VARCHAR(255) NOT NULL, + `real_name` VARCHAR(50) NOT NULL, + `user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL, + `student_id` INT DEFAULT NULL, + `status` TINYINT DEFAULT 1, + `need_change_password` TINYINT DEFAULT 1, + `last_login_time` DATETIME DEFAULT NULL, + `last_login_ip` VARCHAR(45) DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 6. 系统管理员表(独立于班级的超级管理员) +-- =========================================== +CREATE TABLE IF NOT EXISTS `super_admins` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', + `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(MD5(SHA1(password)+salt))', + `salt` VARCHAR(64) NOT NULL COMMENT '密码盐值', + `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', + `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', + `need_change_password` TINYINT DEFAULT 1 COMMENT '是否需要修改密码:1=需要,0=不需要', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 7. 管理员角色表(归属班级) +-- =========================================== +CREATE TABLE IF NOT EXISTS `admin_roles` ( + `admin_role_id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `role_type` ENUM('班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员', '科任老师', '课代表') NOT NULL, + `subject_id` INT DEFAULT NULL COMMENT '关联科目ID(科任老师/课代表必填)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT, + FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`) ON DELETE CASCADE, + UNIQUE KEY `uk_user_class` (`user_id`, `class_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 8. 操行分记录表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `conduct_records` ( + `record_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `student_id` INT NOT NULL, + `points_change` INT NOT NULL, + `reason` VARCHAR(255) NOT NULL, + `recorder_id` INT NOT NULL, + `recorder_name` VARCHAR(50) DEFAULT NULL, + `related_type` ENUM('manual', 'homework', 'attendance') DEFAULT 'manual', + `related_id` INT DEFAULT NULL, + `is_revoked` TINYINT DEFAULT 0, + `revoked_by` INT DEFAULT NULL, + `revoked_at` DATETIME DEFAULT NULL, + `semester_id` INT DEFAULT NULL COMMENT '所属学期ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`), + FOREIGN KEY (`revoked_by`) REFERENCES `users`(`user_id`), + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 9. 作业表(归属班级) +-- =========================================== +CREATE TABLE IF NOT EXISTS `assignments` ( + `assignment_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `subject_id` INT NOT NULL, + `title` VARCHAR(100) NOT NULL, + `description` TEXT, + `deadline` DATE NOT NULL, + `created_by` INT NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT, + FOREIGN KEY (`subject_id`) REFERENCES `subjects`(`subject_id`), + FOREIGN KEY (`created_by`) REFERENCES `users`(`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 10. 作业提交记录表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `homework_submissions` ( + `submission_id` INT PRIMARY KEY AUTO_INCREMENT, + `assignment_id` INT NOT NULL, + `student_id` INT NOT NULL, + `status` ENUM('submitted', 'not_submitted', 'late') DEFAULT 'not_submitted', + `submit_time` DATETIME DEFAULT NULL, + `comments` TEXT, + `deduction_applied` TINYINT DEFAULT 0, + `deduction_record_id` BIGINT DEFAULT NULL, + `updated_by` INT DEFAULT NULL, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`assignment_id`) REFERENCES `assignments`(`assignment_id`) ON DELETE CASCADE, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL, + UNIQUE KEY `uk_assignment_student` (`assignment_id`, `student_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 11. 考勤记录表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `attendance_records` ( + `attendance_id` INT PRIMARY KEY AUTO_INCREMENT, + `student_id` INT NOT NULL, + `date` DATE NOT NULL, + `slot` ENUM('morning', 'afternoon', 'evening') DEFAULT 'morning' COMMENT '考勤时段:早上/下午/晚修', + `status` ENUM('present', 'absent', 'late', 'leave') DEFAULT 'present', + `reason` VARCHAR(255) DEFAULT NULL, + `recorder_id` INT NOT NULL, + `deduction_applied` TINYINT DEFAULT 0, + `deduction_record_id` BIGINT DEFAULT NULL, + `semester_id` INT DEFAULT NULL COMMENT '所属学期ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) ON DELETE CASCADE, + FOREIGN KEY (`recorder_id`) REFERENCES `users`(`user_id`), + FOREIGN KEY (`deduction_record_id`) REFERENCES `conduct_records`(`record_id`) ON DELETE SET NULL, + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`) ON DELETE SET NULL, + UNIQUE KEY `uk_student_date_slot` (`student_id`, `date`, `slot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 12. 操作日志表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `operator_id` INT NOT NULL, + `operator_name` VARCHAR(50) DEFAULT NULL, + `operator_role` VARCHAR(50) DEFAULT NULL, + `class_id` INT DEFAULT NULL COMMENT '操作所属班级ID', + `operation_type` VARCHAR(50) NOT NULL, + `target_type` VARCHAR(50) DEFAULT NULL, + `target_id` INT DEFAULT NULL, + `details` TEXT, + `ip_address` VARCHAR(45) DEFAULT NULL, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 13. 登录日志表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `login_logs` ( + `log_id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL, + `login_result` TINYINT NOT NULL, + `fail_reason` VARCHAR(100) DEFAULT NULL, + `ip_address` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(255) DEFAULT NULL, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 14. 学期归档快照表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `semester_archives` ( + `archive_id` INT PRIMARY KEY AUTO_INCREMENT, + `semester_id` INT NOT NULL, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `student_id` INT NOT NULL, + `student_no` VARCHAR(20) NOT NULL, + `student_name` VARCHAR(50) NOT NULL, + `final_points` INT NOT NULL COMMENT '学期最终操行分', + `rank_position` INT DEFAULT NULL COMMENT '班级内排名', + `total_students` INT DEFAULT NULL COMMENT '班级总人数', + `attendance_present` INT DEFAULT 0 COMMENT '出勤次数', + `attendance_absent` INT DEFAULT 0 COMMENT '缺勤次数', + `attendance_late` INT DEFAULT 0 COMMENT '迟到次数', + `attendance_leave` INT DEFAULT 0 COMMENT '请假次数', + `homework_submitted` INT DEFAULT 0 COMMENT '已交作业数', + `homework_not_submitted` INT DEFAULT 0 COMMENT '未交作业数', + `homework_late` INT DEFAULT 0 COMMENT '迟交作业数', + `archived_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`semester_id`) REFERENCES `semesters`(`semester_id`), + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 15. 系统设置表(全局) +-- =========================================== +CREATE TABLE IF NOT EXISTS `system_settings` ( + `setting_key` VARCHAR(50) PRIMARY KEY, + `setting_value` VARCHAR(255) NOT NULL, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 16. 班级设置表(每班级独立配置,班主任可修改) +-- setting_key 约定: +-- 角色加减分上下限: +-- point_limit_班主任_max / point_limit_班主任_min +-- point_limit_班长_max / point_limit_班长_min (默认 ±5) +-- point_limit_学习委员_max / point_limit_学习委员_min (默认 ±5) +-- point_limit_考勤委员_max / point_limit_考勤委员_min +-- point_limit_劳动委员_max / point_limit_劳动委员_min (默认 ±1) +-- point_limit_志愿委员_max / point_limit_志愿委员_min (默认 0~5) +-- point_limit_科任老师_max / point_limit_科任老师_min (默认 ±5) +-- 操行分初始值: +-- initial_points (默认 60) +-- 扣分规则: +-- deduction_homework_not_submit (默认 2) +-- deduction_homework_late (默认 1) +-- deduction_attendance_absent (默认 3) +-- deduction_attendance_late (默认 1) +-- deduction_attendance_leave (默认 0) +-- 家长相关: +-- parent_password_change_enabled (默认 true) +-- parent_account_enabled (默认 true) +-- 周期重置: +-- reset_frequency (默认 none: 仅学期结算; weekly: 每周; monthly: 每月) +-- reset_day_of_week (默认 1: 周重置日 1=周一 ~ 7=周日,仅 weekly 时生效) +-- reset_day_of_month (默认 1: 月重置日 1~28,仅 monthly 时生效) +-- =========================================== +CREATE TABLE IF NOT EXISTS `class_settings` ( + `setting_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `setting_key` VARCHAR(50) NOT NULL COMMENT '设置项键名', + `setting_value` VARCHAR(255) NOT NULL COMMENT '设置项值', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + UNIQUE KEY `uk_class_setting` (`class_id`, `setting_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 17. 周期归档快照表(周/月重置时创建) +-- =========================================== +CREATE TABLE IF NOT EXISTS `period_archives` ( + `archive_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `period_type` ENUM('weekly', 'monthly') NOT NULL COMMENT '周期类型:weekly=周,monthly=月', + `period_label` VARCHAR(50) NOT NULL COMMENT '周期标签,如 2025-W03、2025-01', + `student_id` INT NOT NULL, + `student_no` VARCHAR(20) NOT NULL, + `student_name` VARCHAR(50) NOT NULL, + `final_points` INT NOT NULL COMMENT '重置前操行分', + `rank_position` INT DEFAULT NULL COMMENT '班级内排名', + `total_students` INT DEFAULT NULL COMMENT '班级总人数', + `archived_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `reset_by` VARCHAR(20) DEFAULT 'auto' COMMENT '重置触发方式:auto=自动定时,manual=手动触发', + `operator_id` INT DEFAULT NULL COMMENT '手动触发的操作者ID', + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT, + FOREIGN KEY (`student_id`) REFERENCES `students`(`student_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 18. 班级功能开关表(控制班级内各功能模块的启用/禁用) +-- feature_key 约定: +-- role_班长_enabled / role_学习委员_enabled / role_考勤委员_enabled +-- role_劳动委员_enabled / role_志愿委员_enabled +-- role_科任老师_enabled / role_课代表_enabled +-- =========================================== +CREATE TABLE IF NOT EXISTS `class_features` ( + `feature_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `feature_key` VARCHAR(50) NOT NULL COMMENT '功能标识', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:1=启用,0=禁用', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + UNIQUE KEY `uk_class_feature` (`class_id`, `feature_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 性能索引(使用存储过程实现 MySQL 5.7 兼容的条件索引创建) +-- =========================================== +DROP PROCEDURE IF EXISTS `create_index_if_not_exists`; +DELIMITER // +CREATE PROCEDURE `create_index_if_not_exists`( + IN `p_table` VARCHAR(64), + IN `p_index` VARCHAR(64), + IN `p_columns` VARCHAR(255) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM `information_schema`.`statistics` + WHERE `table_schema` = DATABASE() + AND `table_name` = p_table + AND `index_name` = p_index + LIMIT 1 + ) THEN + SET @sql = CONCAT('CREATE INDEX `', p_index, '` ON `', p_table, '`(', p_columns, ')'); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END // +DELIMITER ; + +CALL `create_index_if_not_exists`('conduct_records', 'idx_conduct_semester', '`semester_id`'); +CALL `create_index_if_not_exists`('attendance_records', 'idx_attendance_semester', '`semester_id`'); +CALL `create_index_if_not_exists`('conduct_records', 'idx_conduct_student', '`student_id`'); +CALL `create_index_if_not_exists`('conduct_records', 'idx_student_created', '`student_id`, `created_at`'); +CALL `create_index_if_not_exists`('conduct_records', 'idx_recorder_id', '`recorder_id`'); +CALL `create_index_if_not_exists`('conduct_records', 'idx_conduct_type_semester', '`semester_id`, `related_type`, `student_id`'); +CALL `create_index_if_not_exists`('attendance_records', 'idx_attendance_date', '`date`'); +CALL `create_index_if_not_exists`('login_logs', 'idx_username_created', '`username`, `created_at`'); +CALL `create_index_if_not_exists`('operation_logs', 'idx_operator_created', '`operator_id`, `created_at`'); +CALL `create_index_if_not_exists`('semester_archives', 'idx_semester_id', '`semester_id`'); +CALL `create_index_if_not_exists`('students', 'idx_student_class', '`class_id`'); +CALL `create_index_if_not_exists`('admin_roles', 'idx_admin_role_class', '`class_id`'); +CALL `create_index_if_not_exists`('assignments', 'idx_assignment_class', '`class_id`'); +CALL `create_index_if_not_exists`('assignments', 'idx_assignment_subject', '`subject_id`'); +CALL `create_index_if_not_exists`('semester_archives', 'idx_archive_class', '`class_id`'); +CALL `create_index_if_not_exists`('operation_logs', 'idx_operation_class', '`class_id`'); +CALL `create_index_if_not_exists`('period_archives', 'idx_period_archive_type', '`class_id`, `period_type`, `period_label`'); + +DROP PROCEDURE IF EXISTS `create_index_if_not_exists`; + +SET FOREIGN_KEY_CHECKS = 1; + +-- =========================================== +-- 初始数据 +-- =========================================== + +-- 插入初始科目(仅语数英,如不存在) +INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VALUES +('语文', 'CHI', 1), +('数学', 'MATH', 2), +('英语', 'ENG', 3); + +-- 初始化系统版本号 +INSERT INTO `system_settings` (`setting_key`, `setting_value`) +VALUES ('db_version', '2.0') +ON DUPLICATE KEY UPDATE `setting_value` = '2.0'; + +-- 系统管理员独立登录路径 +INSERT INTO `system_settings` (`setting_key`, `setting_value`) +VALUES ('super_admin_login_path', '/super-admin/login') +ON DUPLICATE KEY UPDATE `setting_value` = '/super-admin/login'; + +-- 控制台输出初始化结果(含版本号) +SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message; diff --git a/sql/upgrades/v1.0.sql b/sql/upgrades/v1.0.sql new file mode 100644 index 0000000..34e1036 --- /dev/null +++ b/sql/upgrades/v1.0.sql @@ -0,0 +1,264 @@ +-- =========================================== +-- 多班级版班级管理系统 - v1.0 升级脚本 +-- 从旧版单班级系统升级到多班级版 +-- +-- 开发者: Canglan +-- 联系方式: admin@sea-studio.top +-- 版权归属: Sea Network Technology Studio +-- 许可证: Apache License 2.0 +-- +-- 版权所有 © Sea Network Technology Studio +-- +-- 升级说明: +-- 1. 创建班级表、班级设置表、班级功能开关表 +-- 2. 将现有学生迁移到默认班级 +-- 3. 扩展管理员角色枚举(科任老师、课代表) +-- 4. 修改学生表字段(parent_phone → parent_account) +-- 5. 为所有现有表添加 class_id 字段 +-- 6. 创建系统管理员表(super_admins) +-- 7. 添加班级设置预置项和功能开关预置项 +-- 8. 添加系统管理员登录路径配置 +-- =========================================== + +USE `classmanagerdb`; + +SET FOREIGN_KEY_CHECKS = 0; + +-- =========================================== +-- 1. 创建班级表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `classes` ( + `class_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '班级ID', + `class_name` VARCHAR(100) NOT NULL COMMENT '班级名称,如 高一(1)班', + `grade` VARCHAR(50) DEFAULT NULL COMMENT '年级', + `description` VARCHAR(255) DEFAULT NULL COMMENT '班级描述', + `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_class_name` (`class_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 2. 创建班级设置表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `class_settings` ( + `setting_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `setting_key` VARCHAR(50) NOT NULL COMMENT '设置项键名', + `setting_value` VARCHAR(255) NOT NULL COMMENT '设置项值', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + UNIQUE KEY `uk_class_setting` (`class_id`, `setting_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 3. 创建班级功能开关表 +-- =========================================== +CREATE TABLE IF NOT EXISTS `class_features` ( + `feature_id` INT PRIMARY KEY AUTO_INCREMENT, + `class_id` INT NOT NULL COMMENT '所属班级ID', + `feature_key` VARCHAR(50) NOT NULL COMMENT '功能标识', + `enabled` TINYINT DEFAULT 1 COMMENT '是否启用:1=启用,0=禁用', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE CASCADE, + UNIQUE KEY `uk_class_feature` (`class_id`, `feature_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 4. 创建系统管理员表(super_admins) +-- =========================================== +CREATE TABLE IF NOT EXISTS `super_admins` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', + `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(MD5(SHA1(password)+salt))', + `salt` VARCHAR(64) NOT NULL COMMENT '密码盐值', + `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', + `status` TINYINT DEFAULT 1 COMMENT '状态:1=启用,0=禁用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- =========================================== +-- 5. 插入默认班级(现有数据迁移) +-- =========================================== +INSERT INTO `classes` (`class_name`, `grade`, `description`) +VALUES ('默认班级', NULL, '系统升级时自动创建的默认班级') +ON DUPLICATE KEY UPDATE `class_id` = `class_id`; + +-- 获取默认班级ID +SET @default_class_id = (SELECT `class_id` FROM `classes` WHERE `class_name` = '默认班级' LIMIT 1); + +-- =========================================== +-- 6. 学生表:添加 class_id 字段 +-- =========================================== +ALTER TABLE `students` + ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `student_no`, + ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; + +-- 将现有学生关联到默认班级 +UPDATE `students` SET `class_id` = @default_class_id WHERE `class_id` = 1; + +-- 修改唯一约束(学号+班级联合唯一) +ALTER TABLE `students` DROP INDEX `student_no`; +ALTER TABLE `students` ADD UNIQUE KEY `uk_student_no_class` (`student_no`, `class_id`); + +-- =========================================== +-- 7. 学生表:parent_phone → parent_account +-- =========================================== +ALTER TABLE `students` CHANGE COLUMN `parent_phone` `parent_account` VARCHAR(50) DEFAULT NULL COMMENT '家长登录账号(推荐手机号)'; + +-- 更新宿舍号字段注释(格式:南0-000) +ALTER TABLE `students` MODIFY COLUMN `dormitory_number` VARCHAR(20) DEFAULT NULL COMMENT '宿舍号,格式:南0-000'; + +-- =========================================== +-- 8. 用户表:扩展 user_type 枚举 +-- =========================================== +ALTER TABLE `users` MODIFY COLUMN `user_type` ENUM('student', 'parent', 'admin', 'super_admin') NOT NULL; + +-- =========================================== +-- 9. 管理员角色表:添加 class_id 和扩展枚举 +-- =========================================== +ALTER TABLE `admin_roles` + ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `user_id`, + ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; + +-- 将现有管理员关联到默认班级 +UPDATE `admin_roles` SET `class_id` = @default_class_id WHERE `class_id` = 1; + +-- 扩展角色枚举 +ALTER TABLE `admin_roles` MODIFY COLUMN `role_type` ENUM('班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员', '科任老师', '课代表') NOT NULL; + +-- 添加班级级唯一约束 +ALTER TABLE `admin_roles` ADD UNIQUE KEY `uk_user_class` (`user_id`, `class_id`); + +-- =========================================== +-- 10. 作业表:添加 class_id +-- =========================================== +ALTER TABLE `assignments` + ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `assignment_id`, + ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; + +UPDATE `assignments` SET `class_id` = @default_class_id WHERE `class_id` = 1; + +-- =========================================== +-- 11. 学期归档表:添加 class_id +-- =========================================== +ALTER TABLE `semester_archives` + ADD COLUMN `class_id` INT NOT NULL DEFAULT 1 COMMENT '所属班级ID' AFTER `semester_id`, + ADD FOREIGN KEY (`class_id`) REFERENCES `classes`(`class_id`) ON DELETE RESTRICT; + +UPDATE `semester_archives` SET `class_id` = @default_class_id WHERE `class_id` = 1; + +-- =========================================== +-- 12. 操作日志表:添加 class_id +-- =========================================== +ALTER TABLE `operation_logs` + ADD COLUMN `class_id` INT DEFAULT NULL COMMENT '操作所属班级ID' AFTER `operator_role`; + +-- =========================================== +-- 13. 添加新索引(MySQL 5.7 兼容:使用存储过程检查索引是否存在) +-- =========================================== +DROP PROCEDURE IF EXISTS `add_index_if_not_exists`; +DELIMITER // +CREATE PROCEDURE `add_index_if_not_exists`( + IN p_table VARCHAR(64), + IN p_index VARCHAR(64), + IN p_columns VARCHAR(512) +) +BEGIN + DECLARE index_count INT DEFAULT 0; + SELECT COUNT(*) INTO index_count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = p_table + AND index_name = p_index; + IF index_count = 0 THEN + SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_columns, ')'); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END // +DELIMITER ; + +CALL `add_index_if_not_exists`('students', 'idx_student_class', '`class_id`'); +CALL `add_index_if_not_exists`('admin_roles', 'idx_admin_role_class', '`class_id`'); +CALL `add_index_if_not_exists`('assignments', 'idx_assignment_class', '`class_id`'); +CALL `add_index_if_not_exists`('assignments', 'idx_assignment_subject', '`subject_id`'); +CALL `add_index_if_not_exists`('semester_archives', 'idx_archive_class', '`class_id`'); +CALL `add_index_if_not_exists`('operation_logs', 'idx_operation_class', '`class_id`'); +CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_type_semester', '`semester_id`, `related_type`, `student_id`'); +CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_semester', '`semester_id`'); +CALL `add_index_if_not_exists`('conduct_records', 'idx_conduct_student', '`student_id`'); +CALL `add_index_if_not_exists`('conduct_records', 'idx_student_created', '`student_id`, `created_at`'); +CALL `add_index_if_not_exists`('conduct_records', 'idx_recorder_id', '`recorder_id`'); +CALL `add_index_if_not_exists`('attendance_records', 'idx_attendance_semester', '`semester_id`'); +CALL `add_index_if_not_exists`('attendance_records', 'idx_date', '`date`'); +CALL `add_index_if_not_exists`('login_logs', 'idx_username_created', '`username`, `created_at`'); +CALL `add_index_if_not_exists`('operation_logs', 'idx_operator_created', '`operator_id`, `created_at`'); +CALL `add_index_if_not_exists`('semester_archives', 'idx_semester_id', '`semester_id`'); + +DROP PROCEDURE IF EXISTS `add_index_if_not_exists`; + +SET FOREIGN_KEY_CHECKS = 1; + +-- =========================================== +-- 14. 为默认班级插入预置班级设置 +-- =========================================== +INSERT INTO `class_settings` (`class_id`, `setting_key`, `setting_value`) VALUES +-- 角色加减分上下限 +(@default_class_id, 'point_limit_班主任_max', '10'), +(@default_class_id, 'point_limit_班主任_min', '-10'), +(@default_class_id, 'point_limit_班长_max', '5'), +(@default_class_id, 'point_limit_班长_min', '-5'), +(@default_class_id, 'point_limit_学习委员_max', '5'), +(@default_class_id, 'point_limit_学习委员_min', '-5'), +(@default_class_id, 'point_limit_考勤委员_max', '8'), +(@default_class_id, 'point_limit_考勤委员_min', '-8'), +(@default_class_id, 'point_limit_劳动委员_max', '1'), +(@default_class_id, 'point_limit_劳动委员_min', '-1'), +(@default_class_id, 'point_limit_志愿委员_max', '5'), +(@default_class_id, 'point_limit_志愿委员_min', '0'), +(@default_class_id, 'point_limit_科任老师_max', '5'), +(@default_class_id, 'point_limit_科任老师_min', '-5'), +-- 操行分初始值 +(@default_class_id, 'initial_points', '60'), +-- 扣分规则 +(@default_class_id, 'deduction_homework_not_submit', '2'), +(@default_class_id, 'deduction_homework_late', '1'), +(@default_class_id, 'deduction_attendance_absent', '3'), +(@default_class_id, 'deduction_attendance_late', '1'), +(@default_class_id, 'deduction_attendance_leave', '0'), +-- 家长相关 +(@default_class_id, 'parent_password_change_enabled', 'true'), +(@default_class_id, 'parent_account_enabled', 'true') +ON DUPLICATE KEY UPDATE `setting_value` = VALUES(`setting_value`); + +-- =========================================== +-- 15. 为默认班级插入预置功能开关 +-- =========================================== +INSERT INTO `class_features` (`class_id`, `feature_key`, `enabled`) VALUES +(@default_class_id, 'role_班长_enabled', 1), +(@default_class_id, 'role_学习委员_enabled', 1), +(@default_class_id, 'role_考勤委员_enabled', 1), +(@default_class_id, 'role_劳动委员_enabled', 1), +(@default_class_id, 'role_志愿委员_enabled', 1), +(@default_class_id, 'role_科任老师_enabled', 1), +(@default_class_id, 'role_课代表_enabled', 1) +ON DUPLICATE KEY UPDATE `enabled` = VALUES(`enabled`); + +-- =========================================== +-- 16. 添加系统管理员登录路径配置 +-- =========================================== +INSERT INTO `system_settings` (`setting_key`, `setting_value`) +VALUES ('super_admin_login_path', '/super-admin/login') +ON DUPLICATE KEY UPDATE `setting_value` = '/super-admin/login'; + +-- =========================================== +-- 17. 更新系统版本号 +-- =========================================== +INSERT INTO `system_settings` (`setting_key`, `setting_value`) +VALUES ('db_version', '1.0') +ON DUPLICATE KEY UPDATE `setting_value` = '1.0'; + +-- 升级完成 +SELECT CONCAT('升级完成!版本: v1.0,默认班级ID: ', @default_class_id) AS message; diff --git a/sql/upgrades/v2.1.sql b/sql/upgrades/v2.1.sql new file mode 100644 index 0000000..5891ce8 --- /dev/null +++ b/sql/upgrades/v2.1.sql @@ -0,0 +1,53 @@ +-- =========================================== +-- 多班级版班级管理系统 - v2.1 升级脚本 +-- 修复 review-report-v14 中发现的安全和逻辑问题 +-- +-- 开发者: Canglan +-- 联系方式: admin@sea-studio.top +-- 版权归属: Sea Network Technology Studio +-- 许可证: Apache License 2.0 +-- +-- 版权所有 © Sea Network Technology Studio +-- +-- 升级说明: +-- 1. 为 super_admins 表添加 need_change_password 字段 +-- 2. 为 existing 超级管理员设置默认值(首次登录强制改密) +-- =========================================== + +USE `classmanagerdb`; + +-- 为 super_admins 表添加 need_change_password 字段 +-- 已存在则跳过(使用存储过程检测) +DROP PROCEDURE IF EXISTS `add_column_if_not_exists`; +DELIMITER // +CREATE PROCEDURE `add_column_if_not_exists`( + IN p_table VARCHAR(64), + IN p_column VARCHAR(64), + IN p_definition VARCHAR(512) +) +BEGIN + DECLARE col_count INT DEFAULT 0; + SELECT COUNT(*) INTO col_count + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = p_table + AND column_name = p_column; + IF col_count = 0 THEN + SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE stmt FROM @sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END // +DELIMITER ; + +CALL `add_column_if_not_exists`('super_admins', 'need_change_password', 'TINYINT DEFAULT 1 COMMENT \'是否需要修改密码:1=需要,0=不需要\' AFTER `status`'); + +DROP PROCEDURE IF EXISTS `add_column_if_not_exists`; + +-- 更新系统版本号 +INSERT INTO `system_settings` (`setting_key`, `setting_value`) +VALUES ('db_version', '2.1') +ON DUPLICATE KEY UPDATE `setting_value` = '2.1'; + +SELECT '升级完成!版本: v2.1' AS message; diff --git a/upgrade.php b/upgrade.php new file mode 100644 index 0000000..79fc5c8 --- /dev/null +++ b/upgrade.php @@ -0,0 +1,719 @@ + __DIR__ . '/sql/upgrades/v1.0.sql', + '1.1' => __DIR__ . '/sql/upgrades/v1.1.sql', + '1.2' => __DIR__ . '/sql/upgrades/v1.2.sql', + '1.3' => __DIR__ . '/sql/upgrades/v1.3.sql', + '1.4' => __DIR__ . '/sql/upgrades/v1.4.sql', + '1.5' => __DIR__ . '/sql/upgrades/v1.5.sql', + '1.6' => __DIR__ . '/sql/upgrades/v1.6.sql', + '1.7' => __DIR__ . '/sql/upgrades/v1.7.sql', + '1.8' => __DIR__ . '/sql/upgrades/v1.8.sql', + '2.0' => __DIR__ . '/sql/upgrades/v2.0.sql', + '2.0.1' => __DIR__ . '/sql/upgrades/v2.0.1.sql', + '2.1' => __DIR__ . '/sql/upgrades/v2.1.sql', + '2.2' => __DIR__ . '/sql/upgrades/v2.2.sql', + '2.3' => __DIR__ . '/sql/upgrades/v2.3.sql', + '2.4' => __DIR__ . '/sql/upgrades/v2.4.sql', + '2.5' => __DIR__ . '/sql/upgrades/v2.5.sql', + '2.5.1' => __DIR__ . '/sql/upgrades/v2.5.1.sql', + '2.6' => __DIR__ . '/sql/upgrades/v2.6.sql', + '2.7' => __DIR__ . '/sql/upgrades/v2.7.sql', +]; + +/** + * 读取 backend/.env 文件并解析数据库配置 + */ +function readEnvConfig($envPath) { + if (!file_exists($envPath)) { + throw new RuntimeException('配置文件不存在: ' . $envPath); + } + + $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $config = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || strpos($line, '#') === 0) { + continue; + } + if (strpos($line, '=') !== false) { + list($key, $value) = explode('=', $line, 2); + $config[trim($key)] = trim($value); + } + } + + $required = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']; + foreach ($required as $key) { + if (!isset($config[$key]) || $config[$key] === '') { + throw new RuntimeException("缺少必要的数据库配置: {$key}"); + } + } + + return $config; +} + +/** + * 检测数据库当前版本 + */ +function detectCurrentVersion($pdo) { + try { + $stmt = $pdo->query("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $row['setting_value'] : '0.0.0'; + } catch (PDOException $e) { + return '0.0.0'; + } +} + +/** + * 获取需要执行的升级步骤 + */ +function getUpgradeSteps($currentVersion, $targetVersion) { + global $UPGRADE_VERSIONS; + + $steps = []; + foreach ($UPGRADE_VERSIONS as $version => $sqlFile) { + if (version_compare($version, $currentVersion, '>') && + version_compare($version, $targetVersion, '<=')) { + $steps[$version] = $sqlFile; + } + } + + uksort($steps, 'version_compare'); + return $steps; +} + +/** + * 执行 SQL 内容,处理包含 DELIMITER 的存储过程脚本 + * + * DELIMITER 是 MySQL 客户端指令,MySQL 服务器不认识它, + * 必须在客户端侧解析并拆分为独立的语句后逐条执行。 + */ +function executeSqlContent($pdo, $sqlContent) { + $sqlContent = trim($sqlContent); + if ($sqlContent === '' || $sqlContent === '--') { + return; + } + + // 检查是否包含 DELIMITER 指令 + if (stripos($sqlContent, 'DELIMITER') !== false) { + $lines = explode("\n", $sqlContent); + $currentBlock = []; + $inProcedure = false; + $buffer = ''; + + foreach ($lines as $line) { + $trimmed = trim($line); + + // 跳过纯注释行(存储过程内部注释保留) + if (!$inProcedure && (strpos($trimmed, '--') === 0 || strpos($trimmed, '#') === 0)) { + continue; + } + + if (strtoupper(substr($trimmed, 0, 12)) === 'DELIMITER $$') { + // 开始存储过程定义 + $inProcedure = true; + $currentBlock = []; + continue; + } elseif (strtoupper($trimmed) === 'DELIMITER ;') { + // 执行累积的存储过程块 + if (!empty($currentBlock)) { + $procSql = trim(implode("\n", $currentBlock)); + if ($procSql !== '') { + // 移除存储过程结尾的 $$ 定界符(发送给 MySQL 服务器时不需要) + $procSql = preg_replace('/\$\$\s*$/', '', $procSql); + $pdo->exec($procSql); + } + } + $inProcedure = false; + $currentBlock = []; + continue; + } elseif (strtoupper(substr($trimmed, 0, 9)) === 'DELIMITER') { + // 其他 DELIMITER 指令,跳过 + continue; + } + + if ($inProcedure) { + $currentBlock[] = $line; + } else { + // 普通 SQL,累积直到遇到分号 + if ($trimmed !== '') { + $buffer .= ($buffer !== '' ? ' ' : '') . $trimmed; + + if (rtrim($buffer) !== '' && substr(rtrim($buffer), -1) === ';') { + $stmt = rtrim(rtrim($buffer), ';'); + $stmt = trim($stmt); + if ($stmt !== '' && $stmt !== '--') { + $pdo->exec($stmt); + } + $buffer = ''; + } + } + } + } + + // 处理缓冲区中剩余的语句 + if ($buffer !== '') { + $stmt = rtrim(rtrim($buffer), ';'); + $stmt = trim($stmt); + if ($stmt !== '' && $stmt !== '--') { + $pdo->exec($stmt); + } + } + } else { + // 无 DELIMITER,按分号+换行分割语句 + $statements = preg_split('/;\s*\n/', $sqlContent); + foreach ($statements as $stmt) { + $stmt = trim($stmt); + if ($stmt !== '' && $stmt !== '--') { + $pdo->exec($stmt); + } + } + } +} + +/** + * 验证升级结果:检查版本号是否已正确更新 + * + * @return array ['ok' => bool, 'message' => string] + */ +function verifyUpgrade($pdo, $expectedVersion) { + // 检查 system_settings 表是否存在 + try { + $check = $pdo->query("SELECT 1 FROM system_settings LIMIT 1"); + } catch (PDOException $e) { + return ['ok' => false, 'message' => 'system_settings 表不存在,升级脚本可能未正确执行']; + } + + // 检查版本号是否匹配 + $stmt = $pdo->prepare("SELECT setting_value FROM system_settings WHERE setting_key = 'db_version'"); + $stmt->execute(); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return ['ok' => false, 'message' => 'db_version 记录不存在']; + } + + if ($row['setting_value'] !== $expectedVersion) { + return ['ok' => false, 'message' => "版本号不匹配:期望 {$expectedVersion},实际 {$row['setting_value']}"]; + } + + return ['ok' => true, 'message' => '验证通过']; +} + +/** + * 执行单个版本的升级 SQL(含验证与重试) + * + * @param PDO $pdo 数据库连接 + * @param string $version 目标版本号 + * @param string $sqlFile SQL 文件路径 + * @param int $maxRetries 最大重试次数 + * @throws RuntimeException 升级失败时抛出 + */ +function executeUpgrade($pdo, $version, $sqlFile, $maxRetries = 2) { + if (!file_exists($sqlFile)) { + throw new RuntimeException("SQL 文件不存在: {$sqlFile}"); + } + + $sql = file_get_contents($sqlFile); + $isEmpty = (trim($sql) === '' || trim($sql) === '--'); + + $lastError = null; + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + if (!$isEmpty) { + executeSqlContent($pdo, $sql); + } + + // 更新版本号(使用预处理语句防止 SQL 注入) + $stmt = $pdo->prepare( + "INSERT INTO system_settings (setting_key, setting_value) VALUES ('db_version', :version) + ON DUPLICATE KEY UPDATE setting_value = :version" + ); + $stmt->execute([':version' => $version]); + + // 验证版本号是否正确写入 + $verify = verifyUpgrade($pdo, $version); + if ($verify['ok']) { + return; // 成功 + } + + // 验证失败,准备重试 + $lastError = "升级验证失败: {$verify['message']}"; + if ($attempt < $maxRetries) { + // 回滚版本号到升级前状态,以便重试 + $prevStmt = $pdo->prepare( + "UPDATE system_settings SET setting_value = :ver WHERE setting_key = 'db_version'" + ); + // 获取升级前版本(从 getUpgradeSteps 推断,这里用 0.0.0 作为安全回退) + $prevStmt->execute([':ver' => '0.0.0']); + continue; + } + } catch (PDOException $e) { + $lastError = "SQL 执行失败: " . $e->getMessage(); + if ($attempt < $maxRetries) { + continue; + } + } catch (Exception $e) { + $lastError = $e->getMessage(); + if ($attempt < $maxRetries) { + continue; + } + } + + // 所有重试均失败 + break; + } + + throw new RuntimeException("升级至 v{$version} 失败 (尝试 {$maxRetries} 次): {$lastError}"); +} + +// =========================================== +// 主逻辑 +// =========================================== + +// POST 模式:执行单个升级步骤(依次执行) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'step') { + header('Content-Type: application/json; charset=utf-8'); + + $stepVersion = $_GET['version'] ?? ''; + if (empty($stepVersion)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => '缺少版本号参数']); + exit(); + } + + try { + $envPath = __DIR__ . '/backend/.env'; + $config = readEnvConfig($envPath); + + $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; + $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ]); + + // 获取该版本对应的 SQL 文件 + if (!isset($UPGRADE_VERSIONS[$stepVersion])) { + throw new RuntimeException("未知版本: {$stepVersion}"); + } + + $sqlFile = $UPGRADE_VERSIONS[$stepVersion]; + $shortFile = basename($sqlFile); + + executeUpgrade($pdo, $stepVersion, $sqlFile); + + // 重新检测当前版本 + $newVersion = detectCurrentVersion($pdo); + + echo json_encode([ + 'success' => true, + 'version' => $stepVersion, + 'message' => "升级至 v{$stepVersion} 成功 ({$shortFile})", + 'current' => $newVersion + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'version' => $stepVersion, + 'error' => "升级至 v{$stepVersion} 失败: " . $e->getMessage() + ]); + } + exit(); +} + +// POST 模式:执行升级(AJAX 请求) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_GET['action'] ?? '') === 'execute') { + header('Content-Type: application/json; charset=utf-8'); + + $upgradeLog = []; + $currentVersion = '未知'; + $targetVersion = '未知'; + + try { + $envPath = __DIR__ . '/backend/.env'; + $config = readEnvConfig($envPath); + + $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; + $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ]); + + $currentVersion = detectCurrentVersion($pdo); + + $versionFile = __DIR__ . '/VERSION'; + if (!file_exists($versionFile)) { + throw new RuntimeException('VERSION 文件不存在'); + } + $targetVersion = trim(file_get_contents($versionFile)); + + $upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion); + + if (empty($upgradeSteps)) { + echo json_encode([ + 'success' => true, + 'current' => $currentVersion, + 'target' => $targetVersion, + 'steps' => [['version' => '', 'status' => 'uptodate', 'message' => '数据库已是最新版本,无需升级。']] + ]); + exit(); + } + + $pdo->beginTransaction(); + try { + foreach ($upgradeSteps as $version => $sqlFile) { + $shortFile = basename($sqlFile); + try { + executeUpgrade($pdo, $version, $sqlFile); + $upgradeLog[] = [ + 'version' => $version, + 'status' => 'success', + 'message' => "升级至 v{$version} 成功 ({$shortFile})" + ]; + } catch (Exception $e) { + $upgradeLog[] = [ + 'version' => $version, + 'status' => 'error', + 'message' => "升级至 v{$version} 失败 ({$shortFile}): " . $e->getMessage() + ]; + throw $e; + } + } + $pdo->commit(); + } catch (Exception $e) { + $pdo->rollBack(); + throw $e; + } + + echo json_encode([ + 'success' => true, + 'current' => $currentVersion, + 'target' => $targetVersion, + 'steps' => $upgradeLog + ]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'current' => $currentVersion, + 'target' => $targetVersion, + 'steps' => $upgradeLog, + 'error' => $e->getMessage() + ]); + } + exit(); +} + +// GET 模式:显示升级信息页面 +$currentVersion = '未知'; +$targetVersion = '未知'; +$upgradeSteps = []; +$hasError = false; +$errorMessage = ''; +$isUpToDate = false; + +try { + $envPath = __DIR__ . '/backend/.env'; + $config = readEnvConfig($envPath); + + $dsn = "mysql:host={$config['DB_HOST']};port={$config['DB_PORT']};dbname={$config['DB_NAME']};charset=utf8mb4"; + $pdo = new PDO($dsn, $config['DB_USER'], $config['DB_PASSWORD'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ]); + + $currentVersion = detectCurrentVersion($pdo); + + $versionFile = __DIR__ . '/VERSION'; + if (!file_exists($versionFile)) { + throw new RuntimeException('VERSION 文件不存在: ' . $versionFile); + } + $targetVersion = trim(file_get_contents($versionFile)); + + $upgradeSteps = getUpgradeSteps($currentVersion, $targetVersion); + $isUpToDate = empty($upgradeSteps); +} catch (Exception $e) { + $hasError = true; + $errorMessage = $e->getMessage(); +} + +?> + + + + + + 系统升级 - 多班级版班级管理系统 + + + +
+
+

多班级版班级管理系统 - 数据库升级

+

自动检测版本并执行增量升级

+
+
+
+ 当前数据库版本 + +
+
+ 目标版本 + +
+ + +
+ 错误: +
+ +
+ 💡 解决方法:
+ 1. 进入 backend/ 目录
+ 2. 复制配置模板:cp .env.example .env
+ 3. 编辑 .env 文件,填入实际的数据库连接信息
+ 4. 刷新此页面 +
+ + +
+ ✓ 数据库已是最新版本,无需升级。 +
+ +
待执行升级步骤
+ $sqlFile): ?> +
+ + 升级至 v () +
+ + +
+ ⚠️ 升级前请确保已备份数据库,升级过程中请勿关闭页面。 +
+ +
+ +
+ + + +
+ +
+ + + + + +