first commit

This commit is contained in:
superlishunqin 2025-06-11 19:56:34 +08:00
commit e7fa4bc030
52 changed files with 23013 additions and 0 deletions

476
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,517 @@
# 校园考勤打卡系统
一个基于Flask的校园考勤打卡管理系统支持学生考勤数据的录入、管理、统计和可视化展示。
## 📋 项目简介
本系统是为校园环境设计的考勤打卡管理平台,能够处理学生的日常打卡数据,提供完整的考勤统计分析功能。系统支持管理员和学生两种角色,实现了考勤数据的全生命周期管理。
## ✨ 主要功能
### 👨‍💼 管理员功能
- **学生管理**:添加、编辑、删除学生信息
- **考勤数据导入**支持CSV/XLSX格式的批量数据导入
- **考勤数据管理**:查看、编辑、删除考勤记录
- **统计分析**:生成各类考勤统计报表
- **权限管理**:用户角色和权限控制
- **数据修正**:修正错误的考勤时间和状态
### 👨‍🎓 学生功能
- **个人考勤查询**:查看个人从入学以来的所有考勤记录
- **考勤统计**:按周统计个人出勤时长、迟到、缺勤次数
- **请假管理**:提交请假申请,查看请假记录
- **个人信息**:查看和修改个人基本信息
- **密码管理**:修改登录密码
### 📊 数据展示
- **可视化界面**:直观的数据展示界面
- **多维度查询**:按年级、学号、姓名进行查询
- **实时统计**:实时显示各类考勤统计数据
- **历史记录**:完整的考勤历史记录追踪
## 🛠 技术栈
- **后端框架**Flask
- **数据库**MySQL
- **前端技术**HTML5, CSS3, JavaScript
- **数据处理**PandasCSV/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
- **数据处理**PandasCSV/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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

257
app/static/css/style.css Normal file
View 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
View File

17
app/static/js/main.js Normal file
View 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秒后自动关闭
});
});

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

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

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

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

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

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

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

View 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">&nbsp;</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 %}

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

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

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

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

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

View 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">&copy; 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>

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

24
app/utils/auth_helpers.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

0
config/__init__.py Normal file
View File

40
config/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

0
tests/test_auth.py Normal file
View File

0
tests/test_models.py Normal file
View File