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:
2026-06-22 10:21:52 +08:00
commit c6db68a9f4
135 changed files with 19933 additions and 0 deletions

64
.gitignore vendored Normal file
View 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/

572
INSTALL.md Normal file
View File

@@ -0,0 +1,572 @@
# 多班级版班级管理系统 - 安装部署指南
## 环境要求
### 服务器配置
- **操作系统**: 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 代理(国内环境必配,否则依赖拉取可能失败)
go env -w GOPROXY=https://goproxy.cn,direct
# 验证安装
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
# 方式一:使用 Makefile推荐自动配置 GOPROXY
make tidy
make build
# 方式二:手动编译(国内环境需指定 GOPROXY
# GOPROXY=https://goproxy.cn,direct go mod tidy
# GOPROXY=https://goproxy.cn,direct 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
# 编译(使用 Makefile 自动配置 GOPROXY
make tidy
make build
# 或手动编译:
# GOPROXY=https://goproxy.cn,direct go mod tidy
# GOPROXY=https://goproxy.cn,direct 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`
- 执行 `make tidy` 拉取依赖Makefile 已内置 GOPROXY 配置)
- 或手动配置 Go 代理:`go env -w GOPROXY=https://goproxy.cn,direct`
- 检查网络连接(国内环境访问 Google 服务受限,必须配置 GOPROXY
### 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
View 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
View 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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0

60
backend-go/.env.example Normal file
View 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

19
backend-go/Makefile Normal file
View File

@@ -0,0 +1,19 @@
# Go 代理(国内环境使用 goproxy.cn
GOPROXY ?= https://goproxy.cn,direct
.PHONY: build run dev tidy clean
build:
GOPROXY=$(GOPROXY) go build -o sharedclassmanager ./cmd/server
run:
./sharedclassmanager
dev:
GOPROXY=$(GOPROXY) go run ./cmd/server
tidy:
GOPROXY=$(GOPROXY) go mod tidy
clean:
rm -f sharedclassmanager

View 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
View 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
)

View 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
}

View 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, "操作成功")
}

View 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
}

View 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, "操作成功")
}

View 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, "保存成功")
}

View 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, "操作成功")
}

View 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
}

View 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, "密码修改成功")
}

View 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, "操作成功")
}

View 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, "操作成功")
}

View 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, "科目已禁用")
}
}

View 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, "登录成功")
}

View 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,
)
}
}

View 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
}
// 检查 roleadmin_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 ""
}

View 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
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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()
}

View 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
}
}

View 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 "/"
}
}

View 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
}

View 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
}

View 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"),
}
}

View 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
}

View 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
}

View 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
}
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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, ""
}

View 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
}

View 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
View 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
}

View 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()
}
}

View 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
View 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
View 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')">&times;</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')">&times;</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')">&times;</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'; ?>

View 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'; ?>

View 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')">&times;</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')">&times;</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'; ?>

View 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
View 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()">&times;</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
View 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')">&times;</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')">&times;</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'; ?>

View 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">&times;</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
View 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
View 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')">&times;</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')">&times;</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')">&times;</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'; ?>

View 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'; ?>

View 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'; ?>

View 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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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
View 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')">&times;</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')">&times;</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')">&times;</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')">&times;</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')">&times;</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'; ?>

View 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();

View 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();

View 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();

View 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;
}

View 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);
}

View 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 - 加减分管理函数
*/

View 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;
})();

View 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;
})();

View 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