first commit
This commit is contained in:
commit
e7fa4bc030
476
.gitignore
vendored
Normal file
476
.gitignore
vendored
Normal file
@ -0,0 +1,476 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
||||
# project, it is generally recommended to include the directories .idea/ and .vscode/ in your
|
||||
# .gitignore file.
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Project specific
|
||||
logs/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
database.db
|
||||
app.db
|
||||
|
||||
# Upload directories (if any)
|
||||
uploads/
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Configuration files with sensitive data
|
||||
config/local_config.py
|
||||
instance/config.py
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be added to the global gitignore or merged into this project gitignore. For a PyCharm
|
||||
# project, it is generally recommended to include the directories .idea/ and .vscode/ in your
|
||||
# .gitignore file.
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Project specific
|
||||
logs/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
database.db
|
||||
app.db
|
||||
|
||||
# Upload directories (if any)
|
||||
uploads/
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# Configuration files with sensitive data
|
||||
config/local_config.py
|
||||
instance/config.py
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
517
README.md
Normal file
517
README.md
Normal file
@ -0,0 +1,517 @@
|
||||
# 校园考勤打卡系统
|
||||
|
||||
一个基于Flask的校园考勤打卡管理系统,支持学生考勤数据的录入、管理、统计和可视化展示。
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
本系统是为校园环境设计的考勤打卡管理平台,能够处理学生的日常打卡数据,提供完整的考勤统计分析功能。系统支持管理员和学生两种角色,实现了考勤数据的全生命周期管理。
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
### 👨💼 管理员功能
|
||||
- **学生管理**:添加、编辑、删除学生信息
|
||||
- **考勤数据导入**:支持CSV/XLSX格式的批量数据导入
|
||||
- **考勤数据管理**:查看、编辑、删除考勤记录
|
||||
- **统计分析**:生成各类考勤统计报表
|
||||
- **权限管理**:用户角色和权限控制
|
||||
- **数据修正**:修正错误的考勤时间和状态
|
||||
|
||||
### 👨🎓 学生功能
|
||||
- **个人考勤查询**:查看个人从入学以来的所有考勤记录
|
||||
- **考勤统计**:按周统计个人出勤时长、迟到、缺勤次数
|
||||
- **请假管理**:提交请假申请,查看请假记录
|
||||
- **个人信息**:查看和修改个人基本信息
|
||||
- **密码管理**:修改登录密码
|
||||
|
||||
### 📊 数据展示
|
||||
- **可视化界面**:直观的数据展示界面
|
||||
- **多维度查询**:按年级、学号、姓名进行查询
|
||||
- **实时统计**:实时显示各类考勤统计数据
|
||||
- **历史记录**:完整的考勤历史记录追踪
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
- **后端框架**:Flask
|
||||
- **数据库**:MySQL
|
||||
- **前端技术**:HTML5, CSS3, JavaScript
|
||||
- **数据处理**:Pandas(CSV/XLSX处理)
|
||||
- **认证授权**:Flask-Login
|
||||
- **模板引擎**:Jinja2
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── app/ # 应用主目录
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── student.py # 学生模型
|
||||
│ │ └── attendance.py # 考勤模型
|
||||
│ ├── routes/ # 路由控制器
|
||||
│ │ ├── auth.py # 认证路由
|
||||
│ │ ├── admin.py # 管理员路由
|
||||
│ │ └── student.py # 学生路由
|
||||
│ ├── templates/ # 前端模板
|
||||
│ │ ├── auth/ # 认证相关页面
|
||||
│ │ ├── admin/ # 管理员页面
|
||||
│ │ ├── student/ # 学生页面
|
||||
│ │ └── layout/ # 布局模板
|
||||
│ ├── static/ # 静态资源
|
||||
│ │ ├── css/ # 样式文件
|
||||
│ │ ├── js/ # JavaScript文件
|
||||
│ │ └── images/ # 图片资源
|
||||
│ └── utils/ # 工具函数
|
||||
│ ├── database.py # 数据库操作
|
||||
│ ├── auth_helpers.py # 认证辅助函数
|
||||
│ └── attendance_importer.py # 考勤数据导入
|
||||
├── config/ # 配置文件
|
||||
├── tests/ # 测试文件
|
||||
├── logs/ # 日志文件
|
||||
├── requirements.txt # 依赖包列表
|
||||
├── run.py # 应用启动文件
|
||||
└── init_db.py # 数据库初始化脚本
|
||||
```
|
||||
|
||||
## ⚙️ 考勤规则
|
||||
|
||||
### 工作日打卡时段
|
||||
- **早上时段**:6:00-12:00 可打卡,要求9:45-11:30
|
||||
- **下午时段**:13:30-18:30 可打卡,要求14:45-17:30
|
||||
- **晚上时段**:19:00-23:30 可打卡(非强制)
|
||||
|
||||
### 考勤计算规则
|
||||
- **迟到计算**:超过规定上班时间即为迟到
|
||||
- **缺卡处理**:未在规定时间内打卡视为缺卡
|
||||
- **时长统计**:
|
||||
- 工作日有效打卡计入**实际出勤时长**和**班内工作时长**
|
||||
- 休息日打卡计入**实际出勤时长**和**加班总时长**
|
||||
|
||||
## 🚀 安装部署
|
||||
|
||||
### 环境要求
|
||||
- Python 3.8+
|
||||
- MySQL 5.7+
|
||||
- pip
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd attendance-system
|
||||
```
|
||||
|
||||
2. **创建虚拟环境**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **安装依赖**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **配置数据库**
|
||||
```bash
|
||||
# 创建数据库
|
||||
mysql -u root -p
|
||||
CREATE DATABASE attendance_system;
|
||||
```
|
||||
|
||||
5. **配置环境变量**
|
||||
```bash
|
||||
# 创建 .env 文件
|
||||
cp .env.example .env
|
||||
# 编辑数据库连接信息
|
||||
```
|
||||
|
||||
6. **初始化数据库**
|
||||
```bash
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
7. **启动应用**
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 `.env` 文件中配置以下变量:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=attendance_system
|
||||
|
||||
# Flask配置
|
||||
SECRET_KEY=your_secret_key
|
||||
DEBUG=True
|
||||
```
|
||||
|
||||
## 📖 使用说明
|
||||
|
||||
### 首次使用
|
||||
|
||||
1. **创建管理员账户**
|
||||
```bash
|
||||
python init_db.py --create-admin
|
||||
```
|
||||
|
||||
2. **导入学生数据**
|
||||
- 登录管理员账户
|
||||
- 进入"学生管理"页面
|
||||
- 使用"批量导入"功能导入学生信息
|
||||
|
||||
3. **上传考勤数据**
|
||||
- 进入"考勤管理"页面
|
||||
- 使用"数据导入"功能上传CSV/XLSX文件
|
||||
|
||||
### 数据导入格式
|
||||
|
||||
**学生信息CSV格式**:
|
||||
```csv
|
||||
学号,姓名,性别,年级,专业,导师,学院,学位类型
|
||||
2021001,张三,男,2021,计算机科学,李教授,计算机学院,学硕
|
||||
```
|
||||
|
||||
**考勤数据CSV格式**:
|
||||
```csv
|
||||
学号,姓名,日期,签到时间,签退时间,状态
|
||||
2021001,张三,2024-01-15,09:30,11:45,正常
|
||||
```
|
||||
|
||||
## 👥 用户角色
|
||||
|
||||
### 管理员权限
|
||||
- 查看所有学生信息和考勤数据
|
||||
- 管理学生账户(增删改查)
|
||||
- 导入和管理考勤数据
|
||||
- 生成统计报表
|
||||
- 系统配置管理
|
||||
|
||||
### 学生权限
|
||||
- 查看个人考勤记录
|
||||
- 查看个人统计数据
|
||||
- 申请请假
|
||||
- 修改个人信息
|
||||
- 修改登录密码
|
||||
|
||||
## 📊 数据统计
|
||||
|
||||
系统提供多维度的数据统计功能:
|
||||
|
||||
- **个人统计**:个人出勤时长、迟到次数、缺勤次数
|
||||
- **年级统计**:按年级汇总的考勤数据
|
||||
- **时间统计**:按周、月、学期的考勤趋势
|
||||
- **排行榜**:出勤时长排名、迟到次数排名等
|
||||
|
||||
## 🔧 开发说明
|
||||
|
||||
### 添加新功能
|
||||
1. 在对应的模型文件中添加数据模型
|
||||
2. 在路由文件中添加业务逻辑
|
||||
3. 创建相应的HTML模板
|
||||
4. 更新CSS和JavaScript文件
|
||||
|
||||
### 数据库迁移
|
||||
```bash
|
||||
# 备份数据库
|
||||
mysqldump -u username -p attendance_system > backup.sql
|
||||
|
||||
# 修改数据库结构后重新初始化
|
||||
python init_db.py --reset
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 📧 Email: [your-email@example.com]
|
||||
- 🐛 Issues: [GitHub Issues](https://github.com/your-username/attendance-system/issues)
|
||||
|
||||
## 🎯 Todo List
|
||||
|
||||
- [ ] 移动端适配
|
||||
- [ ] 数据导出功能
|
||||
- [ ] 邮件通知功能
|
||||
- [ ] 更多统计图表
|
||||
- [ ] API接口文档
|
||||
- [ ] 单元测试覆盖
|
||||
|
||||
---
|
||||
|
||||
⭐ 如果这# 校园考勤打卡系统
|
||||
|
||||
一个基于Flask的校园考勤打卡管理系统,支持学生考勤数据的录入、管理、统计和可视化展示。
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
本系统是为校园环境设计的考勤打卡管理平台,能够处理学生的日常打卡数据,提供完整的考勤统计分析功能。系统支持管理员和学生两种角色,实现了考勤数据的全生命周期管理。
|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
### 👨💼 管理员功能
|
||||
- **学生管理**:添加、编辑、删除学生信息
|
||||
- **考勤数据导入**:支持CSV/XLSX格式的批量数据导入
|
||||
- **考勤数据管理**:查看、编辑、删除考勤记录
|
||||
- **统计分析**:生成各类考勤统计报表
|
||||
- **权限管理**:用户角色和权限控制
|
||||
- **数据修正**:修正错误的考勤时间和状态
|
||||
|
||||
### 👨🎓 学生功能
|
||||
- **个人考勤查询**:查看个人从入学以来的所有考勤记录
|
||||
- **考勤统计**:按周统计个人出勤时长、迟到、缺勤次数
|
||||
- **个人信息**:查看和修改个人基本信息
|
||||
- **密码管理**:修改登录密码
|
||||
|
||||
### 📊 数据展示
|
||||
- **可视化界面**:直观的数据展示界面
|
||||
- **多维度查询**:按年级、学号、姓名进行查询
|
||||
- **实时统计**:实时显示各类考勤统计数据
|
||||
- **历史记录**:完整的考勤历史记录追踪
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
- **后端框架**:Flask
|
||||
- **数据库**:MySQL
|
||||
- **前端技术**:HTML5, CSS3, JavaScript
|
||||
- **数据处理**:Pandas(CSV/XLSX处理)
|
||||
- **认证授权**:Flask-Login
|
||||
- **模板引擎**:Jinja2
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── app/ # 应用主目录
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── student.py # 学生模型
|
||||
│ │ └── attendance.py # 考勤模型
|
||||
│ ├── routes/ # 路由控制器
|
||||
│ │ ├── auth.py # 认证路由
|
||||
│ │ ├── admin.py # 管理员路由
|
||||
│ │ └── student.py # 学生路由
|
||||
│ ├── templates/ # 前端模板
|
||||
│ │ ├── auth/ # 认证相关页面
|
||||
│ │ ├── admin/ # 管理员页面
|
||||
│ │ ├── student/ # 学生页面
|
||||
│ │ └── layout/ # 布局模板
|
||||
│ ├── static/ # 静态资源
|
||||
│ │ ├── css/ # 样式文件
|
||||
│ │ ├── js/ # JavaScript文件
|
||||
│ │ └── images/ # 图片资源
|
||||
│ └── utils/ # 工具函数
|
||||
│ ├── database.py # 数据库操作
|
||||
│ ├── auth_helpers.py # 认证辅助函数
|
||||
│ └── attendance_importer.py # 考勤数据导入
|
||||
├── config/ # 配置文件
|
||||
├── tests/ # 测试文件
|
||||
├── logs/ # 日志文件
|
||||
├── requirements.txt # 依赖包列表
|
||||
├── run.py # 应用启动文件
|
||||
└── init_db.py # 数据库初始化脚本
|
||||
```
|
||||
|
||||
## ⚙️ 考勤规则
|
||||
|
||||
### 工作日打卡时段
|
||||
- **早上时段**:6:00-12:00 可打卡,要求9:45-11:30
|
||||
- **下午时段**:13:30-18:30 可打卡,要求14:45-17:30
|
||||
- **晚上时段**:19:00-23:30 可打卡(非强制)
|
||||
|
||||
### 考勤计算规则
|
||||
- **迟到计算**:超过规定上班时间即为迟到
|
||||
- **缺卡处理**:未在规定时间内打卡视为缺卡
|
||||
- **时长统计**:
|
||||
- 工作日有效打卡计入**实际出勤时长**和**班内工作时长**
|
||||
- 休息日打卡计入**实际出勤时长**和**加班总时长**
|
||||
|
||||
## 🚀 安装部署
|
||||
|
||||
### 环境要求
|
||||
- Python 3.8+
|
||||
- MySQL 5.7+
|
||||
- pip
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd attendance-system
|
||||
```
|
||||
|
||||
2. **创建虚拟环境**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **安装依赖**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **配置数据库**
|
||||
```bash
|
||||
# 创建数据库
|
||||
mysql -u root -p
|
||||
CREATE DATABASE attendance_system;
|
||||
```
|
||||
|
||||
5. **配置环境变量**
|
||||
```bash
|
||||
# 创建 .env 文件
|
||||
cp .env.example .env
|
||||
# 编辑数据库连接信息
|
||||
```
|
||||
|
||||
6. **初始化数据库**
|
||||
```bash
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
7. **启动应用**
|
||||
```bash
|
||||
python run.py
|
||||
```
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
在 `.env` 文件中配置以下变量:
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_USER=your_username
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=attendance_system
|
||||
|
||||
# Flask配置
|
||||
SECRET_KEY=your_secret_key
|
||||
DEBUG=True
|
||||
```
|
||||
|
||||
## 📖 使用说明
|
||||
|
||||
### 首次使用
|
||||
|
||||
1. **创建管理员账户**
|
||||
```bash
|
||||
python init_db.py --create-admin
|
||||
```
|
||||
|
||||
2. **导入学生数据**
|
||||
- 登录管理员账户
|
||||
- 进入"学生管理"页面
|
||||
- 使用"批量导入"功能导入学生信息
|
||||
|
||||
3. **上传考勤数据**
|
||||
- 进入"考勤管理"页面
|
||||
- 使用"数据导入"功能上传CSV/XLSX文件
|
||||
|
||||
### 数据导入格式
|
||||
|
||||
**学生信息CSV格式**:
|
||||
```csv
|
||||
学号,姓名,性别,年级,专业,导师,学院,学位类型
|
||||
2021001,张三,男,2021,计算机科学,李教授,计算机学院,学硕
|
||||
```
|
||||
|
||||
**考勤数据CSV格式**:
|
||||
```csv
|
||||
学号,姓名,日期,签到时间,签退时间,状态
|
||||
2021001,张三,2024-01-15,09:30,11:45,正常
|
||||
```
|
||||
|
||||
## 👥 用户角色
|
||||
|
||||
### 管理员权限
|
||||
- 查看所有学生信息和考勤数据
|
||||
- 管理学生账户(增删改查)
|
||||
- 导入和管理考勤数据
|
||||
- 生成统计报表
|
||||
- 系统配置管理
|
||||
|
||||
### 学生权限
|
||||
- 查看个人考勤记录
|
||||
- 查看个人统计数据
|
||||
- 申请请假
|
||||
- 修改个人信息
|
||||
- 修改登录密码
|
||||
|
||||
## 📊 数据统计
|
||||
|
||||
系统提供多维度的数据统计功能:
|
||||
|
||||
- **个人统计**:个人出勤时长、迟到次数、缺勤次数
|
||||
- **年级统计**:按年级汇总的考勤数据
|
||||
- **时间统计**:按周、月、学期的考勤趋势
|
||||
- **排行榜**:出勤时长排名、迟到次数排名等
|
||||
|
||||
## 🔧 开发说明
|
||||
|
||||
### 添加新功能
|
||||
1. 在对应的模型文件中添加数据模型
|
||||
2. 在路由文件中添加业务逻辑
|
||||
3. 创建相应的HTML模板
|
||||
4. 更新CSS和JavaScript文件
|
||||
|
||||
### 数据库迁移
|
||||
```bash
|
||||
# 备份数据库
|
||||
mysqldump -u username -p attendance_system > backup.sql
|
||||
|
||||
# 修改数据库结构后重新初始化
|
||||
python init_db.py --reset
|
||||
```
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
如有问题或建议,请通过以下方式联系:
|
||||
|
||||
- 📧 Email: [2568813500@qq.com]
|
||||
-
|
||||
## 🎯 Todo List
|
||||
|
||||
- [ ] 移动端适配
|
||||
- [ ] 数据导出功能
|
||||
- [ ] 邮件通知功能
|
||||
- [ ] 更多统计图表
|
||||
- [ ] API接口文档
|
||||
- [ ] 单元测试覆盖
|
||||
|
||||
---
|
||||
|
||||
⭐ 如果这个项目对你有帮助,请给一个star吧!
|
64
all_file_output.py
Normal file
64
all_file_output.py
Normal file
@ -0,0 +1,64 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def collect_code_files(output_file="code_collection.txt"):
|
||||
# 定义代码文件扩展名
|
||||
code_extensions = [
|
||||
'.py', '.java', '.cpp', '.c', '.h', '.hpp', '.cs',
|
||||
'.js', '.html', '.css', '.php', '.go', '.rb',
|
||||
'.swift', '.kt', '.ts', '.sh', '.pl', '.r'
|
||||
]
|
||||
|
||||
# 定义要排除的目录
|
||||
excluded_dirs = [
|
||||
'venv', 'env', '.venv', '.env', 'virtualenv',
|
||||
'__pycache__', 'node_modules', '.git', '.idea',
|
||||
'dist', 'build', 'target', 'bin'
|
||||
]
|
||||
|
||||
# 计数器
|
||||
file_count = 0
|
||||
|
||||
# 打开输出文件
|
||||
with open(output_file, 'w', encoding='utf-8') as out_file:
|
||||
# 遍历当前目录及所有子目录
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# 从dirs中移除排除的目录,这会阻止os.walk进入这些目录
|
||||
dirs[:] = [d for d in dirs if d not in excluded_dirs]
|
||||
|
||||
for file in files:
|
||||
# 获取文件扩展名
|
||||
_, ext = os.path.splitext(file)
|
||||
|
||||
# 检查是否为代码文件
|
||||
if ext.lower() in code_extensions:
|
||||
file_path = os.path.join(root, file)
|
||||
file_count += 1
|
||||
|
||||
# 写入文件路径作为分隔
|
||||
out_file.write(f"\n{'=' * 80}\n")
|
||||
out_file.write(f"File: {file_path}\n")
|
||||
out_file.write(f"{'=' * 80}\n\n")
|
||||
|
||||
# 尝试读取文件内容并写入
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as code_file:
|
||||
out_file.write(code_file.read())
|
||||
except UnicodeDecodeError:
|
||||
# 尝试用不同的编码
|
||||
try:
|
||||
with open(file_path, 'r', encoding='latin-1') as code_file:
|
||||
out_file.write(code_file.read())
|
||||
except Exception as e:
|
||||
out_file.write(f"无法读取文件内容: {str(e)}\n")
|
||||
except Exception as e:
|
||||
out_file.write(f"读取文件时出错: {str(e)}\n")
|
||||
|
||||
print(f"已成功收集 {file_count} 个代码文件到 {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 如果提供了命令行参数,则使用它作为输出文件名
|
||||
output_file = sys.argv[1] if len(sys.argv) > 1 else "code_collection.txt"
|
||||
collect_code_files(output_file)
|
121
app/__init__.py
Normal file
121
app/__init__.py
Normal file
@ -0,0 +1,121 @@
|
||||
from flask import Flask, redirect, url_for
|
||||
from flask_login import LoginManager, current_user # 添加 current_user 导入
|
||||
from config.config import config
|
||||
from app.models import db, User, Student # 添加 Student 导入
|
||||
import os
|
||||
import json # 添加 json 导入
|
||||
from datetime import datetime, timedelta # 添加 datetime 和 timedelta 导入
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
if config_name is None:
|
||||
config_name = os.environ.get('FLASK_ENV', 'default')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# 初始化扩展
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化Flask-Login
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = '请先登录后访问此页面。'
|
||||
login_manager.login_message_category = 'info'
|
||||
|
||||
@app.template_filter('fromjson')
|
||||
def from_json(value):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except:
|
||||
return {}
|
||||
|
||||
@app.template_filter('strptime')
|
||||
def strptime_filter(value, format_string):
|
||||
try:
|
||||
return datetime.strptime(value, format_string)
|
||||
except:
|
||||
return None
|
||||
|
||||
@app.template_filter('calculate_duration')
|
||||
def calculate_duration(start_time, end_time, date_str):
|
||||
try:
|
||||
if not start_time or not end_time:
|
||||
return None
|
||||
|
||||
start_datetime = datetime.strptime(f"{date_str} {start_time}", '%Y-%m-%d %H:%M:%S')
|
||||
end_datetime = datetime.strptime(f"{date_str} {end_time}", '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 如果结束时间小于开始时间,说明跨天了
|
||||
if end_datetime < start_datetime:
|
||||
end_datetime += timedelta(days=1)
|
||||
|
||||
duration = (end_datetime - start_datetime).total_seconds() / 3600
|
||||
return round(duration, 1)
|
||||
except:
|
||||
return None
|
||||
|
||||
@app.context_processor
|
||||
def utility_processor():
|
||||
def get_current_student():
|
||||
# 添加安全检查
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
if current_user.is_admin():
|
||||
return None
|
||||
return Student.query.filter_by(student_number=current_user.student_number).first()
|
||||
|
||||
return dict(get_current_student=get_current_student)
|
||||
|
||||
# 注册Python内置函数到Jinja2环境
|
||||
app.jinja_env.globals.update(
|
||||
max=max,
|
||||
min=min,
|
||||
abs=abs,
|
||||
round=round,
|
||||
len=len,
|
||||
int=int,
|
||||
float=float,
|
||||
str=str,
|
||||
sum=sum,
|
||||
enumerate=enumerate,
|
||||
zip=zip
|
||||
)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# 注册蓝图
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.student import student_bp
|
||||
from app.routes.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(student_bp, url_prefix='/student')
|
||||
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||
|
||||
# 注册模板全局函数
|
||||
from app.utils import get_pending_leaves_count
|
||||
app.jinja_env.globals.update(
|
||||
get_pending_leaves_count=get_pending_leaves_count,
|
||||
# 移除这行,因为我们已经通过 context_processor 注册了
|
||||
# get_current_student=get_current_student
|
||||
)
|
||||
|
||||
# 主页路由
|
||||
@app.route('/')
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.is_admin():
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
return redirect(url_for('student.dashboard'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
10
app/models/__init__.py
Normal file
10
app/models/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
# 导入所有模型
|
||||
from app.models.user import User
|
||||
from app.models.student import Student
|
||||
from app.models.attendance import WeeklyAttendance, DailyAttendanceDetail, LeaveRecord
|
||||
|
||||
__all__ = ['db', 'User', 'Student', 'WeeklyAttendance', 'DailyAttendanceDetail', 'LeaveRecord']
|
69
app/models/attendance.py
Normal file
69
app/models/attendance.py
Normal file
@ -0,0 +1,69 @@
|
||||
from app.models import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class WeeklyAttendance(db.Model):
|
||||
__tablename__ = 'weekly_attendance'
|
||||
|
||||
record_id = db.Column(db.Integer, primary_key=True)
|
||||
student_number = db.Column(db.String(20), db.ForeignKey('students.student_number'), nullable=False)
|
||||
name = db.Column(db.String(50), nullable=False)
|
||||
week_start_date = db.Column(db.Date, nullable=False, index=True)
|
||||
week_end_date = db.Column(db.Date, nullable=False, index=True)
|
||||
actual_work_hours = db.Column(db.Numeric(5, 1), default=0)
|
||||
class_work_hours = db.Column(db.Numeric(5, 1), default=0)
|
||||
absent_days = db.Column(db.Integer, default=0)
|
||||
overtime_hours = db.Column(db.Numeric(5, 1), default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 学生关系
|
||||
student_info = db.relationship('Student', backref='weekly_attendance',
|
||||
foreign_keys=[student_number],
|
||||
primaryjoin="WeeklyAttendance.student_number==Student.student_number")
|
||||
|
||||
def __repr__(self):
|
||||
return f'<WeeklyAttendance {self.name} {self.week_start_date}>'
|
||||
|
||||
|
||||
class DailyAttendanceDetail(db.Model):
|
||||
__tablename__ = 'daily_attendance_details'
|
||||
|
||||
detail_id = db.Column(db.Integer, primary_key=True)
|
||||
weekly_record_id = db.Column(db.Integer, db.ForeignKey('weekly_attendance.record_id'))
|
||||
student_number = db.Column(db.String(20), db.ForeignKey('students.student_number'), nullable=False)
|
||||
attendance_date = db.Column(db.Date, nullable=False, index=True)
|
||||
status = db.Column(db.String(20), default='正常')
|
||||
check_in_time = db.Column(db.Time)
|
||||
check_out_time = db.Column(db.Time)
|
||||
remarks = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
weekly_record = db.relationship('WeeklyAttendance', backref='daily_details')
|
||||
student_info = db.relationship('Student', backref='daily_attendance',
|
||||
foreign_keys=[student_number],
|
||||
primaryjoin="DailyAttendanceDetail.student_number==Student.student_number")
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DailyAttendance {self.student_number} {self.attendance_date}>'
|
||||
|
||||
|
||||
class LeaveRecord(db.Model):
|
||||
__tablename__ = 'leave_records'
|
||||
|
||||
leave_id = db.Column(db.Integer, primary_key=True)
|
||||
student_number = db.Column(db.String(20), db.ForeignKey('students.student_number'), nullable=False)
|
||||
leave_start_date = db.Column(db.Date, nullable=False)
|
||||
leave_end_date = db.Column(db.Date, nullable=False)
|
||||
leave_reason = db.Column(db.Text)
|
||||
status = db.Column(db.Enum('待审批', '已批准', '已拒绝'), default='待审批')
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# 学生关系
|
||||
student_info = db.relationship('Student', backref='leave_records',
|
||||
foreign_keys=[student_number],
|
||||
primaryjoin="LeaveRecord.student_number==Student.student_number")
|
||||
|
||||
def __repr__(self):
|
||||
return f'<LeaveRecord {self.student_number} {self.leave_start_date}>'
|
42
app/models/student.py
Normal file
42
app/models/student.py
Normal file
@ -0,0 +1,42 @@
|
||||
from app.models import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Student(db.Model):
|
||||
__tablename__ = 'students'
|
||||
|
||||
student_id = db.Column(db.Integer, primary_key=True)
|
||||
student_number = db.Column(db.String(20), db.ForeignKey('users.student_number'), unique=True, nullable=False)
|
||||
name = db.Column(db.String(50), nullable=False, index=True)
|
||||
gender = db.Column(db.Enum('男', '女'), nullable=False)
|
||||
grade = db.Column(db.Integer, nullable=False, index=True)
|
||||
phone = db.Column(db.String(11))
|
||||
supervisor = db.Column(db.String(50), index=True)
|
||||
college = db.Column(db.String(100))
|
||||
major = db.Column(db.String(100))
|
||||
degree_type = db.Column(db.Enum('专硕', '学博', '学硕', '专博'))
|
||||
status = db.Column(db.Enum('在读', '毕业'), default='在读')
|
||||
enrollment_date = db.Column(db.Date)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# 用户关系
|
||||
user = db.relationship('User', backref='student', foreign_keys=[student_number],
|
||||
primaryjoin="Student.student_number==User.student_number")
|
||||
|
||||
def get_latest_attendance(self, limit=10):
|
||||
"""获取最近的考勤记录"""
|
||||
from app.models.attendance import WeeklyAttendance
|
||||
return WeeklyAttendance.query.filter_by(student_number=self.student_number) \
|
||||
.order_by(WeeklyAttendance.week_start_date.desc()) \
|
||||
.limit(limit)
|
||||
|
||||
def get_attendance_by_date_range(self, start_date, end_date):
|
||||
"""获取指定日期范围的考勤记录"""
|
||||
from app.models.attendance import WeeklyAttendance
|
||||
return WeeklyAttendance.query.filter_by(student_number=self.student_number) \
|
||||
.filter(WeeklyAttendance.week_start_date >= start_date,
|
||||
WeeklyAttendance.week_end_date <= end_date) \
|
||||
.all()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Student {self.name}({self.student_number})>'
|
31
app/models/user.py
Normal file
31
app/models/user.py
Normal file
@ -0,0 +1,31 @@
|
||||
from app.models import db
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
user_id = db.Column(db.Integer, primary_key=True)
|
||||
student_number = db.Column(db.String(20), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.Enum('student', 'admin'), default='student')
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
last_login = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def get_id(self):
|
||||
return str(self.user_id)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def is_admin(self):
|
||||
return self.role == 'admin'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.student_number}>'
|
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
1305
app/routes/admin.py
Normal file
1305
app/routes/admin.py
Normal file
File diff suppressed because it is too large
Load Diff
179
app/routes/auth.py
Normal file
179
app/routes/auth.py
Normal file
@ -0,0 +1,179 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from app.models import db, User, Student
|
||||
from app.utils.database import safe_commit
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
if current_user.is_admin():
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
return redirect(url_for('student.dashboard'))
|
||||
|
||||
if request.method == 'POST':
|
||||
student_number = request.form.get('student_number', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
remember = request.form.get('remember', False)
|
||||
|
||||
if not student_number or not password:
|
||||
flash('请输入学号和密码', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
# 查找用户
|
||||
user = User.query.filter_by(student_number=student_number).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
if not user.is_active:
|
||||
flash('账户已被禁用,请联系管理员', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
# 更新最后登录时间
|
||||
user.last_login = datetime.utcnow()
|
||||
success, error = safe_commit()
|
||||
|
||||
if not success:
|
||||
flash('系统错误,请稍后重试', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
login_user(user, remember=remember)
|
||||
|
||||
# 获取重定向地址
|
||||
next_page = request.args.get('next')
|
||||
if next_page:
|
||||
return redirect(next_page)
|
||||
|
||||
# 根据用户角色重定向
|
||||
if user.is_admin():
|
||||
flash(f'欢迎回来,管理员!', 'success')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
# 获取学生姓名
|
||||
student = Student.query.filter_by(student_number=student_number).first()
|
||||
name = student.name if student else student_number
|
||||
flash(f'欢迎回来,{name}!', 'success')
|
||||
return redirect(url_for('student.dashboard'))
|
||||
else:
|
||||
flash('学号或密码错误', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('您已成功退出登录', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route('/change_password', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
if request.method == 'POST':
|
||||
current_password = request.form.get('current_password', '')
|
||||
new_password = request.form.get('new_password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
# 验证输入
|
||||
if not all([current_password, new_password, confirm_password]):
|
||||
flash('请填写所有密码字段', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# 验证当前密码
|
||||
if not current_user.check_password(current_password):
|
||||
flash('当前密码不正确', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# 验证新密码与确认密码是否一致
|
||||
if new_password != confirm_password:
|
||||
flash('新密码与确认密码不匹配', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# 密码长度验证
|
||||
if len(new_password) < 6:
|
||||
flash('新密码长度至少6位', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# 密码复杂度验证(可选)
|
||||
if not re.search(r'^(?=.*[a-zA-Z])(?=.*\d).+$', new_password):
|
||||
flash('新密码必须包含字母和数字', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
# 检查新密码是否与当前密码相同
|
||||
if current_user.check_password(new_password):
|
||||
flash('新密码不能与当前密码相同', 'error')
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
try:
|
||||
# 更新密码
|
||||
current_user.set_password(new_password)
|
||||
success, error = safe_commit()
|
||||
|
||||
if success:
|
||||
flash('密码修改成功', 'success')
|
||||
# 根据用户角色重定向
|
||||
if current_user.is_admin():
|
||||
return redirect(url_for('auth.profile'))
|
||||
else:
|
||||
return redirect(url_for('auth.profile'))
|
||||
else:
|
||||
flash(f'密码修改失败: {error}', 'error')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'密码修改失败: {str(e)}', 'error')
|
||||
|
||||
return render_template('auth/change_password.html')
|
||||
|
||||
|
||||
@auth_bp.route('/profile')
|
||||
@login_required
|
||||
def profile():
|
||||
"""用户个人信息页面"""
|
||||
try:
|
||||
if current_user.is_admin():
|
||||
# 管理员信息页面
|
||||
user_info = {
|
||||
'user_id': current_user.user_id,
|
||||
'student_number': current_user.student_number,
|
||||
'role': current_user.role,
|
||||
'is_active': current_user.is_active,
|
||||
'last_login': current_user.last_login,
|
||||
'created_at': current_user.created_at
|
||||
}
|
||||
return render_template('auth/admin_profile.html', user_info=user_info)
|
||||
else:
|
||||
# 学生信息页面
|
||||
student = Student.query.filter_by(student_number=current_user.student_number).first()
|
||||
user_info = {
|
||||
'user_id': current_user.user_id,
|
||||
'student_number': current_user.student_number,
|
||||
'role': current_user.role,
|
||||
'is_active': current_user.is_active,
|
||||
'last_login': current_user.last_login,
|
||||
'created_at': current_user.created_at,
|
||||
# 学生特有信息
|
||||
'name': student.name if student else None,
|
||||
'gender': student.gender if student else None,
|
||||
'grade': student.grade if student else None,
|
||||
'phone': student.phone if student else None,
|
||||
'supervisor': student.supervisor if student else None,
|
||||
'college': student.college if student else None,
|
||||
'major': student.major if student else None,
|
||||
'degree_type': student.degree_type if student else None,
|
||||
'status': student.status if student else None,
|
||||
'enrollment_date': student.enrollment_date if student else None
|
||||
}
|
||||
return render_template('auth/student_profile.html', user_info=user_info, student=student)
|
||||
except Exception as e:
|
||||
flash(f'获取个人信息失败: {str(e)}', 'error')
|
||||
if current_user.is_admin():
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
return redirect(url_for('student.dashboard'))
|
437
app/routes/student.py
Normal file
437
app/routes/student.py
Normal file
@ -0,0 +1,437 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import db, Student, WeeklyAttendance, DailyAttendanceDetail, LeaveRecord
|
||||
from app.utils.auth_helpers import student_required
|
||||
from app.utils.database import safe_add_and_commit
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, or_, desc
|
||||
|
||||
student_bp = Blueprint('student', __name__)
|
||||
|
||||
|
||||
@student_bp.route('/dashboard')
|
||||
@student_required
|
||||
def dashboard():
|
||||
"""学生主页"""
|
||||
if current_user.is_admin():
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
student = Student.query.filter_by(student_number=current_user.student_number).first()
|
||||
if not student:
|
||||
flash('学生信息不存在,请联系管理员', 'error')
|
||||
return redirect(url_for('auth.logout'))
|
||||
|
||||
# 获取最近的考勤记录
|
||||
recent_attendance = WeeklyAttendance.query.filter_by(
|
||||
student_number=current_user.student_number
|
||||
).order_by(desc(WeeklyAttendance.week_start_date)).limit(5).all()
|
||||
|
||||
# 统计数据
|
||||
total_records = WeeklyAttendance.query.filter_by(
|
||||
student_number=current_user.student_number
|
||||
).count()
|
||||
|
||||
total_work_hours = db.session.query(
|
||||
db.func.sum(WeeklyAttendance.actual_work_hours)
|
||||
).filter_by(student_number=current_user.student_number).scalar() or 0
|
||||
|
||||
total_absent_days = db.session.query(
|
||||
db.func.sum(WeeklyAttendance.absent_days)
|
||||
).filter_by(student_number=current_user.student_number).scalar() or 0
|
||||
|
||||
# 获取未审批的请假记录
|
||||
pending_leaves = LeaveRecord.query.filter_by(
|
||||
student_number=current_user.student_number,
|
||||
status='待审批'
|
||||
).order_by(desc(LeaveRecord.created_at)).all()
|
||||
|
||||
return render_template('student/dashboard.html',
|
||||
student=student,
|
||||
recent_attendance=recent_attendance,
|
||||
total_records=total_records,
|
||||
total_work_hours=float(total_work_hours),
|
||||
total_absent_days=int(total_absent_days),
|
||||
pending_leaves=pending_leaves)
|
||||
|
||||
|
||||
@student_bp.route('/attendance')
|
||||
@student_required
|
||||
def attendance():
|
||||
"""考勤记录页面"""
|
||||
from sqlalchemy import desc, func, case, or_
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# 日期筛选
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
# 构建基础查询,同时计算迟到次数
|
||||
query = db.session.query(
|
||||
WeeklyAttendance,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||||
else_=0
|
||||
)
|
||||
), 0
|
||||
).label('late_count')
|
||||
).outerjoin(
|
||||
DailyAttendanceDetail,
|
||||
WeeklyAttendance.record_id == DailyAttendanceDetail.weekly_record_id
|
||||
).filter(
|
||||
WeeklyAttendance.student_number == current_user.student_number
|
||||
).group_by(WeeklyAttendance.record_id)
|
||||
|
||||
# 应用筛选条件
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||||
except ValueError:
|
||||
flash('开始日期格式错误', 'error')
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||||
except ValueError:
|
||||
flash('结束日期格式错误', 'error')
|
||||
|
||||
# 执行分页查询
|
||||
pagination = query.order_by(desc(WeeklyAttendance.week_start_date)).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
# 处理结果,将迟到次数添加到记录对象中
|
||||
attendance_records = []
|
||||
for record, late_count in pagination.items:
|
||||
record.late_count = int(late_count) if late_count else 0
|
||||
attendance_records.append(record)
|
||||
|
||||
# 更新pagination对象的items
|
||||
pagination.items = attendance_records
|
||||
|
||||
# 计算总体统计
|
||||
total_stats = None
|
||||
if attendance_records:
|
||||
# 计算所有记录的统计信息
|
||||
all_records_query = db.session.query(
|
||||
WeeklyAttendance,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||||
else_=0
|
||||
)
|
||||
), 0
|
||||
).label('late_count')
|
||||
).outerjoin(
|
||||
DailyAttendanceDetail,
|
||||
WeeklyAttendance.record_id == DailyAttendanceDetail.weekly_record_id
|
||||
).filter(
|
||||
WeeklyAttendance.student_number == current_user.student_number
|
||||
).group_by(WeeklyAttendance.record_id)
|
||||
|
||||
# 应用相同的筛选条件
|
||||
if start_date:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
all_records_query = all_records_query.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||||
if end_date:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
all_records_query = all_records_query.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||||
|
||||
all_records = all_records_query.all()
|
||||
|
||||
total_actual_hours = sum(record.actual_work_hours for record, _ in all_records)
|
||||
total_class_hours = sum(record.class_work_hours for record, _ in all_records)
|
||||
total_absent_days = sum(record.absent_days for record, _ in all_records)
|
||||
total_overtime_hours = sum(record.overtime_hours for record, _ in all_records)
|
||||
total_late_count = sum(late_count for _, late_count in all_records)
|
||||
|
||||
# 计算请假天数
|
||||
record_ids = [record.record_id for record, _ in all_records]
|
||||
total_leave_days = 0
|
||||
if record_ids:
|
||||
total_leave_days = DailyAttendanceDetail.query.filter(
|
||||
DailyAttendanceDetail.weekly_record_id.in_(record_ids),
|
||||
DailyAttendanceDetail.status == '请假'
|
||||
).count()
|
||||
|
||||
total_stats = {
|
||||
'total_weeks': len(all_records),
|
||||
'total_actual_hours': total_actual_hours,
|
||||
'total_class_hours': total_class_hours,
|
||||
'total_absent_days': total_absent_days,
|
||||
'total_overtime_hours': total_overtime_hours,
|
||||
'total_late_count': total_late_count,
|
||||
'total_leave_days': total_leave_days,
|
||||
'avg_weekly_hours': total_actual_hours / max(len(all_records), 1)
|
||||
}
|
||||
|
||||
return render_template('student/attendance.html',
|
||||
attendance_records=attendance_records,
|
||||
pagination=pagination,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
total_stats=total_stats)
|
||||
|
||||
|
||||
@student_bp.route('/attendance/<int:record_id>/details')
|
||||
@student_required
|
||||
def attendance_details(record_id):
|
||||
"""考勤详细信息"""
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
# 获取周考勤汇总记录(确保只能查看自己的记录)
|
||||
record = WeeklyAttendance.query.filter_by(
|
||||
record_id=record_id,
|
||||
student_number=current_user.student_number
|
||||
).first_or_404()
|
||||
|
||||
# 获取学生信息
|
||||
student = Student.query.filter_by(student_number=current_user.student_number).first()
|
||||
|
||||
# 获取该周的每日考勤明细
|
||||
daily_details = DailyAttendanceDetail.query.filter_by(
|
||||
weekly_record_id=record_id
|
||||
).order_by(DailyAttendanceDetail.attendance_date).all()
|
||||
|
||||
# 处理每日详情,计算工作时长和解析详细信息
|
||||
processed_daily_details = []
|
||||
for detail in daily_details:
|
||||
processed_detail = {
|
||||
'detail_id': detail.detail_id,
|
||||
'attendance_date': detail.attendance_date,
|
||||
'status': detail.status,
|
||||
'check_in_time': detail.check_in_time,
|
||||
'check_out_time': detail.check_out_time,
|
||||
'remarks': detail.remarks,
|
||||
'duration_hours': None,
|
||||
'detailed_info': None
|
||||
}
|
||||
|
||||
# 计算工作时长
|
||||
if detail.check_in_time and detail.check_out_time:
|
||||
try:
|
||||
# 创建完整的datetime对象
|
||||
start_datetime = datetime.combine(detail.attendance_date, detail.check_in_time)
|
||||
end_datetime = datetime.combine(detail.attendance_date, detail.check_out_time)
|
||||
|
||||
# 如果结束时间小于开始时间,说明跨天了
|
||||
if end_datetime < start_datetime:
|
||||
end_datetime += timedelta(days=1)
|
||||
|
||||
duration = (end_datetime - start_datetime).total_seconds() / 3600
|
||||
processed_detail['duration_hours'] = round(duration, 1)
|
||||
except Exception as e:
|
||||
print(f"计算工作时长失败: {e}")
|
||||
processed_detail['duration_hours'] = None
|
||||
|
||||
# 解析详细信息
|
||||
if detail.remarks:
|
||||
try:
|
||||
if detail.remarks.startswith('{'):
|
||||
remarks_data = json.loads(detail.remarks)
|
||||
processed_detail['detailed_info'] = remarks_data.get('details')
|
||||
processed_detail['summary_remarks'] = remarks_data.get('summary', detail.remarks)
|
||||
else:
|
||||
processed_detail['summary_remarks'] = detail.remarks
|
||||
except:
|
||||
processed_detail['summary_remarks'] = detail.remarks
|
||||
|
||||
processed_daily_details.append(processed_detail)
|
||||
|
||||
# 计算统计数据
|
||||
total_days = len(processed_daily_details)
|
||||
present_days = len([d for d in processed_daily_details if d['status'] == '正常'])
|
||||
late_days = len([d for d in processed_daily_details if '迟到' in d['status']])
|
||||
absent_days = len([d for d in processed_daily_details if d['status'] == '缺勤'])
|
||||
|
||||
# 计算平均每日工作时长
|
||||
if processed_daily_details:
|
||||
avg_daily_hours = record.actual_work_hours / max(present_days, 1)
|
||||
else:
|
||||
avg_daily_hours = 0
|
||||
|
||||
# 获取该学生最近的其他考勤记录(用于对比)
|
||||
recent_records = WeeklyAttendance.query.filter_by(
|
||||
student_number=current_user.student_number
|
||||
).filter(WeeklyAttendance.record_id != record_id).order_by(
|
||||
desc(WeeklyAttendance.week_start_date)
|
||||
).limit(5).all()
|
||||
|
||||
return render_template('student/attendance_details.html',
|
||||
record=record,
|
||||
student=student,
|
||||
daily_details=processed_daily_details,
|
||||
total_days=total_days,
|
||||
present_days=present_days,
|
||||
late_days=late_days,
|
||||
absent_days=absent_days,
|
||||
avg_daily_hours=avg_daily_hours,
|
||||
recent_records=recent_records)
|
||||
|
||||
|
||||
@student_bp.route('/statistics')
|
||||
@login_required
|
||||
def statistics():
|
||||
"""学生个人统计"""
|
||||
from sqlalchemy import desc, func, case
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 获取当前学生信息
|
||||
student = Student.query.filter_by(student_number=current_user.student_number).first_or_404()
|
||||
|
||||
# 获取筛选参数
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
|
||||
# 构建考勤记录查询
|
||||
attendance_query = WeeklyAttendance.query.filter_by(
|
||||
student_number=current_user.student_number
|
||||
)
|
||||
|
||||
# 应用日期筛选
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
attendance_query = attendance_query.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||||
except ValueError:
|
||||
flash('开始日期格式错误', 'error')
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
attendance_query = attendance_query.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||||
except ValueError:
|
||||
flash('结束日期格式错误', 'error')
|
||||
|
||||
# 获取考勤记录,按周排序
|
||||
attendance_records = attendance_query.order_by(desc(WeeklyAttendance.week_start_date)).all()
|
||||
|
||||
# 计算统计数据
|
||||
total_stats = {
|
||||
'total_work_hours': sum(record.actual_work_hours for record in attendance_records),
|
||||
'total_class_hours': sum(record.class_work_hours for record in attendance_records),
|
||||
'total_overtime_hours': sum(record.overtime_hours for record in attendance_records),
|
||||
'total_absent_days': sum(record.absent_days for record in attendance_records),
|
||||
'attendance_weeks': len(attendance_records)
|
||||
}
|
||||
|
||||
# 计算迟到次数
|
||||
total_late_count = db.session.query(
|
||||
func.sum(
|
||||
case(
|
||||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||||
else_=0
|
||||
)
|
||||
)
|
||||
).join(
|
||||
WeeklyAttendance, DailyAttendanceDetail.weekly_record_id == WeeklyAttendance.record_id
|
||||
).filter(WeeklyAttendance.student_number == current_user.student_number)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
total_late_count = total_late_count.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
total_late_count = total_late_count.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_stats['total_late_count'] = int(total_late_count.scalar() or 0)
|
||||
|
||||
# 计算平均值
|
||||
if total_stats['attendance_weeks'] > 0:
|
||||
total_stats['avg_weekly_hours'] = round(total_stats['total_work_hours'] / total_stats['attendance_weeks'], 1)
|
||||
total_stats['avg_weekly_class_hours'] = round(
|
||||
total_stats['total_class_hours'] / total_stats['attendance_weeks'], 1)
|
||||
else:
|
||||
total_stats['avg_weekly_hours'] = 0
|
||||
total_stats['avg_weekly_class_hours'] = 0
|
||||
|
||||
# 按月统计
|
||||
monthly_stats = db.session.query(
|
||||
func.date_format(WeeklyAttendance.week_start_date, '%Y-%m').label('month'),
|
||||
func.count(WeeklyAttendance.record_id).label('record_count'),
|
||||
func.sum(WeeklyAttendance.actual_work_hours).label('total_hours'),
|
||||
func.sum(WeeklyAttendance.class_work_hours).label('class_hours'),
|
||||
func.sum(WeeklyAttendance.overtime_hours).label('overtime_hours'),
|
||||
func.sum(WeeklyAttendance.absent_days).label('absent_days')
|
||||
).filter_by(student_number=current_user.student_number)
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
monthly_stats = monthly_stats.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
monthly_stats = monthly_stats.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
monthly_stats = monthly_stats.group_by('month').order_by('month').all()
|
||||
|
||||
# 最近几周的趋势数据
|
||||
recent_weeks = attendance_query.order_by(desc(WeeklyAttendance.week_start_date)).limit(12).all()
|
||||
recent_weeks.reverse() # 按时间正序排列
|
||||
|
||||
# 计算入学以来的总体表现
|
||||
all_time_stats = None
|
||||
if student.enrollment_date:
|
||||
all_time_query = WeeklyAttendance.query.filter_by(
|
||||
student_number=current_user.student_number
|
||||
)
|
||||
|
||||
all_records = all_time_query.all()
|
||||
if all_records:
|
||||
# 计算入学以来的总统计
|
||||
enrollment_weeks = (datetime.now().date() - student.enrollment_date).days // 7
|
||||
all_time_stats = {
|
||||
'total_work_hours': sum(record.actual_work_hours for record in all_records),
|
||||
'total_class_hours': sum(record.class_work_hours for record in all_records),
|
||||
'total_overtime_hours': sum(record.overtime_hours for record in all_records),
|
||||
'total_absent_days': sum(record.absent_days for record in all_records),
|
||||
'attendance_weeks': len(all_records),
|
||||
'enrollment_weeks': enrollment_weeks,
|
||||
'attendance_rate': round(len(all_records) / max(enrollment_weeks, 1) * 100,
|
||||
1) if enrollment_weeks > 0 else 0
|
||||
}
|
||||
|
||||
# 计算入学以来的迟到次数
|
||||
all_time_late_count = db.session.query(
|
||||
func.sum(
|
||||
case(
|
||||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||||
else_=0
|
||||
)
|
||||
)
|
||||
).join(
|
||||
WeeklyAttendance, DailyAttendanceDetail.weekly_record_id == WeeklyAttendance.record_id
|
||||
).filter(WeeklyAttendance.student_number == current_user.student_number).scalar()
|
||||
|
||||
all_time_stats['total_late_count'] = int(all_time_late_count or 0)
|
||||
|
||||
return render_template('student/statistics.html',
|
||||
student=student,
|
||||
attendance_records=attendance_records,
|
||||
total_stats=total_stats,
|
||||
monthly_stats=monthly_stats,
|
||||
recent_weeks=recent_weeks,
|
||||
all_time_stats=all_time_stats,
|
||||
start_date=start_date,
|
||||
end_date=end_date)
|
||||
|
0
app/static/css/admin.css
Normal file
0
app/static/css/admin.css
Normal file
257
app/static/css/style.css
Normal file
257
app/static/css/style.css
Normal file
@ -0,0 +1,257 @@
|
||||
/* 全局样式 */
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #198754;
|
||||
--danger-color: #dc3545;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #0dcaf0;
|
||||
--sidebar-width: 250px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0 0.25rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main-content {
|
||||
margin-top: 76px; /* 导航栏高度 */
|
||||
min-height: calc(100vh - 76px);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.full-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem 0.75rem 0 0 !important;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.table {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
/* 分页样式 */
|
||||
.pagination {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
border-radius: 0.375rem;
|
||||
margin: 0 0.125rem;
|
||||
border: 1px solid #dee2e6;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background-color: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-control {
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ced4da;
|
||||
padding: 0.75rem;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* 徽章样式 */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* 状态颜色 */
|
||||
.status-normal { color: var(--success-color); }
|
||||
.status-late { color: var(--warning-color); }
|
||||
.status-absent { color: var(--danger-color); }
|
||||
.status-leave { color: var(--info-color); }
|
||||
|
||||
/* 统计卡片 */
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card .stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* 登录页面样式 */
|
||||
.min-vh-100 {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-top: 66px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-group-vertical {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-group-vertical .btn {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.navbar, .btn, .pagination {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: none;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
0
app/static/js/admin.js
Normal file
0
app/static/js/admin.js
Normal file
17
app/static/js/main.js
Normal file
17
app/static/js/main.js
Normal file
@ -0,0 +1,17 @@
|
||||
// 基础JavaScript功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 初始化所有提示框
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// 自动隐藏alert消息
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000); // 5秒后自动关闭
|
||||
});
|
||||
});
|
247
app/templates/admin/add_student.html
Normal file
247
app/templates/admin/add_student.html
Normal file
@ -0,0 +1,247 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}添加学生 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-user-plus me-2"></i>添加学生
|
||||
</h1>
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回学生列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 添加学生表单 -->
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-info-circle me-2"></i>学生基本信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="addStudentForm" method="POST">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学号 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="student_number" required
|
||||
placeholder="请输入学号">
|
||||
<div class="form-text">学号将作为登录账号使用</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">姓名 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" required
|
||||
placeholder="请输入姓名">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">性别 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="gender" required>
|
||||
<option value="">请选择性别</option>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">年级 <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" name="grade" required
|
||||
min="2020" max="2030" placeholder="如:2023">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">手机号</label>
|
||||
<input type="tel" class="form-control" name="phone" maxlength="11"
|
||||
pattern="1[3-9]\d{9}" placeholder="请输入11位手机号">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学院</label>
|
||||
<input type="text" class="form-control" name="college"
|
||||
placeholder="请输入学院名称">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">专业</label>
|
||||
<input type="text" class="form-control" name="major"
|
||||
placeholder="请输入专业名称">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">导师</label>
|
||||
<input type="text" class="form-control" name="supervisor"
|
||||
placeholder="请输入导师姓名">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学位类型</label>
|
||||
<select class="form-select" name="degree_type">
|
||||
<option value="">请选择学位类型</option>
|
||||
<option value="专硕">专硕</option>
|
||||
<option value="学硕">学硕</option>
|
||||
<option value="学博">学博</option>
|
||||
<option value="专博">专博</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">入学日期</label>
|
||||
<input type="date" class="form-control" name="enrollment_date">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">状态</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="在读" selected>在读</option>
|
||||
<option value="毕业">毕业</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">初始密码</label>
|
||||
<input type="password" class="form-control" name="password"
|
||||
placeholder="默认为123456" value="123456">
|
||||
<div class="form-text">学生可在登录后自行修改密码</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-1"></i>取消
|
||||
</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-save me-1"></i>保存学生信息
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('addStudentForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// 表单验证
|
||||
if (!data.student_number || !data.name || !data.gender || !data.grade) {
|
||||
alert('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
if (data.phone && !/^1[3-9]\d{9}$/.test(data.phone)) {
|
||||
alert('手机号格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用提交按钮
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>保存中...';
|
||||
|
||||
fetch('/admin/students/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// 显示成功消息
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-success alert-dismissible fade show';
|
||||
alertDiv.innerHTML = `
|
||||
<i class="fas fa-check-circle me-2"></i>${result.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
// 插入到表单前面
|
||||
const card = document.querySelector('.card-body');
|
||||
card.insertBefore(alertDiv, card.firstChild);
|
||||
|
||||
// 3秒后跳转到学生列表
|
||||
setTimeout(() => {
|
||||
window.location.href = '/admin/students';
|
||||
}, 2000);
|
||||
} else {
|
||||
// 显示错误消息
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||||
alertDiv.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>${result.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
const card = document.querySelector('.card-body');
|
||||
card.insertBefore(alertDiv, card.firstChild);
|
||||
|
||||
// 恢复提交按钮
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('网络错误,请稍后重试');
|
||||
|
||||
// 恢复提交按钮
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
});
|
||||
});
|
||||
|
||||
// 学号输入框失焦时检查是否已存在
|
||||
document.querySelector('input[name="student_number"]').addEventListener('blur', function() {
|
||||
const studentNumber = this.value.trim();
|
||||
if (studentNumber) {
|
||||
// 这里可以添加AJAX检查学号是否已存在的逻辑
|
||||
// 为了简化,暂时不实现
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
571
app/templates/admin/attendance_details.html
Normal file
571
app/templates/admin/attendance_details.html
Normal file
@ -0,0 +1,571 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}考勤详情 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-calendar-check me-2"></i>考勤详情
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user me-2"></i>学生信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p><strong>学号:</strong> {{ weekly_record.student_number }}</p>
|
||||
<p><strong>姓名:</strong> {{ weekly_record.name }}</p>
|
||||
{% if student %}
|
||||
<p><strong>年级:</strong> {{ student.grade }}</p>
|
||||
<p><strong>学院:</strong> {{ student.college or '未设置' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{% if student %}
|
||||
<p><strong>专业:</strong> {{ student.major or '未设置' }}</p>
|
||||
<p><strong>导师:</strong> {{ student.supervisor or '未设置' }}</p>
|
||||
<p><strong>学位类型:</strong> {{ student.degree_type or '未设置' }}</p>
|
||||
<p><strong>状态:</strong>
|
||||
<span class="badge bg-{{ 'success' if student.status == '在读' else 'secondary' }}">
|
||||
{{ student.status }}
|
||||
</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-calendar-week me-2"></i>考勤周期
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p><strong>开始日期:</strong> {{ weekly_record.week_start_date.strftime('%Y年%m月%d日') }}</p>
|
||||
<p><strong>结束日期:</strong> {{ weekly_record.week_end_date.strftime('%Y年%m月%d日') }}</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p><strong>创建时间:</strong> {{ weekly_record.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
<p><strong>更新时间:</strong> {{ weekly_record.updated_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
实际出勤时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(weekly_record.actual_work_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
班内工作时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(weekly_record.class_work_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
旷工天数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ weekly_record.absent_days }}天
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
加班时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(weekly_record.overtime_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-moon fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日考勤明细 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-list me-2"></i>每日考勤明细
|
||||
<small class="text-muted">(点击日期查看详细时段信息)</small>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if daily_details %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>星期</th>
|
||||
<th>考勤状态</th>
|
||||
<th>签到时间</th>
|
||||
<th>签退时间</th>
|
||||
<th>工作时长</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for detail in daily_details %}
|
||||
<tr>
|
||||
<td>{{ detail.attendance_date.strftime('%m-%d') }}</td>
|
||||
<td>
|
||||
{% set weekday = detail.attendance_date.weekday() %}
|
||||
{% if weekday == 0 %}周一
|
||||
{% elif weekday == 1 %}周二
|
||||
{% elif weekday == 2 %}周三
|
||||
{% elif weekday == 3 %}周四
|
||||
{% elif weekday == 4 %}周五
|
||||
{% elif weekday == 5 %}周六
|
||||
{% else %}周日
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.status == '正常' %}
|
||||
<span class="badge bg-success">{{ detail.status }}</span>
|
||||
{% elif '迟到' in detail.status %}
|
||||
<span class="badge bg-warning">{{ detail.status }}</span>
|
||||
{% elif detail.status == '缺勤' %}
|
||||
<span class="badge bg-danger">{{ detail.status }}</span>
|
||||
{% elif detail.status == '请假' %}
|
||||
<span class="badge bg-orange">{{ detail.status }}</span>
|
||||
{% elif detail.status == '休息' %}
|
||||
<span class="badge bg-info">{{ detail.status }}</span>
|
||||
{% elif detail.status == '加班' %}
|
||||
<span class="badge bg-primary">{{ detail.status }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ detail.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.check_in_time %}
|
||||
{{ detail.check_in_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">未打卡</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.check_out_time %}
|
||||
{{ detail.check_out_time.strftime('%H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">未打卡</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.duration_hours %}
|
||||
<span class="badge bg-primary">{{ detail.duration_hours }}h</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.summary_remarks %}
|
||||
<small class="text-muted">{{ detail.summary_remarks }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="showDetailModal('{{ detail.detail_id }}', '{{ detail.attendance_date.strftime('%Y-%m-%d') }}', '{{ detail.remarks|escape }}')"
|
||||
title="查看详细时段">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-3">
|
||||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">暂无每日考勤明细</h5>
|
||||
<p class="text-muted">该考勤周期内没有详细的打卡记录</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计分析和历史对比的其他部分... -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-pie me-2"></i>考勤统计分析
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-success">{{ present_days }}</h6>
|
||||
<small class="text-muted">正常天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-warning">{{ late_days }}</h6>
|
||||
<small class="text-muted">迟到天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-danger">{{ absent_days }}</h6>
|
||||
<small class="text-muted">缺勤天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<h6 class="text-info">{{ "%.1f"|format(avg_daily_hours) }}h</h6>
|
||||
<small class="text-muted">日均时长</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-history me-2"></i>历史对比
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if historical_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>周期</th>
|
||||
<th>出勤时长</th>
|
||||
<th>旷工天数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in historical_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<small>{{ record.week_start_date.strftime('%m-%d') }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-warning">{{ record.absent_days }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center mb-0">暂无历史记录</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细时段信息模态框 -->
|
||||
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered"> <!-- 添加 modal-dialog-centered -->
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="detailModalLabel">
|
||||
<i class="fas fa-clock me-2"></i>详细打卡时段 - <span id="modalDate"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="detailContent">
|
||||
<!-- 内容将通过JavaScript动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||||
}
|
||||
|
||||
.period-card {
|
||||
border: 1px solid #e3e6f0;
|
||||
border-radius: 0.35rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.period-header {
|
||||
font-weight: 600;
|
||||
color: #5a5c69;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge.bg-orange {
|
||||
background-color: #fd7e14 !important;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function showDetailModal(detailId, date, remarksJson) {
|
||||
document.getElementById('modalDate').textContent = date;
|
||||
|
||||
console.log('调用showDetailModal', detailId, date, remarksJson); // 调试信息
|
||||
|
||||
let detailsData = null;
|
||||
|
||||
try {
|
||||
if (remarksJson && remarksJson.startsWith('{')) {
|
||||
const parsed = JSON.parse(remarksJson);
|
||||
detailsData = parsed.details;
|
||||
console.log('解析的详细数据:', detailsData); // 调试信息
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析详细信息失败:', e);
|
||||
}
|
||||
|
||||
if (!detailsData) {
|
||||
document.getElementById('detailContent').innerHTML =
|
||||
'<p class="text-muted">暂无详细时段信息</p><p class="text-muted">原始数据: ' + remarksJson + '</p>';
|
||||
} else {
|
||||
let html = '';
|
||||
|
||||
// 显示各个时段的详情
|
||||
const periods = [
|
||||
{ key: 'morning', name: '早上时段', time: '09:45-11:30' },
|
||||
{ key: 'afternoon', name: '下午时段', time: '13:30-18:30' },
|
||||
{ key: 'evening', name: '晚上时段', time: '19:00-23:30' }
|
||||
];
|
||||
|
||||
periods.forEach(period => {
|
||||
if (detailsData[period.key]) {
|
||||
const data = detailsData[period.key];
|
||||
html += `
|
||||
<div class="period-card">
|
||||
<div class="period-header">
|
||||
<i class="fas fa-clock me-2"></i>${period.name} (${period.time})
|
||||
</div>
|
||||
<div class="time-info">
|
||||
<div>
|
||||
<strong>签到:</strong>
|
||||
<span class="badge ${getStatusClass(data.status, 'in')}">
|
||||
${data.in || '未打卡'}
|
||||
</span>
|
||||
${data.late_minutes ? `<small class="text-warning">(迟到${data.late_minutes}分钟)</small>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>签退:</strong>
|
||||
<span class="badge ${getStatusClass(data.status, 'out')}">
|
||||
${data.out || '未打卡'}
|
||||
</span>
|
||||
${data.early_minutes ? `<small class="text-warning">(早退${data.early_minutes}分钟)</small>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>工时:</strong>
|
||||
${calculatePeriodHours(data.in, data.out)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// 如果是周末加班
|
||||
if (detailsData.overtime) {
|
||||
html += `
|
||||
<div class="period-card">
|
||||
<div class="period-header">
|
||||
<i class="fas fa-moon me-2"></i>周末加班
|
||||
</div>
|
||||
<div class="time-info">
|
||||
<div>
|
||||
<strong>开始:</strong>
|
||||
<span class="badge bg-info">${detailsData.overtime.in || '未记录'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>结束:</strong>
|
||||
<span class="badge bg-info">${detailsData.overtime.out || '未记录'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>加班时长:</strong>
|
||||
${calculatePeriodHours(detailsData.overtime.in, detailsData.overtime.out)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (html === '') {
|
||||
html = '<p class="text-muted">该日期没有详细的时段打卡信息</p>';
|
||||
}
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function getStatusClass(status, type) {
|
||||
if (status === 'normal') return 'bg-success';
|
||||
if (status === 'late' && type === 'in') return 'bg-warning';
|
||||
if (status === 'early_leave' && type === 'out') return 'bg-warning';
|
||||
if (status === 'missing') return 'bg-secondary';
|
||||
return 'bg-secondary';
|
||||
}
|
||||
|
||||
function calculatePeriodHours(startTime, endTime) {
|
||||
if (!startTime || !endTime) return '<span class="text-muted">-</span>';
|
||||
|
||||
try {
|
||||
const start = new Date(`2000-01-01 ${startTime}:00`);
|
||||
const end = new Date(`2000-01-01 ${endTime}:00`);
|
||||
const diff = (end - start) / (1000 * 60 * 60);
|
||||
|
||||
if (diff > 0) {
|
||||
return `<span class="badge bg-primary">${diff.toFixed(1)}h</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('计算时长失败:', e);
|
||||
}
|
||||
|
||||
return '<span class="text-muted">-</span>';
|
||||
}
|
||||
|
||||
// 测试函数
|
||||
function testModal() {
|
||||
console.log('测试模态框');
|
||||
document.getElementById('modalDate').textContent = '测试日期';
|
||||
document.getElementById('detailContent').innerHTML = '<p>测试内容</p>';
|
||||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
656
app/templates/admin/attendance_management.html
Normal file
656
app/templates/admin/attendance_management.html
Normal file
@ -0,0 +1,656 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}考勤管理 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-calendar-check me-2"></i>考勤管理
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-primary me-2">
|
||||
<i class="fas fa-upload me-2"></i>上传数据
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-home me-2"></i>返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-search me-2"></i>搜索筛选
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3" id="searchForm">
|
||||
<div class="col-md-2">
|
||||
<label for="start_date" class="form-label">开始日期</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||||
value="{{ start_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="end_date" class="form-label">结束日期</label>
|
||||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||||
value="{{ end_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="student_search" class="form-label">学生姓名/学号</label>
|
||||
<input type="text" class="form-control" id="student_search" name="student_search"
|
||||
placeholder="输入姓名或学号" value="{{ student_search or '' }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="sort_by" class="form-label">排序方式</label>
|
||||
<select class="form-select" id="sort_by" name="sort_by">
|
||||
<option value="created_at_desc" {{ 'selected' if sort_by == 'created_at_desc' else '' }}>最新记录</option>
|
||||
<option value="created_at_asc" {{ 'selected' if sort_by == 'created_at_asc' else '' }}>最早记录</option>
|
||||
<option value="actual_work_hours_desc" {{ 'selected' if sort_by == 'actual_work_hours_desc' else '' }}>出勤时长↓</option>
|
||||
<option value="actual_work_hours_asc" {{ 'selected' if sort_by == 'actual_work_hours_asc' else '' }}>出勤时长↑</option>
|
||||
<option value="class_work_hours_desc" {{ 'selected' if sort_by == 'class_work_hours_desc' else '' }}>班内工作↓</option>
|
||||
<option value="class_work_hours_asc" {{ 'selected' if sort_by == 'class_work_hours_asc' else '' }}>班内工作↑</option>
|
||||
<option value="absent_days_desc" {{ 'selected' if sort_by == 'absent_days_desc' else '' }}>旷工天数↓</option>
|
||||
<option value="absent_days_asc" {{ 'selected' if sort_by == 'absent_days_asc' else '' }}>旷工天数↑</option>
|
||||
<option value="late_count_desc" {{ 'selected' if sort_by == 'late_count_desc' else '' }}>迟到次数↓</option>
|
||||
<option value="late_count_asc" {{ 'selected' if sort_by == 'late_count_asc' else '' }}>迟到次数↑</option>
|
||||
<option value="overtime_hours_desc" {{ 'selected' if sort_by == 'overtime_hours_desc' else '' }}>加班时长↓</option>
|
||||
<option value="overtime_hours_asc" {{ 'selected' if sort_by == 'overtime_hours_asc' else '' }}>加班时长↑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary me-2">
|
||||
<i class="fas fa-search me-1"></i>搜索
|
||||
</button>
|
||||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-refresh"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-info" onclick="toggleAdvancedSearch()" title="高级搜索">
|
||||
<i class="fas fa-cog"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 隐藏字段保持分页状态 -->
|
||||
<input type="hidden" name="page" value="{{ pagination.page if pagination else 1 }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 考勤记录表格 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-table me-2"></i>考勤记录
|
||||
</h6>
|
||||
{% if attendance_records %}
|
||||
<span class="badge bg-info">
|
||||
共 {{ pagination.total }} 条记录
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if attendance_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>考勤周期</th>
|
||||
<th class="sortable" data-sort="actual_work_hours">
|
||||
出勤时长
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="class_work_hours">
|
||||
班内工作
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="absent_days">
|
||||
旷工天数
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="late_count">
|
||||
迟到次数
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="overtime_hours">
|
||||
加班时长
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="created_at">
|
||||
记录时间
|
||||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||||
</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in attendance_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.student_detail', student_number=record.student_number) }}"
|
||||
class="text-decoration-none">
|
||||
{{ record.student_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ record.name }}</td>
|
||||
<td>
|
||||
<small>
|
||||
{{ record.week_start_date.strftime('%Y-%m-%d') }}<br>
|
||||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{{ "%.1f"|format(record.actual_work_hours) }}h
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">
|
||||
{{ "%.1f"|format(record.class_work_hours) }}h
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-warning">{{ record.absent_days }}天</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0天</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set late_count = record.late_count if record.late_count is defined else 0 %}
|
||||
{% if late_count > 0 %}
|
||||
<span class="badge bg-warning">{{ late_count }}次</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0次</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if record.overtime_hours > 0 %}
|
||||
<span class="badge bg-info">
|
||||
{{ "%.1f"|format(record.overtime_hours) }}h
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">0h</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="viewDetails({{ record.record_id }})"
|
||||
title="查看详情">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="deleteRecord({{ record.record_id }}, '{{ record.name }}')"
|
||||
title="删除">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="考勤记录分页">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||||
page=pagination.prev_num,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
student_search=student_search,
|
||||
sort_by=sort_by) }}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != pagination.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||||
page=page_num,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
student_search=student_search,
|
||||
sort_by=sort_by) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||||
page=pagination.next_num,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
student_search=student_search,
|
||||
sort_by=sort_by) }}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- 空状态 -->
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">暂无考勤记录</h5>
|
||||
<p class="text-muted mb-4">还没有上传任何考勤数据</p>
|
||||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload me-2"></i>立即上传考勤数据
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息卡片 -->
|
||||
{% if attendance_records %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-bar me-2"></i>当前筛选统计
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-2">
|
||||
<div class="border-end">
|
||||
<h6 class="text-primary">{{ pagination.total }}</h6>
|
||||
<small class="text-muted">总记录</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div class="border-end">
|
||||
<h6 class="text-success">
|
||||
{{ attendance_records|sum(attribute='actual_work_hours')|round(1) }}h
|
||||
</h6>
|
||||
<small class="text-muted">总出勤</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<div class="border-end">
|
||||
<h6 class="text-danger">
|
||||
{{ attendance_records|sum(attribute='absent_days') }}
|
||||
</h6>
|
||||
<small class="text-muted">旷工</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<div class="border-end">
|
||||
<h6 class="text-warning">
|
||||
{% set total_leave = statistics.total_leave_days if statistics and statistics.total_leave_days else 0 %}
|
||||
{{ total_leave }}
|
||||
</h6>
|
||||
<small class="text-muted">请假</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div class="border-end">
|
||||
<h6 class="text-warning">
|
||||
{% set total_late = attendance_records|sum(attribute='late_count') if attendance_records[0].late_count is defined else 0 %}
|
||||
{{ total_late }}
|
||||
</h6>
|
||||
<small class="text-muted">迟到次数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div class="border-end">
|
||||
<h6 class="text-info">
|
||||
{{ attendance_records|sum(attribute='overtime_hours')|round(1) }}h
|
||||
</h6>
|
||||
<small class="text-muted">总加班</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<h6 class="text-secondary">
|
||||
{{ "%.1f"|format((attendance_records|sum(attribute='actual_work_hours')) / (attendance_records|length) if attendance_records|length > 0 else 0) }}h
|
||||
</h6>
|
||||
<small class="text-muted">平均出勤</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-tools me-2"></i>快捷操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('admin.upload_attendance') }}"
|
||||
class="btn btn-outline-primary btn-block">
|
||||
<i class="fas fa-upload me-2"></i>上传新数据
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('admin.student_list') }}"
|
||||
class="btn btn-outline-success btn-block">
|
||||
<i class="fas fa-users me-2"></i>学生管理
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('admin.statistics') }}"
|
||||
class="btn btn-outline-info btn-block">
|
||||
<i class="fas fa-chart-line me-2"></i>统计报表
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<button class="btn btn-outline-secondary btn-block" onclick="exportData()">
|
||||
<i class="fas fa-download me-2"></i>导出数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>确认删除
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>您确定要删除 <strong id="studentName"></strong> 的这条考勤记录吗?</p>
|
||||
<p class="text-danger"><small>此操作不可撤销!</small></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDelete">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fc;
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #5a5c69;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-group-sm > .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 排序功能样式 */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable:hover {
|
||||
background-color: #e9ecef !important;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: 0.7rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable:hover .sort-icon {
|
||||
color: #007bff !important;
|
||||
}
|
||||
|
||||
.sortable.sort-active {
|
||||
background-color: #e7f1ff !important;
|
||||
}
|
||||
|
||||
.sortable.sort-active .sort-icon {
|
||||
color: #007bff !important;
|
||||
}
|
||||
|
||||
.sortable.sort-asc .sort-icon:before {
|
||||
content: "\f0de"; /* fa-sort-up */
|
||||
}
|
||||
|
||||
.sortable.sort-desc .sort-icon:before {
|
||||
content: "\f0dd"; /* fa-sort-down */
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.col-2 {
|
||||
flex: 0 0 33.333333%;
|
||||
max-width: 33.333333%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let recordToDelete = null;
|
||||
|
||||
function viewDetails(recordId) {
|
||||
window.location.href = `/admin/attendance/${recordId}/details`;
|
||||
}
|
||||
|
||||
function editRecord(recordId) {
|
||||
window.location.href = `/admin/attendance/${recordId}/edit`;
|
||||
}
|
||||
|
||||
function deleteRecord(recordId, studentName) {
|
||||
recordToDelete = recordId;
|
||||
document.getElementById('studentName').textContent = studentName;
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
document.getElementById('confirmDelete').addEventListener('click', function() {
|
||||
if (recordToDelete) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/admin/attendance/${recordToDelete}/delete`;
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (csrfToken) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'csrf_token';
|
||||
input.value = csrfToken.getAttribute('content');
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
function exportData() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('export', 'excel');
|
||||
window.location.href = '{{ url_for("admin.attendance_management") }}?' + params.toString();
|
||||
}
|
||||
|
||||
// 自动设置结束日期为开始日期的一周后
|
||||
document.getElementById('start_date').addEventListener('change', function() {
|
||||
const startDate = new Date(this.value);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(startDate.getDate() + 6);
|
||||
document.getElementById('end_date').value = endDate.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// 排序选择器变化时自动提交表单
|
||||
document.getElementById('sort_by').addEventListener('change', function() {
|
||||
// 重置到第一页
|
||||
const pageInput = document.querySelector('input[name="page"]');
|
||||
if (pageInput) {
|
||||
pageInput.value = 1;
|
||||
}
|
||||
document.getElementById('searchForm').submit();
|
||||
});
|
||||
|
||||
// 获取当前URL参数的函数
|
||||
function getUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
start_date: params.get('start_date') || '',
|
||||
end_date: params.get('end_date') || '',
|
||||
student_search: params.get('student_search') || '',
|
||||
sort_by: params.get('sort_by') || 'created_at_desc'
|
||||
};
|
||||
}
|
||||
|
||||
// 构建新的排序URL
|
||||
function buildSortUrl(sortField, currentParams) {
|
||||
const currentSort = currentParams.sort_by;
|
||||
let newSort;
|
||||
|
||||
if (currentSort === `${sortField}_asc`) {
|
||||
newSort = `${sortField}_desc`;
|
||||
} else {
|
||||
newSort = `${sortField}_asc`;
|
||||
}
|
||||
|
||||
const url = new URL(window.location.origin + window.location.pathname);
|
||||
url.searchParams.set('sort_by', newSort);
|
||||
if (currentParams.start_date) url.searchParams.set('start_date', currentParams.start_date);
|
||||
if (currentParams.end_date) url.searchParams.set('end_date', currentParams.end_date);
|
||||
if (currentParams.student_search) url.searchParams.set('student_search', currentParams.student_search);
|
||||
// 重置到第一页
|
||||
url.searchParams.set('page', '1');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// 表头排序功能
|
||||
function setupTableSorting() {
|
||||
const sortableHeaders = document.querySelectorAll('.sortable');
|
||||
const currentParams = getUrlParams();
|
||||
|
||||
console.log('当前URL参数:', currentParams); // 调试信息
|
||||
|
||||
sortableHeaders.forEach(header => {
|
||||
header.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const sortField = this.getAttribute('data-sort');
|
||||
console.log('点击排序字段:', sortField); // 调试信息
|
||||
|
||||
const newUrl = buildSortUrl(sortField, currentParams);
|
||||
console.log('新URL:', newUrl); // 调试信息
|
||||
|
||||
window.location.href = newUrl;
|
||||
});
|
||||
});
|
||||
|
||||
// 更新当前排序状态的显示
|
||||
const currentSortBy = currentParams.sort_by;
|
||||
console.log('当前排序:', currentSortBy); // 调试用
|
||||
|
||||
if (currentSortBy && currentSortBy.includes('_')) {
|
||||
// 移除所有现有的排序状态
|
||||
sortableHeaders.forEach(header => {
|
||||
header.classList.remove('sort-active', 'sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
// 解析排序字段和方向
|
||||
const lastUnderscoreIndex = currentSortBy.lastIndexOf('_');
|
||||
if (lastUnderscoreIndex > 0) {
|
||||
const field = currentSortBy.substring(0, lastUnderscoreIndex);
|
||||
const direction = currentSortBy.substring(lastUnderscoreIndex + 1);
|
||||
|
||||
console.log('解析排序:', field, direction); // 调试用
|
||||
|
||||
const header = document.querySelector(`[data-sort="${field}"]`);
|
||||
if (header) {
|
||||
header.classList.add('sort-active');
|
||||
if (direction === 'desc') {
|
||||
header.classList.add('sort-desc');
|
||||
} else {
|
||||
header.classList.add('sort-asc');
|
||||
}
|
||||
console.log('设置排序状态成功:', header.textContent.trim()); // 调试用
|
||||
} else {
|
||||
console.log('未找到排序表头:', field); // 调试用
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 高级搜索切换(预留功能)
|
||||
function toggleAdvancedSearch() {
|
||||
alert('高级搜索功能开发中...');
|
||||
}
|
||||
|
||||
// DOM加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('页面加载完成,初始化排序功能'); // 调试信息
|
||||
setupTableSorting();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
383
app/templates/admin/dashboard.html
Normal file
383
app/templates/admin/dashboard.html
Normal file
@ -0,0 +1,383 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}管理员控制台 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-tachometer-alt me-2"></i>管理员控制台
|
||||
</h1>
|
||||
<div class="text-muted" id="current-time">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
学生总数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_students or 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
考勤记录总数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_attendance_records or 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-calendar-check fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
待审批请假
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ pending_leaves or 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
本周新记录
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ recent_records or 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="row">
|
||||
<!-- 学院统计 -->
|
||||
<div class="col-xl-6 col-lg-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-university me-2"></i>学院分布
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if college_stats %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>学院</th>
|
||||
<th class="text-end">学生数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for college, count in college_stats %}
|
||||
<tr>
|
||||
<td>{{ college or '未知学院' }}</td>
|
||||
<td class="text-end">
|
||||
<span class="badge bg-primary">{{ count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-chart-bar fa-3x mb-3"></i>
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导师统计 -->
|
||||
<div class="col-xl-6 col-lg-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user-tie me-2"></i>导师排行(TOP 10)
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if supervisor_stats %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>导师</th>
|
||||
<th class="text-end">学生数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for supervisor, count in supervisor_stats %}
|
||||
<tr>
|
||||
<td>{{ supervisor or '未知导师' }}</td>
|
||||
<td class="text-end">
|
||||
<span class="badge bg-success">{{ count }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-chart-bar fa-3x mb-3"></i>
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近请假申请 -->
|
||||
{% if recent_leaves %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-file-alt me-2"></i>最近请假申请
|
||||
</h6>
|
||||
<a href="{{ url_for('admin.pending_leaves') }}" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-eye me-1"></i>查看全部
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>学号</th>
|
||||
<th>请假日期</th>
|
||||
<th>请假原因</th>
|
||||
<th>申请时间</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for leave in recent_leaves %}
|
||||
<tr>
|
||||
<td>{{ leave.student_number }}</td>
|
||||
<td>
|
||||
{{ leave.leave_start_date.strftime('%Y-%m-%d') }}
|
||||
{% if leave.leave_start_date != leave.leave_end_date %}
|
||||
至 {{ leave.leave_end_date.strftime('%Y-%m-%d') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-truncate" style="max-width: 200px; display: inline-block;"
|
||||
title="{{ leave.leave_reason }}">
|
||||
{{ leave.leave_reason[:50] }}{% if leave.leave_reason|length > 50 %}...{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ leave.created_at.strftime('%m-%d %H:%M') }}</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-success btn-sm"
|
||||
onclick="approveLeave({{ leave.leave_id }})">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="rejectLeave({{ leave.leave_id }})">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-bolt me-2"></i>快捷操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-primary btn-block h-100">
|
||||
<i class="fas fa-users fa-2x mb-2"></i><br>
|
||||
<span>学生管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-outline-success btn-block h-100">
|
||||
<i class="fas fa-calendar-check fa-2x mb-2"></i><br>
|
||||
<span>考勤管理</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-outline-info btn-block h-100">
|
||||
<i class="fas fa-upload fa-2x mb-2"></i><br>
|
||||
<span>上传数据</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<a href="{{ url_for('admin.statistics') }}" class="btn btn-outline-warning btn-block h-100">
|
||||
<i class="fas fa-chart-bar fa-2x mb-2"></i><br>
|
||||
<span>统计报表</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #5a5c69 !important;
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
color: #dddfeb !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 显示当前时间
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
const timeString = `${now.getFullYear()}年${(now.getMonth()+1).toString().padStart(2,'0')}月${now.getDate().toString().padStart(2,'0')}日 ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
|
||||
document.getElementById('current-time').innerHTML = `<i class="fas fa-clock me-1"></i>${timeString}`;
|
||||
}
|
||||
|
||||
function approveLeave(leaveId) {
|
||||
if (confirm('确认批准这个请假申请吗?')) {
|
||||
fetch(`/admin/leave/${leaveId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('操作失败,请稍后重试');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rejectLeave(leaveId) {
|
||||
if (confirm('确认拒绝这个请假申请吗?')) {
|
||||
fetch(`/admin/leave/${leaveId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('操作失败,请稍后重试');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时更新时间,然后每分钟更新一次
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCurrentTime();
|
||||
setInterval(updateCurrentTime, 60000); // 每分钟更新一次
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
307
app/templates/admin/edit_student.html
Normal file
307
app/templates/admin/edit_student.html
Normal file
@ -0,0 +1,307 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}编辑学生 - {{ student.name }} - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-user-edit me-2"></i>编辑学生信息
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||||
class="btn btn-outline-info me-2">
|
||||
<i class="fas fa-eye me-1"></i>查看详情
|
||||
</a>
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑学生表单 -->
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-info-circle me-2"></i>学生基本信息
|
||||
</h6>
|
||||
<span class="badge bg-secondary">学号: {{ student.student_number }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="editStudentForm" method="POST">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学号</label>
|
||||
<input type="text" class="form-control" name="student_number"
|
||||
value="{{ student.student_number }}" readonly
|
||||
style="background-color: #f8f9fa;">
|
||||
<div class="form-text">学号不可修改</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">姓名 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" required
|
||||
value="{{ student.name }}" placeholder="请输入姓名">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">性别 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" name="gender" required>
|
||||
<option value="">请选择性别</option>
|
||||
<option value="男" {% if student.gender == '男' %}selected{% endif %}>男</option>
|
||||
<option value="女" {% if student.gender == '女' %}selected{% endif %}>女</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">年级 <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" name="grade" required
|
||||
min="2020" max="2030" value="{{ student.grade }}" placeholder="如:2023">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">手机号</label>
|
||||
<input type="tel" class="form-control" name="phone" maxlength="11"
|
||||
pattern="1[3-9]\d{9}" value="{{ student.phone or '' }}" placeholder="请输入11位手机号">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学院</label>
|
||||
<input type="text" class="form-control" name="college"
|
||||
value="{{ student.college or '' }}" placeholder="请输入学院名称">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">专业</label>
|
||||
<input type="text" class="form-control" name="major"
|
||||
value="{{ student.major or '' }}" placeholder="请输入专业名称">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">导师</label>
|
||||
<input type="text" class="form-control" name="supervisor"
|
||||
value="{{ student.supervisor or '' }}" placeholder="请输入导师姓名">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">学位类型</label>
|
||||
<select class="form-select" name="degree_type">
|
||||
<option value="">请选择学位类型</option>
|
||||
<option value="专硕" {% if student.degree_type == '专硕' %}selected{% endif %}>专硕</option>
|
||||
<option value="学硕" {% if student.degree_type == '学硕' %}selected{% endif %}>学硕</option>
|
||||
<option value="学博" {% if student.degree_type == '学博' %}selected{% endif %}>学博</option>
|
||||
<option value="专博" {% if student.degree_type == '专博' %}selected{% endif %}>专博</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">入学日期</label>
|
||||
<input type="date" class="form-control" name="enrollment_date"
|
||||
value="{{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">状态</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="在读" {% if student.status == '在读' %}selected{% endif %}>在读</option>
|
||||
<option value="毕业" {% if student.status == '毕业' %}selected{% endif %}>毕业</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<button type="button" class="btn btn-warning" onclick="resetPassword()">
|
||||
<i class="fas fa-key me-1"></i>重置密码
|
||||
</button>
|
||||
{% if student.user %}
|
||||
<button type="button" class="btn btn-outline-secondary ms-2" onclick="toggleAccountStatus()">
|
||||
{% if student.user.is_active %}
|
||||
<i class="fas fa-ban me-1"></i>禁用账户
|
||||
{% else %}
|
||||
<i class="fas fa-check me-1"></i>启用账户
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-times me-1"></i>取消
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('editStudentForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// 表单验证
|
||||
if (!data.name || !data.gender || !data.grade) {
|
||||
alert('请填写所有必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
if (data.phone && !/^1[3-9]\d{9}$/.test(data.phone)) {
|
||||
alert('手机号格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用提交按钮
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>保存中...';
|
||||
|
||||
fetch(`/admin/students/{{ student.student_number }}/edit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// 显示成功消息
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-success alert-dismissible fade show';
|
||||
alertDiv.innerHTML = `
|
||||
<i class="fas fa-check-circle me-2"></i>${result.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
const card = document.querySelector('.card-body');
|
||||
card.insertBefore(alertDiv, card.firstChild);
|
||||
|
||||
// 滚动到顶部
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// 恢复提交按钮
|
||||
setTimeout(() => {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}, 2000);
|
||||
} else {
|
||||
// 显示错误消息
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||||
alertDiv.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>${result.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
const card = document.querySelector('.card-body');
|
||||
card.insertBefore(alertDiv, card.firstChild);
|
||||
|
||||
// 恢复提交按钮
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('网络错误,请稍后重试');
|
||||
|
||||
// 恢复提交按钮
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
});
|
||||
});
|
||||
|
||||
// 重置密码
|
||||
function resetPassword() {
|
||||
if (confirm('确认重置该学生的密码为默认密码(123456)吗?')) {
|
||||
fetch(`/admin/students/{{ student.student_number }}/reset_password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert('密码重置成功');
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 切换账户状态
|
||||
function toggleAccountStatus() {
|
||||
const isActive = {{ 'true' if student.user and student.user.is_active else 'false' }};
|
||||
const action = isActive ? '禁用' : '启用';
|
||||
|
||||
if (confirm(`确认${action}该学生的账户吗?`)) {
|
||||
fetch(`/admin/students/{{ student.student_number }}/toggle_status`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload(); // 重新加载页面以更新按钮状态
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
430
app/templates/admin/statistics.html
Normal file
430
app/templates/admin/statistics.html
Normal file
@ -0,0 +1,430 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}统计报表 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 300px !important;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
min-height: 350px; /* 确保容器有足够高度 */
|
||||
}
|
||||
|
||||
.statistics-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grade-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grade-title {
|
||||
color: #495057;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.student-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.student-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-chart-bar me-2"></i>统计报表</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.export_statistics') }}" class="btn btn-success">
|
||||
<i class="fas fa-download me-1"></i>导出数据
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总体统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="statistics-card">
|
||||
<div class="text-center">
|
||||
<h3>{{ overall_stats.total_students }}</h3>
|
||||
<p class="mb-0">总学生数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="statistics-card">
|
||||
<div class="text-center">
|
||||
<h3>{{ "%.1f"|format(overall_stats.total_work_hours) }}</h3>
|
||||
<p class="mb-0">总出勤时长(小时)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="statistics-card">
|
||||
<div class="text-center">
|
||||
<h3>{{ overall_stats.total_absent_days }}</h3>
|
||||
<p class="mb-0">总缺勤天数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="statistics-card">
|
||||
<div class="text-center">
|
||||
<h3>{{ overall_stats.total_late_count }}</h3>
|
||||
<p class="mb-0">总迟到次数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-section">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">搜索学生</label>
|
||||
<input type="text" class="form-control" name="search"
|
||||
value="{{ search }}" placeholder="姓名或学号">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">年级</label>
|
||||
<select class="form-select" name="grade">
|
||||
<option value="">全部年级</option>
|
||||
{% for grade in grades %}
|
||||
<option value="{{ grade }}" {{ 'selected' if selected_grade == grade|string }}>
|
||||
{% if grade == 1 %}研一{% elif grade == 2 %}研二{% elif grade == 3 %}研三{% else %}{{ grade }}年级{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">学院</label>
|
||||
<select class="form-select" name="college">
|
||||
<option value="">全部学院</option>
|
||||
{% for college in colleges %}
|
||||
<option value="{{ college }}" {{ 'selected' if selected_college == college }}>{{ college }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">导师</label>
|
||||
<select class="form-select" name="supervisor">
|
||||
<option value="">全部导师</option>
|
||||
{% for supervisor in supervisors %}
|
||||
<option value="{{ supervisor }}" {{ 'selected' if selected_supervisor == supervisor }}>{{ supervisor }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">时间范围</label>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<input type="date" class="form-control" name="start_date" value="{{ start_date }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="date" class="form-control" name="end_date" value="{{ end_date }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search me-1"></i>筛选
|
||||
</button>
|
||||
<a href="{{ url_for('admin.statistics') }}" class="btn btn-outline-secondary ms-2">
|
||||
<i class="fas fa-undo me-1"></i>重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 按年级分组的学生统计 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% for grade_label, students in grade_groups.items() %}
|
||||
<div class="grade-section">
|
||||
<h4 class="grade-title">
|
||||
<i class="fas fa-graduation-cap me-2"></i>{{ grade_label }} ({{ students|length }}人)
|
||||
</h4>
|
||||
|
||||
<div class="row">
|
||||
{% for student in students %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="student-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||||
class="text-decoration-none">
|
||||
{{ student.name }}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">{{ student.student_number }}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
{% if student.total_work_hours >= 200 %}
|
||||
<span class="badge bg-success">优秀</span>
|
||||
{% elif student.total_work_hours >= 100 %}
|
||||
<span class="badge bg-primary">良好</span>
|
||||
{% elif student.total_work_hours >= 50 %}
|
||||
<span class="badge bg-warning">一般</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">待改进</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row mt-3">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ "%.1f"|format(student.total_work_hours) }}</div>
|
||||
<div class="stat-label">总工时</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ student.total_absent_days }}</div>
|
||||
<div class="stat-label">缺勤天数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ student.total_late_count }}</div>
|
||||
<div class="stat-label">迟到次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ student.avg_weekly_hours }}</div>
|
||||
<div class="stat-label">周均工时</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-building me-1"></i>{{ student.college or '未设置' }} |
|
||||
<i class="fas fa-user-tie me-1"></i>{{ student.supervisor or '未设置' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not grade_groups %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">没有找到符合条件的学生</h5>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<h5><i class="fas fa-chart-line me-2"></i>月度考勤趋势</h5>
|
||||
<canvas id="monthlyChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<h5><i class="fas fa-chart-pie me-2"></i>学院分布</h5>
|
||||
<canvas id="collegeChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 检查数据
|
||||
console.log('月度统计数据:', [{% for stat in monthly_stats %}{ month: '{{ stat.month }}', hours: {{ stat.total_hours or 0 }} }{% if not loop.last %},{% endif %}{% endfor %}]);
|
||||
console.log('学院统计数据:', [{% for stat in college_stats %}{ college: '{{ stat.college or "未设置" }}', count: {{ stat.student_count }} }{% if not loop.last %},{% endif %}{% endfor %}]);
|
||||
|
||||
// 月度趋势图
|
||||
const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
|
||||
|
||||
// 准备月度数据
|
||||
const monthlyData = [{% for stat in monthly_stats %}{{ stat.total_hours or 0 }}{% if not loop.last %},{% endif %}{% endfor %}];
|
||||
const monthlyLabels = [{% for stat in monthly_stats %}'{{ stat.month }}'{% if not loop.last %},{% endif %}{% endfor %}];
|
||||
|
||||
console.log('图表数据:', monthlyData);
|
||||
console.log('图表标签:', monthlyLabels);
|
||||
|
||||
const monthlyChart = new Chart(monthlyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: monthlyLabels,
|
||||
datasets: [{
|
||||
label: '总工时',
|
||||
data: monthlyData,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '工时(小时)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '月份'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '月度工时统计'
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 学院分布图
|
||||
const collegeCtx = document.getElementById('collegeChart').getContext('2d');
|
||||
|
||||
// 准备学院数据
|
||||
const collegeData = [{% for stat in college_stats %}{{ stat.student_count }}{% if not loop.last %},{% endif %}{% endfor %}];
|
||||
const collegeLabels = [{% for stat in college_stats %}'{{ stat.college or "未设置" }}'{% if not loop.last %},{% endif %}{% endfor %}];
|
||||
|
||||
console.log('学院数据:', collegeData);
|
||||
console.log('学院标签:', collegeLabels);
|
||||
|
||||
// 只有当有数据时才创建图表
|
||||
if (collegeData.length > 0 && collegeData.some(d => d > 0)) {
|
||||
const collegeChart = new Chart(collegeCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: collegeLabels,
|
||||
datasets: [{
|
||||
data: collegeData,
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40',
|
||||
'#C7C7C7',
|
||||
'#FF6384'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '学院学生分布'
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果没有数据,显示提示信息
|
||||
collegeCtx.font = '16px Arial';
|
||||
collegeCtx.textAlign = 'center';
|
||||
collegeCtx.fillText('暂无数据', collegeCtx.canvas.width / 2, collegeCtx.canvas.height / 2);
|
||||
}
|
||||
|
||||
// 如果月度数据为空,显示提示
|
||||
if (monthlyData.length === 0 || monthlyData.every(d => d === 0)) {
|
||||
const monthlyCanvas = document.getElementById('monthlyChart');
|
||||
const ctx = monthlyCanvas.getContext('2d');
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('暂无月度数据', monthlyCanvas.width / 2, monthlyCanvas.height / 2);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
548
app/templates/admin/student_detail.html
Normal file
548
app/templates/admin/student_detail.html
Normal file
@ -0,0 +1,548 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}学生详情 - {{ student.name }} - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 返回按钮 -->
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回学生列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 学生基本信息 -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user me-2"></i>基本信息
|
||||
</h6>
|
||||
<div class="btn-group">
|
||||
<a href="{{ url_for('admin.edit_student', student_number=student.student_number) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-edit me-1"></i>编辑
|
||||
</a>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteStudent('{{ student.student_number }}')">
|
||||
<i class="fas fa-trash me-1"></i>删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<td class="fw-bold text-muted" style="width: 30%;">学号:</td>
|
||||
<td>{{ student.student_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">姓名:</td>
|
||||
<td><strong>{{ student.name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">性别:</td>
|
||||
<td>
|
||||
{% if student.gender == '男' %}
|
||||
<i class="fas fa-mars text-primary me-1"></i>{{ student.gender }}
|
||||
{% else %}
|
||||
<i class="fas fa-venus text-danger me-1"></i>{{ student.gender }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">年级:</td>
|
||||
<td>{{ student.grade }}级</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">手机号:</td>
|
||||
<td>
|
||||
{% if student.phone %}
|
||||
<i class="fas fa-phone me-1"></i>{{ student.phone }}
|
||||
{% else %}
|
||||
<span class="text-muted">未填写</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless table-sm">
|
||||
<tr>
|
||||
<td class="fw-bold text-muted" style="width: 30%;">学院:</td>
|
||||
<td>
|
||||
{% if student.college %}
|
||||
<i class="fas fa-university me-1"></i>{{ student.college }}
|
||||
{% else %}
|
||||
<span class="text-muted">未填写</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">专业:</td>
|
||||
<td>
|
||||
{% if student.major %}
|
||||
<i class="fas fa-graduation-cap me-1"></i>{{ student.major }}
|
||||
{% else %}
|
||||
<span class="text-muted">未填写</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">导师:</td>
|
||||
<td>
|
||||
{% if student.supervisor %}
|
||||
<i class="fas fa-user-tie me-1"></i>{{ student.supervisor }}
|
||||
{% else %}
|
||||
<span class="text-muted">未分配</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">学位类型:</td>
|
||||
<td>
|
||||
{% if student.degree_type %}
|
||||
<span class="badge bg-info">{{ student.degree_type }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未填写</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fw-bold text-muted">状态:</td>
|
||||
<td>
|
||||
{% if student.status == '在读' %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>在读
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-graduation-cap me-1"></i>毕业
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="fw-bold text-muted mb-2">
|
||||
<i class="fas fa-calendar-alt me-1"></i>入学日期:
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
{% if student.enrollment_date %}
|
||||
<span class="badge bg-light text-dark">
|
||||
{{ student.enrollment_date.strftime('%Y年%m月%d日') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未填写</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="fw-bold text-muted mb-2">
|
||||
<i class="fas fa-clock me-1"></i>注册时间:
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<span class="badge bg-light text-dark">
|
||||
{{ student.created_at.strftime('%Y年%m月%d日 %H:%M') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- 考勤统计 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-bar me-2"></i>考勤统计
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="row">
|
||||
<div class="col-6 border-end">
|
||||
<div class="h4 mb-0 text-primary">{{ "%.1f"|format(total_work_hours) }}</div>
|
||||
<div class="text-muted small">总工作时长(小时)</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="h4 mb-0 text-warning">{{ total_absent_days }}</div>
|
||||
<div class="text-muted small">旷工天数</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-3">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="h5 mb-0 text-info">{{ attendance_records|length }}</div>
|
||||
<div class="text-muted small">考勤记录数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户信息 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-key me-2"></i>账户信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if student.user %}
|
||||
<div class="mb-3">
|
||||
<span class="fw-bold">账户状态:</span>
|
||||
{% if student.user.is_active %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>正常
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-ban me-1"></i>已禁用
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<span class="fw-bold">最后登录:</span>
|
||||
<br>
|
||||
{% if student.user.last_login %}
|
||||
<small class="text-muted">
|
||||
{{ student.user.last_login.strftime('%Y-%m-%d %H:%M') }}
|
||||
</small>
|
||||
{% else %}
|
||||
<small class="text-muted">从未登录</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="resetPassword()">
|
||||
<i class="fas fa-key me-1"></i>重置密码
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="toggleAccountStatus()">
|
||||
{% if student.user.is_active %}
|
||||
<i class="fas fa-ban me-1"></i>禁用账户
|
||||
{% else %}
|
||||
<i class="fas fa-check me-1"></i>启用账户
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted">
|
||||
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
|
||||
<p>该学生暂无账户信息</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-bolt me-2"></i>快速操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.attendance_management') }}?student_search={{ student.student_number }}"
|
||||
class="btn btn-outline-info btn-sm">
|
||||
<i class="fas fa-calendar-check me-1"></i>查看考勤详情
|
||||
</a>
|
||||
<a href="{{ url_for('admin.pending_leaves') }}?student_search={{ student.student_number }}"
|
||||
class="btn btn-outline-success btn-sm">
|
||||
<i class="fas fa-file-alt me-1"></i>查看请假记录
|
||||
</a>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="exportStudentData()">
|
||||
<i class="fas fa-download me-1"></i>导出数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近考勤记录 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-calendar-check me-2"></i>最近考勤记录
|
||||
</h6>
|
||||
<a href="{{ url_for('admin.attendance_management') }}?student_search={{ student.student_number }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-eye me-1"></i>查看全部
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if attendance_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>周期</th>
|
||||
<th>实际工作时长</th>
|
||||
<th>班内工作时长</th>
|
||||
<th>旷工天数</th>
|
||||
<th>加班时长</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in attendance_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold">
|
||||
{{ record.week_start_date.strftime('%m-%d') }}
|
||||
至
|
||||
{{ record.week_end_date.strftime('%m-%d') }}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ record.week_start_date.strftime('%Y年') }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">
|
||||
{{ "%.1f"|format(record.actual_work_hours or 0) }}h
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
{{ "%.1f"|format(record.class_work_hours or 0) }}h
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-warning">{{ record.absent_days }}天</span>
|
||||
{% else %}
|
||||
<span class="text-success">
|
||||
<i class="fas fa-check"></i> 0天
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if record.overtime_hours > 0 %}
|
||||
<span class="badge bg-success">
|
||||
{{ "%.1f"|format(record.overtime_hours) }}h
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0h</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">暂无考勤记录</p>
|
||||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-upload me-1"></i>上传考勤数据
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近请假记录 -->
|
||||
{% if leave_records %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-file-alt me-2"></i>最近请假记录
|
||||
</h6>
|
||||
<a href="{{ url_for('admin.pending_leaves') }}?student_search={{ student.student_number }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-eye me-1"></i>查看全部
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>请假日期</th>
|
||||
<th>请假原因</th>
|
||||
<th>申请时间</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for leave in leave_records %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ leave.leave_start_date.strftime('%Y-%m-%d') }}
|
||||
{% if leave.leave_start_date != leave.leave_end_date %}
|
||||
至 {{ leave.leave_end_date.strftime('%Y-%m-%d') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate" style="max-width: 300px;"
|
||||
title="{{ leave.leave_reason }}">
|
||||
{{ leave.leave_reason }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ leave.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if leave.status == '待审批' %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="fas fa-clock me-1"></i>待审批
|
||||
</span>
|
||||
{% elif leave.status == '已批准' %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check me-1"></i>已批准
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-times me-1"></i>已拒绝
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 删除学生
|
||||
function deleteStudent(studentNumber) {
|
||||
if (confirm('确认删除这个学生吗?此操作不可恢复!\n\n学生的所有考勤记录也将被删除。')) {
|
||||
fetch(`/admin/students/${studentNumber}/delete`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.href = '/admin/students';
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('删除失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
function resetPassword() {
|
||||
if (confirm('确认重置该学生的密码为默认密码(123456)吗?')) {
|
||||
fetch(`/admin/students/{{ student.student_number }}/reset_password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert('密码重置成功');
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 切换账户状态
|
||||
function toggleAccountStatus() {
|
||||
{% if student.user %}
|
||||
const isActive = {{ 'true' if student.user.is_active else 'false' }};
|
||||
const action = isActive ? '禁用' : '启用';
|
||||
|
||||
if (confirm(`确认${action}该学生的账户吗?`)) {
|
||||
fetch(`/admin/students/{{ student.student_number }}/toggle_status`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
location.reload(); // 重新加载页面以更新按钮状态
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
{% else %}
|
||||
alert('该学生暂无账户信息');
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
// 导出学生数据(可选功能)
|
||||
function exportStudentData() {
|
||||
// 这里可以实现导出学生考勤数据的功能
|
||||
alert('导出功能正在开发中...');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6 !important;
|
||||
}
|
||||
|
||||
.table-borderless td {
|
||||
border: none !important;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #f8f9fc;
|
||||
border-bottom: 1px solid #e3e6f0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
293
app/templates/admin/student_list.html
Normal file
293
app/templates/admin/student_list.html
Normal file
@ -0,0 +1,293 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}学生管理 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-users me-2"></i>学生管理
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.add_student') }}" class="btn btn-success">
|
||||
<i class="fas fa-plus me-1"></i>添加学生
|
||||
</a>
|
||||
<div class="btn-group ms-2">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
批量操作
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onclick="batchAction('graduate')">设为毕业</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="#" onclick="batchAction('delete')">批量删除</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">搜索</label>
|
||||
<input type="text" class="form-control" name="search" value="{{ search }}"
|
||||
placeholder="学号或姓名">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">年级</label>
|
||||
<select class="form-select" name="grade">
|
||||
<option value="">全部年级</option>
|
||||
{% for grade in grades %}
|
||||
<option value="{{ grade }}" {% if grade|string == selected_grade %}selected{% endif %}>
|
||||
{{ grade }}级
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">学院</label>
|
||||
<select class="form-select" name="college">
|
||||
<option value="">全部学院</option>
|
||||
{% for college in colleges %}
|
||||
<option value="{{ college }}" {% if college == selected_college %}selected{% endif %}>
|
||||
{{ college }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">导师</label>
|
||||
<select class="form-select" name="supervisor">
|
||||
<option value="">全部导师</option>
|
||||
{% for supervisor in supervisors %}
|
||||
<option value="{{ supervisor }}" {% if supervisor == selected_supervisor %}selected{% endif %}>
|
||||
{{ supervisor }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label"> </label>
|
||||
<div>
|
||||
<button type="submit" class="btn btn-primary">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学生列表 -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
学生列表 (共 {{ pagination.total }} 人)
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if students %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
|
||||
</th>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>性别</th>
|
||||
<th>年级</th>
|
||||
<th>学院</th>
|
||||
<th>导师</th>
|
||||
<th>学位类型</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student in students %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="student-checkbox"
|
||||
value="{{ student.student_number }}">
|
||||
</td>
|
||||
<td>{{ student.student_number }}</td>
|
||||
<td>
|
||||
<strong>{{ student.name }}</strong>
|
||||
{% if student.phone %}
|
||||
<br><small class="text-muted">{{ student.phone }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ student.gender }}</td>
|
||||
<td>{{ student.grade }}级</td>
|
||||
<td>{{ student.college or '-' }}</td>
|
||||
<td>{{ student.supervisor or '-' }}</td>
|
||||
<td>
|
||||
{% if student.degree_type %}
|
||||
<span class="badge bg-info">{{ student.degree_type }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if student.status == '在读' %}
|
||||
<span class="badge bg-success">在读</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">毕业</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||||
class="btn btn-outline-info" title="查看详情">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.edit_student', student_number=student.student_number) }}"
|
||||
class="btn btn-outline-primary" title="编辑">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-danger"
|
||||
onclick="deleteStudent('{{ student.student_number }}')" title="删除">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.prev_num, **request.args) }}">
|
||||
上一页
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
|
||||
{% if page_num %}
|
||||
{% if page_num != pagination.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.student_list', page=page_num, **request.args) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.next_num, **request.args) }}">
|
||||
下一页
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">暂无学生数据</p>
|
||||
<a href="{{ url_for('admin.add_student') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-1"></i>添加第一个学生
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 全选/取消全选
|
||||
function toggleSelectAll() {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.student-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAll.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// 删除学生
|
||||
function deleteStudent(studentNumber) {
|
||||
if (confirm('确认删除这个学生吗?此操作不可恢复!')) {
|
||||
fetch(`/admin/students/${studentNumber}/delete`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('删除失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
function batchAction(action) {
|
||||
const selectedStudents = Array.from(document.querySelectorAll('.student-checkbox:checked'))
|
||||
.map(checkbox => checkbox.value);
|
||||
|
||||
if (selectedStudents.length === 0) {
|
||||
alert('请选择要操作的学生');
|
||||
return;
|
||||
}
|
||||
|
||||
let confirmMessage = '';
|
||||
if (action === 'delete') {
|
||||
confirmMessage = `确认删除选中的 ${selectedStudents.length} 个学生吗?此操作不可恢复!`;
|
||||
} else if (action === 'graduate') {
|
||||
confirmMessage = `确认将选中的 ${selectedStudents.length} 个学生设为毕业状态吗?`;
|
||||
}
|
||||
|
||||
if (confirm(confirmMessage)) {
|
||||
fetch('/admin/students/batch_action', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: action,
|
||||
student_numbers: selectedStudents
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
152
app/templates/admin/upload_attendance.html
Normal file
152
app/templates/admin/upload_attendance.html
Normal file
@ -0,0 +1,152 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}上传考勤数据 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-10 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-upload me-2"></i>上传考勤数据
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data" id="uploadForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="week_start" class="form-label">周开始日期</label>
|
||||
<input type="date" class="form-control" id="week_start" name="week_start" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="week_end" class="form-label">周结束日期</label>
|
||||
<input type="date" class="form-control" id="week_end" name="week_end" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="attendance_file" class="form-label">
|
||||
<i class="fas fa-clock me-2"></i>考勤记录文件 <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="attendance_file" name="attendance_file"
|
||||
accept=".xlsx,.xls" required>
|
||||
<div class="form-text">必须上传考勤记录Excel文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="leave_file" class="form-label">
|
||||
<i class="fas fa-file-alt me-2"></i>请假单文件 <span class="text-muted">(可选)</span>
|
||||
</label>
|
||||
<input type="file" class="form-control" id="leave_file" name="leave_file"
|
||||
accept=".xlsx,.xls">
|
||||
<div class="form-text">如有请假记录,请上传请假单Excel文件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>导入说明:</h6>
|
||||
<ul class="mb-0">
|
||||
<li><strong>考勤记录文件:</strong>包含姓名列和每日考勤数据,系统会自动计算工作时长、迟到次数等</li>
|
||||
<li><strong>请假单文件:</strong>包含请假人员、请假开始时间、请假结束时间等信息</li>
|
||||
<li><strong>处理规则:</strong>
|
||||
<ul>
|
||||
<li>请假时间内的缺卡记录会自动转换为请假</li>
|
||||
<li>请假时间内的正常打卡记录(正常、迟到、早退)保持不变</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>如果记录已存在,将会更新现有数据</li>
|
||||
<li>请确保学生信息已在系统中注册</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6><i class="fas fa-exclamation-triangle me-2"></i>注意事项:</h6>
|
||||
<ul class="mb-0">
|
||||
<li>请假单中的时间格式会自动转换(支持数字格式和标准日期格式)</li>
|
||||
<li>请假人员姓名必须与学生表中的姓名完全一致</li>
|
||||
<li>建议先上传考勤记录,再选择性上传请假单</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('admin.attendance_management') }}"
|
||||
class="btn btn-secondary me-md-2">
|
||||
<i class="fas fa-arrow-left me-2"></i>返回
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||||
<i class="fas fa-upload me-2"></i>开始导入
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const weekStartInput = document.getElementById('week_start');
|
||||
const weekEndInput = document.getElementById('week_end');
|
||||
const form = document.getElementById('uploadForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const attendanceFileInput = document.getElementById('attendance_file');
|
||||
const leaveFileInput = document.getElementById('leave_file');
|
||||
|
||||
// 自动设置周结束日期
|
||||
weekStartInput.addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
const startDate = new Date(this.value);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(startDate.getDate() + 6);
|
||||
|
||||
weekEndInput.value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
|
||||
// 文件选择提示
|
||||
attendanceFileInput.addEventListener('change', function() {
|
||||
if (this.files.length > 0) {
|
||||
console.log('已选择考勤记录文件:', this.files[0].name);
|
||||
}
|
||||
});
|
||||
|
||||
leaveFileInput.addEventListener('change', function() {
|
||||
if (this.files.length > 0) {
|
||||
console.log('已选择请假单文件:', this.files[0].name);
|
||||
}
|
||||
});
|
||||
|
||||
// 表单提交处理
|
||||
form.addEventListener('submit', function(e) {
|
||||
const hasAttendanceFile = attendanceFileInput.files.length > 0;
|
||||
|
||||
if (!hasAttendanceFile) {
|
||||
e.preventDefault();
|
||||
alert('请选择考勤记录文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>导入中...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// 显示处理进度提示
|
||||
const progressAlert = document.createElement('div');
|
||||
progressAlert.className = 'alert alert-info mt-3';
|
||||
progressAlert.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>正在处理文件,请稍候...';
|
||||
form.appendChild(progressAlert);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
128
app/templates/auth/admin_profile.html
Normal file
128
app/templates/auth/admin_profile.html
Normal file
@ -0,0 +1,128 @@
|
||||
{% extends "layout/base.html" %}
|
||||
|
||||
{% block title %}个人信息 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-user-circle me-2"></i>个人信息</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">控制台</a></li>
|
||||
<li class="breadcrumb-item active">个人信息</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 个人信息卡片 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-user-cog me-2"></i>管理员信息</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if user_info %}
|
||||
<div class="row">
|
||||
<!-- 基本信息 -->
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">用户ID</label>
|
||||
<div class="info-value">{{ user_info.user_id }}</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">账号</label>
|
||||
<div class="info-value">{{ user_info.student_number }}</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">角色</label>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-crown me-1"></i>
|
||||
{% if user_info.role == 'admin' %}管理员{% else %}普通用户{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">账户状态</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.is_active %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check-circle me-1"></i>活跃
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-times-circle me-1"></i>已禁用
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">最后登录</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.last_login %}
|
||||
{{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
<span class="text-muted">从未登录</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">账户创建时间</label>
|
||||
<div class="info-value">{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary me-2">
|
||||
<i class="fas fa-key me-1"></i>修改密码
|
||||
</a>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回控制台
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
无法获取用户信息
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.info-group {
|
||||
border-left: 3px solid #007bff;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 10px 10px 0 0 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
237
app/templates/auth/change_password.html
Normal file
237
app/templates/auth/change_password.html
Normal file
@ -0,0 +1,237 @@
|
||||
{% extends "layout/base.html" %}
|
||||
|
||||
{% block title %}修改密码 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-key me-2"></i>修改密码</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ url_for('admin.dashboard' if current_user.is_admin() else 'student.dashboard') }}">
|
||||
{% if current_user.is_admin() %}控制台{% else %}首页{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('auth.profile') }}">个人信息</a></li>
|
||||
<li class="breadcrumb-item active">修改密码</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码表单 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mx-auto">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-shield-alt me-2"></i>安全设置
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 安全提示 -->
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>密码要求:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>长度至少6位</li>
|
||||
<li>必须包含字母和数字</li>
|
||||
<li>建议使用字母、数字和特殊字符的组合</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="POST" id="changePasswordForm">
|
||||
<!-- 当前密码 -->
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">
|
||||
<i class="fas fa-lock me-1"></i>当前密码 <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('current_password')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新密码 -->
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">
|
||||
<i class="fas fa-key me-1"></i>新密码 <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('new_password')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<div id="password-strength" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label">
|
||||
<i class="fas fa-check-double me-1"></i>确认新密码 <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('confirm_password')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="password-match" class="form-text"></div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||
<button type="submit" class="btn btn-primary btn-lg me-md-2">
|
||||
<i class="fas fa-save me-1"></i>保存密码
|
||||
</button>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-times me-1"></i>取消
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 10px 10px 0 0 !important;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.password-strength-weak { color: #dc3545; }
|
||||
.password-strength-medium { color: #ffc107; }
|
||||
.password-strength-strong { color: #28a745; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 切换密码显示/隐藏
|
||||
function togglePassword(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const button = field.nextElementSibling;
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (field.type === 'password') {
|
||||
field.type = 'text';
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
} else {
|
||||
field.type = 'password';
|
||||
icon.className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
|
||||
// 密码强度检查
|
||||
document.getElementById('new_password').addEventListener('input', function() {
|
||||
const password = this.value;
|
||||
const strengthDiv = document.getElementById('password-strength');
|
||||
|
||||
if (password.length === 0) {
|
||||
strengthDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
let feedback = [];
|
||||
|
||||
// 长度检查
|
||||
if (password.length >= 6) score += 1;
|
||||
else feedback.push('至少6位字符');
|
||||
|
||||
// 包含字母
|
||||
if (/[a-zA-Z]/.test(password)) score += 1;
|
||||
else feedback.push('包含字母');
|
||||
|
||||
// 包含数字
|
||||
if (/\d/.test(password)) score += 1;
|
||||
else feedback.push('包含数字');
|
||||
|
||||
// 包含特殊字符
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 1;
|
||||
|
||||
let strengthText = '';
|
||||
let strengthClass = '';
|
||||
|
||||
if (score < 2) {
|
||||
strengthText = '弱';
|
||||
strengthClass = 'password-strength-weak';
|
||||
} else if (score < 3) {
|
||||
strengthText = '中等';
|
||||
strengthClass = 'password-strength-medium';
|
||||
} else {
|
||||
strengthText = '强';
|
||||
strengthClass = 'password-strength-strong';
|
||||
}
|
||||
|
||||
strengthDiv.innerHTML = `<span class="${strengthClass}">密码强度: ${strengthText}</span>`;
|
||||
if (feedback.length > 0) {
|
||||
strengthDiv.innerHTML += `<br><small class="text-muted">建议: ${feedback.join(', ')}</small>`;
|
||||
}
|
||||
});
|
||||
|
||||
// 密码匹配检查
|
||||
document.getElementById('confirm_password').addEventListener('input', function() {
|
||||
const newPassword = document.getElementById('new_password').value;
|
||||
const confirmPassword = this.value;
|
||||
const matchDiv = document.getElementById('password-match');
|
||||
|
||||
if (confirmPassword.length === 0) {
|
||||
matchDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === confirmPassword) {
|
||||
matchDiv.innerHTML = '<span class="text-success"><i class="fas fa-check me-1"></i>密码匹配</span>';
|
||||
} else {
|
||||
matchDiv.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>密码不匹配</span>';
|
||||
}
|
||||
});
|
||||
|
||||
// 表单提交验证
|
||||
document.getElementById('changePasswordForm').addEventListener('submit', function(e) {
|
||||
const newPassword = document.getElementById('new_password').value;
|
||||
const confirmPassword = document.getElementById('confirm_password').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
e.preventDefault();
|
||||
alert('新密码与确认密码不匹配!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
e.preventDefault();
|
||||
alert('新密码长度至少6位!');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^(?=.*[a-zA-Z])(?=.*\d).+$/.test(newPassword)) {
|
||||
e.preventDefault();
|
||||
alert('新密码必须包含字母和数字!');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
164
app/templates/auth/login.html
Normal file
164
app/templates/auth/login.html
Normal file
@ -0,0 +1,164 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}登录 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-vh-100 d-flex align-items-center bg-light">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<!-- Logo和标题 -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
<i class="fas fa-clock fa-4x text-primary"></i>
|
||||
</div>
|
||||
<h2 class="fw-bold mb-2">CHM考勤系统</h2>
|
||||
<p class="text-muted">请使用学号和密码登录</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="student_number" class="form-label">
|
||||
<i class="fas fa-user me-1"></i>学号
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control form-control-lg"
|
||||
id="student_number"
|
||||
name="student_number"
|
||||
placeholder="请输入学号"
|
||||
value="{{ request.form.get('student_number', '') }}"
|
||||
required
|
||||
autocomplete="username">
|
||||
<div class="invalid-feedback">
|
||||
请输入学号
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">
|
||||
<i class="fas fa-lock me-1"></i>密码
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password"
|
||||
class="form-control form-control-lg"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
autocomplete="current-password">
|
||||
<button class="btn btn-outline-secondary" type="button" id="togglePassword">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
请输入密码
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember" value="true">
|
||||
<label class="form-check-label" for="remember">
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>登录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 帮助信息 -->
|
||||
<div class="text-center mt-4">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
如有登录问题,请联系管理员
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统说明 -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-body text-center">
|
||||
<h6 class="card-title">
|
||||
<i class="fas fa-lightbulb me-1 text-warning"></i>
|
||||
使用说明
|
||||
</h6>
|
||||
<div class="row text-start">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-check text-success me-1"></i> 查看个人考勤记录</li>
|
||||
<li><i class="fas fa-check text-success me-1"></i> 申请请假审批</li>
|
||||
</ul>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><i class="fas fa-check text-success me-1"></i> 个人统计分析</li>
|
||||
<li><i class="fas fa-check text-success me-1"></i> 修改个人密码</li>
|
||||
</ul>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 密码显示/隐藏切换
|
||||
const togglePassword = document.getElementById('togglePassword');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
togglePassword.addEventListener('click', function() {
|
||||
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
|
||||
passwordInput.setAttribute('type', type);
|
||||
|
||||
const icon = this.querySelector('i');
|
||||
icon.className = type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
|
||||
});
|
||||
|
||||
// 表单验证
|
||||
const form = document.querySelector('form');
|
||||
form.addEventListener('submit', function(event) {
|
||||
const studentNumber = document.getElementById('student_number').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!studentNumber || !password) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!studentNumber) {
|
||||
document.getElementById('student_number').classList.add('is-invalid');
|
||||
}
|
||||
if (!password) {
|
||||
document.getElementById('password').classList.add('is-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
form.classList.add('was-validated');
|
||||
});
|
||||
|
||||
// 清除验证状态
|
||||
document.getElementById('student_number').addEventListener('input', function() {
|
||||
this.classList.remove('is-invalid');
|
||||
});
|
||||
|
||||
document.getElementById('password').addEventListener('input', function() {
|
||||
this.classList.remove('is-invalid');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
269
app/templates/auth/student_profile.html
Normal file
269
app/templates/auth/student_profile.html
Normal file
@ -0,0 +1,269 @@
|
||||
{% extends "layout/base.html" %}
|
||||
|
||||
{% block title %}个人信息 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-user-circle me-2"></i>个人信息</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||||
<li class="breadcrumb-item active">个人信息</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 个人信息卡片 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
{% if user_info %}
|
||||
<!-- 基本信息卡片 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-id-card me-2"></i>基本信息</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- 左侧个人信息 -->
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">学号</label>
|
||||
<div class="info-value">{{ user_info.student_number }}</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">姓名</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.name %}
|
||||
{{ user_info.name }}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">性别</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.gender %}
|
||||
<span class="badge bg-{{ 'info' if user_info.gender == '男' else 'warning' }}">
|
||||
<i class="fas fa-{{ 'mars' if user_info.gender == '男' else 'venus' }} me-1"></i>
|
||||
{{ user_info.gender }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">年级</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.grade %}
|
||||
{{ user_info.grade }}级
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">手机号</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.phone %}
|
||||
{{ user_info.phone }}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧学术信息 -->
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">导师</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.supervisor %}
|
||||
{{ user_info.supervisor }}
|
||||
{% else %}
|
||||
<span class="text-muted">未分配</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">学院</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.college %}
|
||||
{{ user_info.college }}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">专业</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.major %}
|
||||
{{ user_info.major }}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">学位类型</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.degree_type %}
|
||||
<span class="badge bg-success">{{ user_info.degree_type }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">入学日期</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.enrollment_date %}
|
||||
{{ user_info.enrollment_date.strftime('%Y年%m月%d日') }}
|
||||
{% else %}
|
||||
<span class="text-muted">未设置</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户信息卡片 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-user-cog me-2"></i>账户信息</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">用户ID</label>
|
||||
<div class="info-value">{{ user_info.user_id }}</div>
|
||||
</div>
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">角色</label>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-primary">
|
||||
<i class="fas fa-user me-1"></i>学生
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">账户状态</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.is_active %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check-circle me-1"></i>活跃
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-times-circle me-1"></i>已禁用
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user_info.status %}
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">在读状态</label>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-{{ 'success' if user_info.status == '在读' else 'secondary' }}">
|
||||
{{ user_info.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">最后登录</label>
|
||||
<div class="info-value">
|
||||
{% if user_info.last_login %}
|
||||
{{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
<span class="text-muted">从未登录</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="info-group mb-3">
|
||||
<label class="form-label text-muted">账户创建时间</label>
|
||||
<div class="info-value">{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-warning btn-lg me-3">
|
||||
<i class="fas fa-key me-1"></i>修改密码
|
||||
</a>
|
||||
<a href="{{ url_for('student.dashboard') }}" class="btn btn-secondary btn-lg">
|
||||
<i class="fas fa-arrow-left me-1"></i>返回首页
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
无法获取用户信息,请联系管理员
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.info-group {
|
||||
border-left: 3px solid #007bff;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-weight: 500;
|
||||
font-size: 1.1em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-radius: 10px 10px 0 0 !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-group {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
63
app/templates/layout/base.html
Normal file
63
app/templates/layout/base.html
Normal file
@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}CHM考勤管理系统{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Custom CSS -->
|
||||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- 导航栏 -->
|
||||
{% if current_user.is_authenticated %}
|
||||
{% include 'layout/nav.html' %}
|
||||
{% endif %}
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="{% if current_user.is_authenticated %}main-content{% else %}full-page{% endif %}">
|
||||
<!-- Flash消息 -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="container-fluid mt-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'info' if category == 'info' else 'success' if category == 'success' else 'warning' }} alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' if category == 'info' else 'check-circle' if category == 'success' else 'exclamation-circle' }}"></i>
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- 页面内容 -->
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
{% if current_user.is_authenticated %}
|
||||
<footer class="bg-light text-center py-3 mt-auto">
|
||||
<div class="container">
|
||||
<span class="text-muted">© 2025 CHM考勤管理系统. All rights reserved.</span>
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Chart.js (用于统计图表) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
102
app/templates/layout/nav.html
Normal file
102
app/templates/layout/nav.html
Normal file
@ -0,0 +1,102 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||||
<div class="container-fluid">
|
||||
<!-- Logo -->
|
||||
<a class="navbar-brand" href="{{ url_for('student.dashboard' if not current_user.is_admin() else 'admin.dashboard') }}">
|
||||
<i class="fas fa-clock me-2"></i>
|
||||
CHM考勤系统
|
||||
</a>
|
||||
|
||||
<!-- 移动端切换按钮 -->
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<!-- 左侧导航菜单 -->
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if current_user.is_admin() %}
|
||||
<!-- 管理员菜单 -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt me-1"></i>控制台
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-users me-1"></i>学生管理
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.student_list') }}">
|
||||
<i class="fas fa-list me-2"></i>学生列表
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-calendar-check me-1"></i>考勤管理
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.attendance_management') }}">
|
||||
<i class="fas fa-table me-2"></i>考勤记录
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin.upload_attendance') }}">
|
||||
<i class="fas fa-upload me-2"></i>上传数据
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.statistics' }}" href="{{ url_for('admin.statistics') }}">
|
||||
<i class="fas fa-chart-bar me-1"></i>统计报表
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<!-- 学生菜单 -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'student.dashboard' }}" href="{{ url_for('student.dashboard') }}">
|
||||
<i class="fas fa-home me-1"></i>首页
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'student.attendance' }}" href="{{ url_for('student.attendance') }}">
|
||||
<i class="fas fa-calendar-check me-1"></i>我的考勤
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'student.statistics' }}" href="{{ url_for('student.statistics') }}">
|
||||
<i class="fas fa-chart-line me-1"></i>个人统计
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- 右侧用户菜单 -->
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-user-circle me-2"></i>
|
||||
<span>
|
||||
{% if current_user.is_admin() %}
|
||||
管理员
|
||||
{% else %}
|
||||
{% set student = get_current_student() %}
|
||||
{{ student.name if student else current_user.student_number }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
<i class="fas fa-user me-2"></i>个人信息
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||||
<i class="fas fa-key me-2"></i>修改密码
|
||||
</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||||
<i class="fas fa-sign-out-alt me-2"></i>退出登录
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
0
app/templates/student/apply_leave.html
Normal file
0
app/templates/student/apply_leave.html
Normal file
539
app/templates/student/attendance.html
Normal file
539
app/templates/student/attendance.html
Normal file
@ -0,0 +1,539 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}我的考勤 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold text-primary">
|
||||
<i class="fas fa-calendar-check me-2"></i>我的考勤记录
|
||||
</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||||
<li class="breadcrumb-item active">考勤记录</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
{% if total_stats %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
总考勤周数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_stats.total_weeks }}周
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
总出勤时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(total_stats.total_actual_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
班内工作
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(total_stats.total_class_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
迟到次数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_stats.total_late_count }}次
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-danger shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">
|
||||
旷工天数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_stats.total_absent_days }}天
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-times-circle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<div class="card border-left-secondary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
|
||||
周均时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-filter me-2"></i>筛选条件
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('student.attendance') }}" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="start_date" class="form-label">开始日期</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||||
value="{{ start_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="end_date" class="form-label">结束日期</label>
|
||||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||||
value="{{ end_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="d-grid gap-2 d-md-flex w-100">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search me-1"></i>筛选
|
||||
</button>
|
||||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-refresh me-1"></i>重置
|
||||
</a>
|
||||
{% if attendance_records %}
|
||||
<button type="button" class="btn btn-outline-info" onclick="exportData()">
|
||||
<i class="fas fa-download me-1"></i>导出
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 考勤记录表格 -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-table me-2"></i>考勤记录列表
|
||||
</h6>
|
||||
{% if attendance_records %}
|
||||
<span class="badge bg-info">
|
||||
共 {{ pagination.total }} 条记录
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if attendance_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>周次</th>
|
||||
<th>实际工作时长</th>
|
||||
<th>班内工作时长</th>
|
||||
<th>迟到次数</th>
|
||||
<th>旷工天数</th>
|
||||
<th>加班时长</th>
|
||||
<th>记录时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in attendance_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ record.week_start_date.strftime('%Y-%m-%d') }}</strong>
|
||||
<small class="d-block text-muted">
|
||||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ "%.1f"|format(record.class_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.late_count > 0 %}
|
||||
<span class="badge bg-warning">{{ record.late_count }}次</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0次</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0天</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if record.overtime_hours > 0 %}
|
||||
<span class="badge bg-info">{{ "%.1f"|format(record.overtime_hours) }}h</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">0h</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">
|
||||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('student.attendance_details', record_id=record.record_id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye me-1"></i>查看详情
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="考勤记录分页" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('student.attendance', page=pagination.prev_num, start_date=start_date, end_date=end_date) }}">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != pagination.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('student.attendance', page=page_num, start_date=start_date, end_date=end_date) }}">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('student.attendance', page=pagination.next_num, start_date=start_date, end_date=end_date) }}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-calendar-times fa-4x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">暂无考勤记录</h5>
|
||||
<p class="text-muted">当前筛选条件下没有找到考勤记录</p>
|
||||
<a href="{{ url_for('student.dashboard') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home me-2"></i>返回首页
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
{% if attendance_records %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-pie me-2"></i>本期统计分析
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<h6 class="text-primary">{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h</h6>
|
||||
<small class="text-muted">周均出勤</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<h6 class="text-success">{{ "%.1f"|format((total_stats.total_class_hours / total_stats.total_actual_hours * 100) if total_stats.total_actual_hours > 0 else 0) }}%</h6>
|
||||
<small class="text-muted">班内工作率</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h6 class="text-info">{{ "%.1f"|format((total_stats.total_overtime_hours / total_stats.total_weeks) if total_stats.total_weeks > 0 else 0) }}h</h6>
|
||||
<small class="text-muted">周均加班</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-tools me-2"></i>快捷操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-primary btn-block">
|
||||
<i class="fas fa-chart-bar me-2"></i>统计报表
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-block">
|
||||
<i class="fas fa-user me-2"></i>个人资料
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-6 mb-2">
|
||||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-outline-warning btn-block">
|
||||
<i class="fas fa-key me-2"></i>修改密码
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.border-left-danger {
|
||||
border-left: 0.25rem solid #e74a3b !important;
|
||||
}
|
||||
|
||||
.border-left-secondary {
|
||||
border-left: 0.25rem solid #858796 !important;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fc;
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #5a5c69;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.col-md-2 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function exportData() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('export', 'excel');
|
||||
|
||||
// 构建导出URL
|
||||
const exportUrl = '{{ url_for("student.attendance") }}?' + params.toString();
|
||||
|
||||
// 创建临时链接并触发下载
|
||||
const link = document.createElement('a');
|
||||
link.href = exportUrl;
|
||||
link.download = '我的考勤记录.xlsx';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// 自动设置结束日期为开始日期的一个月后
|
||||
document.getElementById('start_date').addEventListener('change', function() {
|
||||
const startDate = new Date(this.value);
|
||||
if (startDate && !document.getElementById('end_date').value) {
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setMonth(startDate.getMonth() + 1);
|
||||
document.getElementById('end_date').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
|
||||
// 页面加载完成后的初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('学生考勤记录页面已加载');
|
||||
|
||||
// 显示统计信息的提示
|
||||
{% if total_stats and total_stats.total_weeks > 0 %}
|
||||
console.log('统计信息:', {
|
||||
总周数: {{ total_stats.total_weeks }},
|
||||
总出勤时长: {{ total_stats.total_actual_hours }},
|
||||
迟到次数: {{ total_stats.total_late_count }},
|
||||
周均时长: {{ total_stats.avg_weekly_hours }}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
// 如果有迟到记录,显示温馨提示
|
||||
{% if total_stats and total_stats.total_late_count > 0 %}
|
||||
setTimeout(function() {
|
||||
if ({{ total_stats.total_late_count }} > 5) {
|
||||
const toast = `
|
||||
<div class="toast show position-fixed bottom-0 end-0 m-3" role="alert" style="z-index: 1055;">
|
||||
<div class="toast-header">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||
<strong class="me-auto">考勤提醒</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
您的迟到次数较多({{ total_stats.total_late_count }}次),请注意准时上班。
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', toast);
|
||||
|
||||
// 5秒后自动关闭
|
||||
setTimeout(function() {
|
||||
const toastElement = document.querySelector('.toast');
|
||||
if (toastElement) {
|
||||
toastElement.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}, 1000);
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
673
app/templates/student/attendance_details.html
Normal file
673
app/templates/student/attendance_details.html
Normal file
@ -0,0 +1,673 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}考勤详情 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold text-primary mb-0">
|
||||
<i class="fas fa-calendar-check me-2"></i>我的考勤详情
|
||||
</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student.attendance') }}">考勤记录</a></li>
|
||||
<li class="breadcrumb-item active">考勤详情</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('student.attendance') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left me-2"></i>返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user me-2"></i>学生信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p><strong>学号:</strong> {{ record.student_number }}</p>
|
||||
<p><strong>姓名:</strong> {{ record.name }}</p>
|
||||
{% if student %}
|
||||
<p><strong>年级:</strong> {{ student.grade }}</p>
|
||||
<p><strong>学院:</strong> {{ student.college or '未设置' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{% if student %}
|
||||
<p><strong>专业:</strong> {{ student.major or '未设置' }}</p>
|
||||
<p><strong>导师:</strong> {{ student.supervisor or '未设置' }}</p>
|
||||
<p><strong>学位类型:</strong> {{ student.degree_type or '未设置' }}</p>
|
||||
<p><strong>状态:</strong>
|
||||
<span class="badge bg-{{ 'success' if student.status == '在读' else 'secondary' }}">
|
||||
{{ student.status }}
|
||||
</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-calendar-week me-2"></i>考勤周期
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p><strong>开始日期:</strong> {{ record.week_start_date.strftime('%Y年%m月%d日') }}</p>
|
||||
<p><strong>结束日期:</strong> {{ record.week_end_date.strftime('%Y年%m月%d日') }}</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p><strong>创建时间:</strong> {{ record.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
<p><strong>更新时间:</strong> {{ record.updated_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
实际出勤时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(record.actual_work_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
班内工作时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(record.class_work_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
旷工天数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ record.absent_days }}天
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
加班时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(record.overtime_hours) }}小时
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-moon fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 每日考勤明细 -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-list me-2"></i>每日考勤明细
|
||||
<small class="text-muted">(点击详情按钮查看详细时段信息)</small>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if daily_details %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>星期</th>
|
||||
<th>考勤状态</th>
|
||||
<th>签到时间</th>
|
||||
<th>签退时间</th>
|
||||
<th>工作时长</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for detail in daily_details %}
|
||||
<tr class="{% if '迟到' in detail.status %}table-warning{% elif detail.status == '缺勤' %}table-danger{% endif %}">
|
||||
<td>
|
||||
<strong>{{ detail.attendance_date.strftime('%m-%d') }}</strong>
|
||||
<small class="d-block text-muted">{{ detail.attendance_date.strftime('%Y') }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% set weekday = detail.attendance_date.weekday() %}
|
||||
{% if weekday == 0 %}周一
|
||||
{% elif weekday == 1 %}周二
|
||||
{% elif weekday == 2 %}周三
|
||||
{% elif weekday == 3 %}周四
|
||||
{% elif weekday == 4 %}周五
|
||||
{% elif weekday == 5 %}周六
|
||||
{% else %}周日
|
||||
{% endif %}
|
||||
|
||||
{% if weekday >= 5 %}
|
||||
<small class="badge bg-info">休息日</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.status == '正常' %}
|
||||
<span class="badge bg-success">{{ detail.status }}</span>
|
||||
{% elif '迟到' in detail.status %}
|
||||
<span class="badge bg-warning">{{ detail.status }}</span>
|
||||
{% elif detail.status == '缺勤' %}
|
||||
<span class="badge bg-danger">{{ detail.status }}</span>
|
||||
{% elif detail.status == '请假' %}
|
||||
<span class="badge bg-orange">{{ detail.status }}</span>
|
||||
{% elif detail.status == '休息' %}
|
||||
<span class="badge bg-info">{{ detail.status }}</span>
|
||||
{% elif detail.status == '加班' %}
|
||||
<span class="badge bg-primary">{{ detail.status }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ detail.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.check_in_time %}
|
||||
<span class="badge bg-primary">{{ detail.check_in_time.strftime('%H:%M') }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未打卡</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.check_out_time %}
|
||||
<span class="badge bg-success">{{ detail.check_out_time.strftime('%H:%M') }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未打卡</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.duration_hours %}
|
||||
<span class="badge bg-primary">{{ detail.duration_hours }}h</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.summary_remarks %}
|
||||
<small class="text-muted">{{ detail.summary_remarks }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
onclick="showDetailModal('{{ detail.detail_id }}', '{{ detail.attendance_date.strftime('%Y-%m-%d') }}', '{{ detail.remarks|escape }}')"
|
||||
title="查看详细时段">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-calendar-times fa-4x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">暂无每日考勤明细</h5>
|
||||
<p class="text-muted">该考勤周期内没有详细的打卡记录</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计分析和历史对比 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-pie me-2"></i>本周考勤统计
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-success">{{ present_days }}</h6>
|
||||
<small class="text-muted">正常天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-warning">{{ late_days }}</h6>
|
||||
<small class="text-muted">迟到天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="border-end">
|
||||
<h6 class="text-danger">{{ absent_days }}</h6>
|
||||
<small class="text-muted">缺勤天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<h6 class="text-info">{{ "%.1f"|format(avg_daily_hours) }}h</h6>
|
||||
<small class="text-muted">日均时长</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 出勤率计算 -->
|
||||
<hr>
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<h6 class="text-primary">{{ "%.1f"|format((present_days / max(total_days, 1) * 100)) }}%</h6>
|
||||
<small class="text-muted">出勤率</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h6 class="text-success">{{ "%.1f"|format((record.class_work_hours / max(record.actual_work_hours, 1) * 100)) }}%</h6>
|
||||
<small class="text-muted">班内工作率</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h6 class="text-info">{{ total_days }}</h6>
|
||||
<small class="text-muted">考勤天数</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-history me-2"></i>最近记录对比
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>周期</th>
|
||||
<th>出勤时长</th>
|
||||
<th>旷工天数</th>
|
||||
<th>对比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record_item in recent_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<small>{{ record_item.week_start_date.strftime('%m-%d') }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ "%.1f"|format(record_item.actual_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record_item.absent_days > 0 %}
|
||||
<span class="badge bg-warning">{{ record_item.absent_days }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set diff = record.actual_work_hours - record_item.actual_work_hours %}
|
||||
{% if diff > 0 %}
|
||||
<small class="text-success">+{{ "%.1f"|format(diff) }}h</small>
|
||||
{% elif diff < 0 %}
|
||||
<small class="text-danger">{{ "%.1f"|format(diff) }}h</small>
|
||||
{% else %}
|
||||
<small class="text-muted">-</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted text-center mb-0">暂无历史记录</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作建议 -->
|
||||
{% if late_days > 0 or absent_days > 0 %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h6 class="alert-heading"><i class="fas fa-exclamation-triangle me-2"></i>考勤提醒</h6>
|
||||
<p class="mb-0">
|
||||
{% if late_days > 0 %}
|
||||
本周有 <strong>{{ late_days }}</strong> 天迟到,
|
||||
{% endif %}
|
||||
{% if absent_days > 0 %}
|
||||
有 <strong>{{ absent_days }}</strong> 天缺勤,
|
||||
{% endif %}
|
||||
请注意调整作息时间,保持良好的考勤记录。
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 详细时段信息模态框 -->
|
||||
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="detailModalLabel">
|
||||
<i class="fas fa-clock me-2"></i>详细打卡时段 - <span id="modalDate"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="detailContent">
|
||||
<!-- 内容将通过JavaScript动态填充 -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.border-end {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||||
}
|
||||
|
||||
.period-card {
|
||||
border: 1px solid #e3e6f0;
|
||||
border-radius: 0.35rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background-color: #f8f9fc;
|
||||
}
|
||||
|
||||
.period-header {
|
||||
font-weight: 600;
|
||||
color: #5a5c69;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e3e6f0;
|
||||
}
|
||||
|
||||
.time-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.badge.bg-orange {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-info > div {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 高亮迟到和缺勤行 */
|
||||
.table-warning {
|
||||
--bs-table-accent-bg: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.table-danger {
|
||||
--bs-table-accent-bg: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.time-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.time-info > div {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function showDetailModal(detailId, date, remarksJson) {
|
||||
document.getElementById('modalDate').textContent = date;
|
||||
|
||||
console.log('调用showDetailModal', detailId, date, remarksJson);
|
||||
|
||||
let detailsData = null;
|
||||
|
||||
try {
|
||||
if (remarksJson && remarksJson.startsWith('{')) {
|
||||
const parsed = JSON.parse(remarksJson);
|
||||
detailsData = parsed.details;
|
||||
console.log('解析的详细数据:', detailsData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析详细信息失败:', e);
|
||||
}
|
||||
|
||||
if (!detailsData) {
|
||||
document.getElementById('detailContent').innerHTML =
|
||||
'<div class="alert alert-info"><i class="fas fa-info-circle me-2"></i>暂无详细时段信息</div>';
|
||||
} else {
|
||||
let html = '';
|
||||
|
||||
// 显示各个时段的详情
|
||||
const periods = [
|
||||
{ key: 'morning', name: '早上时段', time: '09:45-11:30', icon: 'fa-sun' },
|
||||
{ key: 'afternoon', name: '下午时段', time: '13:30-18:30', icon: 'fa-cloud-sun' },
|
||||
{ key: 'evening', name: '晚上时段', time: '19:00-23:30', icon: 'fa-moon' }
|
||||
];
|
||||
|
||||
periods.forEach(period => {
|
||||
if (detailsData[period.key]) {
|
||||
const data = detailsData[period.key];
|
||||
html += `
|
||||
<div class="period-card">
|
||||
<div class="period-header">
|
||||
<i class="fas ${period.icon} me-2"></i>${period.name}
|
||||
<small class="text-muted">(${period.time})</small>
|
||||
</div>
|
||||
<div class="time-info">
|
||||
<div>
|
||||
<strong>签到:</strong>
|
||||
<span class="badge ${getStatusClass(data.status, 'in')}">
|
||||
${data.in || '未打卡'}
|
||||
</span>
|
||||
${data.late_minutes ? `<small class="text-warning d-block">(迟到${data.late_minutes}分钟)</small>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>签退:</strong>
|
||||
<span class="badge ${getStatusClass(data.status, 'out')}">
|
||||
${data.out || '未打卡'}
|
||||
</span>
|
||||
${data.early_minutes ? `<small class="text-warning d-block">(早退${data.early_minutes}分钟)</small>` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>工时:</strong>
|
||||
${calculatePeriodHours(data.in, data.out)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// 如果是周末加班
|
||||
if (detailsData.overtime) {
|
||||
html += `
|
||||
<div class="period-card">
|
||||
<div class="period-header">
|
||||
<i class="fas fa-business-time me-2"></i>周末加班
|
||||
</div>
|
||||
<div class="time-info">
|
||||
<div>
|
||||
<strong>开始:</strong>
|
||||
<span class="badge bg-info">${detailsData.overtime.in || '未记录'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>结束:</strong>
|
||||
<span class="badge bg-info">${detailsData.overtime.out || '未记录'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>加班时长:</strong>
|
||||
${calculatePeriodHours(detailsData.overtime.in, detailsData.overtime.out)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (html === '') {
|
||||
html = '<div class="alert alert-warning"><i class="fas fa-exclamation-triangle me-2"></i>该日期没有详细的时段打卡信息</div>';
|
||||
}
|
||||
|
||||
document.getElementById('detailContent').innerHTML = html;
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function getStatusClass(status, type) {
|
||||
if (status === 'normal') return 'bg-success';
|
||||
if (status === 'late' && type === 'in') return 'bg-warning';
|
||||
if (status === 'early_leave' && type === 'out') return 'bg-warning';
|
||||
if (status === 'missing') return 'bg-secondary';
|
||||
return 'bg-secondary';
|
||||
}
|
||||
|
||||
function calculatePeriodHours(startTime, endTime) {
|
||||
if (!startTime || !endTime) return '<span class="text-muted">-</span>';
|
||||
|
||||
try {
|
||||
const start = new Date(`2000-01-01 ${startTime}:00`);
|
||||
const end = new Date(`2000-01-01 ${endTime}:00`);
|
||||
let diff = (end - start) / (1000 * 60 * 60);
|
||||
|
||||
// 处理跨天情况
|
||||
if (diff < 0) {
|
||||
diff += 24;
|
||||
}
|
||||
|
||||
if (diff > 0) {
|
||||
return `<span class="badge bg-primary">${diff.toFixed(1)}h</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('计算时长失败:', e);
|
||||
}
|
||||
|
||||
return '<span class="text-muted">-</span>';
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('学生考勤详情页面已加载');
|
||||
|
||||
// 显示考勤统计
|
||||
console.log('考勤统计:', {
|
||||
正常天数: {{ present_days }},
|
||||
迟到天数: {{ late_days }},
|
||||
缺勤天数: {{ absent_days }},
|
||||
日均时长: {{ "%.1f"|format(avg_daily_hours) }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
250
app/templates/student/dashboard.html
Normal file
250
app/templates/student/dashboard.html
Normal file
@ -0,0 +1,250 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}学生主页 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 欢迎标题 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold text-primary">
|
||||
<i class="fas fa-home me-2"></i>
|
||||
欢迎回来,{{ student.name }}!
|
||||
</h2>
|
||||
<p class="text-muted mb-0">
|
||||
学号:{{ student.student_number }} |
|
||||
学院:{{ student.college }} |
|
||||
导师:{{ student.supervisor }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="card-title">{{ total_records }}</h4>
|
||||
<p class="card-text">考勤记录</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-calendar-check fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-success">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="card-title">{{ "%.1f"|format(total_work_hours) }}</h4>
|
||||
<p class="card-text">总工作时长(小时)</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-clock fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-warning">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="card-title">{{ total_absent_days }}</h4>
|
||||
<p class="card-text">旷工天数</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-exclamation-triangle fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-info">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<h4 class="card-title">{{ pending_leaves|length }}</h4>
|
||||
<p class="card-text">待审批请假</p>
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<i class="fas fa-file-alt fa-2x opacity-75"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="row">
|
||||
<!-- 最近考勤记录 -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-calendar-check me-2"></i>最近考勤记录
|
||||
</h5>
|
||||
<a href="{{ url_for('student.attendance') }}" class="btn btn-sm btn-outline-primary">
|
||||
查看全部 <i class="fas fa-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_attendance %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>周次</th>
|
||||
<th>实际工作时长</th>
|
||||
<th>班内工作时长</th>
|
||||
<th>旷工天数</th>
|
||||
<th>加班时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in recent_attendance %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ record.week_start_date.strftime('%m-%d') }}</strong>
|
||||
至
|
||||
<strong>{{ record.week_end_date.strftime('%m-%d') }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ record.actual_work_hours }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ record.class_work_hours }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0天</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ record.overtime_hours }}h</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">暂无考勤记录</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="col-lg-4">
|
||||
<!-- 待审批请假 -->
|
||||
{% if pending_leaves %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-clock me-2"></i>待审批请假
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for leave in pending_leaves %}
|
||||
<div class="border-start border-warning border-3 ps-3 mb-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>{{ leave.leave_start_date.strftime('%Y-%m-%d') }}</strong>
|
||||
至
|
||||
<strong>{{ leave.leave_end_date.strftime('%Y-%m-%d') }}</strong>
|
||||
</div>
|
||||
<span class="badge bg-warning text-dark">待审批</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ leave.leave_reason[:30] }}...</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="{{ url_for('student.leave_records') }}" class="btn btn-sm btn-outline-primary w-100">
|
||||
查看所有请假记录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-bolt me-2"></i>快速操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-calendar-check me-2"></i>查看考勤记录
|
||||
</a>
|
||||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-chart-line me-2"></i>个人统计
|
||||
</a>
|
||||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-key me-2"></i>修改密码
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人信息 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-user me-2"></i>个人信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><strong>姓名:</strong></div>
|
||||
<div class="col-8">{{ student.name }}</div>
|
||||
|
||||
<div class="col-4"><strong>性别:</strong></div>
|
||||
<div class="col-8">{{ student.gender }}</div>
|
||||
|
||||
<div class="col-4"><strong>年级:</strong></div>
|
||||
<div class="col-8">{{ student.grade }}级</div>
|
||||
|
||||
<div class="col-4"><strong>专业:</strong></div>
|
||||
<div class="col-8">{{ student.major }}</div>
|
||||
|
||||
<div class="col-4"><strong>学位:</strong></div>
|
||||
<div class="col-8">{{ student.degree_type }}</div>
|
||||
|
||||
{% if student.phone %}
|
||||
<div class="col-4"><strong>电话:</strong></div>
|
||||
<div class="col-8">{{ student.phone }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 如果有数据,可以在这里添加图表初始化代码
|
||||
console.log('Dashboard loaded');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
0
app/templates/student/leave_records.html
Normal file
0
app/templates/student/leave_records.html
Normal file
563
app/templates/student/statistics.html
Normal file
563
app/templates/student/statistics.html
Normal file
@ -0,0 +1,563 @@
|
||||
{% extends 'layout/base.html' %}
|
||||
|
||||
{% block title %}个人统计 - CHM考勤管理系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- 页面标题 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2 class="fw-bold text-primary">
|
||||
<i class="fas fa-chart-line me-2"></i>个人统计分析
|
||||
</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||||
<li class="breadcrumb-item active">个人统计</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学生基本信息 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user me-2"></i>基本信息
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<strong>学号:</strong> {{ student.student_number }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>姓名:</strong> {{ student.name }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>年级:</strong> {{ student.grade }}级
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<strong>入学日期:</strong> {{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '未设置' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-filter me-2"></i>筛选条件
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="GET" action="{{ url_for('student.statistics') }}" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="start_date" class="form-label">开始日期</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||||
value="{{ start_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="end_date" class="form-label">结束日期</label>
|
||||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||||
value="{{ end_date or '' }}">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="d-grid gap-2 d-md-flex w-100">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search me-1"></i>筛选
|
||||
</button>
|
||||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-refresh me-1"></i>重置
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 入学以来总体统计 -->
|
||||
{% if all_time_stats %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="card shadow border-left-primary">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-graduation-cap me-2"></i>入学以来总体表现
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary">{{ all_time_stats.attendance_weeks }}</h4>
|
||||
<small class="text-muted">总考勤周数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success">{{ "%.1f"|format(all_time_stats.total_work_hours) }}</h4>
|
||||
<small class="text-muted">总工作时长(h)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info">{{ "%.1f"|format(all_time_stats.total_class_hours) }}</h4>
|
||||
<small class="text-muted">班内工作(h)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning">{{ all_time_stats.total_late_count }}</h4>
|
||||
<small class="text-muted">迟到次数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-danger">{{ all_time_stats.total_absent_days }}</h4>
|
||||
<small class="text-muted">旷工天数</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-secondary">{{ "%.1f"|format(all_time_stats.attendance_rate) }}%</h4>
|
||||
<small class="text-muted">出勤率</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 当前筛选条件下的统计 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
筛选期间考勤周数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_stats.attendance_weeks }}周
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
总出勤时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(total_stats.total_work_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
迟到次数
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ total_stats.total_late_count }}次
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
周均工作时长
|
||||
</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||||
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表展示区域 -->
|
||||
<div class="row mb-4">
|
||||
<!-- 月度统计图表 -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-bar me-2"></i>月度考勤统计
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="monthlyChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近趋势图表 -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-chart-line me-2"></i>最近12周趋势
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="trendChart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细记录表格 -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-table me-2"></i>详细考勤记录
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if attendance_records %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>周次</th>
|
||||
<th>实际工作时长</th>
|
||||
<th>班内工作时长</th>
|
||||
<th>加班时长</th>
|
||||
<th>旷工天数</th>
|
||||
<th>考勤状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in attendance_records %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ record.week_start_date.strftime('%Y-%m-%d') }}</strong>
|
||||
<small class="d-block text-muted">
|
||||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success">{{ "%.1f"|format(record.class_work_hours) }}h</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if record.overtime_hours > 0 %}
|
||||
<span class="badge bg-info">{{ "%.1f"|format(record.overtime_hours) }}h</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">0h</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if record.absent_days > 0 %}
|
||||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">0天</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set performance_score = (record.actual_work_hours / 40 * 100) if record.actual_work_hours else 0 %}
|
||||
{% if performance_score >= 80 %}
|
||||
<span class="badge bg-success">优秀</span>
|
||||
{% elif performance_score >= 60 %}
|
||||
<span class="badge bg-warning">良好</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">待改善</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('student.attendance_details', record_id=record.record_id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye me-1"></i>详情
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-chart-line fa-4x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">暂无统计数据</h5>
|
||||
<p class="text-muted">当前筛选条件下没有找到考勤记录</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-tools me-2"></i>快捷操作
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-primary w-100">
|
||||
<i class="fas fa-calendar-check me-2"></i>考勤记录
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-user me-2"></i>个人资料
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<button type="button" class="btn btn-outline-success w-100" onclick="exportStatistics()">
|
||||
<i class="fas fa-download me-2"></i>导出统计
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0;
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8f9fc;
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #5a5c69;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* 图表容器样式 */
|
||||
canvas {
|
||||
max-height: 300px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 月度统计图表
|
||||
const monthlyData = {
|
||||
labels: [
|
||||
{% for stat in monthly_stats %}
|
||||
'{{ stat.month }}',
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: '总工作时长',
|
||||
data: [
|
||||
{% for stat in monthly_stats %}
|
||||
{{ stat.total_hours or 0 }},
|
||||
{% endfor %}
|
||||
],
|
||||
backgroundColor: 'rgba(78, 115, 223, 0.2)',
|
||||
borderColor: 'rgba(78, 115, 223, 1)',
|
||||
borderWidth: 2,
|
||||
fill: true
|
||||
}, {
|
||||
label: '班内工作时长',
|
||||
data: [
|
||||
{% for stat in monthly_stats %}
|
||||
{{ stat.class_hours or 0 }},
|
||||
{% endfor %}
|
||||
],
|
||||
backgroundColor: 'rgba(28, 200, 138, 0.2)',
|
||||
borderColor: 'rgba(28, 200, 138, 1)',
|
||||
borderWidth: 2,
|
||||
fill: true
|
||||
}]
|
||||
};
|
||||
|
||||
const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
|
||||
const monthlyChart = new Chart(monthlyCtx, {
|
||||
type: 'line',
|
||||
data: monthlyData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '工作时长 (小时)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '月份'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '月度工作时长趋势'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 最近12周趋势图表
|
||||
const trendData = {
|
||||
labels: [
|
||||
{% for week in recent_weeks %}
|
||||
'{{ week.week_start_date.strftime("%m/%d") }}',
|
||||
{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: '周工作时长',
|
||||
data: [
|
||||
{% for week in recent_weeks %}
|
||||
{{ week.actual_work_hours }},
|
||||
{% endfor %}
|
||||
],
|
||||
backgroundColor: 'rgba(54, 185, 204, 0.2)',
|
||||
borderColor: 'rgba(54, 185, 204, 1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.1
|
||||
}]
|
||||
};
|
||||
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
const trendChart = new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: trendData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '时长(h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '近期趋势'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 导出统计功能
|
||||
function exportStatistics() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('export', 'excel');
|
||||
|
||||
const exportUrl = '{{ url_for("student.statistics") }}?' + params.toString();
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = exportUrl;
|
||||
link.download = '个人考勤统计.xlsx';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// 页面加载完成后的初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('个人统计页面已加载');
|
||||
|
||||
// 显示统计摘要
|
||||
{% if total_stats %}
|
||||
console.log('统计摘要:', {
|
||||
考勤周数: {{ total_stats.attendance_weeks }},
|
||||
总工作时长: {{ total_stats.total_work_hours }},
|
||||
迟到次数: {{ total_stats.total_late_count }},
|
||||
周均时长: {{ total_stats.avg_weekly_hours }}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
16
app/utils/__init__.py
Normal file
16
app/utils/__init__.py
Normal file
@ -0,0 +1,16 @@
|
||||
from flask import current_app
|
||||
from app.models import LeaveRecord, Student
|
||||
from flask_login import current_user
|
||||
|
||||
def get_pending_leaves_count():
|
||||
"""获取待审批请假数量"""
|
||||
try:
|
||||
return LeaveRecord.query.filter_by(status='待审批').count()
|
||||
except:
|
||||
return 0
|
||||
|
||||
def get_current_student():
|
||||
"""获取当前登录用户的学生信息"""
|
||||
if current_user.is_authenticated and not current_user.is_admin():
|
||||
return Student.query.filter_by(student_number=current_user.student_number).first()
|
||||
return None
|
1227
app/utils/attendance_importer.py
Normal file
1227
app/utils/attendance_importer.py
Normal file
File diff suppressed because it is too large
Load Diff
24
app/utils/auth_helpers.py
Normal file
24
app/utils/auth_helpers.py
Normal file
@ -0,0 +1,24 @@
|
||||
from functools import wraps
|
||||
from flask import redirect, url_for, flash, request
|
||||
from flask_login import current_user
|
||||
def admin_required(f):
|
||||
"""要求管理员权限的装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('请先登录', 'error')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
if not current_user.is_admin():
|
||||
flash('权限不足,需要管理员权限', 'error')
|
||||
return redirect(url_for('student.dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
def student_required(f):
|
||||
"""要求学生权限的装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
flash('请先登录', 'error')
|
||||
return redirect(url_for('auth.login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
366
app/utils/data_import.py
Normal file
366
app/utils/data_import.py
Normal file
@ -0,0 +1,366 @@
|
||||
import pandas as pd
|
||||
import re
|
||||
from datetime import datetime, timedelta, time
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from app.utils.database import get_db_connection
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AttendanceDataImporter:
|
||||
def __init__(self):
|
||||
self.work_time_rules = {
|
||||
'morning': {
|
||||
'work_start': time(9, 45),
|
||||
'work_end': time(11, 30),
|
||||
'card_start': time(6, 0),
|
||||
'card_end': time(12, 0)
|
||||
},
|
||||
'afternoon': {
|
||||
'work_start': time(13, 30),
|
||||
'work_end': time(18, 30),
|
||||
'card_start': time(13, 30),
|
||||
'card_end': time(18, 30)
|
||||
},
|
||||
'evening': {
|
||||
'work_start': time(19, 0),
|
||||
'work_end': time(23, 30),
|
||||
'card_start': time(19, 0),
|
||||
'card_end': time(23, 30)
|
||||
}
|
||||
}
|
||||
|
||||
def parse_xlsx_file(self, file_path: str) -> Dict:
|
||||
"""解析xlsx文件"""
|
||||
try:
|
||||
df = pd.read_excel(file_path)
|
||||
logger.info(f"成功读取文件: {file_path}")
|
||||
return self._process_dataframe(df)
|
||||
except Exception as e:
|
||||
logger.error(f"读取文件失败: {e}")
|
||||
raise
|
||||
|
||||
def _process_dataframe(self, df: pd.DataFrame) -> Dict:
|
||||
"""处理DataFrame数据"""
|
||||
results = {}
|
||||
|
||||
# 获取日期列(跳过前几列的统计数据)
|
||||
date_columns = [col for col in df.columns if '2025-' in str(col)]
|
||||
|
||||
for _, row in df.iterrows():
|
||||
name = row['姓名']
|
||||
if pd.isna(name):
|
||||
continue
|
||||
|
||||
# 解析每日考勤数据
|
||||
daily_data = {}
|
||||
for date_col in date_columns:
|
||||
date_str = str(date_col).split()[0] # 提取日期部分
|
||||
attendance_str = str(row[date_col])
|
||||
daily_data[date_str] = self._parse_daily_attendance(attendance_str)
|
||||
|
||||
results[name] = daily_data
|
||||
|
||||
return results
|
||||
|
||||
def _parse_daily_attendance(self, attendance_str: str) -> Dict:
|
||||
"""解析单日考勤字符串"""
|
||||
if pd.isna(attendance_str) or attendance_str == 'nan':
|
||||
return {'status': 'absent', 'records': []}
|
||||
|
||||
if '休息' in attendance_str:
|
||||
return self._parse_weekend_attendance(attendance_str)
|
||||
|
||||
# 解析工作日考勤
|
||||
records = []
|
||||
parts = attendance_str.split(',')
|
||||
|
||||
time_periods = ['morning_in', 'morning_out', 'afternoon_in', 'afternoon_out', 'evening_in', 'evening_out']
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if i >= len(time_periods):
|
||||
break
|
||||
|
||||
part = part.strip()
|
||||
period = time_periods[i]
|
||||
|
||||
if '缺卡' in part:
|
||||
records.append({'period': period, 'status': 'missing', 'time': None})
|
||||
elif '正常' in part:
|
||||
time_match = re.search(r'\((\d{2}:\d{2})\)', part)
|
||||
card_time = time_match.group(1) if time_match else None
|
||||
records.append({'period': period, 'status': 'normal', 'time': card_time})
|
||||
elif '迟到' in part:
|
||||
time_match = re.search(r'\((\d{2}:\d{2})\)', part)
|
||||
late_match = re.search(r'迟到(\d+)分钟', part)
|
||||
card_time = time_match.group(1) if time_match else None
|
||||
late_minutes = int(late_match.group(1)) if late_match else 0
|
||||
records.append({
|
||||
'period': period,
|
||||
'status': 'late',
|
||||
'time': card_time,
|
||||
'late_minutes': late_minutes
|
||||
})
|
||||
elif '早退' in part:
|
||||
time_match = re.search(r'\((\d{2}:\d{2})\)', part)
|
||||
early_match = re.search(r'早退(\d+)分钟', part)
|
||||
card_time = time_match.group(1) if time_match else None
|
||||
early_minutes = int(early_match.group(1)) if early_match else 0
|
||||
records.append({
|
||||
'period': period,
|
||||
'status': 'early_leave',
|
||||
'time': card_time,
|
||||
'early_minutes': early_minutes
|
||||
})
|
||||
|
||||
return {'status': 'workday', 'records': records}
|
||||
|
||||
def _parse_weekend_attendance(self, attendance_str: str) -> Dict:
|
||||
"""解析周末考勤"""
|
||||
if '休息(-,-)' in attendance_str:
|
||||
return {'status': 'weekend_rest', 'records': []}
|
||||
|
||||
# 解析周末加班
|
||||
time_match = re.search(r'休息打卡\((\d{2}:\d{2}),?(\d{2}:\d{2})?\)', attendance_str)
|
||||
if time_match:
|
||||
start_time = time_match.group(1)
|
||||
end_time = time_match.group(2) if time_match.group(2) else None
|
||||
return {
|
||||
'status': 'weekend_work',
|
||||
'records': [{'start': start_time, 'end': end_time}]
|
||||
}
|
||||
|
||||
return {'status': 'weekend_rest', 'records': []}
|
||||
|
||||
def calculate_weekly_statistics(self, daily_data: Dict, week_start: str, week_end: str) -> Dict:
|
||||
"""计算周统计数据"""
|
||||
stats = {
|
||||
'actual_work_hours': 0.0,
|
||||
'class_work_hours': 0.0,
|
||||
'absent_days': 0,
|
||||
'overtime_hours': 0.0
|
||||
}
|
||||
|
||||
start_date = datetime.strptime(week_start, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(week_end, '%Y-%m-%d')
|
||||
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime('%Y-%m-%d')
|
||||
is_weekday = current_date.weekday() < 5 # 0-4是工作日
|
||||
|
||||
if date_str in daily_data:
|
||||
day_data = daily_data[date_str]
|
||||
if day_data['status'] == 'workday':
|
||||
day_stats = self._calculate_daily_hours(day_data['records'], is_weekday)
|
||||
stats['actual_work_hours'] += day_stats['actual_hours']
|
||||
if is_weekday:
|
||||
stats['class_work_hours'] += day_stats['actual_hours']
|
||||
else:
|
||||
stats['overtime_hours'] += day_stats['actual_hours']
|
||||
elif day_data['status'] == 'weekend_work':
|
||||
overtime = self._calculate_weekend_overtime(day_data['records'])
|
||||
stats['actual_work_hours'] += overtime
|
||||
stats['overtime_hours'] += overtime
|
||||
elif day_data['status'] == 'absent' and is_weekday:
|
||||
stats['absent_days'] += 1
|
||||
elif is_weekday:
|
||||
stats['absent_days'] += 1
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return stats
|
||||
|
||||
def _calculate_daily_hours(self, records: List[Dict], is_weekday: bool) -> Dict:
|
||||
"""计算每日工作时长"""
|
||||
total_hours = 0.0
|
||||
|
||||
# 处理早上时段
|
||||
morning_in = None
|
||||
morning_out = None
|
||||
afternoon_in = None
|
||||
afternoon_out = None
|
||||
evening_in = None
|
||||
evening_out = None
|
||||
|
||||
for record in records:
|
||||
if record['period'] == 'morning_in' and record['status'] in ['normal', 'late'] and record['time']:
|
||||
morning_in = datetime.strptime(record['time'], '%H:%M').time()
|
||||
elif record['period'] == 'morning_out' and record['status'] in ['normal', 'early_leave'] and record['time']:
|
||||
morning_out = datetime.strptime(record['time'], '%H:%M').time()
|
||||
elif record['period'] == 'afternoon_in' and record['status'] in ['normal', 'late'] and record['time']:
|
||||
afternoon_in = datetime.strptime(record['time'], '%H:%M').time()
|
||||
elif record['period'] == 'afternoon_out' and record['status'] in ['normal', 'early_leave'] and record[
|
||||
'time']:
|
||||
afternoon_out = datetime.strptime(record['time'], '%H:%M').time()
|
||||
elif record['period'] == 'evening_in' and record['status'] in ['normal', 'late'] and record['time']:
|
||||
evening_in = datetime.strptime(record['time'], '%H:%M').time()
|
||||
elif record['period'] == 'evening_out' and record['status'] in ['normal', 'early_leave'] and record['time']:
|
||||
evening_out = datetime.strptime(record['time'], '%H:%M').time()
|
||||
|
||||
# 计算各时段工时
|
||||
if morning_in and morning_out:
|
||||
morning_hours = self._calculate_time_diff(morning_in, morning_out)
|
||||
total_hours += morning_hours
|
||||
|
||||
if afternoon_in and afternoon_out:
|
||||
afternoon_hours = self._calculate_time_diff(afternoon_in, afternoon_out)
|
||||
total_hours += afternoon_hours
|
||||
|
||||
if evening_in and evening_out:
|
||||
evening_hours = self._calculate_time_diff(evening_in, evening_out)
|
||||
total_hours += evening_hours
|
||||
|
||||
return {'actual_hours': total_hours}
|
||||
|
||||
def _calculate_weekend_overtime(self, records: List[Dict]) -> float:
|
||||
"""计算周末加班时长"""
|
||||
if not records or not records[0].get('start'):
|
||||
return 0.0
|
||||
|
||||
start_time = datetime.strptime(records[0]['start'], '%H:%M').time()
|
||||
end_time = None
|
||||
|
||||
if records[0].get('end'):
|
||||
end_time = datetime.strptime(records[0]['end'], '%H:%M').time()
|
||||
|
||||
if start_time and end_time:
|
||||
return self._calculate_time_diff(start_time, end_time)
|
||||
|
||||
return 0.0
|
||||
|
||||
def _calculate_time_diff(self, start_time: time, end_time: time) -> float:
|
||||
"""计算时间差(小时)"""
|
||||
start_minutes = start_time.hour * 60 + start_time.minute
|
||||
end_minutes = end_time.hour * 60 + end_time.minute
|
||||
|
||||
if end_minutes < start_minutes: # 跨天
|
||||
end_minutes += 24 * 60
|
||||
|
||||
diff_minutes = end_minutes - start_minutes
|
||||
return round(diff_minutes / 60.0, 1)
|
||||
|
||||
def import_to_database(self, data: Dict, week_start: str, week_end: str):
|
||||
"""导入数据到数据库"""
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
for name, daily_data in data.items():
|
||||
# 获取学生信息
|
||||
cursor.execute("SELECT student_number FROM students WHERE name = %s", (name,))
|
||||
student_result = cursor.fetchone()
|
||||
|
||||
if not student_result:
|
||||
logger.warning(f"未找到学生: {name}")
|
||||
continue
|
||||
|
||||
student_number = student_result[0]
|
||||
|
||||
# 计算周统计
|
||||
weekly_stats = self.calculate_weekly_statistics(daily_data, week_start, week_end)
|
||||
|
||||
# 插入周考勤汇总
|
||||
insert_weekly_sql = """
|
||||
INSERT INTO weekly_attendance
|
||||
(student_number, name, week_start_date, week_end_date,
|
||||
actual_work_hours, class_work_hours, absent_days, overtime_hours)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
actual_work_hours = VALUES(actual_work_hours),
|
||||
class_work_hours = VALUES(class_work_hours),
|
||||
absent_days = VALUES(absent_days),
|
||||
overtime_hours = VALUES(overtime_hours),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
|
||||
cursor.execute(insert_weekly_sql, (
|
||||
student_number, name, week_start, week_end,
|
||||
weekly_stats['actual_work_hours'],
|
||||
weekly_stats['class_work_hours'],
|
||||
weekly_stats['absent_days'],
|
||||
weekly_stats['overtime_hours']
|
||||
))
|
||||
|
||||
weekly_record_id = cursor.lastrowid
|
||||
|
||||
# 插入每日考勤明细
|
||||
self._insert_daily_details(cursor, weekly_record_id, student_number, daily_data, week_start, week_end)
|
||||
|
||||
conn.commit()
|
||||
logger.info("数据导入成功")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"数据导入失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def _insert_daily_details(self, cursor, weekly_record_id: int, student_number: str,
|
||||
daily_data: Dict, week_start: str, week_end: str):
|
||||
"""插入每日考勤明细"""
|
||||
start_date = datetime.strptime(week_start, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(week_end, '%Y-%m-%d')
|
||||
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime('%Y-%m-%d')
|
||||
|
||||
if date_str in daily_data:
|
||||
day_data = daily_data[date_str]
|
||||
status = self._get_daily_status(day_data)
|
||||
|
||||
# 插入每日记录
|
||||
insert_daily_sql = """
|
||||
INSERT INTO daily_attendance_details
|
||||
(weekly_record_id, student_number, attendance_date, status, remarks)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
remarks = VALUES(remarks)
|
||||
"""
|
||||
|
||||
remarks = self._generate_remarks(day_data)
|
||||
cursor.execute(insert_daily_sql, (
|
||||
weekly_record_id, student_number, current_date.date(), status, remarks
|
||||
))
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
def _get_daily_status(self, day_data: Dict) -> str:
|
||||
"""获取每日状态"""
|
||||
if day_data['status'] == 'absent':
|
||||
return '缺勤'
|
||||
elif day_data['status'] == 'weekend_rest':
|
||||
return '休息'
|
||||
elif day_data['status'] == 'weekend_work':
|
||||
return '加班'
|
||||
else:
|
||||
# 检查是否有迟到
|
||||
for record in day_data['records']:
|
||||
if record.get('status') == 'late':
|
||||
return '迟到'
|
||||
return '正常'
|
||||
|
||||
def _generate_remarks(self, day_data: Dict) -> str:
|
||||
"""生成备注信息"""
|
||||
if day_data['status'] == 'absent':
|
||||
return '缺勤'
|
||||
elif day_data['status'] == 'weekend_rest':
|
||||
return '休息日'
|
||||
elif day_data['status'] == 'weekend_work':
|
||||
return '周末加班'
|
||||
|
||||
remarks = []
|
||||
for record in day_data['records']:
|
||||
if record.get('status') == 'late':
|
||||
remarks.append(f"迟到{record.get('late_minutes', 0)}分钟")
|
||||
elif record.get('status') == 'early_leave':
|
||||
remarks.append(f"早退{record.get('early_minutes', 0)}分钟")
|
||||
elif record.get('status') == 'missing':
|
||||
remarks.append("缺卡")
|
||||
|
||||
return '; '.join(remarks) if remarks else '正常'
|
32
app/utils/database.py
Normal file
32
app/utils/database.py
Normal file
@ -0,0 +1,32 @@
|
||||
from app.models import db
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from flask import current_app
|
||||
def safe_commit():
|
||||
"""安全提交数据库更改"""
|
||||
try:
|
||||
db.session.commit()
|
||||
return True, None
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Database commit error: {e}")
|
||||
return False, str(e)
|
||||
def safe_add_and_commit(obj):
|
||||
"""安全添加并提交对象"""
|
||||
try:
|
||||
db.session.add(obj)
|
||||
db.session.commit()
|
||||
return True, None
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Database add error: {e}")
|
||||
return False, str(e)
|
||||
def safe_delete_and_commit(obj):
|
||||
"""安全删除并提交对象"""
|
||||
try:
|
||||
db.session.delete(obj)
|
||||
db.session.commit()
|
||||
return True, None
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Database delete error: {e}")
|
||||
return False, str(e)
|
11107
code_collection.txt
Normal file
11107
code_collection.txt
Normal file
File diff suppressed because it is too large
Load Diff
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
40
config/config.py
Normal file
40
config/config.py
Normal file
@ -0,0 +1,40 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_recycle': 300,
|
||||
'pool_pre_ping': True,
|
||||
'pool_size': 10,
|
||||
'max_overflow': 20
|
||||
}
|
||||
|
||||
# 分页配置
|
||||
STUDENTS_PER_PAGE = 20
|
||||
ATTENDANCE_PER_PAGE = 50
|
||||
|
||||
# 文件上传配置
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
ALLOWED_EXTENSIONS = {'csv', 'xlsx', 'xls'}
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
14
config/database.py
Normal file
14
config/database.py
Normal file
@ -0,0 +1,14 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
DATABASE_URL = os.environ.get('DATABASE_URL')
|
||||
engine = create_engine(DATABASE_URL, echo=False)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
41
init_db.py
Normal file
41
init_db.py
Normal file
@ -0,0 +1,41 @@
|
||||
from app import create_app
|
||||
from app.models import db, User, Student
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def init_database():
|
||||
"""初始化数据库,修复密码哈希问题"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
print("正在修复用户密码...")
|
||||
|
||||
# 获取所有用户
|
||||
users = User.query.all()
|
||||
|
||||
for user in users:
|
||||
if user.student_number == 'admin':
|
||||
# 管理员密码设为 admin123
|
||||
new_password = 'admin123'
|
||||
else:
|
||||
# 学生密码设为学号
|
||||
new_password = user.student_number
|
||||
|
||||
# 生成正确的密码哈希
|
||||
user.password_hash = generate_password_hash(new_password)
|
||||
print(f"修复用户: {user.student_number}, 密码: {new_password}")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
print("✅ 所有用户密码修复完成!")
|
||||
print("\n登录信息:")
|
||||
print("管理员 - 学号: admin, 密码: admin123")
|
||||
print("学生用户 - 学号: [学号], 密码: [学号]")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"❌ 修复失败: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_database()
|
40
requirements.txt
Normal file
40
requirements.txt
Normal file
@ -0,0 +1,40 @@
|
||||
# Flask核心框架
|
||||
Flask==3.0.0
|
||||
Werkzeug==3.0.1
|
||||
|
||||
# 数据库相关
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
PyMySQL==1.1.0
|
||||
SQLAlchemy==2.0.23
|
||||
|
||||
# 用户认证
|
||||
Flask-Login==0.6.3
|
||||
|
||||
# 表单处理
|
||||
Flask-WTF==1.2.1
|
||||
WTForms==3.1.0
|
||||
|
||||
# 密码加密
|
||||
bcrypt==4.1.2
|
||||
|
||||
# 环境变量管理
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# 模板引擎(通常Flask自带,但明确指定版本)
|
||||
Jinja2==3.1.2
|
||||
MarkupSafe==2.1.3
|
||||
|
||||
# 数据处理(用于Excel/CSV导入导出)
|
||||
pandas==2.1.4
|
||||
openpyxl==3.1.2
|
||||
xlrd==2.0.1
|
||||
|
||||
# HTTP请求处理
|
||||
requests==2.31.0
|
||||
|
||||
# 日期时间处理
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# 其他工具
|
||||
click==8.1.7
|
||||
itsdangerous==2.1.2
|
6
run.py
Normal file
6
run.py
Normal file
@ -0,0 +1,6 @@
|
||||
from app import create_app
|
||||
import os
|
||||
app = create_app()
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 23944))
|
||||
app.run(host='0.0.0.0', port=port, debug=True)
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/test_auth.py
Normal file
0
tests/test_auth.py
Normal file
0
tests/test_models.py
Normal file
0
tests/test_models.py
Normal file
Loading…
x
Reference in New Issue
Block a user