feat: 多班级版班级管理系统 v2.0
技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 主要功能: - 多班级完全隔离(class_id 贯穿全系统) - 后端 Go Gin(端口 56789),Nginx 反代 - 超级管理员独立登录(env 配置,默认账密 admin/Admin123) - bcrypt 密码加密(无 PASSWORD_SALT) - 科任老师/课代表新角色 - 课代表作业管理页面 - 排行榜分项排行(操行分/考勤/作业) - 角色加减分上下限由班主任配置 - 家长改密功能(可开关) - 班级角色按需开关 - 宿舍号格式:南0-000 - 周度/月度重置功能 - MySQL 5.7 兼容 - 43 轮代码审查 + 全部修复 开发者: Canglan 版权归属: Sea Network Technology Studio 许可证: Apache License 2.0
This commit is contained in:
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
@@ -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/
|
||||
558
INSTALL.md
Normal file
558
INSTALL.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# 多班级版班级管理系统 - 安装部署指南
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 服务器配置
|
||||
- **操作系统**: 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 密钥(使用下方命令生成)
|
||||
|
||||
**生成 JWT 密钥**:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
将输出的随机字符串填入 `.env` 的 `JWT_SECRET_KEY` 配置项。
|
||||
|
||||
#### 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"
|
||||
|
||||
---
|
||||
|
||||
## 手动部署(无宝塔面板)
|
||||
|
||||
### 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 # 根据实际情况修改配置
|
||||
|
||||
# 生成 JWT 密钥
|
||||
openssl rand -base64 32
|
||||
# 将输出填入 .env 的 JWT_SECRET_KEY
|
||||
|
||||
# 编译
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 密码加密说明
|
||||
|
||||
系统使用 **bcrypt** 算法进行密码哈希,bcrypt 内置随机盐值管理机制。
|
||||
|
||||
- **无需配置 `PASSWORD_SALT`**:已移除该环境变量,bcrypt 自动生成盐值并嵌入哈希结果中,无需外部管理。
|
||||
- **密码强度要求**:密码长度 6-20 位,必须包含大写字母、小写字母、数字、特殊符号中的至少 3 种。
|
||||
- **兼容性**:所有用户(超级管理员、普通管理员、学生、家长)均使用 bcrypt 统一加密。
|
||||
|
||||
---
|
||||
|
||||
## MySQL 5.7 兼容说明
|
||||
|
||||
系统已针对 MySQL 5.7 进行兼容适配:
|
||||
|
||||
- **已移除 CHECK 约束**:初始化 SQL 不包含 MySQL 8.0.16+ 才支持的 CHECK 约束语法。
|
||||
- **已移除窗口函数**:不使用 ROW_NUMBER()、RANK() 等 8.0+ 窗口函数。
|
||||
- **索引创建兼容**:通过存储过程安全创建索引,避免在 5.7 中直接使用 `IF NOT EXISTS` 等不兼容语法。
|
||||
- **字符集**:统一使用 `utf8mb4` + `utf8mb4_unicode_ci`,兼容 5.7 和 8.0。
|
||||
|
||||
> **建议**:推荐使用 MySQL 5.7.8+ 版本(支持 JSON 类型)。如使用 MySQL 8.0+,所有功能同样兼容。
|
||||
|
||||
---
|
||||
|
||||
## 超级管理员首次登录
|
||||
|
||||
Go 后端首次启动时会**自动创建**超级管理员账号,无需手动操作。
|
||||
|
||||
### 默认账号信息
|
||||
|
||||
| 配置项 | 环境变量 | 默认值 |
|
||||
|-------|---------|-------|
|
||||
| 登录路径 | `SUPER_ADMIN_LOGIN_PATH` | `/super-admin` |
|
||||
| 用户名 | `SUPER_ADMIN_DEFAULT_USERNAME` | `admin` |
|
||||
| 密码 | `SUPER_ADMIN_DEFAULT_PASSWORD` | `Admin123` |
|
||||
|
||||
### 首次登录流程
|
||||
|
||||
1. 启动 Go 后端服务
|
||||
2. 访问 `https://your-domain.com/SUPER_ADMIN_LOGIN_PATH`(路径由 `.env` 中 `SUPER_ADMIN_LOGIN_PATH` 配置)
|
||||
3. 使用默认用户名和密码登录
|
||||
4. **系统强制要求修改密码**:首次登录后将自动跳转到改密页面,修改密码后方可进入管理后台
|
||||
|
||||
> **安全提示**:强烈建议在 `.env` 中将 `SUPER_ADMIN_DEFAULT_PASSWORD` 修改为强密码后再启动服务,避免使用默认密码暴露在生产环境中。
|
||||
|
||||
---
|
||||
|
||||
## 多班级使用流程
|
||||
|
||||
### 完整操作步骤
|
||||
|
||||
1. **系统管理员登录** — 使用超级管理员账号登录管理后台
|
||||
2. **创建班级** — 在"班级管理"中创建班级(可设置年级、描述等信息)
|
||||
3. **为班级添加班主任** — 在"管理员管理"中创建普通管理员账号,并关联到对应班级
|
||||
4. **班主任登录并配置班级** — 班主任首次登录后:
|
||||
- 导入学生名单(支持 JSON 批量导入)
|
||||
- 配置班级设置(扣分规则、初始积分等)
|
||||
- 启用/禁用班级功能开关
|
||||
5. **各角色开始使用**
|
||||
- **班主任/管理员**:考勤管理、操行分管理、作业管理、排行榜查看
|
||||
- **课代表**:发布和管理作业
|
||||
- **学生**:查看个人信息、考勤记录、作业、排行榜
|
||||
- **家长**:查看学生考勤和历史记录、修改密码
|
||||
|
||||
### 班级设置说明
|
||||
|
||||
班主任可在管理端"班级设置"页面自定义本班配置,包括:
|
||||
|
||||
- **扣分规则**:缺勤扣分、迟到扣分、未交作业扣分、迟交作业扣分等
|
||||
- **初始积分**:新学生入班时的默认积分
|
||||
- **功能开关**:按需启用或禁用各项班级功能
|
||||
|
||||
> **注意**:扣分规则等班级级配置已迁移到数据库 `class_settings` 表中,班主任可在管理端自行修改,无需修改环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 新增功能说明
|
||||
|
||||
### 周期重置(周/月)
|
||||
|
||||
系统支持按周期重置学生积分:
|
||||
- **周重置**:每周一自动将学生积分重置为初始值
|
||||
- **月重置**:每月 1 日自动将学生积分重置为初始值
|
||||
- 周期类型在班级设置中配置,历史数据保留在学期记录中
|
||||
|
||||
### 课代表作业管理
|
||||
|
||||
班主任可为管理员分配"课代表"角色,使其拥有作业管理权限:
|
||||
- 课代表可发布作业(标题、描述、截止时间)
|
||||
- 课代表可查看作业提交情况
|
||||
- 学生端可查看作业列表及截止时间
|
||||
|
||||
### 排行榜分项排行
|
||||
|
||||
排行榜支持按类别分项查看:
|
||||
- **综合排行**:所有积分汇总排名
|
||||
- **考勤排行**:仅考勤相关积分排名
|
||||
- **作业排行**:仅作业相关积分排名
|
||||
- **操行排行**:仅操行分相关积分排名
|
||||
|
||||
### 家长改密(可开关)
|
||||
|
||||
家长账号支持修改学生密码,该功能可通过班级功能开关控制:
|
||||
- **开启**:家长登录后可在"修改密码"页面为学生修改密码
|
||||
- **关闭**:家长端不显示改密入口,密码仅由班主任管理
|
||||
|
||||
### 班级角色开关
|
||||
|
||||
系统支持为每个班级独立启用/禁用功能模块:
|
||||
- 考勤管理
|
||||
- 操行分管理
|
||||
- 作业管理
|
||||
- 排行榜
|
||||
- 家长改密
|
||||
- 等更多可配置项
|
||||
|
||||
> 功能开关存储在数据库 `class_features` 表中,班主任可在"班级设置"页面管理。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量说明
|
||||
|
||||
Go 后端 `.env` 文件全部配置项(参考 `backend-go/.env.example`):
|
||||
|
||||
### 应用配置
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|-------|------|-------|
|
||||
| `APP_NAME` | 应用名称 | 多班级版班级管理系统 |
|
||||
| `APP_ENV` | 运行环境 | production |
|
||||
| `DEBUG` | 调试模式(生产环境设为 false) | false |
|
||||
| `APP_PORT` | 服务端口 | 56789 |
|
||||
|
||||
### MySQL 数据库
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|-------|------|-------|
|
||||
| `DB_HOST` | 数据库地址 | localhost |
|
||||
| `DB_PORT` | 数据库端口 | 3306 |
|
||||
| `DB_USER` | 数据库用户名 | class_admin |
|
||||
| `DB_PASSWORD` | 数据库密码 | *(无默认值,必须配置)* |
|
||||
| `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 |
|
||||
|
||||
> **生成方法**:`openssl rand -base64 32`
|
||||
|
||||
### 超级管理员
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|-------|------|-------|
|
||||
| `SUPER_ADMIN_LOGIN_PATH` | 超级管理员登录页面路径 | /super-admin |
|
||||
| `SUPER_ADMIN_DEFAULT_USERNAME` | 默认超级管理员用户名 | admin |
|
||||
| `SUPER_ADMIN_DEFAULT_PASSWORD` | 默认超级管理员密码(**部署时必须修改**) | Admin123 |
|
||||
|
||||
### 日志配置
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|-------|------|-------|
|
||||
| `LOG_LEVEL` | 日志级别(debug/info/warn/error) | info |
|
||||
| `LOG_FILE` | 日志文件路径 | logs/app.log |
|
||||
|
||||
> **注意**:密码加密使用 bcrypt 自动加盐,无需配置 `PASSWORD_SALT`。扣分规则等班级级配置已迁移到数据库 `class_settings` 表中,班主任可在管理端"班级设置"页面修改,无需修改环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 后端启动失败
|
||||
- 检查端口 56789 是否被占用:`sudo lsof -i :56789`
|
||||
- 检查数据库和 Redis 连接配置
|
||||
- 确认 `JWT_SECRET_KEY` 已配置(不能为空)
|
||||
- 查看日志:`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`)
|
||||
|
||||
### Q6: 首次登录后忘记修改默认密码
|
||||
- 默认超管密码通过 `.env` 中 `SUPER_ADMIN_DEFAULT_PASSWORD` 设置
|
||||
- 首次登录系统会强制跳转到改密页面
|
||||
- 如需重置密码,可修改 `.env` 中的密码配置后重启服务,系统将使用新密码重新初始化
|
||||
|
||||
### Q7: MySQL 5.7 导入 SQL 报错
|
||||
- 确认使用项目提供的 `sql/init.sql`,已针对 5.7 兼容
|
||||
- 如从旧版本升级,请先备份数据库再执行导入
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
- 开发者: Canglan
|
||||
- 联系方式: admin@sea-studio.top
|
||||
- 版权归属: Sea Network Technology Studio
|
||||
- 许可证: Apache License 2.0
|
||||
199
LICENSE
Normal file
199
LICENSE
Normal file
@@ -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.
|
||||
233
README.md
Normal file
233
README.md
Normal file
@@ -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
|
||||
60
backend-go/.env.example
Normal file
60
backend-go/.env.example
Normal file
@@ -0,0 +1,60 @@
|
||||
# ===========================================
|
||||
# 多班级版班级管理系统 - 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
|
||||
|
||||
# ===========================================
|
||||
# 系统管理员配置
|
||||
# ===========================================
|
||||
|
||||
SUPER_ADMIN_LOGIN_PATH=/super-admin
|
||||
SUPER_ADMIN_DEFAULT_USERNAME=admin
|
||||
# ⚠️ 部署时必须修改为强密码,否则存在安全风险
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=Admin123
|
||||
|
||||
# ===========================================
|
||||
# 日志配置
|
||||
# ===========================================
|
||||
|
||||
LOG_LEVEL=info
|
||||
LOG_FILE=logs/app.log
|
||||
63
backend-go/Makefile
Normal file
63
backend-go/Makefile
Normal file
@@ -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)"
|
||||
210
backend-go/cmd/server/main.go
Normal file
210
backend-go/cmd/server/main.go
Normal file
@@ -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("服务已安全停止")
|
||||
}
|
||||
14
backend-go/go.mod
Normal file
14
backend-go/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
golang.org/x/crypto v0.31.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
155
backend-go/internal/config/config.go
Normal file
155
backend-go/internal/config/config.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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
|
||||
|
||||
// 系统管理员配置
|
||||
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),
|
||||
|
||||
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 不能为空")
|
||||
}
|
||||
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
|
||||
}
|
||||
602
backend-go/internal/handler/admin_handler.go
Normal file
602
backend-go/internal/handler/admin_handler.go
Normal file
@@ -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, "操作成功")
|
||||
}
|
||||
131
backend-go/internal/handler/auth_handler.go
Normal file
131
backend-go/internal/handler/auth_handler.go
Normal file
@@ -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
|
||||
}
|
||||
143
backend-go/internal/handler/cadre_handler.go
Normal file
143
backend-go/internal/handler/cadre_handler.go
Normal file
@@ -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, "操作成功")
|
||||
}
|
||||
271
backend-go/internal/handler/class_handler.go
Normal file
271
backend-go/internal/handler/class_handler.go
Normal file
@@ -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, "保存成功")
|
||||
}
|
||||
44
backend-go/internal/handler/config_handler.go
Normal file
44
backend-go/internal/handler/config_handler.go
Normal file
@@ -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, "操作成功")
|
||||
}
|
||||
20
backend-go/internal/handler/handler_utils.go
Normal file
20
backend-go/internal/handler/handler_utils.go
Normal file
@@ -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
|
||||
}
|
||||
115
backend-go/internal/handler/parent_handler.go
Normal file
115
backend-go/internal/handler/parent_handler.go
Normal file
@@ -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, "密码修改成功")
|
||||
}
|
||||
230
backend-go/internal/handler/semester_handler.go
Normal file
230
backend-go/internal/handler/semester_handler.go
Normal file
@@ -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, "操作成功")
|
||||
}
|
||||
|
||||
192
backend-go/internal/handler/student_handler.go
Normal file
192
backend-go/internal/handler/student_handler.go
Normal file
@@ -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, "操作成功")
|
||||
}
|
||||
152
backend-go/internal/handler/subject_handler.go
Normal file
152
backend-go/internal/handler/subject_handler.go
Normal file
@@ -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, "科目已禁用")
|
||||
}
|
||||
}
|
||||
56
backend-go/internal/handler/super_admin_handler.go
Normal file
56
backend-go/internal/handler/super_admin_handler.go
Normal file
@@ -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, "登录成功")
|
||||
}
|
||||
57
backend-go/internal/middleware/access_log.go
Normal file
57
backend-go/internal/middleware/access_log.go
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
227
backend-go/internal/middleware/auth.go
Normal file
227
backend-go/internal/middleware/auth.go
Normal file
@@ -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 ""
|
||||
}
|
||||
131
backend-go/internal/middleware/sanitize.go
Normal file
131
backend-go/internal/middleware/sanitize.go
Normal file
@@ -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
|
||||
}
|
||||
36
backend-go/internal/model/admin_role.go
Normal file
36
backend-go/internal/model/admin_role.go
Normal file
@@ -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"
|
||||
}
|
||||
53
backend-go/internal/model/assignment.go
Normal file
53
backend-go/internal/model/assignment.go
Normal file
@@ -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"
|
||||
}
|
||||
38
backend-go/internal/model/attendance.go
Normal file
38
backend-go/internal/model/attendance.go
Normal file
@@ -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"
|
||||
}
|
||||
60
backend-go/internal/model/class_model.go
Normal file
60
backend-go/internal/model/class_model.go
Normal file
@@ -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"
|
||||
}
|
||||
44
backend-go/internal/model/conduct.go
Normal file
44
backend-go/internal/model/conduct.go
Normal file
@@ -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"
|
||||
}
|
||||
50
backend-go/internal/model/log.go
Normal file
50
backend-go/internal/model/log.go
Normal file
@@ -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"
|
||||
}
|
||||
88
backend-go/internal/model/semester.go
Normal file
88
backend-go/internal/model/semester.go
Normal file
@@ -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"
|
||||
}
|
||||
37
backend-go/internal/model/student.go
Normal file
37
backend-go/internal/model/student.go
Normal file
@@ -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"
|
||||
}
|
||||
29
backend-go/internal/model/subject.go
Normal file
29
backend-go/internal/model/subject.go
Normal file
@@ -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"
|
||||
}
|
||||
31
backend-go/internal/model/super_admin.go
Normal file
31
backend-go/internal/model/super_admin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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(60);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"
|
||||
}
|
||||
26
backend-go/internal/model/system_setting.go
Normal file
26
backend-go/internal/model/system_setting.go
Normal file
@@ -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"
|
||||
}
|
||||
34
backend-go/internal/model/user.go
Normal file
34
backend-go/internal/model/user.go
Normal file
@@ -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(60);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"
|
||||
}
|
||||
112
backend-go/internal/repository/admin_role_repo.go
Normal file
112
backend-go/internal/repository/admin_role_repo.go
Normal file
@@ -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
|
||||
}
|
||||
168
backend-go/internal/repository/assignment_repo.go
Normal file
168
backend-go/internal/repository/assignment_repo.go
Normal file
@@ -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
|
||||
}
|
||||
184
backend-go/internal/repository/attendance_repo.go
Normal file
184
backend-go/internal/repository/attendance_repo.go
Normal file
@@ -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
|
||||
}
|
||||
184
backend-go/internal/repository/class_repo.go
Normal file
184
backend-go/internal/repository/class_repo.go
Normal file
@@ -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
|
||||
}
|
||||
294
backend-go/internal/repository/conduct_repo.go
Normal file
294
backend-go/internal/repository/conduct_repo.go
Normal file
@@ -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
|
||||
}
|
||||
91
backend-go/internal/repository/log_repo.go
Normal file
91
backend-go/internal/repository/log_repo.go
Normal file
@@ -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
|
||||
}
|
||||
291
backend-go/internal/repository/semester_repo.go
Normal file
291
backend-go/internal/repository/semester_repo.go
Normal file
@@ -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
|
||||
}
|
||||
230
backend-go/internal/repository/student_repo.go
Normal file
230
backend-go/internal/repository/student_repo.go
Normal file
@@ -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
|
||||
}
|
||||
104
backend-go/internal/repository/subject_repo.go
Normal file
104
backend-go/internal/repository/subject_repo.go
Normal file
@@ -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
|
||||
}
|
||||
101
backend-go/internal/repository/super_admin_repo.go
Normal file
101
backend-go/internal/repository/super_admin_repo.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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).
|
||||
Updates(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"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, realName string) error {
|
||||
admin := model.SuperAdmin{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
RealName: realName,
|
||||
Status: 1,
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&admin).Error
|
||||
}
|
||||
100
backend-go/internal/repository/system_setting_repo.go
Normal file
100
backend-go/internal/repository/system_setting_repo.go
Normal file
@@ -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
|
||||
}
|
||||
166
backend-go/internal/repository/user_repo.go
Normal file
166
backend-go/internal/repository/user_repo.go
Normal file
@@ -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
|
||||
}
|
||||
206
backend-go/internal/router/router.go
Normal file
206
backend-go/internal/router/router.go
Normal file
@@ -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
|
||||
}
|
||||
33
backend-go/internal/schema/admin.go
Normal file
33
backend-go/internal/schema/admin.go
Normal file
@@ -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"`
|
||||
}
|
||||
30
backend-go/internal/schema/attendance.go
Normal file
30
backend-go/internal/schema/attendance.go
Normal file
@@ -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"`
|
||||
}
|
||||
26
backend-go/internal/schema/auth.go
Normal file
26
backend-go/internal/schema/auth.go
Normal file
@@ -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"`
|
||||
}
|
||||
|
||||
44
backend-go/internal/schema/class.go
Normal file
44
backend-go/internal/schema/class.go
Normal file
@@ -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"`
|
||||
}
|
||||
43
backend-go/internal/schema/conduct.go
Normal file
43
backend-go/internal/schema/conduct.go
Normal file
@@ -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"`
|
||||
}
|
||||
50
backend-go/internal/schema/ranking.go
Normal file
50
backend-go/internal/schema/ranking.go
Normal file
@@ -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"`
|
||||
}
|
||||
38
backend-go/internal/schema/semester.go
Normal file
38
backend-go/internal/schema/semester.go
Normal file
@@ -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"`
|
||||
}
|
||||
54
backend-go/internal/schema/student.go
Normal file
54
backend-go/internal/schema/student.go
Normal file
@@ -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"`
|
||||
}
|
||||
27
backend-go/internal/schema/subject.go
Normal file
27
backend-go/internal/schema/subject.go
Normal file
@@ -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"`
|
||||
}
|
||||
462
backend-go/internal/service/admin_service.go
Normal file
462
backend-go/internal/service/admin_service.go
Normal file
@@ -0,0 +1,462 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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/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
|
||||
}
|
||||
|
||||
// hashPassword 对密码进行 bcrypt 哈希,失败时 panic(不应发生)
|
||||
func hashPasswordOrPanic(password string) string {
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
logger.Sugared.Fatalf("密码哈希失败: %v", err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// AddStudent 新增学生
|
||||
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
|
||||
// 校验宿舍号格式
|
||||
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 := hashPasswordOrPanic(defaultPassword)
|
||||
_, 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 := hashPasswordOrPanic(defaultPassword)
|
||||
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) {
|
||||
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 := hashPasswordOrPanic(password)
|
||||
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 := hashPasswordOrPanic(password)
|
||||
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)
|
||||
}
|
||||
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, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败")
|
||||
}
|
||||
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) {
|
||||
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, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
passwordHash, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败")
|
||||
}
|
||||
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()
|
||||
}
|
||||
226
backend-go/internal/service/attendance_service.go
Normal file
226
backend-go/internal/service/attendance_service.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
459
backend-go/internal/service/auth_service.go
Normal file
459
backend-go/internal/service/auth_service.go
Normal file
@@ -0,0 +1,459 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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/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"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 验证密码(bcrypt)
|
||||
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
user, err := s.userRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
// 验证原密码(强制改密时跳过)
|
||||
if !force {
|
||||
if !crypto.VerifyPassword(oldPassword, user.PasswordHash) {
|
||||
return fmt.Errorf("原密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证新密码强度
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
newHash, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败")
|
||||
}
|
||||
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 "/"
|
||||
}
|
||||
}
|
||||
224
backend-go/internal/service/class_service.go
Normal file
224
backend-go/internal/service/class_service.go
Normal file
@@ -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
|
||||
}
|
||||
384
backend-go/internal/service/conduct_service.go
Normal file
384
backend-go/internal/service/conduct_service.go
Normal file
@@ -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
|
||||
}
|
||||
49
backend-go/internal/service/config_service.go
Normal file
49
backend-go/internal/service/config_service.go
Normal file
@@ -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"),
|
||||
}
|
||||
}
|
||||
70
backend-go/internal/service/log_service.go
Normal file
70
backend-go/internal/service/log_service.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
80
backend-go/internal/service/ranking_service.go
Normal file
80
backend-go/internal/service/ranking_service.go
Normal file
@@ -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
|
||||
}
|
||||
665
backend-go/internal/service/semester_service.go
Normal file
665
backend-go/internal/service/semester_service.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
171
backend-go/internal/service/student_service.go
Normal file
171
backend-go/internal/service/student_service.go
Normal file
@@ -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
|
||||
}
|
||||
92
backend-go/internal/service/subject_service.go
Normal file
92
backend-go/internal/service/subject_service.go
Normal file
@@ -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)
|
||||
}
|
||||
153
backend-go/internal/service/super_admin_service.go
Normal file
153
backend-go/internal/service/super_admin_service.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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 并重启服务")
|
||||
|
||||
passwordHash, err := crypto.HashPassword(cfg.SuperAdminDefaultPass)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码哈希失败: %w", err)
|
||||
}
|
||||
if err := s.superAdminRepo.EnsureDefaultAdmin(
|
||||
cfg.SuperAdminDefaultUser,
|
||||
passwordHash,
|
||||
"系统管理员",
|
||||
); 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) {
|
||||
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 超级管理员修改密码
|
||||
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) {
|
||||
return fmt.Errorf("原密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证新密码强度
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
newHash, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
if err := s.superAdminRepo.UpdatePassword(adminID, newHash); err != nil {
|
||||
return fmt.Errorf("密码修改失败")
|
||||
}
|
||||
|
||||
// 清除旧 Token,强制重新登录
|
||||
ctx := context.Background()
|
||||
_ = database.DeleteUserToken(ctx, adminID)
|
||||
|
||||
return nil
|
||||
}
|
||||
25
backend-go/internal/service/utils.go
Normal file
25
backend-go/internal/service/utils.go
Normal file
@@ -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
|
||||
}
|
||||
98
backend-go/pkg/crypto/password.go
Normal file
98
backend-go/pkg/crypto/password.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPassword 使用 bcrypt 对密码进行哈希
|
||||
// bcrypt 自带盐值管理,无需外部 salt
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密码哈希失败: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 验证密码是否与 bcrypt 哈希匹配
|
||||
func VerifyPassword(password, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// GenerateRandomPassword 生成随机密码
|
||||
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, ""
|
||||
}
|
||||
71
backend-go/pkg/database/mysql.go
Normal file
71
backend-go/pkg/database/mysql.go
Normal file
@@ -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
|
||||
}
|
||||
80
backend-go/pkg/database/redis.go
Normal file
80
backend-go/pkg/database/redis.go
Normal file
@@ -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 存储操作 ---
|
||||
|
||||
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()
|
||||
}
|
||||
93
backend-go/pkg/jwt/jwt.go
Normal file
93
backend-go/pkg/jwt/jwt.go
Normal file
@@ -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
|
||||
}
|
||||
64
backend-go/pkg/logger/logger.go
Normal file
64
backend-go/pkg/logger/logger.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
106
backend-go/pkg/response/response.go
Normal file
106
backend-go/pkg/response/response.go
Normal file
@@ -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,
|
||||
}, "操作成功")
|
||||
}
|
||||
40
frontend/.env.example
Normal file
40
frontend/.env.example
Normal file
@@ -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
|
||||
154
frontend/admin/admins.php
Normal file
154
frontend/admin/admins.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端管理员管理
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
if ($role !== '班主任') {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '管理员管理';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-primary" onclick="showAddAdminModal()">添加管理员</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>姓名</th><th>角色</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody id="adminList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加管理员模态框 -->
|
||||
<div id="addAdminModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>添加管理员</h3>
|
||||
<button class="modal-close" onclick="closeModal('addAdminModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitAddAdmin()">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="adminUsername" required placeholder="登录账号">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input type="text" id="adminRealName" required placeholder="真实姓名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="text" id="adminPassword" placeholder="留空则自动生成">
|
||||
<small>自动生成8位随机密码</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select id="adminRole" required>
|
||||
<option value="">请选择角色</option>
|
||||
<option value='班长'>班长</option>
|
||||
<option value='学习委员'>学习委员</option>
|
||||
<option value='考勤委员'>考勤委员</option>
|
||||
<option value='劳动委员'>劳动委员</option>
|
||||
<option value='志愿委员'>志愿委员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" class="btn" onclick="closeModal('addAdminModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑管理员模态框 -->
|
||||
<div id="editAdminModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>编辑管理员</h3>
|
||||
<button class="modal-close" onclick="closeModal('editAdminModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitEditAdmin()">
|
||||
<input type="hidden" id="editAdminUserId">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="editAdminUsername" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input type="text" id="editAdminRealName" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select id="editAdminRole" required>
|
||||
<option value="">请选择角色</option>
|
||||
<option value='班长'>班长</option>
|
||||
<option value='学习委员'>学习委员</option>
|
||||
<option value='考勤委员'>考勤委员</option>
|
||||
<option value='劳动委员'>劳动委员</option>
|
||||
<option value='志愿委员'>志愿委员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<button type="button" class="btn" onclick="closeModal('editAdminModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码模态框 -->
|
||||
<div id="resetPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>重置密码</h3>
|
||||
<button class="modal-close" onclick="closeModal('resetPasswordModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitResetPassword()">
|
||||
<input type="hidden" id="resetPasswordUserId">
|
||||
<div class="form-group">
|
||||
<label>管理员</label>
|
||||
<input type="text" id="resetPasswordAdminName" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input type="text" id="newPassword" required minlength="6" placeholder="请输入新密码(至少6位)">
|
||||
<small>密码长度至少6位</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认重置</button>
|
||||
<button type="button" class="btn" onclick="closeModal('resetPasswordModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/modules/admin-mgmt.js"></script>
|
||||
<script src="/assets/js/admins.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
100
frontend/admin/attendance.php
Normal file
100
frontend/admin/attendance.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端考勤管理
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '考勤管理';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
|
||||
if (!in_array($role, ['班主任', '考勤委员'])) {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<!-- 考勤操作工具栏 -->
|
||||
<div class="card">
|
||||
<div class="attendance-toolbar">
|
||||
<div class="toolbar-field">
|
||||
<label class="toolbar-label">日期</label>
|
||||
<input type="date" id="attendanceDate" value="<?php echo date('Y-m-d'); ?>">
|
||||
</div>
|
||||
<div class="toolbar-field">
|
||||
<label class="toolbar-label">时段</label>
|
||||
<select id="attendanceSlot">
|
||||
<option value="morning">早上 7:15</option>
|
||||
<option value="afternoon">中午 14:00</option>
|
||||
<option value="evening">晚修 19:30</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="status-group" style="margin-left:auto;">
|
||||
<button class="status-btn active" data-status="absent" onclick="selectStatus(this)" id="btnAbsent">缺勤</button>
|
||||
<button class="status-btn" data-status="late" onclick="selectStatus(this)" id="btnLate">迟到</button>
|
||||
<button class="status-btn" data-status="leave" onclick="selectStatus(this)" id="btnLeave">请假</button>
|
||||
</div>
|
||||
<button class="btn btn-danger" onclick="submitAttendance()">提交考勤</button>
|
||||
</div>
|
||||
<div class="attendance-toolbar">
|
||||
<div class="toolbar-field">
|
||||
<label class="toolbar-label">扣分</label>
|
||||
<input type="number" id="customDeduction" placeholder="默认值" min="0" max="20" style="width:80px;" title="留空或0使用默认值">
|
||||
</div>
|
||||
<div class="toolbar-field" style="flex:1;min-width:180px;">
|
||||
<label class="toolbar-label">原因</label>
|
||||
<input type="text" id="attendanceReason" placeholder="选填">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="margin-left:auto;" onclick="selectAllStudents()">全选</button>
|
||||
<button class="btn" onclick="deselectAllStudents()">取消全选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学生方格网格 -->
|
||||
<div class="card">
|
||||
<div class="card-title">点击选择有考勤异常的学生</div>
|
||||
<div class="student-grid" id="studentGrid">
|
||||
<!-- JS 动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史考勤记录 -->
|
||||
<div class="card">
|
||||
<div class="card-title">考勤记录</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>学号</th><th>姓名</th><th>状态</th><th>原因</th><th>记录人</th><th>扣分</th></tr>
|
||||
</thead>
|
||||
<tbody id="attendanceList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-cell { display: flex; flex-direction: column; align-items: center; }
|
||||
.student-cell-name { font-size: 14px; font-weight: 500; }
|
||||
.student-cell-no { font-size: 11px; color: #999; }
|
||||
</style>
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/attendance-manage.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
125
frontend/admin/cadre_homework.php
Normal file
125
frontend/admin/cadre_homework.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 课代表作业管理页
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
$page_title = '作业管理';
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
if ($role !== '课代表') {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
require_once __DIR__ . '/../includes/nav.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>作业管理</h2>
|
||||
<p class="text-muted">课代表可管理所代表科目的作业缺交情况</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="showPublishModal()">发布作业</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>作业标题</th>
|
||||
<th>科目</th>
|
||||
<th>截止日期</th>
|
||||
<th>描述</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="homeworkList">
|
||||
<tr><td colspan="5" style="text-align:center;">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination" style="display:none;">
|
||||
<button class="btn btn-sm" id="prevBtn" onclick="changePage(-1)">上一页</button>
|
||||
<span id="pageInfo">1 / 1</span>
|
||||
<button class="btn btn-sm" id="nextBtn" onclick="changePage(1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布作业模态框 -->
|
||||
<div id="publishModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>发布作业</h3>
|
||||
<button class="modal-close" onclick="closeModal('publishModal')">×</button>
|
||||
</div>
|
||||
<form id="publishForm" onsubmit="event.preventDefault(); submitHomework()">
|
||||
<div class="form-group">
|
||||
<label>作业标题 <span style="color:red;">*</span></label>
|
||||
<input type="text" id="hwTitle" required placeholder="例如:第三章练习">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>截止日期 <span style="color:red;">*</span></label>
|
||||
<input type="date" id="hwDeadline" required value="<?php echo date('Y-m-d'); ?>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea id="hwDescription" rows="3" placeholder="选填,作业详细说明"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">发布</button>
|
||||
<button type="button" class="btn" onclick="closeModal('publishModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缺交登记模态框 -->
|
||||
<div id="absentModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>登记缺交学生</h3>
|
||||
<button class="modal-close" onclick="closeModal('absentModal')">×</button>
|
||||
</div>
|
||||
<div id="absentStudentList"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="submitAbsent()">提交缺交记录</button>
|
||||
<button class="btn" onclick="closeModal('absentModal')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.pagination .btn { padding: 6px 16px; font-size: 13px; }
|
||||
.pagination span { color: #666; font-size: 14px; }
|
||||
</style>
|
||||
|
||||
<script src="/assets/js/cadre-homework.js"></script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
439
frontend/admin/class_settings.php
Normal file
439
frontend/admin/class_settings.php
Normal file
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 班级设置页面
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
$page_title = '班级设置';
|
||||
require_once __DIR__ . '/../config.php';
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
if (!in_array($role, ['班主任', '系统管理员'])) {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
require_once __DIR__ . '/../includes/nav.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>班级设置</h2>
|
||||
<p class="text-muted">修改当前班级的扣分规则、加减分限制和功能开关(仅班主任可修改)</p>
|
||||
</div>
|
||||
|
||||
<!-- 扣分规则 -->
|
||||
<div class="card">
|
||||
<h3>扣分规则</h3>
|
||||
<div id="deductionRules">
|
||||
<div class="form-group">
|
||||
<label>学生初始操行分</label>
|
||||
<input type="number" id="setting_student_initial_points" value="60" min="0" max="200">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>作业未提交扣分</label>
|
||||
<input type="number" id="setting_deduction_homework_not_submit" value="2" min="0" max="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>作业迟交扣分</label>
|
||||
<input type="number" id="setting_deduction_homework_late" value="1" min="0" max="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>缺勤扣分</label>
|
||||
<input type="number" id="setting_deduction_attendance_absent" value="3" min="0" max="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>迟到扣分</label>
|
||||
<input type="number" id="setting_deduction_attendance_late" value="1" min="0" max="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>请假扣分(0=不扣分)</label>
|
||||
<input type="number" id="setting_deduction_attendance_leave" value="0" min="0" max="20">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色加减分上下限 -->
|
||||
<div class="card">
|
||||
<h3>角色加减分限制</h3>
|
||||
<p class="text-muted" style="margin-bottom:15px;">配置各角色单次加减分的上下限</p>
|
||||
<div id="pointLimits">
|
||||
<div class="settings-grid">
|
||||
<div class="form-group">
|
||||
<label>班长单次加分上限</label>
|
||||
<input type="number" id="limit_monitor_max_add" value="5" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>班长单次扣分下限</label>
|
||||
<input type="number" id="limit_monitor_max_subtract" value="-5" min="-100" max="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>学习委员加分上限</label>
|
||||
<input type="number" id="limit_study_comm_max_points" value="5" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>学习委员扣分下限</label>
|
||||
<input type="number" id="limit_study_comm_min_points" value="-5" min="-100" max="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>考勤委员扣分上限</label>
|
||||
<input type="number" id="limit_attendance_rep_max_points" value="8" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>考勤委员扣分下限</label>
|
||||
<input type="number" id="limit_attendance_rep_min_points" value="-8" min="-100" max="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>劳动委员加分上限</label>
|
||||
<input type="number" id="limit_labor_rep_max_points" value="1" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>劳动委员扣分下限</label>
|
||||
<input type="number" id="limit_labor_rep_min_points" value="-1" min="-100" max="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>志愿委员加分上限</label>
|
||||
<input type="number" id="limit_volunteer_rep_max_points" value="5" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>志愿委员扣分下限</label>
|
||||
<input type="number" id="limit_volunteer_rep_min_points" value="-5" min="-100" max="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>科任老师加分上限</label>
|
||||
<input type="number" id="limit_subject_teacher_max_points" value="5" min="0" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>科任老师扣分下限</label>
|
||||
<input type="number" id="limit_subject_teacher_min_points" value="-5" min="-100" max="0">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="savePointLimits()">保存加减分限制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周期重置 -->
|
||||
<div class="card">
|
||||
<h3>周期重置</h3>
|
||||
<p class="text-muted" style="margin-bottom:15px;">按周或按月自动重置学生操行分(需配合定时任务或手动触发)</p>
|
||||
<div id="periodResetSettings">
|
||||
<div class="form-group">
|
||||
<label>重置频率</label>
|
||||
<select id="setting_reset_frequency" onchange="toggleResetDay()">
|
||||
<option value="none">不重置(仅学期结算)</option>
|
||||
<option value="weekly">每周重置</option>
|
||||
<option value="monthly">每月重置</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="reset_day_of_week_group" style="display:none;">
|
||||
<label>每周重置日</label>
|
||||
<select id="setting_reset_day_of_week">
|
||||
<option value="1">周一</option>
|
||||
<option value="2">周二</option>
|
||||
<option value="3">周三</option>
|
||||
<option value="4">周四</option>
|
||||
<option value="5">周五</option>
|
||||
<option value="6">周六</option>
|
||||
<option value="7">周日</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="reset_day_of_month_group" style="display:none;">
|
||||
<label>每月重置日</label>
|
||||
<input type="number" id="setting_reset_day_of_month" value="1" min="1" max="28">
|
||||
<small style="color:#999;">1~28日,建议避免月末最后几天</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="savePeriodResetSettings()">保存周期重置设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色开关 -->
|
||||
<div class="card">
|
||||
<h3>功能开关</h3>
|
||||
<p class="text-muted" style="margin-bottom:15px;">控制各角色的功能启用状态</p>
|
||||
<div id="roleToggles">
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_parent_account_enabled">
|
||||
<span>家长账号启用</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_parent_password_change_enabled">
|
||||
<span>家长改密启用</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_parent_view_attendance">
|
||||
<span>家长查看考勤</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_parent_view_ranking">
|
||||
<span>家长查看排名</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_student_view_ranking">
|
||||
<span>学生查看排行榜</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_homework_management">
|
||||
<span>作业管理模块</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_attendance_management">
|
||||
<span>考勤管理模块</span>
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="toggle_cadre_homework">
|
||||
<span>课代表作业管理</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveRoleToggles()">保存功能开关</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toggle-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// limitFieldMap: 前端 input ID 后缀 → 后端 class_settings 表 key
|
||||
var limitFieldMap = {
|
||||
'monitor_max_add': 'point_limit_班长_max',
|
||||
'monitor_max_subtract': 'point_limit_班长_min',
|
||||
'study_comm_max_points': 'point_limit_学习委员_max',
|
||||
'study_comm_min_points': 'point_limit_学习委员_min',
|
||||
'attendance_rep_max_points': 'point_limit_考勤委员_max',
|
||||
'attendance_rep_min_points': 'point_limit_考勤委员_min',
|
||||
'labor_rep_max_points': 'point_limit_劳动委员_max',
|
||||
'labor_rep_min_points': 'point_limit_劳动委员_min',
|
||||
'volunteer_rep_max_points': 'point_limit_志愿委员_max',
|
||||
'volunteer_rep_min_points': 'point_limit_志愿委员_min',
|
||||
'subject_teacher_max_points': 'point_limit_科任老师_max',
|
||||
'subject_teacher_min_points': 'point_limit_科任老师_min'
|
||||
};
|
||||
var limitFields = Object.keys(limitFieldMap);
|
||||
|
||||
var toggleFields = [
|
||||
'parent_account_enabled', 'parent_password_change_enabled', 'parent_view_attendance',
|
||||
'parent_view_ranking', 'student_view_ranking', 'homework_management',
|
||||
'attendance_management', 'cadre_homework'
|
||||
];
|
||||
|
||||
async function loadSettings() {
|
||||
var params = {};
|
||||
if (window.CLASS_ID) {
|
||||
params.class_id = window.CLASS_ID;
|
||||
}
|
||||
var result = await apiGet('/api/config/deduction-rules', params);
|
||||
if (result && result.success && result.data) {
|
||||
var d = result.data;
|
||||
document.getElementById('setting_student_initial_points').value = d.STUDENT_INITIAL_POINTS || 60;
|
||||
document.getElementById('setting_deduction_homework_not_submit').value = d.DEDUCTION_HOMEWORK_NOT_SUBMIT || 2;
|
||||
document.getElementById('setting_deduction_homework_late').value = d.DEDUCTION_HOMEWORK_LATE || 1;
|
||||
document.getElementById('setting_deduction_attendance_absent').value = d.DEDUCTION_ATTENDANCE_ABSENT || 3;
|
||||
document.getElementById('setting_deduction_attendance_late').value = d.DEDUCTION_ATTENDANCE_LATE || 1;
|
||||
document.getElementById('setting_deduction_attendance_leave').value = d.DEDUCTION_ATTENDANCE_LEAVE || 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPointLimits() {
|
||||
var result = await apiGet('/api/class/point-limits');
|
||||
if (result && result.success && result.data) {
|
||||
var d = result.data.settings || result.data;
|
||||
limitFields.forEach(function(key) {
|
||||
var el = document.getElementById('limit_' + key);
|
||||
var backendKey = limitFieldMap[key];
|
||||
if (el) {
|
||||
// 优先读取后端 point_limit_* key,兼容旧 key
|
||||
if (backendKey && d[backendKey] !== undefined) {
|
||||
el.value = d[backendKey];
|
||||
} else if (d[key] !== undefined) {
|
||||
el.value = d[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoleToggles() {
|
||||
var result = await apiGet('/api/class/features');
|
||||
if (result && result.success && result.data) {
|
||||
var d = result.data.features || result.data;
|
||||
toggleFields.forEach(function(key) {
|
||||
var el = document.getElementById('toggle_' + key);
|
||||
if (el) {
|
||||
el.checked = d[key] === true || d[key] === 1 || d[key] === '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
var settings = [
|
||||
{ key: 'initial_points', value: document.getElementById('setting_student_initial_points').value },
|
||||
{ key: 'deduction_homework_not_submit', value: document.getElementById('setting_deduction_homework_not_submit').value },
|
||||
{ key: 'deduction_homework_late', value: document.getElementById('setting_deduction_homework_late').value },
|
||||
{ key: 'deduction_attendance_absent', value: document.getElementById('setting_deduction_attendance_absent').value },
|
||||
{ key: 'deduction_attendance_late', value: document.getElementById('setting_deduction_attendance_late').value },
|
||||
{ key: 'deduction_attendance_leave', value: document.getElementById('setting_deduction_attendance_leave').value },
|
||||
];
|
||||
|
||||
var success = true;
|
||||
for (var i = 0; i < settings.length; i++) {
|
||||
var s = settings[i];
|
||||
var result = await apiPost('/api/class/settings', { setting_key: s.key, setting_value: s.value });
|
||||
if (!result || !result.success) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
showToast('班级设置已保存');
|
||||
} else {
|
||||
showToast('部分设置保存失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function savePointLimits() {
|
||||
var data = {};
|
||||
limitFields.forEach(function(key) {
|
||||
var el = document.getElementById('limit_' + key);
|
||||
if (el) {
|
||||
// 使用后端 point_limit_* key 保存,确保 conduct_service 能正确读取
|
||||
var backendKey = limitFieldMap[key] || key;
|
||||
data[backendKey] = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
var result = await apiPost('/api/class/point-limits', data);
|
||||
if (result && result.success) {
|
||||
showToast('加减分限制已保存');
|
||||
} else {
|
||||
showToast(result && result.message ? result.message : '保存失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRoleToggles() {
|
||||
var success = true;
|
||||
for (var i = 0; i < toggleFields.length; i++) {
|
||||
var key = toggleFields[i];
|
||||
var el = document.getElementById('toggle_' + key);
|
||||
if (el) {
|
||||
var result = await apiPost('/api/class/features', {
|
||||
feature_key: key,
|
||||
enabled: el.checked ? 1 : 0
|
||||
});
|
||||
if (!result || !result.success) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
showToast('功能开关已保存');
|
||||
} else {
|
||||
showToast('部分开关保存失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 周期重置设置 ==========
|
||||
|
||||
async function loadPeriodResetSettings() {
|
||||
var result = await apiGet('/api/class/settings');
|
||||
if (result && result.success && result.data && result.data.settings) {
|
||||
var s = result.data.settings;
|
||||
var freqSelect = document.getElementById('setting_reset_frequency');
|
||||
freqSelect.value = s['reset_frequency'] || 'none';
|
||||
toggleResetDay();
|
||||
|
||||
if (s['reset_day_of_week']) {
|
||||
document.getElementById('setting_reset_day_of_week').value = s['reset_day_of_week'];
|
||||
}
|
||||
if (s['reset_day_of_month']) {
|
||||
document.getElementById('setting_reset_day_of_month').value = s['reset_day_of_month'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleResetDay() {
|
||||
var freq = document.getElementById('setting_reset_frequency').value;
|
||||
document.getElementById('reset_day_of_week_group').style.display = (freq === 'weekly') ? 'block' : 'none';
|
||||
document.getElementById('reset_day_of_month_group').style.display = (freq === 'monthly') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function savePeriodResetSettings() {
|
||||
var freq = document.getElementById('setting_reset_frequency').value;
|
||||
var settings = [
|
||||
{ key: 'reset_frequency', value: freq }
|
||||
];
|
||||
if (freq === 'weekly') {
|
||||
settings.push({ key: 'reset_day_of_week', value: document.getElementById('setting_reset_day_of_week').value });
|
||||
} else if (freq === 'monthly') {
|
||||
settings.push({ key: 'reset_day_of_month', value: document.getElementById('setting_reset_day_of_month').value });
|
||||
}
|
||||
|
||||
var success = true;
|
||||
for (var i = 0; i < settings.length; i++) {
|
||||
var result = await apiPost('/api/class/settings', { setting_key: settings[i].key, setting_value: settings[i].value });
|
||||
if (!result || !result.success) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
showToast('周期重置设置已保存');
|
||||
} else {
|
||||
showToast('部分设置保存失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
loadPointLimits();
|
||||
loadRoleToggles();
|
||||
loadPeriodResetSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
214
frontend/admin/classes.php
Normal file
214
frontend/admin/classes.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
/**
|
||||
* 共享班级管理系统 - 班级管理页面
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
$page_title = '班级管理';
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
// 权限检查
|
||||
if (!isset($_SESSION['user_type']) || $_SESSION['user_type'] !== 'super_admin') {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
require_once __DIR__ . '/../includes/nav.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>班级管理</h2>
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">新增班级</button>
|
||||
</div>
|
||||
|
||||
<div id="classList" class="card-grid">
|
||||
<div class="loading">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑班级弹窗 -->
|
||||
<div id="classModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">新增班级</h3>
|
||||
<button class="btn-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editClassId">
|
||||
<div class="form-group">
|
||||
<label>班级名称 <span class="required">*</span></label>
|
||||
<input type="text" id="className" placeholder="如:高一(1)班" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>年级</label>
|
||||
<input type="text" id="classGrade" placeholder="如:高一" maxlength="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea id="classDesc" placeholder="班级描述(选填)" maxlength="255"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="saveClass()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadClasses() {
|
||||
const result = await apiGet('/api/class/list', { include_disabled: true });
|
||||
if (result && result.success) {
|
||||
renderClasses(result.data.classes || []);
|
||||
} else {
|
||||
document.getElementById('classList').innerHTML = '<div class="empty-state">暂无班级数据</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClasses(classes) {
|
||||
if (classes.length === 0) {
|
||||
document.getElementById('classList').innerHTML = '<div class="empty-state">暂无班级数据,点击右上角"新增班级"创建</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
classes.forEach(cls => {
|
||||
const statusBadge = cls.status === 1
|
||||
? '<span class="status-badge status-submitted">启用</span>'
|
||||
: '<span class="status-badge status-not_submitted">禁用</span>';
|
||||
html += `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>${escapeHtml(cls.class_name)}</h3>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>年级: ${escapeHtml(cls.grade || '-')}</p>
|
||||
<p>学生人数: ${cls.student_count || 0}</p>
|
||||
<p>描述: ${escapeHtml(cls.description || '-')}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button class="btn btn-sm btn-primary" onclick="switchClass(${cls.class_id}, '${escapeHtml(cls.class_name)}')">进入班级</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="editClass(${cls.class_id}, '${escapeHtml(cls.class_name)}', '${escapeHtml(cls.grade || '')}', '${escapeHtml(cls.description || '')}')">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteClass(${cls.class_id}, '${escapeHtml(cls.class_name)}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
document.getElementById('classList').innerHTML = html;
|
||||
}
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = '新增班级';
|
||||
document.getElementById('editClassId').value = '';
|
||||
document.getElementById('className').value = '';
|
||||
document.getElementById('classGrade').value = '';
|
||||
document.getElementById('classDesc').value = '';
|
||||
document.getElementById('classModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function editClass(classId, name, grade, desc) {
|
||||
document.getElementById('modalTitle').textContent = '编辑班级';
|
||||
document.getElementById('editClassId').value = classId;
|
||||
document.getElementById('className').value = name;
|
||||
document.getElementById('classGrade').value = grade;
|
||||
document.getElementById('classDesc').value = desc;
|
||||
document.getElementById('classModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('classModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveClass() {
|
||||
const classId = document.getElementById('editClassId').value;
|
||||
const data = {
|
||||
class_name: document.getElementById('className').value.trim(),
|
||||
grade: document.getElementById('classGrade').value.trim() || null,
|
||||
description: document.getElementById('classDesc').value.trim() || null
|
||||
};
|
||||
|
||||
if (!data.class_name) {
|
||||
showToast('请输入班级名称', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (classId) {
|
||||
result = await apiPut(`/api/class/update/${classId}`, data);
|
||||
} else {
|
||||
result = await apiPost('/api/class/create', data);
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
showToast(classId ? '班级更新成功' : '班级创建成功');
|
||||
closeModal();
|
||||
loadClasses();
|
||||
} else {
|
||||
showToast(result ? result.message : '操作失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClass(classId, className) {
|
||||
if (!confirm(`确定要删除班级 "${className}" 吗?此操作不可撤销。`)) return;
|
||||
|
||||
const result = await apiDelete(`/api/class/delete/${classId}`);
|
||||
if (result && result.success) {
|
||||
showToast('班级已删除');
|
||||
loadClasses();
|
||||
} else {
|
||||
showToast(result ? result.message : '删除失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function switchClass(classId, className) {
|
||||
const result = await apiPost('/api/class/switch', { class_id: classId });
|
||||
if (result && result.success) {
|
||||
// 更新本地存储的用户信息
|
||||
const userInfo = getUserInfo();
|
||||
if (userInfo) {
|
||||
userInfo.class_id = classId;
|
||||
userInfo.class_name = className;
|
||||
// 更新token
|
||||
if (result.data && result.data.token) {
|
||||
localStorage.setItem(window.JWT_STORAGE_KEY || 'class_system_token', result.data.token);
|
||||
}
|
||||
setUserInfo(userInfo);
|
||||
}
|
||||
// 同步到 PHP Session
|
||||
try {
|
||||
await fetch('/api/save_session.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() },
|
||||
body: JSON.stringify({
|
||||
user_id: userInfo.user_id,
|
||||
user_type: userInfo.user_type,
|
||||
username: userInfo.username,
|
||||
real_name: userInfo.real_name,
|
||||
role: userInfo.role,
|
||||
class_id: classId,
|
||||
class_name: className
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('同步Session失败', e);
|
||||
}
|
||||
showToast(`已切换到: ${className}`);
|
||||
window.location.href = '/admin/dashboard.php';
|
||||
} else {
|
||||
showToast(result ? result.message : '切换失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadClasses);
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
154
frontend/admin/conduct.php
Normal file
154
frontend/admin/conduct.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端操行分管理
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '操行分管理';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
|
||||
if (!in_array($role, ['班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员', '科任老师'])) {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="showBatchPointsModal()">批量加减分</button>
|
||||
<button class="btn btn-secondary" onclick="showDormitoryPointsModal()">宿舍加分</button>
|
||||
<?php if ($role === '班主任'): ?>
|
||||
<button class="btn btn-secondary" onclick="exportMoralityRecords()">导出德育分记录</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>当前操行分</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="studentList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/modules/utils.js"></script>
|
||||
<script src="/assets/js/modules/points-mgmt.js"></script>
|
||||
<script src="/assets/js/conduct.js"></script>
|
||||
|
||||
<!-- 批量加减分模态框 -->
|
||||
<div id="batchPointsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>批量加减分</h3>
|
||||
<button class="modal-close" onclick="closeModal('batchPointsModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitBatchPoints()">
|
||||
<div class="form-group">
|
||||
<label>选中学生</label>
|
||||
<div id="selectedStudentsCount">0 人</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>扣分类型</label>
|
||||
<div class="deduction-types" style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '卫生')">卫生</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '课堂')">课堂</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '纪律')">纪律</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '作业')">作业</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '考勤')">考勤</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '劳动')">劳动</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '志愿')">志愿</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(0, '')">自定义</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分数变动</label>
|
||||
<input type="number" id="pointsChange" required placeholder="正数为加分,负数为扣分">
|
||||
<small><?php
|
||||
$hints = [
|
||||
'班长' => '班长单次±5分以内',
|
||||
'学习委员' => '学习委员单次±5分以内',
|
||||
'考勤委员' => '考勤委员仅限扣分,单次最多扣8分',
|
||||
'劳动委员' => '劳动委员单次±1分以内',
|
||||
'志愿委员' => '志愿委员仅限加分,最多+5分',
|
||||
];
|
||||
echo $hints[$role] ?? '班主任无限制';
|
||||
?></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>原因</label>
|
||||
<textarea id="pointsReason" required rows="3" placeholder="请填写加减分原因"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认提交</button>
|
||||
<button type="button" class="btn" onclick="closeModal('batchPointsModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 宿舍集体加分模态框 -->
|
||||
<div id="dormitoryPointsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>宿舍集体加分</h3>
|
||||
<button class="modal-close" onclick="closeModal('dormitoryPointsModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitDormitoryPoints()">
|
||||
<div class="form-group">
|
||||
<label>选择宿舍</label>
|
||||
<select id="dormitorySelect" onchange="onDormitorySelected()" required>
|
||||
<option value="">-- 请选择宿舍 --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="dormitoryStudentsGroup" style="display:none;">
|
||||
<label>宿舍成员</label>
|
||||
<div id="dormitoryStudentsList" style="max-height: 150px; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 4px; padding: 8px;">
|
||||
</div>
|
||||
<small id="dormitoryStudentsCount"></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分数变动</label>
|
||||
<input type="number" id="dormitoryPointsChange" required placeholder="正数为加分,负数为扣分">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>原因</label>
|
||||
<textarea id="dormitoryPointsReason" required rows="3" placeholder="请填写加减分原因"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认提交</button>
|
||||
<button type="button" class="btn" onclick="closeModal('dormitoryPointsModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
219
frontend/admin/dashboard.php
Normal file
219
frontend/admin/dashboard.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端首页
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '首页';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="stats-grid" id="dashboardStats"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">快捷操作</div>
|
||||
<div class="action-buttons" id="quickActions"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">操行分排行榜</div>
|
||||
<div class="table-wrapper">
|
||||
<div style="display: flex; align-items: center; margin-bottom: 12px; gap: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">显示前</span>
|
||||
<input type="number" id="percentileFilter" style="width: 70px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px;" min="1" max="100" value="100" placeholder="1-100">
|
||||
<span style="font-size: 14px; color: #666;">% 的学生</span>
|
||||
<button class="btn btn-sm btn-primary" onclick="applyPercentileFilter()">筛选</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="resetPercentileFilter()">显示全部</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>排名</th><th>学号</th><th>姓名</th><th>操行分</th></tr>
|
||||
</thead>
|
||||
<tbody id="rankingList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($role === '班主任'): ?>
|
||||
<!-- 升级提示模态框 -->
|
||||
<div id="upgradeModal" class="modal" style="display:none;">
|
||||
<div class="modal-content" style="max-width:520px;">
|
||||
<div class="modal-header">
|
||||
<h3>🔄 系统升级</h3>
|
||||
<button class="modal-close" onclick="closeModal('upgradeModal')" id="upgradeCloseBtn">×</button>
|
||||
</div>
|
||||
<div style="padding: 16px 0;">
|
||||
<div style="text-align: center; margin-bottom: 12px;">
|
||||
<p style="font-size: 15px; color: var(--color-text);">检测到数据库有新版本可用</p>
|
||||
<p style="font-size: 13px; color: var(--color-text-muted); margin-top: 8px;">
|
||||
当前版本: <span id="currentDbVersion">--</span> → 目标版本: <span id="targetDbVersion">--</span>
|
||||
</p>
|
||||
</div>
|
||||
<div id="upgradeStepsList" style="margin: 12px 0;"></div>
|
||||
<div id="upgradeResult" style="display:none; margin-top: 12px;"></div>
|
||||
<p id="upgradeWarning" class="text-danger" style="font-size: 12px; text-align: center; margin-top: 8px;">⚠️ 升级前请确保已备份数据库</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" onclick="closeModal('upgradeModal')" id="upgradeLaterBtn">稍后再说</button>
|
||||
<button class="btn btn-primary" onclick="startUpgrade()" id="startUpgradeBtn">开始升级</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>window.PAGE_CONFIG = { role: '<?php echo $role; ?>' };</script>
|
||||
<script src="/assets/js/dashboard.js"></script>
|
||||
|
||||
<?php if ($role === '班主任'): ?>
|
||||
<script>
|
||||
(function() {
|
||||
var upgradeSteps = [];
|
||||
var currentStepIndex = 0;
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') return '';
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
fetch('/api/check_upgrade.php')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
// 检查是否返回了错误
|
||||
if (data.error) {
|
||||
console.warn('升级检查失败:', data.error);
|
||||
return;
|
||||
}
|
||||
if (data.needs_upgrade) {
|
||||
document.getElementById('currentDbVersion').textContent = data.current;
|
||||
document.getElementById('targetDbVersion').textContent = data.target;
|
||||
upgradeSteps = data.steps || [];
|
||||
|
||||
// 渲染步骤列表
|
||||
var listHtml = '';
|
||||
for (var i = 0; i < upgradeSteps.length; i++) {
|
||||
listHtml += '<div style="display:flex;align-items:center;padding:8px 12px;margin:4px 0;border-radius:6px;font-size:13px;background:var(--color-hover);border-left:3px solid var(--color-border);" id="ustep-' + i + '">' +
|
||||
'<span style="margin-right:8px;" id="ustep-icon-' + i + '">○</span>' +
|
||||
'<span>升级至 v' + escapeHtml(upgradeSteps[i].version) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
document.getElementById('upgradeStepsList').innerHTML = listHtml;
|
||||
document.getElementById('upgradeModal').style.display = 'flex';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('升级检查请求失败:', err);
|
||||
});
|
||||
|
||||
window.startUpgrade = function() {
|
||||
var btn = document.getElementById('startUpgradeBtn');
|
||||
var closeBtn = document.getElementById('upgradeCloseBtn');
|
||||
var laterBtn = document.getElementById('upgradeLaterBtn');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '升级中...';
|
||||
btn.style.opacity = '0.7';
|
||||
closeBtn.style.display = 'none';
|
||||
laterBtn.style.display = 'none';
|
||||
document.getElementById('upgradeWarning').style.display = 'none';
|
||||
|
||||
currentStepIndex = 0;
|
||||
executeNextUpgradeStep();
|
||||
};
|
||||
|
||||
function executeNextUpgradeStep() {
|
||||
if (currentStepIndex >= upgradeSteps.length) {
|
||||
// 所有步骤完成
|
||||
var btn = document.getElementById('startUpgradeBtn');
|
||||
btn.textContent = '升级完成 ✓';
|
||||
btn.style.background = '#52c41a';
|
||||
|
||||
var laterBtn = document.getElementById('upgradeLaterBtn');
|
||||
laterBtn.style.display = '';
|
||||
laterBtn.textContent = '关闭';
|
||||
laterBtn.onclick = function() { location.reload(); };
|
||||
|
||||
document.getElementById('upgradeResult').style.display = 'block';
|
||||
document.getElementById('upgradeResult').innerHTML = '<div style="background:#f6ffed;border:1px solid #b7eb8f;border-radius:6px;padding:12px;text-align:center;color:var(--color-success);font-size:14px;">✓ 数据库升级成功!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var step = upgradeSteps[currentStepIndex];
|
||||
var stepEl = document.getElementById('ustep-' + currentStepIndex);
|
||||
var iconEl = document.getElementById('ustep-icon-' + currentStepIndex);
|
||||
|
||||
// 标记为执行中
|
||||
if (stepEl) {
|
||||
stepEl.style.borderLeftColor = 'var(--color-primary)';
|
||||
stepEl.style.background = 'var(--color-primary-light)';
|
||||
}
|
||||
if (iconEl) iconEl.textContent = '⟳';
|
||||
|
||||
fetch('/api/execute_upgrade.php?action=step&version=' + encodeURIComponent(step.version), { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
if (stepEl) {
|
||||
stepEl.style.borderLeftColor = 'var(--color-success)';
|
||||
stepEl.style.background = '#f6ffed';
|
||||
}
|
||||
if (iconEl) iconEl.textContent = '✓';
|
||||
currentStepIndex++;
|
||||
executeNextUpgradeStep();
|
||||
} else {
|
||||
if (stepEl) {
|
||||
stepEl.style.borderLeftColor = 'var(--color-danger)';
|
||||
stepEl.style.background = 'var(--color-danger-light)';
|
||||
}
|
||||
if (iconEl) iconEl.textContent = '✗';
|
||||
showUpgradeError(data.error || '未知错误');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (stepEl) {
|
||||
stepEl.style.borderLeftColor = 'var(--color-danger)';
|
||||
stepEl.style.background = 'var(--color-danger-light)';
|
||||
}
|
||||
if (iconEl) iconEl.textContent = '✗';
|
||||
showUpgradeError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showUpgradeError(msg) {
|
||||
var btn = document.getElementById('startUpgradeBtn');
|
||||
btn.textContent = '升级失败';
|
||||
btn.style.background = 'var(--color-danger)';
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '';
|
||||
|
||||
var laterBtn = document.getElementById('upgradeLaterBtn');
|
||||
laterBtn.style.display = '';
|
||||
laterBtn.textContent = '关闭';
|
||||
|
||||
document.getElementById('upgradeResult').style.display = 'block';
|
||||
document.getElementById('upgradeResult').innerHTML = '<div style="background:var(--color-danger-light);border:1px solid #ffccc7;border-radius:6px;padding:12px;color:var(--color-danger-dark);font-size:13px;"><strong>升级失败:</strong>' + escapeHtml(msg) + '</div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
120
frontend/admin/history.php
Normal file
120
frontend/admin/history.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端历史记录
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '历史记录';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="filter-bar" id="historyFilterBar">
|
||||
<button class="btn btn-primary" onclick="loadHistory(1)">查询</button>
|
||||
<button class="btn btn-ghost" id="filterToggleBtn" onclick="toggleFilterPanel()">展开筛选 ▼</button>
|
||||
<label class="history-grouped-label">
|
||||
<input type="checkbox" id="historyGrouped" checked onchange="loadHistory(1)"> 批次合并
|
||||
</label>
|
||||
<?php if ($role === '班主任'): ?>
|
||||
<button class="btn btn-secondary" onclick="exportHistoryRecords()">导出</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<!-- 高级筛选面板(默认折叠) -->
|
||||
<div id="advancedFilters" style="display:none; padding: 0 16px 16px; border-top: 1px solid var(--color-border-light);">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:16px; padding-top:16px;">
|
||||
<div class="filter-group">
|
||||
<label>学生</label>
|
||||
<select id="historyStudentId" onchange="onStudentFilterChange()">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>科目</label>
|
||||
<select id="historySubjectFilter" onchange="onSubjectFilterChange()">
|
||||
<option value="">全部科目</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>搜索原因</label>
|
||||
<input type="text" id="historyReasonSearch" placeholder="输入关键词..." style="min-width:150px;">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>开始日期</label>
|
||||
<input type="date" id="historyStartDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>结束日期</label>
|
||||
<input type="date" id="historyEndDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>扣分类型</label>
|
||||
<select id="historyReasonFilter">
|
||||
<option value="">全部类型</option>
|
||||
<option value="卫生">卫生</option>
|
||||
<option value="课堂">课堂</option>
|
||||
<option value="纪律">纪律</option>
|
||||
<option value="作业">作业</option>
|
||||
<option value="考勤">考勤</option>
|
||||
<option value="劳动">劳动</option>
|
||||
<option value="志愿">志愿</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php if ($role === '班主任' || $role === '班长'): ?>
|
||||
<div class="filter-group">
|
||||
<label>状态</label>
|
||||
<select id="historyStatusFilter">
|
||||
<option value="">全部</option>
|
||||
<option value="0">正常</option>
|
||||
<option value="1">已撤销</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr id="historyTableHead">
|
||||
<th>类型</th>
|
||||
<th>分值</th>
|
||||
<th>原因</th>
|
||||
<th>学生</th>
|
||||
<th style="white-space: nowrap; min-width: 80px;">操作人</th>
|
||||
<th>时间</th>
|
||||
<?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员'): ?>
|
||||
<th>操作</th>
|
||||
<?php endif; ?>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="historyPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.PAGE_CONFIG = { role: '<?php echo htmlspecialchars($role, ENT_QUOTES, 'UTF-8'); ?>', userId: <?php echo intval($_SESSION['user_id']); ?> };</script>
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/modules/utils.js"></script>
|
||||
<script src="/assets/js/modules/points-mgmt.js"></script>
|
||||
<script src="/assets/js/history.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
255
frontend/admin/homework.php
Normal file
255
frontend/admin/homework.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端作业扣分
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '作业扣分';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
|
||||
if (!in_array($role, ['班主任', '学习委员'])) {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<!-- 科目管理折叠面板 -->
|
||||
<div class="card collapsible-card" style="margin-bottom: 20px;">
|
||||
<div class="collapsible-header" id="subjectPanelHeader">
|
||||
<h3 style="margin: 0; font-size: 16px;">科目管理</h3>
|
||||
<span id="subjectPanelToggle" class="toggle-icon">▶ 展开</span>
|
||||
</div>
|
||||
<div id="subjectPanelContent" class="collapsible-content">
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-primary" onclick="showAddSubjectModal()">添加科目</button>
|
||||
</div>
|
||||
<div id="subjectList" class="subject-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="showBatchPointsModal()">批量加减分</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>当前操行分</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="studentList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="batchPointsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>批量加减分</h3>
|
||||
<button class="modal-close" onclick="closeModal('batchPointsModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); handleSubmitPoints()">
|
||||
<div class="form-group">
|
||||
<label>已选学生</label>
|
||||
<div id="selectedStudentsCount" class="selected-info">未选择学生</div>
|
||||
</div>
|
||||
<?php if (in_array($role, ['班主任', '学习委员'])): ?>
|
||||
<div class="form-group">
|
||||
<label>科目</label>
|
||||
<select id="hwSubjectSelect">
|
||||
<option value="">不选择科目</option>
|
||||
</select>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="form-group">
|
||||
<label>扣分类型</label>
|
||||
<div class="deduction-types" style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(-window.DEDUCTION_HOMEWORK_NOT_SUBMIT, '未交作业')">未交作业(-<span class="hw-not-submit"></span>分)</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(-window.DEDUCTION_HOMEWORK_LATE, '迟交作业')">迟交作业(-<span class="hw-late"></span>分)</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '作业未完成')">作业未完成</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '作业抄袭')">作业抄袭</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(null, '作业态度')">作业态度</button>
|
||||
<button type="button" class="btn btn-sm" onclick="selectDeductionType(0, '')">自定义</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (in_array($role, ['班主任', '学习委员'])): ?>
|
||||
<div class="form-group">
|
||||
<label>具体作业</label>
|
||||
<input type="text" id="hwTitle" placeholder="选填,如:第三章练习">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>缴交时间</label>
|
||||
<input type="date" id="hwDeadline" value="<?php echo date('Y-m-d'); ?>">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="form-group">
|
||||
<label>分数变动</label>
|
||||
<input type="number" id="pointsChange" required min="-5" max="5" step="1" placeholder="正数加分,负数扣分">
|
||||
<small><?php
|
||||
if ($role === '学习委员') echo '学习委员单次±5分以内';
|
||||
else echo '班主任无限制';
|
||||
?></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>原因</label>
|
||||
<textarea id="pointsReason" rows="3" required placeholder="请输入加减分原因"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认提交</button>
|
||||
<button type="button" class="btn" onclick="closeModal('batchPointsModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加科目模态框 -->
|
||||
<div id="addSubjectModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>添加科目</h3>
|
||||
<button class="modal-close" onclick="closeModal('addSubjectModal')">×</button>
|
||||
</div>
|
||||
<form id="addSubjectFormInHw" onsubmit="event.preventDefault(); submitAddSubject()">
|
||||
<div class="form-group">
|
||||
<label>科目名称</label>
|
||||
<input type="text" id="subjectName" required placeholder="例如:语文、数学">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>科目代码</label>
|
||||
<input type="text" id="subjectCode" placeholder="例如:CHI、MATH">
|
||||
<small>可选,用于排序和标识</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
<button type="button" class="btn" onclick="closeModal('addSubjectModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑科目模态框 -->
|
||||
<div id="editSubjectModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>编辑科目</h3>
|
||||
<button class="modal-close" onclick="closeModal('editSubjectModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitEditSubject()">
|
||||
<input type="hidden" id="editSubjectId">
|
||||
<div class="form-group">
|
||||
<label>科目名称</label>
|
||||
<input type="text" id="editSubjectName" required placeholder="例如:语文、数学">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>科目代码</label>
|
||||
<input type="text" id="editSubjectCode" placeholder="例如:CHI、MATH">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>排序序号</label>
|
||||
<input type="number" id="editSubjectSortOrder" placeholder="数字越小越靠前" min="0">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<button type="button" class="btn" onclick="closeModal('editSubjectModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.subject-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.subject-item {
|
||||
background: var(--color-hover);
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.subject-name {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
.subject-code {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.subject-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.subject-status-active {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
.subject-status-inactive {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
.collapsible-card .collapsible-header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.collapsible-card .collapsible-header:hover {
|
||||
background: var(--color-hover, #f7fafc);
|
||||
}
|
||||
.collapsible-card .collapsible-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out, padding 0.3s ease-out;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.collapsible-card .collapsible-content.expanded {
|
||||
max-height: 1000px;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>window.PAGE_CONFIG = { role: '<?php echo $role; ?>' };</script>
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/modules/utils.js"></script>
|
||||
<script src="/assets/js/modules/points-mgmt.js"></script>
|
||||
<script src="/assets/js/homework-manage.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
81
frontend/admin/password.php
Normal file
81
frontend/admin/password.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端修改密码
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '修改密码';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-title">修改密码</div>
|
||||
<form id="passwordForm">
|
||||
<div class="form-group">
|
||||
<label>原密码 <span style="color:red;">*</span></label>
|
||||
<input type="password" id="oldPassword" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码 <span style="color:red;">*</span></label>
|
||||
<input type="password" id="newPassword" required>
|
||||
<small>密码长度6-20位,需包含大写字母、小写字母、数字、特殊符号中的至少3种</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码 <span style="color:red;">*</span></label>
|
||||
<input type="password" id="confirmPassword" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">确认修改</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const oldPassword = document.getElementById('oldPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast('两次输入的新密码不一致', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6 || newPassword.length > 20) {
|
||||
showToast('密码长度需为6-20位', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await apiPost('/api/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
|
||||
if (res && res.success) {
|
||||
showToast('密码修改成功,请重新登录');
|
||||
setTimeout(() => logout(), 1500);
|
||||
} else {
|
||||
showToast(res?.message || '密码修改失败', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
91
frontend/admin/rankings.php
Normal file
91
frontend/admin/rankings.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 排行榜页
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
$page_title = '排行榜';
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['user_type'], ['admin', 'super_admin'])) {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
if (!in_array($role, ['班主任', '系统管理员'])) {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../includes/header.php';
|
||||
require_once __DIR__ . '/../includes/nav.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h2>排行榜</h2>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" onclick="switchTab('conduct', this)">操行分排行</button>
|
||||
<button class="tab-btn" onclick="switchTab('attendance', this)">考勤排行</button>
|
||||
<button class="tab-btn" onclick="switchTab('homework', this)">作业排行</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>分值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rankingList">
|
||||
<tr><td colspan="4" style="text-align:center;">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: #4a6cf7;
|
||||
border-bottom-color: #4a6cf7;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="/assets/js/rankings.js"></script>
|
||||
|
||||
<?php require_once __DIR__ . '/../includes/footer.php'; ?>
|
||||
264
frontend/admin/semesters.php
Normal file
264
frontend/admin/semesters.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 学期管理页面
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || !in_array($_SESSION['user_type'], ['admin', 'super_admin'])) {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
if ($role !== '班主任') {
|
||||
header('Location: /admin/dashboard.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '学期管理';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<!-- 周期重置 -->
|
||||
<div class="card">
|
||||
<h3>周期重置</h3>
|
||||
<p class="text-muted" style="margin-bottom:15px;">手动触发当前班级的周/月操行分重置(重置前会自动创建分数快照)</p>
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-warning" onclick="confirmPeriodReset('weekly')">执行本周重置</button>
|
||||
<button class="btn btn-warning" onclick="confirmPeriodReset('monthly')">执行本月重置</button>
|
||||
<button class="btn btn-secondary" onclick="showPeriodArchives('weekly')">查看周归档</button>
|
||||
<button class="btn btn-secondary" onclick="showPeriodArchives('monthly')">查看月归档</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-primary" onclick="showCreateSemesterModal()">创建新学期</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>学期名称</th>
|
||||
<th>开始日期</th>
|
||||
<th>结束日期</th>
|
||||
<th>当前周数</th>
|
||||
<th>状态</th>
|
||||
<th>记录数</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="semesterList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建学期模态框 -->
|
||||
<div id="createSemesterModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>创建新学期</h3>
|
||||
<button class="modal-close" onclick="closeModal('createSemesterModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitCreateSemester()">
|
||||
<div class="form-group">
|
||||
<label>学期名称 <span style="color:red;">*</span></label>
|
||||
<input type="text" id="semesterName" required placeholder="如:2025春季学期" maxlength="100">
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<button type="button" class="btn btn-sm btn-outline" style="margin-right: 6px;" onclick="fillSemesterDates('upper')">上学期(9月-次年2月)</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="fillSemesterDates('lower')">下学期(3月-7月)</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开始日期</label>
|
||||
<input type="date" id="semesterStartDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期 <small style="color: #999;">(可选)</small></label>
|
||||
<input type="date" id="semesterEndDate" placeholder="可选,不确定可不填">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">创建学期</button>
|
||||
<button type="button" class="btn" onclick="closeModal('createSemesterModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑学期模态框 -->
|
||||
<div id="editSemesterModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>编辑学期</h3>
|
||||
<button class="modal-close" onclick="closeModal('editSemesterModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitEditSemester()">
|
||||
<input type="hidden" id="editSemesterId">
|
||||
<div class="form-group">
|
||||
<label>学期名称 <span style="color:red;">*</span></label>
|
||||
<input type="text" id="editSemesterName" required placeholder="如:2025春季学期" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开始日期</label>
|
||||
<input type="date" id="editSemesterStartDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期 <small style="color: #999;">(可选)</small></label>
|
||||
<input type="date" id="editSemesterEndDate">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">保存修改</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteSemester()">删除学期</button>
|
||||
<button type="button" class="btn" onclick="closeModal('editSemesterModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联数据确认模态框 -->
|
||||
<div id="associateConfirmModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>关联数据到学期</h3>
|
||||
<button class="modal-close" onclick="closeModal('associateConfirmModal')">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p id="associateConfirmText" style="margin: 10px 0;"></p>
|
||||
<p style="color: #666; font-size: 14px;">将把该日期范围内所有未分配学期的操行分记录和考勤记录关联到此学期。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="confirmAssociate()">确认关联</button>
|
||||
<button type="button" class="btn" onclick="closeModal('associateConfirmModal')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 归档确认模态框 -->
|
||||
<div id="archiveConfirmModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>确认归档学期</h3>
|
||||
<button class="modal-close" onclick="closeModal('archiveConfirmModal')">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p id="archiveConfirmText" style="margin: 10px 0;"></p>
|
||||
<p style="color: #666; font-size: 14px;">归档会创建所有学生当前操行分的数据快照,原始数据不受影响。</p>
|
||||
<div style="margin-top: 10px;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 14px;">
|
||||
<input type="checkbox" id="archiveResetScores">
|
||||
归档后重置所有学生操行分为初始值(60分)
|
||||
</label>
|
||||
</div>
|
||||
<p style="color: #e74c3c; font-size: 13px; margin-top: 6px;">注意:归档前需确保学期已设置开始日期,否则无法归档。归档后该学期数据将变为只读,不可撤销。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" onclick="confirmArchive()">确认归档</button>
|
||||
<button type="button" class="btn" onclick="closeModal('archiveConfirmModal')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 归档数据查看模态框 -->
|
||||
<div id="archiveDataModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="archiveDataTitle">归档数据</h3>
|
||||
<button class="modal-close" onclick="closeModal('archiveDataModal')">×</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align:center; border-bottom: none;">基本信息</th>
|
||||
<th rowspan="2" style="vertical-align: middle;">姓名</th>
|
||||
<th rowspan="2" style="vertical-align: middle;">操行分</th>
|
||||
<th colspan="4" style="text-align:center; border-bottom: none;">考勤统计</th>
|
||||
<th colspan="3" style="text-align:center; border-bottom: none;">作业统计</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>学号</th>
|
||||
<th>出勤</th>
|
||||
<th>缺勤</th>
|
||||
<th>迟到</th>
|
||||
<th>请假</th>
|
||||
<th>已交</th>
|
||||
<th>未交</th>
|
||||
<th>迟交</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="archiveDataList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="archivePagination"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" onclick="closeModal('archiveDataModal')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周期重置确认模态框 -->
|
||||
<div id="periodResetModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>确认周期重置</h3>
|
||||
<button class="modal-close" onclick="closeModal('periodResetModal')">×</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p id="periodResetText" style="margin: 10px 0;"></p>
|
||||
<p style="color: #e74c3c; font-size: 13px; margin-top: 6px;">注意:重置前会自动保存当前操行分快照,重置后所有学生操行分将恢复为初始值。此操作不可撤销。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-warning" onclick="executePeriodReset()">确认重置</button>
|
||||
<button type="button" class="btn" onclick="closeModal('periodResetModal')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周期归档数据查看模态框 -->
|
||||
<div id="periodArchivesModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="periodArchivesTitle">周期归档数据</h3>
|
||||
<button class="modal-close" onclick="closeModal('periodArchivesModal')">×</button>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>周期标签</th>
|
||||
<th>排名</th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>操行分</th>
|
||||
<th>触发方式</th>
|
||||
<th>归档时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="periodArchivesList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="periodArchivePagination"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" onclick="closeModal('periodArchivesModal')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/semesters.js"></script>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
218
frontend/admin/students.php
Normal file
218
frontend/admin/students.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 管理端学生管理
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
|
||||
header('Location: /index.php');
|
||||
exit();
|
||||
}
|
||||
|
||||
$page_title = '学生管理';
|
||||
$role = $_SESSION['role'] ?? '';
|
||||
include __DIR__ . '/../includes/header.php';
|
||||
?>
|
||||
|
||||
<?php include __DIR__ . '/../includes/nav.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<?php if ($role === '班主任'): ?>
|
||||
<button class="btn btn-primary" onclick="showImportModal()">导入学生</button>
|
||||
<button class="btn btn-primary" onclick="showAddStudentModal()">新增学生</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="搜索姓名/学号">
|
||||
<button class="btn btn-primary" onclick="loadStudents(1)">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="selectAll" onclick="toggleSelectAll()"></th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>宿舍号</th>
|
||||
<th>操行分</th>
|
||||
<?php if ($role === '班主任'): ?><th>家长账号(推荐手机号)</th><?php endif; ?>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="studentList"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入学生模态框 -->
|
||||
<div id="importModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>导入学生</h3>
|
||||
<button class="modal-close" onclick="closeModal('importModal')">×</button>
|
||||
</div>
|
||||
<div class="import-area" onclick="document.getElementById('importFile').click()">
|
||||
<p>点击选择JSON文件</p>
|
||||
<p class="import-label">或点击此处上传</p>
|
||||
<input type="file" id="importFile" accept=".json">
|
||||
<p style="margin-top: 10px; font-size: 12px; color: #999;">
|
||||
<a href="/assets/uploads/sample_import.json" download style="color: #667eea;">下载示例文件</a>
|
||||
</p>
|
||||
</div>
|
||||
<div id="importPreview" class="preview-table" style="display: none;"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" onclick="doImport()" id="importBtn" style="display: none;">确认导入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增学生模态框 -->
|
||||
<div id="addStudentModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>新增学生</h3>
|
||||
<button class="modal-close" onclick="closeModal('addStudentModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitAddStudent()">
|
||||
<div class="form-group">
|
||||
<label>学号 <span style="color:red;">*</span></label>
|
||||
<input type="text" id="studentNo" required placeholder="4-20位字母数字组合">
|
||||
<small>学号将作为学生登录账号</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名 <span style="color:red;">*</span></label>
|
||||
<input type="text" id="studentName" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>家长账号(推荐手机号)</label>
|
||||
<input type="tel" id="parentPhone" placeholder="11位手机号">
|
||||
<small>填写后将自动创建家长账号(密码同学生初始密码123456)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>宿舍号</label>
|
||||
<input type="text" id="addDormitoryNumber" placeholder="选填">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认添加</button>
|
||||
<button type="button" class="btn" onclick="closeModal('addStudentModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑学生模态框 -->
|
||||
<div id="editStudentModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>编辑学生信息</h3>
|
||||
<button class="modal-close" onclick="closeModal('editStudentModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitEditStudent()">
|
||||
<input type="hidden" id="editStudentId">
|
||||
<div class="form-group">
|
||||
<label>学号</label>
|
||||
<input type="text" id="editStudentNo" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input type="text" id="editStudentName" required maxlength="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>家长账号(推荐手机号)</label>
|
||||
<input type="text" id="editStudentPhone" maxlength="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>宿舍号</label>
|
||||
<input type="text" id="editDormitoryNumber" placeholder="选填">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">保存修改</button>
|
||||
<button type="button" class="btn" onclick="closeModal('editStudentModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置学生密码模态框 -->
|
||||
<div id="resetStudentPasswordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>重置学生密码</h3>
|
||||
<button class="modal-close" onclick="closeModal('resetStudentPasswordModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitResetStudentPassword()">
|
||||
<input type="hidden" id="resetStudentId">
|
||||
<p id="resetStudentInfo" style="margin: 10px 0;"></p>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="newStudentPassword" required minlength="6" maxlength="20">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-warning">确认重置</button>
|
||||
<button type="button" class="btn" onclick="closeModal('resetStudentPasswordModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>window.PAGE_CONFIG = { role: '<?php echo $role; ?>' };</script>
|
||||
<script src="/assets/js/modules/modal-utils.js"></script>
|
||||
<script src="/assets/js/modules/utils.js"></script>
|
||||
<script src="/assets/js/modules/student-mgmt.js"></script>
|
||||
<script src="/assets/js/modules/points-mgmt.js"></script>
|
||||
<script src="/assets/js/students-manage.js"></script>
|
||||
|
||||
<!-- 批量加减分模态框(共用) -->
|
||||
<div id="batchPointsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>批量加减分</h3>
|
||||
<button class="modal-close" onclick="closeModal('batchPointsModal')">×</button>
|
||||
</div>
|
||||
<form onsubmit="event.preventDefault(); submitBatchPoints()">
|
||||
<div class="form-group">
|
||||
<label>选中学生</label>
|
||||
<div id="selectedStudentsCount">0 人</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分数变动</label>
|
||||
<input type="number" id="pointsChange" required placeholder="正数为加分,负数为扣分">
|
||||
<small><?php
|
||||
$hints = [
|
||||
'班长' => '班长单次±5分以内',
|
||||
'学习委员' => '学习委员单次±5分以内',
|
||||
'考勤委员' => '考勤委员仅限扣分,单次最多扣8分',
|
||||
'劳动委员' => '劳动委员单次±1分以内',
|
||||
'志愿委员' => '志愿委员仅限加分,最多+5分',
|
||||
];
|
||||
echo $hints[$role] ?? '班主任无限制';
|
||||
?></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>原因</label>
|
||||
<textarea id="pointsReason" required rows="3" placeholder="请填写加减分原因"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">确认提交</button>
|
||||
<button type="button" class="btn" onclick="closeModal('batchPointsModal')">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/../includes/footer.php'; ?>
|
||||
14
frontend/admin/subjects.php
Normal file
14
frontend/admin/subjects.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - 科目管理(已合并至作业管理页)
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
header('Location: /admin/homework.php');
|
||||
exit();
|
||||
64
frontend/api/clear_session.php
Normal file
64
frontend/api/clear_session.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - Session 退出清除接口
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*
|
||||
* 说明:退出登录时,清除 PHP Session
|
||||
*/
|
||||
|
||||
// 引入配置文件以初始化 Session
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
// 设置响应头
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// 仅允许同源请求
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
// 处理预检请求
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
// 只允许 POST 请求
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode([
|
||||
'success' => 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();
|
||||
219
frontend/api/save_session.php
Normal file
219
frontend/api/save_session.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
/**
|
||||
* 多班级版班级管理系统 - Session 保存接口
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*
|
||||
* 说明:登录成功后,前端调用此接口将用户信息同步到 PHP Session
|
||||
*/
|
||||
|
||||
// 引入配置文件以初始化 Session
|
||||
require_once __DIR__ . '/../config.php';
|
||||
|
||||
// 设置响应头
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// 仅允许同源请求
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
|
||||
// 处理预检请求
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
// 只允许 POST 请求
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode([
|
||||
'success' => 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();
|
||||
265
frontend/assets/css/admin.css
Normal file
265
frontend/assets/css/admin.css
Normal file
@@ -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;
|
||||
}
|
||||
998
frontend/assets/css/style.css
Normal file
998
frontend/assets/css/style.css
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
14
frontend/assets/js/admin.js
Normal file
14
frontend/assets/js/admin.js
Normal file
@@ -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 - 加减分管理函数
|
||||
*/
|
||||
146
frontend/assets/js/admins.js
Normal file
146
frontend/assets/js/admins.js
Normal file
@@ -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 += `<tr>
|
||||
<td>${escapeHtml(admin.username)}</td>
|
||||
<td>${escapeHtml(admin.real_name)}</td>
|
||||
<td>${escapeHtml(admin.role_type)}</td>
|
||||
<td>
|
||||
<div class="action-dropdown">
|
||||
<button class="btn btn-sm action-dropdown-toggle" onclick="toggleActionDropdown(this)">操作 ▼</button>
|
||||
<div class="action-dropdown-menu">
|
||||
<a onclick="showEditAdminModal(${admin.user_id}, '${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}', '${escapeHtml(admin.role_type)}')">编辑</a>
|
||||
<a onclick="resetAdminPassword(${admin.user_id}, '${escapeHtml(admin.real_name)}')">重置密码</a>
|
||||
<a onclick="unlockUser('${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}')">解锁</a>
|
||||
<a class="danger" onclick="deleteAdmin(${admin.user_id}, '${escapeHtml(admin.real_name)}')">删除</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
if (res.data.admins.length === 0) {
|
||||
html = '<tr><td colspan="4" style="text-align:center;">暂无管理员</td></tr>';
|
||||
}
|
||||
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;
|
||||
|
||||
})();
|
||||
195
frontend/assets/js/attendance-manage.js
Normal file
195
frontend/assets/js/attendance-manage.js
Normal file
@@ -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 = '<div style="text-align:center;padding:20px;color:#999;">加载学生列表失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 += `<div class="student-cell${hasRecord ? ' has-record' : ''}"
|
||||
data-id="${student.student_id}"
|
||||
data-name="${escapeHtml(student.name)}"
|
||||
onclick="toggleStudent(this)">
|
||||
<span class="student-cell-name">${escapeHtml(student.name)}</span>
|
||||
<span class="student-cell-no">${escapeHtml(student.student_no)}</span>
|
||||
</div>`;
|
||||
});
|
||||
if (studentsData.length === 0) {
|
||||
html = '<div style="text-align:center;padding:20px;color:#999;width:100%;">暂无学生数据</div>';
|
||||
}
|
||||
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 += `<tr>
|
||||
<td>${escapeHtml(record.student_no)}</td>
|
||||
<td>${escapeHtml(record.student_name)}</td>
|
||||
<td>${getStatusBadge(record.status, 'attendance')}</td>
|
||||
<td>${escapeHtml(record.reason || '-')}</td>
|
||||
<td>${escapeHtml(record.recorder_name || '-')}</td>
|
||||
<td>${record.deduction_applied ? '已扣分' : '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
if (records.length === 0) {
|
||||
html = '<tr><td colspan="6" style="text-align:center;">暂无考勤记录</td></tr>';
|
||||
}
|
||||
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;
|
||||
|
||||
})();
|
||||
159
frontend/assets/js/cadre-homework.js
Normal file
159
frontend/assets/js/cadre-homework.js
Normal file
@@ -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 = '<tr><td colspan="5" style="text-align:center;">暂无作业记录</td></tr>';
|
||||
} else {
|
||||
items.forEach(function(item) {
|
||||
html += '<tr>' +
|
||||
'<td>' + escapeHtml(item.title || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(item.subject_name || '-') + '</td>' +
|
||||
'<td>' + formatDate(item.deadline) + '</td>' +
|
||||
'<td>' + escapeHtml(item.description || '-') + '</td>' +
|
||||
'<td><button class="btn btn-sm btn-outline" onclick="showAbsentModal(' + item.assignment_id + ')">登记缺交</button></td>' +
|
||||
'</tr>';
|
||||
});
|
||||
}
|
||||
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 = '<div class="form-group"><label>选择缺交学生</label></div>';
|
||||
if (students.length === 0) {
|
||||
html += '<p style="text-align:center;padding:20px;">暂无学生数据</p>';
|
||||
} else {
|
||||
html += '<div class="table-wrapper"><table class="table"><thead><tr>' +
|
||||
'<th><input type="checkbox" id="selectAllAbsent" onchange="toggleAllAbsent(this)"></th>' +
|
||||
'<th>学号</th><th>姓名</th></tr></thead><tbody>';
|
||||
students.forEach(function(s) {
|
||||
html += '<tr>' +
|
||||
'<td><input type="checkbox" class="absent-checkbox" data-id="' + s.student_id + '"></td>' +
|
||||
'<td>' + escapeHtml(s.student_no) + '</td>' +
|
||||
'<td>' + escapeHtml(s.name) + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user