From e7fa4bc0303c2a6671bb370b0460326178772b49 Mon Sep 17 00:00:00 2001 From: superlishunqin <852326703@qq.com> Date: Wed, 11 Jun 2025 19:56:34 +0800 Subject: [PATCH] first commit --- .gitignore | 476 + README.md | 517 + all_file_output.py | 64 + app/__init__.py | 121 + app/models/__init__.py | 10 + app/models/attendance.py | 69 + app/models/student.py | 42 + app/models/user.py | 31 + app/routes/__init__.py | 0 app/routes/admin.py | 1305 ++ app/routes/auth.py | 179 + app/routes/student.py | 437 + app/static/css/admin.css | 0 app/static/css/style.css | 257 + app/static/js/admin.js | 0 app/static/js/main.js | 17 + app/templates/admin/add_student.html | 247 + app/templates/admin/attendance_details.html | 571 + .../admin/attendance_management.html | 656 + app/templates/admin/dashboard.html | 383 + app/templates/admin/edit_student.html | 307 + app/templates/admin/statistics.html | 430 + app/templates/admin/student_detail.html | 548 + app/templates/admin/student_list.html | 293 + app/templates/admin/upload_attendance.html | 152 + app/templates/auth/admin_profile.html | 128 + app/templates/auth/change_password.html | 237 + app/templates/auth/login.html | 164 + app/templates/auth/student_profile.html | 269 + app/templates/layout/base.html | 63 + app/templates/layout/nav.html | 102 + app/templates/student/apply_leave.html | 0 app/templates/student/attendance.html | 539 + app/templates/student/attendance_details.html | 673 + app/templates/student/dashboard.html | 250 + app/templates/student/leave_records.html | 0 app/templates/student/statistics.html | 563 + app/utils/__init__.py | 16 + app/utils/attendance_importer.py | 1227 ++ app/utils/auth_helpers.py | 24 + app/utils/data_import.py | 366 + app/utils/database.py | 32 + code_collection.txt | 11107 ++++++++++++++++ config/__init__.py | 0 config/config.py | 40 + config/database.py | 14 + init_db.py | 41 + requirements.txt | 40 + run.py | 6 + tests/__init__.py | 0 tests/test_auth.py | 0 tests/test_models.py | 0 52 files changed, 23013 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 all_file_output.py create mode 100644 app/__init__.py create mode 100644 app/models/__init__.py create mode 100644 app/models/attendance.py create mode 100644 app/models/student.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/student.py create mode 100644 app/static/css/admin.css create mode 100644 app/static/css/style.css create mode 100644 app/static/js/admin.js create mode 100644 app/static/js/main.js create mode 100644 app/templates/admin/add_student.html create mode 100644 app/templates/admin/attendance_details.html create mode 100644 app/templates/admin/attendance_management.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/edit_student.html create mode 100644 app/templates/admin/statistics.html create mode 100644 app/templates/admin/student_detail.html create mode 100644 app/templates/admin/student_list.html create mode 100644 app/templates/admin/upload_attendance.html create mode 100644 app/templates/auth/admin_profile.html create mode 100644 app/templates/auth/change_password.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/student_profile.html create mode 100644 app/templates/layout/base.html create mode 100644 app/templates/layout/nav.html create mode 100644 app/templates/student/apply_leave.html create mode 100644 app/templates/student/attendance.html create mode 100644 app/templates/student/attendance_details.html create mode 100644 app/templates/student/dashboard.html create mode 100644 app/templates/student/leave_records.html create mode 100644 app/templates/student/statistics.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/attendance_importer.py create mode 100644 app/utils/auth_helpers.py create mode 100644 app/utils/data_import.py create mode 100644 app/utils/database.py create mode 100644 code_collection.txt create mode 100644 config/__init__.py create mode 100644 config/config.py create mode 100644 config/database.py create mode 100644 init_db.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 tests/__init__.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_models.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5421f1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9458086 --- /dev/null +++ b/README.md @@ -0,0 +1,517 @@ +# 校园考勤打卡系统 + +一个基于Flask的校园考勤打卡管理系统,支持学生考勤数据的录入、管理、统计和可视化展示。 + +## 📋 项目简介 + +本系统是为校园环境设计的考勤打卡管理平台,能够处理学生的日常打卡数据,提供完整的考勤统计分析功能。系统支持管理员和学生两种角色,实现了考勤数据的全生命周期管理。 + +## ✨ 主要功能 + +### 👨‍💼 管理员功能 +- **学生管理**:添加、编辑、删除学生信息 +- **考勤数据导入**:支持CSV/XLSX格式的批量数据导入 +- **考勤数据管理**:查看、编辑、删除考勤记录 +- **统计分析**:生成各类考勤统计报表 +- **权限管理**:用户角色和权限控制 +- **数据修正**:修正错误的考勤时间和状态 + +### 👨‍🎓 学生功能 +- **个人考勤查询**:查看个人从入学以来的所有考勤记录 +- **考勤统计**:按周统计个人出勤时长、迟到、缺勤次数 +- **请假管理**:提交请假申请,查看请假记录 +- **个人信息**:查看和修改个人基本信息 +- **密码管理**:修改登录密码 + +### 📊 数据展示 +- **可视化界面**:直观的数据展示界面 +- **多维度查询**:按年级、学号、姓名进行查询 +- **实时统计**:实时显示各类考勤统计数据 +- **历史记录**:完整的考勤历史记录追踪 + +## 🛠 技术栈 + +- **后端框架**:Flask +- **数据库**:MySQL +- **前端技术**:HTML5, CSS3, JavaScript +- **数据处理**:Pandas(CSV/XLSX处理) +- **认证授权**:Flask-Login +- **模板引擎**:Jinja2 + +## 📁 项目结构 + +``` +. +├── app/ # 应用主目录 +│ ├── models/ # 数据模型 +│ │ ├── user.py # 用户模型 +│ │ ├── student.py # 学生模型 +│ │ └── attendance.py # 考勤模型 +│ ├── routes/ # 路由控制器 +│ │ ├── auth.py # 认证路由 +│ │ ├── admin.py # 管理员路由 +│ │ └── student.py # 学生路由 +│ ├── templates/ # 前端模板 +│ │ ├── auth/ # 认证相关页面 +│ │ ├── admin/ # 管理员页面 +│ │ ├── student/ # 学生页面 +│ │ └── layout/ # 布局模板 +│ ├── static/ # 静态资源 +│ │ ├── css/ # 样式文件 +│ │ ├── js/ # JavaScript文件 +│ │ └── images/ # 图片资源 +│ └── utils/ # 工具函数 +│ ├── database.py # 数据库操作 +│ ├── auth_helpers.py # 认证辅助函数 +│ └── attendance_importer.py # 考勤数据导入 +├── config/ # 配置文件 +├── tests/ # 测试文件 +├── logs/ # 日志文件 +├── requirements.txt # 依赖包列表 +├── run.py # 应用启动文件 +└── init_db.py # 数据库初始化脚本 +``` + +## ⚙️ 考勤规则 + +### 工作日打卡时段 +- **早上时段**:6:00-12:00 可打卡,要求9:45-11:30 +- **下午时段**:13:30-18:30 可打卡,要求14:45-17:30 +- **晚上时段**:19:00-23:30 可打卡(非强制) + +### 考勤计算规则 +- **迟到计算**:超过规定上班时间即为迟到 +- **缺卡处理**:未在规定时间内打卡视为缺卡 +- **时长统计**: + - 工作日有效打卡计入**实际出勤时长**和**班内工作时长** + - 休息日打卡计入**实际出勤时长**和**加班总时长** + +## 🚀 安装部署 + +### 环境要求 +- Python 3.8+ +- MySQL 5.7+ +- pip + +### 安装步骤 + +1. **克隆项目** +```bash +git clone +cd attendance-system +``` + +2. **创建虚拟环境** +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +# venv\Scripts\activate # Windows +``` + +3. **安装依赖** +```bash +pip install -r requirements.txt +``` + +4. **配置数据库** +```bash +# 创建数据库 +mysql -u root -p +CREATE DATABASE attendance_system; +``` + +5. **配置环境变量** +```bash +# 创建 .env 文件 +cp .env.example .env +# 编辑数据库连接信息 +``` + +6. **初始化数据库** +```bash +python init_db.py +``` + +7. **启动应用** +```bash +python run.py +``` + +### 环境变量配置 + +在 `.env` 文件中配置以下变量: + +```env +# 数据库配置 +DB_HOST=localhost +DB_USER=your_username +DB_PASSWORD=your_password +DB_NAME=attendance_system + +# Flask配置 +SECRET_KEY=your_secret_key +DEBUG=True +``` + +## 📖 使用说明 + +### 首次使用 + +1. **创建管理员账户** +```bash +python init_db.py --create-admin +``` + +2. **导入学生数据** + - 登录管理员账户 + - 进入"学生管理"页面 + - 使用"批量导入"功能导入学生信息 + +3. **上传考勤数据** + - 进入"考勤管理"页面 + - 使用"数据导入"功能上传CSV/XLSX文件 + +### 数据导入格式 + +**学生信息CSV格式**: +```csv +学号,姓名,性别,年级,专业,导师,学院,学位类型 +2021001,张三,男,2021,计算机科学,李教授,计算机学院,学硕 +``` + +**考勤数据CSV格式**: +```csv +学号,姓名,日期,签到时间,签退时间,状态 +2021001,张三,2024-01-15,09:30,11:45,正常 +``` + +## 👥 用户角色 + +### 管理员权限 +- 查看所有学生信息和考勤数据 +- 管理学生账户(增删改查) +- 导入和管理考勤数据 +- 生成统计报表 +- 系统配置管理 + +### 学生权限 +- 查看个人考勤记录 +- 查看个人统计数据 +- 申请请假 +- 修改个人信息 +- 修改登录密码 + +## 📊 数据统计 + +系统提供多维度的数据统计功能: + +- **个人统计**:个人出勤时长、迟到次数、缺勤次数 +- **年级统计**:按年级汇总的考勤数据 +- **时间统计**:按周、月、学期的考勤趋势 +- **排行榜**:出勤时长排名、迟到次数排名等 + +## 🔧 开发说明 + +### 添加新功能 +1. 在对应的模型文件中添加数据模型 +2. 在路由文件中添加业务逻辑 +3. 创建相应的HTML模板 +4. 更新CSS和JavaScript文件 + +### 数据库迁移 +```bash +# 备份数据库 +mysqldump -u username -p attendance_system > backup.sql + +# 修改数据库结构后重新初始化 +python init_db.py --reset +``` + +## 📄 许可证 + +[MIT License](LICENSE) + +## 🤝 贡献指南 + +1. Fork 本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建 Pull Request + +## 📞 联系我们 + +如有问题或建议,请通过以下方式联系: + +- 📧 Email: [your-email@example.com] +- 🐛 Issues: [GitHub Issues](https://github.com/your-username/attendance-system/issues) + +## 🎯 Todo List + +- [ ] 移动端适配 +- [ ] 数据导出功能 +- [ ] 邮件通知功能 +- [ ] 更多统计图表 +- [ ] API接口文档 +- [ ] 单元测试覆盖 + +--- + +⭐ 如果这# 校园考勤打卡系统 + +一个基于Flask的校园考勤打卡管理系统,支持学生考勤数据的录入、管理、统计和可视化展示。 + +## 📋 项目简介 + +本系统是为校园环境设计的考勤打卡管理平台,能够处理学生的日常打卡数据,提供完整的考勤统计分析功能。系统支持管理员和学生两种角色,实现了考勤数据的全生命周期管理。 + +## ✨ 主要功能 + +### 👨‍💼 管理员功能 +- **学生管理**:添加、编辑、删除学生信息 +- **考勤数据导入**:支持CSV/XLSX格式的批量数据导入 +- **考勤数据管理**:查看、编辑、删除考勤记录 +- **统计分析**:生成各类考勤统计报表 +- **权限管理**:用户角色和权限控制 +- **数据修正**:修正错误的考勤时间和状态 + +### 👨‍🎓 学生功能 +- **个人考勤查询**:查看个人从入学以来的所有考勤记录 +- **考勤统计**:按周统计个人出勤时长、迟到、缺勤次数 +- **个人信息**:查看和修改个人基本信息 +- **密码管理**:修改登录密码 + +### 📊 数据展示 +- **可视化界面**:直观的数据展示界面 +- **多维度查询**:按年级、学号、姓名进行查询 +- **实时统计**:实时显示各类考勤统计数据 +- **历史记录**:完整的考勤历史记录追踪 + +## 🛠 技术栈 + +- **后端框架**:Flask +- **数据库**:MySQL +- **前端技术**:HTML5, CSS3, JavaScript +- **数据处理**:Pandas(CSV/XLSX处理) +- **认证授权**:Flask-Login +- **模板引擎**:Jinja2 + +## 📁 项目结构 + +``` +. +├── app/ # 应用主目录 +│ ├── models/ # 数据模型 +│ │ ├── user.py # 用户模型 +│ │ ├── student.py # 学生模型 +│ │ └── attendance.py # 考勤模型 +│ ├── routes/ # 路由控制器 +│ │ ├── auth.py # 认证路由 +│ │ ├── admin.py # 管理员路由 +│ │ └── student.py # 学生路由 +│ ├── templates/ # 前端模板 +│ │ ├── auth/ # 认证相关页面 +│ │ ├── admin/ # 管理员页面 +│ │ ├── student/ # 学生页面 +│ │ └── layout/ # 布局模板 +│ ├── static/ # 静态资源 +│ │ ├── css/ # 样式文件 +│ │ ├── js/ # JavaScript文件 +│ │ └── images/ # 图片资源 +│ └── utils/ # 工具函数 +│ ├── database.py # 数据库操作 +│ ├── auth_helpers.py # 认证辅助函数 +│ └── attendance_importer.py # 考勤数据导入 +├── config/ # 配置文件 +├── tests/ # 测试文件 +├── logs/ # 日志文件 +├── requirements.txt # 依赖包列表 +├── run.py # 应用启动文件 +└── init_db.py # 数据库初始化脚本 +``` + +## ⚙️ 考勤规则 + +### 工作日打卡时段 +- **早上时段**:6:00-12:00 可打卡,要求9:45-11:30 +- **下午时段**:13:30-18:30 可打卡,要求14:45-17:30 +- **晚上时段**:19:00-23:30 可打卡(非强制) + +### 考勤计算规则 +- **迟到计算**:超过规定上班时间即为迟到 +- **缺卡处理**:未在规定时间内打卡视为缺卡 +- **时长统计**: + - 工作日有效打卡计入**实际出勤时长**和**班内工作时长** + - 休息日打卡计入**实际出勤时长**和**加班总时长** + +## 🚀 安装部署 + +### 环境要求 +- Python 3.8+ +- MySQL 5.7+ +- pip + +### 安装步骤 + +1. **克隆项目** +```bash +git clone +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吧! \ No newline at end of file diff --git a/all_file_output.py b/all_file_output.py new file mode 100644 index 0000000..ef4d03d --- /dev/null +++ b/all_file_output.py @@ -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) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f67b5b3 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..85a073d --- /dev/null +++ b/app/models/__init__.py @@ -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'] diff --git a/app/models/attendance.py b/app/models/attendance.py new file mode 100644 index 0000000..2b9db4b --- /dev/null +++ b/app/models/attendance.py @@ -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'' + + +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'' + + +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'' diff --git a/app/models/student.py b/app/models/student.py new file mode 100644 index 0000000..3d7b5ee --- /dev/null +++ b/app/models/student.py @@ -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'' diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..7287709 --- /dev/null +++ b/app/models/user.py @@ -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'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..c4375c9 --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,1305 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +from flask_login import login_required, current_user +from app.models import db, User, Student, WeeklyAttendance, DailyAttendanceDetail, LeaveRecord +from app.utils.auth_helpers import admin_required +from app.utils.database import safe_add_and_commit, safe_commit, safe_delete_and_commit +from datetime import datetime, timedelta +from sqlalchemy import and_, or_, desc, func +import pandas as pd +import io +import re +from werkzeug.security import generate_password_hash +from app.utils.attendance_importer import AttendanceDataImporter +from werkzeug.utils import secure_filename +import os +import tempfile + +admin_bp = Blueprint('admin', __name__) + + +@admin_bp.route('/dashboard') +@admin_required +def dashboard(): + """管理员主页""" + # 统计数据 + total_students = Student.query.count() + total_attendance_records = WeeklyAttendance.query.count() + pending_leaves = LeaveRecord.query.filter_by(status='待审批').count() + + # 最近一周的考勤统计 + week_ago = datetime.now().date() - timedelta(days=7) + recent_records = WeeklyAttendance.query.filter( + WeeklyAttendance.week_start_date >= week_ago + ).count() + + # 按学院统计学生数量 + college_stats = db.session.query( + Student.college, + func.count(Student.student_id).label('count') + ).group_by(Student.college).all() + + # 按导师统计学生数量 + supervisor_stats = db.session.query( + Student.supervisor, + func.count(Student.student_id).label('count') + ).group_by(Student.supervisor).order_by(desc('count')).limit(10).all() + + # 最近的请假申请 + recent_leaves = LeaveRecord.query.filter_by( + status='待审批' + ).order_by(desc(LeaveRecord.created_at)).limit(5).all() + + return render_template('admin/dashboard.html', + total_students=total_students, + total_attendance_records=total_attendance_records, + pending_leaves=pending_leaves, + recent_records=recent_records, + college_stats=college_stats, + supervisor_stats=supervisor_stats, + recent_leaves=recent_leaves) + + +@admin_bp.route('/students') +@admin_required +def student_list(): + """学生列表""" + page = request.args.get('page', 1, type=int) + per_page = 20 + + # 搜索和筛选 + search = request.args.get('search', '').strip() + college = request.args.get('college', '').strip() + supervisor = request.args.get('supervisor', '').strip() + grade = request.args.get('grade', '', type=str) + + query = Student.query + + if search: + query = query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if college: + query = query.filter(Student.college == college) + + if supervisor: + query = query.filter(Student.supervisor == supervisor) + + if grade: + try: + grade_int = int(grade) + query = query.filter(Student.grade == grade_int) + except ValueError: + pass + + pagination = query.order_by(Student.student_number).paginate( + page=page, per_page=per_page, error_out=False + ) + + students = pagination.items + + # 获取筛选选项 + colleges = db.session.query(Student.college).distinct().all() + colleges = [c[0] for c in colleges if c[0]] + + supervisors = db.session.query(Student.supervisor).distinct().all() + supervisors = [s[0] for s in supervisors if s[0]] + + grades = db.session.query(Student.grade).distinct().all() + grades = sorted([g[0] for g in grades if g[0]]) + + return render_template('admin/student_list.html', + students=students, + pagination=pagination, + colleges=colleges, + supervisors=supervisors, + grades=grades, + search=search, + selected_college=college, + selected_supervisor=supervisor, + selected_grade=grade) + + +@admin_bp.route('/students/') +@admin_required +def student_detail(student_number): + """学生详细信息""" + student = Student.query.filter_by(student_number=student_number).first_or_404() + + # 获取考勤记录 + attendance_records = WeeklyAttendance.query.filter_by( + student_number=student_number + ).order_by(desc(WeeklyAttendance.week_start_date)).limit(10).all() + + # 获取请假记录 + leave_records = LeaveRecord.query.filter_by( + student_number=student_number + ).order_by(desc(LeaveRecord.created_at)).limit(10).all() + + # 统计数据 + total_work_hours = db.session.query( + func.sum(WeeklyAttendance.actual_work_hours) + ).filter_by(student_number=student_number).scalar() or 0 + + total_absent_days = db.session.query( + func.sum(WeeklyAttendance.absent_days) + ).filter_by(student_number=student_number).scalar() or 0 + + return render_template('admin/student_detail.html', + student=student, + attendance_records=attendance_records, + leave_records=leave_records, + total_work_hours=float(total_work_hours), + total_absent_days=int(total_absent_days)) + + +@admin_bp.route('/attendance') +@admin_required +def attendance_management(): + """考勤管理""" + + # ========== 导出功能处理 ========== + if request.args.get('export') == 'excel': + try: + return export_attendance_data() + except Exception as e: + flash(f'导出失败: {str(e)}', 'error') + # 移除export参数,重定向到正常页面 + args = dict(request.args) + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + # ================================== + + from sqlalchemy import desc, func, case, or_ + + page = request.args.get('page', 1, type=int) + per_page = 50 + + # 筛选条件 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + student_search = request.args.get('student_search', '').strip() + sort_by = request.args.get('sort_by', 'week_start_date_desc') # 默认按周开始日期降序 + + print(f"收到排序参数: {sort_by}") # 调试信息 + + # 构建基础查询,同时计算迟到次数 + 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 + ).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') + + if student_search: + query = query.filter(or_( + WeeklyAttendance.name.contains(student_search), + WeeklyAttendance.student_number.contains(student_search) + )) + + # 处理排序 + if sort_by and '_' in sort_by: + field, direction = sort_by.rsplit('_', 1) + print(f"排序字段: {field}, 方向: {direction}") # 调试信息 + + if direction not in ['asc', 'desc']: + direction = 'desc' + + # 根据字段设置排序 + if field == 'actual_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.actual_work_hours)) + else: + query = query.order_by(WeeklyAttendance.actual_work_hours) + elif field == 'class_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.class_work_hours)) + else: + query = query.order_by(WeeklyAttendance.class_work_hours) + elif field == 'absent_days': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.absent_days)) + else: + query = query.order_by(WeeklyAttendance.absent_days) + elif field == 'overtime_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.overtime_hours)) + else: + query = query.order_by(WeeklyAttendance.overtime_hours) + elif field == 'late_count': + # 按迟到次数排序 + if direction == 'desc': + query = query.order_by(desc('late_count')) + else: + query = query.order_by('late_count') + elif field == 'created_at': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.created_at)) + else: + query = query.order_by(WeeklyAttendance.created_at) + elif field == 'week_start_date': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(WeeklyAttendance.week_start_date) + else: + # 未知字段,使用默认排序 + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + # 默认排序:按周开始日期降序 + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + + # 执行分页查询 + try: + pagination = query.paginate( + page=page, + per_page=per_page, + error_out=False + ) + except Exception as e: + print(f"查询分页失败: {e}") + flash('查询数据时出现错误', 'error') + return redirect(url_for('admin.dashboard')) + + # 处理结果,将迟到次数添加到记录对象中 + attendance_records = [] + for record, late_count in pagination.items: + # 给记录对象添加迟到次数属性 + record.late_count = int(late_count) if late_count else 0 + attendance_records.append(record) + + print(f"查询结果: {len(attendance_records)} 条记录") # 调试信息 + if attendance_records: + print(f"第一条记录迟到次数: {attendance_records[0].late_count}") # 调试信息 + + # 更新pagination对象的items + pagination.items = attendance_records + + # ========== 计算请假统计 ========== + statistics = None + if attendance_records: + # 获取当前筛选结果中的所有考勤记录ID + record_ids = [record.record_id for record in attendance_records] + + # 统计请假天数 + leave_count = DailyAttendanceDetail.query.filter( + DailyAttendanceDetail.weekly_record_id.in_(record_ids), + DailyAttendanceDetail.status == '请假' + ).count() + + statistics = { + 'total_leave_days': leave_count + } + + # 确保总是返回模板 + return render_template('admin/attendance_management.html', + attendance_records=attendance_records, + pagination=pagination, + start_date=start_date, + end_date=end_date, + student_search=student_search, + sort_by=sort_by, + statistics=statistics) + + +@admin_bp.route('/upload/attendance', methods=['GET', 'POST']) +@admin_required +def upload_attendance(): + """上传考勤数据""" + if request.method == 'POST': + # 检查考勤记录文件 + if 'attendance_file' not in request.files: + flash('请选择考勤记录文件', 'error') + return render_template('admin/upload_attendance.html') + + attendance_file = request.files['attendance_file'] + if attendance_file.filename == '': + flash('请选择考勤记录文件', 'error') + return render_template('admin/upload_attendance.html') + + # 检查请假单文件(可选) + leave_file = request.files.get('leave_file') + has_leave_file = leave_file and leave_file.filename != '' + + week_start = request.form.get('week_start') + week_end = request.form.get('week_end') + + if not week_start or not week_end: + flash('请选择周开始和结束日期', 'error') + return render_template('admin/upload_attendance.html') + + if attendance_file and attendance_file.filename.endswith(('.xlsx', '.xls')): + attendance_filename = secure_filename(attendance_file.filename) + leave_filename = secure_filename(leave_file.filename) if has_leave_file else None + + # 使用临时文件 + attendance_temp_file = None + leave_temp_file = None + + try: + # 保存考勤记录文件 + attendance_temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') + attendance_file.save(attendance_temp_file.name) + attendance_temp_file.close() + + # 保存请假单文件(如果有) + if has_leave_file and leave_file.filename.endswith(('.xlsx', '.xls')): + leave_temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') + leave_file.save(leave_temp_file.name) + leave_temp_file.close() + + # 处理数据 + importer = AttendanceDataImporter() + + # 解析考勤数据 + attendance_data = importer.parse_xlsx_file(attendance_temp_file.name) + + # 解析请假数据(如果有) + leave_data = None + if leave_temp_file: + try: + leave_data = importer.parse_leave_file(leave_temp_file.name) + flash(f'成功解析请假记录 {len(leave_data)} 条', 'info') + except Exception as e: + flash(f'请假单解析失败:{str(e)}', 'warning') + leave_data = None + + # 应用请假数据到考勤数据 + if leave_data: + attendance_data = importer.apply_leave_records(attendance_data, leave_data, week_start, week_end) + + # 导入到数据库 + success_count, error_count, error_messages = importer.import_to_database( + attendance_data, week_start, week_end) + + # 如果有请假数据,同时保存到请假记录表 + if leave_data: + leave_success_count = importer.import_leave_records_to_database(leave_data) + flash(f'请假记录导入:{leave_success_count} 条', 'info') + + if success_count > 0: + message = f'导入完成:成功 {success_count} 条,失败 {error_count} 条' + if has_leave_file: + message += f',已处理请假记录' + flash(message, 'success') + + if error_messages: + for msg in error_messages[:5]: # 只显示前5个错误 + flash(msg, 'warning') + else: + flash('导入失败,请检查文件格式和数据', 'error') + for msg in error_messages[:3]: + flash(msg, 'error') + + except Exception as e: + flash(f'文件处理失败:{str(e)}', 'error') + logger.error(f"文件处理失败: {str(e)}", exc_info=True) + finally: + # 删除临时文件 + try: + if attendance_temp_file: + os.unlink(attendance_temp_file.name) + if leave_temp_file: + os.unlink(leave_temp_file.name) + except: + pass + + return redirect(url_for('admin.attendance_management')) + else: + flash('请上传Excel文件(.xlsx或.xls)', 'error') + + return render_template('admin/upload_attendance.html') + + +# 添加一个新的路由来删除考勤记录 +@admin_bp.route('/attendance//delete', methods=['POST']) +@admin_required +def delete_attendance_record(record_id): + """删除考勤记录""" + try: + record = WeeklyAttendance.query.get_or_404(record_id) + db.session.delete(record) + db.session.commit() + flash('考勤记录删除成功', 'success') + except Exception as e: + db.session.rollback() + flash(f'删除失败: {str(e)}', 'error') + + return redirect(url_for('admin.attendance_management')) + + +@admin_bp.route('/statistics') +@admin_required +def statistics(): + """统计报表""" + from sqlalchemy import desc, func, case, or_, and_ + from datetime import datetime, timedelta + + # 获取筛选参数 + search = request.args.get('search', '').strip() + grade_filter = request.args.get('grade', '').strip() + college_filter = request.args.get('college', '').strip() + supervisor_filter = request.args.get('supervisor', '').strip() + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + # 构建基础查询 + base_query = db.session.query( + Student.student_number, + Student.name, + Student.grade, + Student.college, + Student.supervisor, + Student.degree_type, + Student.enrollment_date, + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_work_hours'), + func.coalesce(func.sum(WeeklyAttendance.class_work_hours), 0).label('total_class_hours'), + func.coalesce(func.sum(WeeklyAttendance.overtime_hours), 0).label('total_overtime_hours'), + func.coalesce(func.sum(WeeklyAttendance.absent_days), 0).label('total_absent_days'), + func.count(WeeklyAttendance.record_id).label('attendance_weeks'), + # 计算迟到次数 + func.coalesce( + func.sum( + case( + (DailyAttendanceDetail.status.like('%迟到%'), 1), + else_=0 + ) + ), 0 + ).label('total_late_count') + ).outerjoin( + WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number + ).outerjoin( + DailyAttendanceDetail, WeeklyAttendance.record_id == DailyAttendanceDetail.weekly_record_id + ) + + # 应用日期筛选 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + base_query = base_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() + base_query = base_query.filter(WeeklyAttendance.week_end_date <= end_date_obj) + except ValueError: + flash('结束日期格式错误', 'error') + + # 应用学生筛选 + if search: + base_query = base_query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if grade_filter: + try: + grade_int = int(grade_filter) + base_query = base_query.filter(Student.grade == grade_int) + except ValueError: + pass + + if college_filter: + base_query = base_query.filter(Student.college == college_filter) + + if supervisor_filter: + base_query = base_query.filter(Student.supervisor == supervisor_filter) + + # 按学生分组 + base_query = base_query.group_by(Student.student_id) + + # 获取学生统计数据 + students_stats = base_query.all() + + # 年级映射函数 + def get_grade_label(grade, degree_type): + if degree_type in ['学博', '专博']: + return f'博士{grade}年级' + else: + if grade == 1: + return '研一' + elif grade == 2: + return '研二' + elif grade == 3: + return '研三' + else: + return f'研{grade}' + + # 处理学生数据并按年级分组 + grade_groups = {} + all_students_data = [] + + for stat in students_stats: + grade_label = get_grade_label(stat.grade, stat.degree_type) + + student_data = { + 'student_number': stat.student_number, + 'name': stat.name, + 'grade': stat.grade, + 'grade_label': grade_label, + 'college': stat.college, + 'supervisor': stat.supervisor, + 'degree_type': stat.degree_type, + 'enrollment_date': stat.enrollment_date, + 'total_work_hours': float(stat.total_work_hours), + 'total_class_hours': float(stat.total_class_hours), + 'total_overtime_hours': float(stat.total_overtime_hours), + 'total_absent_days': int(stat.total_absent_days), + 'total_late_count': int(stat.total_late_count), + 'attendance_weeks': int(stat.attendance_weeks), + 'avg_weekly_hours': round(float(stat.total_work_hours) / max(stat.attendance_weeks, 1), + 1) if stat.attendance_weeks > 0 else 0 + } + + all_students_data.append(student_data) + + if grade_label not in grade_groups: + grade_groups[grade_label] = [] + grade_groups[grade_label].append(student_data) + + # 按出勤时长排序每个年级的学生 + for grade in grade_groups: + grade_groups[grade].sort(key=lambda x: x['total_work_hours'], reverse=True) + + # 总体统计 + overall_stats = { + 'total_students': len(all_students_data), + 'total_work_hours': sum(s['total_work_hours'] for s in all_students_data), + 'total_absent_days': sum(s['total_absent_days'] for s in all_students_data), + 'total_late_count': sum(s['total_late_count'] for s in all_students_data), + 'avg_work_hours_per_student': round( + sum(s['total_work_hours'] for s in all_students_data) / max(len(all_students_data), 1), 1) + } + + # 🔥 修正月度统计查询 + monthly_query = 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.absent_days).label('total_absent') + ).group_by('month').order_by('month') + + # 应用相同的筛选条件到月度统计 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + monthly_query = monthly_query.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_query = monthly_query.filter(WeeklyAttendance.week_end_date <= end_date_obj) + except ValueError: + pass + + # 如果有学生筛选,需要关联学生表 + if search or grade_filter or college_filter or supervisor_filter: + monthly_query = monthly_query.join( + Student, WeeklyAttendance.student_number == Student.student_number + ) + + if search: + monthly_query = monthly_query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if grade_filter: + try: + grade_int = int(grade_filter) + monthly_query = monthly_query.filter(Student.grade == grade_int) + except ValueError: + pass + + if college_filter: + monthly_query = monthly_query.filter(Student.college == college_filter) + + if supervisor_filter: + monthly_query = monthly_query.filter(Student.supervisor == supervisor_filter) + + monthly_stats = monthly_query.all() + + # 🔥 修正按学院统计查询 + college_query = db.session.query( + Student.college, + func.count(Student.student_id).label('student_count'), + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_hours') + ).outerjoin(WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number) + + # 应用筛选条件到学院统计 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + college_query = college_query.filter( + or_(WeeklyAttendance.week_start_date.is_(None), + 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() + college_query = college_query.filter( + or_(WeeklyAttendance.week_end_date.is_(None), + WeeklyAttendance.week_end_date <= end_date_obj) + ) + except ValueError: + pass + + college_stats = college_query.group_by(Student.college).all() + + # 获取筛选选项 + colleges = db.session.query(Student.college).distinct().all() + colleges = [c[0] for c in colleges if c[0]] + + supervisors = db.session.query(Student.supervisor).distinct().all() + supervisors = [s[0] for s in supervisors if s[0]] + + grades = db.session.query(Student.grade).distinct().all() + grades = sorted([g[0] for g in grades if g[0]]) + + print("=== 调试信息 ===") + print(f"月度统计数据: {monthly_stats}") + print(f"学院统计数据: {college_stats}") + print("===============") + + return render_template('admin/statistics.html', + grade_groups=grade_groups, + all_students_data=all_students_data, + overall_stats=overall_stats, + monthly_stats=monthly_stats, + college_stats=college_stats, + colleges=colleges, + supervisors=supervisors, + grades=grades, + search=search, + selected_grade=grade_filter, + selected_college=college_filter, + selected_supervisor=supervisor_filter, + start_date=start_date, + end_date=end_date) + + +@admin_bp.route('/statistics/export') +@admin_required +def export_statistics(): + """导出统计数据""" + import pandas as pd + from io import BytesIO + + # 获取所有学生统计数据(复用上面的查询逻辑) + students_query = db.session.query( + Student.student_number, + Student.name, + Student.grade, + Student.college, + Student.supervisor, + Student.degree_type, + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_work_hours'), + func.coalesce(func.sum(WeeklyAttendance.class_work_hours), 0).label('total_class_hours'), + func.coalesce(func.sum(WeeklyAttendance.overtime_hours), 0).label('total_overtime_hours'), + func.coalesce(func.sum(WeeklyAttendance.absent_days), 0).label('total_absent_days'), + func.count(WeeklyAttendance.record_id).label('attendance_weeks') + ).outerjoin( + WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number + ).group_by(Student.student_id).all() + + # 转换为DataFrame + data = [] + for stat in students_query: + grade_label = f'博士{stat.grade}年级' if stat.degree_type in ['学博', '专博'] else f'研{stat.grade}' + data.append({ + '学号': stat.student_number, + '姓名': stat.name, + '年级': grade_label, + '学院': stat.college, + '导师': stat.supervisor, + '学位类型': stat.degree_type, + '总出勤时长(小时)': float(stat.total_work_hours), + '班内工作时长(小时)': float(stat.total_class_hours), + '加班时长(小时)': float(stat.total_overtime_hours), + '缺勤天数': int(stat.total_absent_days), + '考勤周数': int(stat.attendance_weeks), + '周均工作时长': round(float(stat.total_work_hours) / max(stat.attendance_weeks, 1), 1) + }) + + df = pd.DataFrame(data) + + # 创建Excel文件 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='学生考勤统计', index=False) + + output.seek(0) + + filename = f"学生考勤统计_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + +@admin_bp.route('/students/add', methods=['GET', 'POST']) +@admin_required +def add_student(): + """添加学生""" + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 检查学号是否已存在 + if Student.query.filter_by(student_number=data['student_number']).first(): + if request.is_json: + return jsonify({'success': False, 'message': '学号已存在'}) + else: + flash('学号已存在', 'error') + return render_template('admin/add_student.html') + + # 创建用户账户 + user = User( + student_number=data['student_number'], + password_hash=generate_password_hash(data.get('password', '123456')), + role='student' + ) + + success, error = safe_add_and_commit(user) + if not success: + if request.is_json: + return jsonify({'success': False, 'message': f'创建用户失败: {error}'}) + else: + flash(f'创建用户失败: {error}', 'error') + return render_template('admin/add_student.html') + + # 创建学生记录 + student = Student( + student_number=data['student_number'], + name=data['name'], + gender=data['gender'], + grade=int(data['grade']), + phone=data.get('phone', ''), + supervisor=data.get('supervisor', ''), + college=data.get('college', ''), + major=data.get('major', ''), + degree_type=data.get('degree_type') if data.get('degree_type') else None, + status=data.get('status', '在读'), + enrollment_date=datetime.strptime(data['enrollment_date'], '%Y-%m-%d').date() if data.get( + 'enrollment_date') else None + ) + + success, error = safe_add_and_commit(student) + if success: + if request.is_json: + return jsonify({'success': True, 'message': '学生添加成功'}) + else: + flash('学生添加成功', 'success') + return redirect(url_for('admin.student_list')) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'添加失败: {error}'}) + else: + flash(f'添加失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'添加失败: {str(e)}'}) + else: + flash(f'添加失败: {str(e)}', 'error') + + return render_template('admin/add_student.html') + + +@admin_bp.route('/students//edit', methods=['GET', 'POST']) +@admin_required +def edit_student(student_number): + """编辑学生信息""" + student = Student.query.filter_by(student_number=student_number).first_or_404() + + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 更新学生信息 + student.name = data['name'] + student.gender = data['gender'] + student.grade = int(data['grade']) + student.phone = data.get('phone', '') + student.supervisor = data.get('supervisor', '') + student.college = data.get('college', '') + student.major = data.get('major', '') + student.degree_type = data.get('degree_type') if data.get('degree_type') else None + student.status = data.get('status', '在读') + + if data.get('enrollment_date'): + student.enrollment_date = datetime.strptime(data['enrollment_date'], '%Y-%m-%d').date() + + success, error = safe_commit() + if success: + if request.is_json: + return jsonify({'success': True, 'message': '学生信息更新成功'}) + else: + flash('学生信息更新成功', 'success') + return redirect(url_for('admin.student_detail', student_number=student_number)) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'更新失败: {error}'}) + else: + flash(f'更新失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'更新失败: {str(e)}'}) + else: + flash(f'更新失败: {str(e)}', 'error') + + # GET请求,返回学生数据用于编辑 + if request.is_json: + return jsonify({ + 'success': True, + 'student': { + 'student_number': student.student_number, + 'name': student.name, + 'gender': student.gender, + 'grade': student.grade, + 'phone': student.phone, + 'supervisor': student.supervisor, + 'college': student.college, + 'major': student.major, + 'degree_type': student.degree_type, + 'status': student.status, + 'enrollment_date': student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '' + } + }) + + return render_template('admin/edit_student.html', student=student) + + +@admin_bp.route('/students//delete', methods=['POST']) +@admin_required +def delete_student(student_number): + """删除学生""" + try: + student = Student.query.filter_by(student_number=student_number).first_or_404() + student_name = student.name + + # 删除学生记录(用户记录会因为外键约束自动删除) + success, error = safe_delete_and_commit(student) + + if success: + if request.is_json: + return jsonify({'success': True, 'message': f'学生 {student_name} 删除成功'}) + else: + flash(f'学生 {student_name} 删除成功', 'success') + return redirect(url_for('admin.student_list')) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'删除失败: {error}'}) + else: + flash(f'删除失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}) + else: + flash(f'删除失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_list')) + + +@admin_bp.route('/students/batch_action', methods=['POST']) +@admin_required +def batch_action(): + """批量操作学生""" + try: + data = request.get_json() + action = data.get('action') + student_numbers = data.get('student_numbers', []) + + if not student_numbers: + return jsonify({'success': False, 'message': '请选择要操作的学生'}) + + if action == 'delete': + # 批量删除 + students = Student.query.filter(Student.student_number.in_(student_numbers)).all() + for student in students: + db.session.delete(student) + + success, error = safe_commit() + if success: + return jsonify({'success': True, 'message': f'成功删除 {len(student_numbers)} 个学生'}) + else: + return jsonify({'success': False, 'message': f'删除失败: {error}'}) + + elif action == 'graduate': + # 批量设为毕业 + Student.query.filter(Student.student_number.in_(student_numbers)).update( + {'status': '毕业'}, synchronize_session=False + ) + success, error = safe_commit() + if success: + return jsonify({'success': True, 'message': f'成功将 {len(student_numbers)} 个学生设为毕业状态'}) + else: + return jsonify({'success': False, 'message': f'操作失败: {error}'}) + + else: + return jsonify({'success': False, 'message': '无效的操作'}) + + except Exception as e: + return jsonify({'success': False, 'message': f'操作失败: {str(e)}'}) + + +@admin_bp.route('/students//reset_password', methods=['POST']) +@admin_required +def reset_student_password(student_number): + """重置学生密码""" + try: + user = User.query.filter_by(student_number=student_number).first_or_404() + + # 重置为默认密码 + new_password = request.get_json().get('password', '123456') if request.is_json else '123456' + user.password_hash = generate_password_hash(new_password) + + success, error = safe_commit() + if success: + if request.is_json: + return jsonify({'success': True, 'message': '密码重置成功'}) + else: + flash('密码重置成功', 'success') + return redirect(url_for('admin.student_detail', student_number=student_number)) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'重置失败: {error}'}) + else: + flash(f'重置失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'重置失败: {str(e)}'}) + else: + flash(f'重置失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_detail', student_number=student_number)) + + +@admin_bp.route('/students//toggle_status', methods=['POST']) +@admin_required +def toggle_student_status(student_number): + """切换学生账户状态""" + try: + user = User.query.filter_by(student_number=student_number).first_or_404() + user.is_active = not user.is_active + + success, error = safe_commit() + if success: + status_text = '启用' if user.is_active else '禁用' + if request.is_json: + return jsonify({'success': True, 'message': f'账户{status_text}成功'}) + else: + flash(f'账户{status_text}成功', 'success') + else: + if request.is_json: + return jsonify({'success': False, 'message': f'操作失败: {error}'}) + else: + flash(f'操作失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'操作失败: {str(e)}'}) + else: + flash(f'操作失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_detail', student_number=student_number)) + + +@admin_bp.route('/attendance//details') +@admin_required +def attendance_record_details(record_id): + """查看考勤记录详情""" + from datetime import datetime, timedelta + import json + + # 获取周考勤汇总记录 + weekly_record = WeeklyAttendance.query.get_or_404(record_id) + + # 获取学生信息 + student = Student.query.filter_by(student_number=weekly_record.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 = weekly_record.actual_work_hours / max(present_days, 1) + else: + avg_daily_hours = 0 + + # 获取该学生的历史考勤记录(用于对比) + historical_records = WeeklyAttendance.query.filter_by( + student_number=weekly_record.student_number + ).filter(WeeklyAttendance.record_id != record_id).order_by( + desc(WeeklyAttendance.week_start_date) + ).limit(5).all() + + return render_template('admin/attendance_details.html', + weekly_record=weekly_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, + historical_records=historical_records) + +@admin_bp.route('/attendance//edit', methods=['GET', 'POST']) +@admin_required +def edit_attendance_record(record_id): + """编辑考勤记录""" + weekly_record = WeeklyAttendance.query.get_or_404(record_id) + + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 更新周考勤记录 + weekly_record.actual_work_hours = float(data.get('actual_work_hours', 0)) + weekly_record.class_work_hours = float(data.get('class_work_hours', 0)) + weekly_record.absent_days = int(data.get('absent_days', 0)) + weekly_record.overtime_hours = float(data.get('overtime_hours', 0)) + + success, error = safe_commit() + if success: + flash('考勤记录更新成功', 'success') + return redirect(url_for('admin.attendance_record_details', record_id=record_id)) + else: + flash(f'更新失败: {error}', 'error') + + except Exception as e: + flash(f'更新失败: {str(e)}', 'error') + + return render_template('admin/edit_attendance_record.html', weekly_record=weekly_record) + + +def export_attendance_data(): + """导出考勤数据到Excel""" + from sqlalchemy import desc, func, case, or_ + from io import BytesIO + + try: + # 获取筛选参数 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + student_search = request.args.get('student_search', '').strip() + sort_by = request.args.get('sort_by', 'week_start_date_desc') + + # 构建查询 + 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 + ).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: + pass + + 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: + pass + + if student_search: + query = query.filter(or_( + WeeklyAttendance.name.contains(student_search), + WeeklyAttendance.student_number.contains(student_search) + )) + + # 应用排序 + if sort_by and '_' in sort_by: + field, direction = sort_by.rsplit('_', 1) + if direction not in ['asc', 'desc']: + direction = 'desc' + + if field == 'actual_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.actual_work_hours)) + else: + query = query.order_by(WeeklyAttendance.actual_work_hours) + elif field == 'week_start_date': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(WeeklyAttendance.week_start_date) + else: + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + + # 获取所有记录 + results = query.all() + + if not results: + flash('没有数据可导出', 'warning') + args = request.args.copy() + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + + # 准备数据 + data = [] + for record, late_count in results: + data.append({ + '学号': record.student_number, + '姓名': record.name, + '周开始日期': record.week_start_date.strftime('%Y-%m-%d'), + '周结束日期': record.week_end_date.strftime('%Y-%m-%d'), + '实际出勤时长(小时)': float(record.actual_work_hours), + '班内工作时长(小时)': float(record.class_work_hours), + '旷工天数': int(record.absent_days), + '迟到次数': int(late_count) if late_count else 0, + '加班时长(小时)': float(record.overtime_hours), + '记录创建时间': record.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + # 创建DataFrame + df = pd.DataFrame(data) + + # 创建Excel文件 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='考勤记录', index=False) + + # 调整列宽 + workbook = writer.book + worksheet = writer.sheets['考勤记录'] + + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + # 生成文件名 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"考勤记录_{timestamp}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + flash(f'导出失败: {str(e)}', 'error') + # 移除export参数,重定向到正常页面 + args = request.args.copy() + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..a54016c --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/student.py b/app/routes/student.py new file mode 100644 index 0000000..7e45b04 --- /dev/null +++ b/app/routes/student.py @@ -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//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) + diff --git a/app/static/css/admin.css b/app/static/css/admin.css new file mode 100644 index 0000000..e69de29 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..b7568be --- /dev/null +++ b/app/static/css/style.css @@ -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; + } +} diff --git a/app/static/js/admin.js b/app/static/js/admin.js new file mode 100644 index 0000000..e69de29 diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..5357413 --- /dev/null +++ b/app/static/js/main.js @@ -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秒后自动关闭 + }); +}); diff --git a/app/templates/admin/add_student.html b/app/templates/admin/add_student.html new file mode 100644 index 0000000..dbe68de --- /dev/null +++ b/app/templates/admin/add_student.html @@ -0,0 +1,247 @@ +{% extends 'layout/base.html' %} + +{% block title %}添加学生 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 添加学生 +

+ + 返回学生列表 + +
+ + +
+
+
+
+
+ 学生基本信息 +
+
+
+
+
+
+
+ + +
学号将作为登录账号使用
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
学生可在登录后自行修改密码
+
+
+
+ +
+ +
+ + 取消 + + +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/admin/attendance_details.html b/app/templates/admin/attendance_details.html new file mode 100644 index 0000000..f2315d8 --- /dev/null +++ b/app/templates/admin/attendance_details.html @@ -0,0 +1,571 @@ +{% extends 'layout/base.html' %} + +{% block title %}考勤详情 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 考勤详情 +

+ +
+ + +
+
+
+
+
+ 学生信息 +
+
+
+
+
+

学号: {{ weekly_record.student_number }}

+

姓名: {{ weekly_record.name }}

+ {% if student %} +

年级: {{ student.grade }}

+

学院: {{ student.college or '未设置' }}

+ {% endif %} +
+
+ {% if student %} +

专业: {{ student.major or '未设置' }}

+

导师: {{ student.supervisor or '未设置' }}

+

学位类型: {{ student.degree_type or '未设置' }}

+

状态: + + {{ student.status }} + +

+ {% endif %} +
+
+
+
+
+ +
+
+
+
+ 考勤周期 +
+
+
+
+
+

开始日期: {{ weekly_record.week_start_date.strftime('%Y年%m月%d日') }}

+

结束日期: {{ weekly_record.week_end_date.strftime('%Y年%m月%d日') }}

+
+
+

创建时间: {{ weekly_record.created_at.strftime('%Y-%m-%d %H:%M') }}

+

更新时间: {{ weekly_record.updated_at.strftime('%Y-%m-%d %H:%M') }}

+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ 实际出勤时长 +
+
+ {{ "%.1f"|format(weekly_record.actual_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作时长 +
+
+ {{ "%.1f"|format(weekly_record.class_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ weekly_record.absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 加班时长 +
+
+ {{ "%.1f"|format(weekly_record.overtime_hours) }}小时 +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ 每日考勤明细 + (点击日期查看详细时段信息) +
+
+
+ {% if daily_details %} +
+ + + + + + + + + + + + + + + {% for detail in daily_details %} + + + + + + + + + + + {% endfor %} + +
日期星期考勤状态签到时间签退时间工作时长备注操作
{{ detail.attendance_date.strftime('%m-%d') }} + {% 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 detail.status == '正常' %} + {{ detail.status }} + {% elif '迟到' in detail.status %} + {{ detail.status }} + {% elif detail.status == '缺勤' %} + {{ detail.status }} + {% elif detail.status == '请假' %} + {{ detail.status }} + {% elif detail.status == '休息' %} + {{ detail.status }} + {% elif detail.status == '加班' %} + {{ detail.status }} + {% else %} + {{ detail.status }} + {% endif %} + + {% if detail.check_in_time %} + {{ detail.check_in_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.check_out_time %} + {{ detail.check_out_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.duration_hours %} + {{ detail.duration_hours }}h + {% else %} + - + {% endif %} + + {% if detail.summary_remarks %} + {{ detail.summary_remarks }} + {% else %} + - + {% endif %} + + {% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %} + + {% endif %} +
+
+ {% else %} +
+ +
暂无每日考勤明细
+

该考勤周期内没有详细的打卡记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 考勤统计分析 +
+
+
+
+
+
+
{{ present_days }}
+ 正常天数 +
+
+
+
+
{{ late_days }}
+ 迟到天数 +
+
+
+
+
{{ absent_days }}
+ 缺勤天数 +
+
+
+
{{ "%.1f"|format(avg_daily_hours) }}h
+ 日均时长 +
+
+
+
+
+ +
+
+
+
+ 历史对比 +
+
+
+ {% if historical_records %} +
+ + + + + + + + + + {% for record in historical_records %} + + + + + + {% endfor %} + +
周期出勤时长旷工天数
+ {{ record.week_start_date.strftime('%m-%d') }} + + {{ "%.1f"|format(record.actual_work_hours) }}h + + {% if record.absent_days > 0 %} + {{ record.absent_days }} + {% else %} + 0 + {% endif %} +
+
+ {% else %} +

暂无历史记录

+ {% endif %} +
+
+
+
+
+ + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/admin/attendance_management.html b/app/templates/admin/attendance_management.html new file mode 100644 index 0000000..939aa6e --- /dev/null +++ b/app/templates/admin/attendance_management.html @@ -0,0 +1,656 @@ +{% extends 'layout/base.html' %} + +{% block title %}考勤管理 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 考勤管理 +

+ +
+ + +
+
+
+ 搜索筛选 +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + +
+
+
+ + +
+
+
+ 考勤记录 +
+ {% if attendance_records %} + + 共 {{ pagination.total }} 条记录 + + {% endif %} +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + + + + {% endfor %} + +
学号姓名考勤周期 + 出勤时长 + + + 班内工作 + + + 旷工天数 + + + 迟到次数 + + + 加班时长 + + + 记录时间 + + 操作
+ + {{ record.student_number }} + + {{ record.name }} + + {{ record.week_start_date.strftime('%Y-%m-%d') }}
+ 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} +
+
+ + {{ "%.1f"|format(record.actual_work_hours) }}h + + + + {{ "%.1f"|format(record.class_work_hours) }}h + + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% set late_count = record.late_count if record.late_count is defined else 0 %} + {% if late_count > 0 %} + {{ late_count }}次 + {% else %} + 0次 + {% endif %} + + {% if record.overtime_hours > 0 %} + + {{ "%.1f"|format(record.overtime_hours) }}h + + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + + +
+ + +
+
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} + +
+ +
暂无考勤记录
+

还没有上传任何考勤数据

+ + 立即上传考勤数据 + +
+ {% endif %} +
+
+ + + {% if attendance_records %} +
+
+
+
+
+ 当前筛选统计 +
+
+
+
+
+
+
{{ pagination.total }}
+ 总记录 +
+
+
+
+
+ {{ attendance_records|sum(attribute='actual_work_hours')|round(1) }}h +
+ 总出勤 +
+
+
+
+
+ {{ attendance_records|sum(attribute='absent_days') }} +
+ 旷工 +
+
+
+
+
+ {% set total_leave = statistics.total_leave_days if statistics and statistics.total_leave_days else 0 %} + {{ total_leave }} +
+ 请假 +
+
+
+
+
+ {% set total_late = attendance_records|sum(attribute='late_count') if attendance_records[0].late_count is defined else 0 %} + {{ total_late }} +
+ 迟到次数 +
+
+
+
+
+ {{ attendance_records|sum(attribute='overtime_hours')|round(1) }}h +
+ 总加班 +
+
+
+
+ {{ "%.1f"|format((attendance_records|sum(attribute='actual_work_hours')) / (attendance_records|length) if attendance_records|length > 0 else 0) }}h +
+ 平均出勤 +
+
+
+
+
+
+
+
+
+ 快捷操作 +
+
+
+
+ + + +
+ +
+
+
+
+
+
+ {% endif %} +
+ + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..afaccd6 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,383 @@ +{% extends 'layout/base.html' %} + +{% block title %}管理员控制台 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 管理员控制台 +

+
+ + 加载中... +
+
+ + +
+
+
+
+
+
+
+ 学生总数 +
+
+ {{ total_students or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 考勤记录总数 +
+
+ {{ total_attendance_records or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 待审批请假 +
+
+ {{ pending_leaves or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 本周新记录 +
+
+ {{ recent_records or 0 }} +
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 学院分布 +
+
+
+ {% if college_stats %} +
+ + + + + + + + + {% for college, count in college_stats %} + + + + + {% endfor %} + +
学院学生数
{{ college or '未知学院' }} + {{ count }} +
+
+ {% else %} +
+ +

暂无数据

+
+ {% endif %} +
+
+
+ + +
+
+
+
+ 导师排行(TOP 10) +
+
+
+ {% if supervisor_stats %} +
+ + + + + + + + + {% for supervisor, count in supervisor_stats %} + + + + + {% endfor %} + +
导师学生数
{{ supervisor or '未知导师' }} + {{ count }} +
+
+ {% else %} +
+ +

暂无数据

+
+ {% endif %} +
+
+
+
+ + + {% if recent_leaves %} +
+
+
+
+
+ 最近请假申请 +
+ + 查看全部 + +
+
+
+ + + + + + + + + + + + {% for leave in recent_leaves %} + + + + + + + + {% endfor %} + +
学号请假日期请假原因申请时间操作
{{ leave.student_number }} + {{ 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 %} + + + {{ leave.leave_reason[:50] }}{% if leave.leave_reason|length > 50 %}...{% endif %} + + {{ leave.created_at.strftime('%m-%d %H:%M') }} +
+ + +
+
+
+
+
+
+
+ {% endif %} + + +
+
+
+
+
+ 快捷操作 +
+
+ +
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/admin/edit_student.html b/app/templates/admin/edit_student.html new file mode 100644 index 0000000..f016498 --- /dev/null +++ b/app/templates/admin/edit_student.html @@ -0,0 +1,307 @@ +{% extends 'layout/base.html' %} + +{% block title %}编辑学生 - {{ student.name }} - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 编辑学生信息 +

+ +
+ + +
+
+
+
+
+ 学生基本信息 +
+ 学号: {{ student.student_number }} +
+
+
+
+
+
+ + +
学号不可修改
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+ + {% if student.user %} + + {% endif %} +
+
+ + 取消 + + +
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/admin/statistics.html b/app/templates/admin/statistics.html new file mode 100644 index 0000000..d36b587 --- /dev/null +++ b/app/templates/admin/statistics.html @@ -0,0 +1,430 @@ +{% extends 'layout/base.html' %} + +{% block title %}统计报表 - CHM考勤管理系统{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

统计报表

+ +
+
+
+ + +
+
+
+
+

{{ overall_stats.total_students }}

+

总学生数

+
+
+
+
+
+
+

{{ "%.1f"|format(overall_stats.total_work_hours) }}

+

总出勤时长(小时)

+
+
+
+
+
+
+

{{ overall_stats.total_absent_days }}

+

总缺勤天数

+
+
+
+
+
+
+

{{ overall_stats.total_late_count }}

+

总迟到次数

+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + 重置 + +
+
+
+ + +
+
+ {% for grade_label, students in grade_groups.items() %} +
+

+ {{ grade_label }} ({{ students|length }}人) +

+ +
+ {% for student in students %} +
+
+
+
+
+ + {{ student.name }} + +
+ {{ student.student_number }} +
+
+ {% if student.total_work_hours >= 200 %} + 优秀 + {% elif student.total_work_hours >= 100 %} + 良好 + {% elif student.total_work_hours >= 50 %} + 一般 + {% else %} + 待改进 + {% endif %} +
+
+ +
+
+
{{ "%.1f"|format(student.total_work_hours) }}
+
总工时
+
+
+
{{ student.total_absent_days }}
+
缺勤天数
+
+
+
{{ student.total_late_count }}
+
迟到次数
+
+
+
{{ student.avg_weekly_hours }}
+
周均工时
+
+
+ +
+ + {{ student.college or '未设置' }} | + {{ student.supervisor or '未设置' }} + +
+
+
+ {% endfor %} +
+
+ {% endfor %} + + {% if not grade_groups %} +
+ +
没有找到符合条件的学生
+
+ {% endif %} +
+
+ + +
+
+
+
月度考勤趋势
+ +
+
+
+
+
学院分布
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + diff --git a/app/templates/admin/student_detail.html b/app/templates/admin/student_detail.html new file mode 100644 index 0000000..38df0db --- /dev/null +++ b/app/templates/admin/student_detail.html @@ -0,0 +1,548 @@ +{% extends 'layout/base.html' %} + +{% block title %}学生详情 - {{ student.name }} - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+
+ 基本信息 +
+
+ + 编辑 + + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
学号:{{ student.student_number }}
姓名:{{ student.name }}
性别: + {% if student.gender == '男' %} + {{ student.gender }} + {% else %} + {{ student.gender }} + {% endif %} +
年级:{{ student.grade }}级
手机号: + {% if student.phone %} + {{ student.phone }} + {% else %} + 未填写 + {% endif %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
学院: + {% if student.college %} + {{ student.college }} + {% else %} + 未填写 + {% endif %} +
专业: + {% if student.major %} + {{ student.major }} + {% else %} + 未填写 + {% endif %} +
导师: + {% if student.supervisor %} + {{ student.supervisor }} + {% else %} + 未分配 + {% endif %} +
学位类型: + {% if student.degree_type %} + {{ student.degree_type }} + {% else %} + 未填写 + {% endif %} +
状态: + {% if student.status == '在读' %} + + 在读 + + {% else %} + + 毕业 + + {% endif %} +
+
+
+ +
+
+
+ 入学日期: +
+
+ {% if student.enrollment_date %} + + {{ student.enrollment_date.strftime('%Y年%m月%d日') }} + + {% else %} + 未填写 + {% endif %} +
+
+
+ +
+
+
+ 注册时间: +
+
+ + {{ student.created_at.strftime('%Y年%m月%d日 %H:%M') }} + +
+
+
+
+
+
+ +
+ +
+
+
+ 考勤统计 +
+
+
+
+
+
{{ "%.1f"|format(total_work_hours) }}
+
总工作时长(小时)
+
+
+
{{ total_absent_days }}
+
旷工天数
+
+
+
+
+
+
{{ attendance_records|length }}
+
考勤记录数
+
+
+
+
+ + +
+
+
+ 账户信息 +
+
+
+ {% if student.user %} +
+ 账户状态: + {% if student.user.is_active %} + + 正常 + + {% else %} + + 已禁用 + + {% endif %} +
+
+ 最后登录: +
+ {% if student.user.last_login %} + + {{ student.user.last_login.strftime('%Y-%m-%d %H:%M') }} + + {% else %} + 从未登录 + {% endif %} +
+
+ + +
+ {% else %} +
+ +

该学生暂无账户信息

+
+ {% endif %} +
+
+ + +
+
+
+ 快速操作 +
+
+ +
+
+
+ + +
+
+
+
+
+ 最近考勤记录 +
+ + 查看全部 + +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + {% endfor %} + +
周期实际工作时长班内工作时长旷工天数加班时长创建时间
+
+ {{ record.week_start_date.strftime('%m-%d') }} + 至 + {{ record.week_end_date.strftime('%m-%d') }} +
+ + {{ record.week_start_date.strftime('%Y年') }} + +
+ + {{ "%.1f"|format(record.actual_work_hours or 0) }}h + + + + {{ "%.1f"|format(record.class_work_hours or 0) }}h + + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + + 0天 + + {% endif %} + + {% if record.overtime_hours > 0 %} + + {{ "%.1f"|format(record.overtime_hours) }}h + + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + +
+
+ {% else %} +
+ +

暂无考勤记录

+ + 上传考勤数据 + +
+ {% endif %} +
+
+
+
+ + + {% if leave_records %} +
+
+
+
+
+ 最近请假记录 +
+ + 查看全部 + +
+
+
+ + + + + + + + + + + {% for leave in leave_records %} + + + + + + + {% endfor %} + +
请假日期请假原因申请时间状态
+ {{ 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 %} + +
+ {{ leave.leave_reason }} +
+
+ + {{ leave.created_at.strftime('%Y-%m-%d %H:%M') }} + + + {% if leave.status == '待审批' %} + + 待审批 + + {% elif leave.status == '已批准' %} + + 已批准 + + {% else %} + + 已拒绝 + + {% endif %} +
+
+
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/app/templates/admin/student_list.html b/app/templates/admin/student_list.html new file mode 100644 index 0000000..c79f878 --- /dev/null +++ b/app/templates/admin/student_list.html @@ -0,0 +1,293 @@ +{% extends 'layout/base.html' %} + +{% block title %}学生管理 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 学生管理 +

+
+ + 添加学生 + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ 学生列表 (共 {{ pagination.total }} 人) +
+
+
+ {% if students %} +
+ + + + + + + + + + + + + + + + + {% for student in students %} + + + + + + + + + + + + + {% endfor %} + +
+ + 学号姓名性别年级学院导师学位类型状态操作
+ + {{ student.student_number }} + {{ student.name }} + {% if student.phone %} +
{{ student.phone }} + {% endif %} +
{{ student.gender }}{{ student.grade }}级{{ student.college or '-' }}{{ student.supervisor or '-' }} + {% if student.degree_type %} + {{ student.degree_type }} + {% else %} + - + {% endif %} + + {% if student.status == '在读' %} + 在读 + {% else %} + 毕业 + {% endif %} + +
+ + + + + + + +
+
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} +
+ +

暂无学生数据

+ + 添加第一个学生 + +
+ {% endif %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/admin/upload_attendance.html b/app/templates/admin/upload_attendance.html new file mode 100644 index 0000000..d02dc77 --- /dev/null +++ b/app/templates/admin/upload_attendance.html @@ -0,0 +1,152 @@ +{% extends 'layout/base.html' %} + +{% block title %}上传考勤数据 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ 上传考勤数据 +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
必须上传考勤记录Excel文件
+
+
+
+
+ + +
如有请假记录,请上传请假单Excel文件
+
+
+
+ +
+
导入说明:
+
    +
  • 考勤记录文件:包含姓名列和每日考勤数据,系统会自动计算工作时长、迟到次数等
  • +
  • 请假单文件:包含请假人员、请假开始时间、请假结束时间等信息
  • +
  • 处理规则: +
      +
    • 请假时间内的缺卡记录会自动转换为请假
    • +
    • 请假时间内的正常打卡记录(正常、迟到、早退)保持不变
    • +
    +
  • +
  • 如果记录已存在,将会更新现有数据
  • +
  • 请确保学生信息已在系统中注册
  • +
+
+ +
+
注意事项:
+
    +
  • 请假单中的时间格式会自动转换(支持数字格式和标准日期格式)
  • +
  • 请假人员姓名必须与学生表中的姓名完全一致
  • +
  • 建议先上传考勤记录,再选择性上传请假单
  • +
+
+ +
+ + 返回 + + +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/auth/admin_profile.html b/app/templates/auth/admin_profile.html new file mode 100644 index 0000000..408cca2 --- /dev/null +++ b/app/templates/auth/admin_profile.html @@ -0,0 +1,128 @@ +{% extends "layout/base.html" %} + +{% block title %}个人信息 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ +
+

个人信息

+ +
+ + +
+
+
+
+
管理员信息
+
+
+ {% if user_info %} +
+ +
+
+ +
{{ user_info.user_id }}
+
+
+ +
{{ user_info.student_number }}
+
+
+ +
+ + + {% if user_info.role == 'admin' %}管理员{% else %}普通用户{% endif %} + +
+
+
+ + +
+
+ +
+ {% if user_info.is_active %} + + 活跃 + + {% else %} + + 已禁用 + + {% endif %} +
+
+
+ +
+ {% if user_info.last_login %} + {{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + 从未登录 + {% endif %} +
+
+
+ +
{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+ + + + {% else %} +
+ + 无法获取用户信息 +
+ {% endif %} +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 0000000..3e9655b --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,237 @@ +{% extends "layout/base.html" %} + +{% block title %}修改密码 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ + + + +
+
+
+
+
+ 安全设置 +
+
+
+ +
+ + 密码要求: +
    +
  • 长度至少6位
  • +
  • 必须包含字母和数字
  • +
  • 建议使用字母、数字和特殊字符的组合
  • +
+
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+ + + 取消 + +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..a461e44 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,164 @@ +{% extends 'layout/base.html' %} + +{% block title %}登录 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +
+
+ +
+

CHM考勤系统

+

请使用学号和密码登录

+
+ + +
+
+ + +
+ 请输入学号 +
+
+ +
+ +
+ + +
+
+ 请输入密码 +
+
+ +
+ + +
+ +
+ +
+
+ + +
+ + + 如有登录问题,请联系管理员 + +
+
+
+ + +
+
+
+ + 使用说明 +
+
+
+ +
    +
  • 查看个人考勤记录
  • +
  • 申请请假审批
  • +
+
+
+
+ +
    +
  • 个人统计分析
  • +
  • 修改个人密码
  • +
+
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/auth/student_profile.html b/app/templates/auth/student_profile.html new file mode 100644 index 0000000..9e44c75 --- /dev/null +++ b/app/templates/auth/student_profile.html @@ -0,0 +1,269 @@ +{% extends "layout/base.html" %} + +{% block title %}个人信息 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ +
+

个人信息

+ +
+ + +
+
+ {% if user_info %} + +
+
+
基本信息
+
+
+
+ +
+
+ +
{{ user_info.student_number }}
+
+
+ +
+ {% if user_info.name %} + {{ user_info.name }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.gender %} + + + {{ user_info.gender }} + + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.grade %} + {{ user_info.grade }}级 + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.phone %} + {{ user_info.phone }} + {% else %} + 未设置 + {% endif %} +
+
+
+ + +
+
+ +
+ {% if user_info.supervisor %} + {{ user_info.supervisor }} + {% else %} + 未分配 + {% endif %} +
+
+
+ +
+ {% if user_info.college %} + {{ user_info.college }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.major %} + {{ user_info.major }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.degree_type %} + {{ user_info.degree_type }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.enrollment_date %} + {{ user_info.enrollment_date.strftime('%Y年%m月%d日') }} + {% else %} + 未设置 + {% endif %} +
+
+
+
+
+
+ + +
+
+
账户信息
+
+
+
+
+
+ +
{{ user_info.user_id }}
+
+
+ +
+ + 学生 + +
+
+
+
+
+ +
+ {% if user_info.is_active %} + + 活跃 + + {% else %} + + 已禁用 + + {% endif %} +
+
+ {% if user_info.status %} +
+ +
+ + {{ user_info.status }} + +
+
+ {% endif %} +
+
+ +
+
+
+ +
+ {% if user_info.last_login %} + {{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + 从未登录 + {% endif %} +
+
+
+
+
+ +
{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+
+
+ + + + + {% else %} +
+ + 无法获取用户信息,请联系管理员 +
+ {% endif %} +
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/layout/base.html b/app/templates/layout/base.html new file mode 100644 index 0000000..e088624 --- /dev/null +++ b/app/templates/layout/base.html @@ -0,0 +1,63 @@ + + + + + + {% block title %}CHM考勤管理系统{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + {% include 'layout/nav.html' %} + {% endif %} + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + + {% block content %}{% endblock %} +
+ + + {% if current_user.is_authenticated %} +
+
+ © 2025 CHM考勤管理系统. All rights reserved. +
+
+ {% endif %} + + + + + + + + + {% block extra_js %}{% endblock %} + + + diff --git a/app/templates/layout/nav.html b/app/templates/layout/nav.html new file mode 100644 index 0000000..815ec3b --- /dev/null +++ b/app/templates/layout/nav.html @@ -0,0 +1,102 @@ + diff --git a/app/templates/student/apply_leave.html b/app/templates/student/apply_leave.html new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/student/attendance.html b/app/templates/student/attendance.html new file mode 100644 index 0000000..5e400ca --- /dev/null +++ b/app/templates/student/attendance.html @@ -0,0 +1,539 @@ +{% extends 'layout/base.html' %} + +{% block title %}我的考勤 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 我的考勤记录 +

+ +
+
+ + + {% if total_stats %} +
+
+
+
+
+
+
+ 总考勤周数 +
+
+ {{ total_stats.total_weeks }}周 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 总出勤时长 +
+
+ {{ "%.1f"|format(total_stats.total_actual_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作 +
+
+ {{ "%.1f"|format(total_stats.total_class_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 迟到次数 +
+
+ {{ total_stats.total_late_count }}次 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ total_stats.total_absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 周均时长 +
+
+ {{ "%.1f"|format(total_stats.avg_weekly_hours) }}h +
+
+
+ +
+
+
+
+
+
+ {% endif %} + + +
+
+
+ 筛选条件 +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + 重置 + + {% if attendance_records %} + + {% endif %} +
+
+
+
+
+ + +
+
+
+ 考勤记录列表 +
+ {% if attendance_records %} + + 共 {{ pagination.total }} 条记录 + + {% endif %} +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长迟到次数旷工天数加班时长记录时间操作
+
+ {{ record.week_start_date.strftime('%Y-%m-%d') }} + + 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} + +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}h + + {{ "%.1f"|format(record.class_work_hours) }}h + + {% if record.late_count > 0 %} + {{ record.late_count }}次 + {% else %} + 0次 + {% endif %} + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% if record.overtime_hours > 0 %} + {{ "%.1f"|format(record.overtime_hours) }}h + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + + + + 查看详情 + +
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} +
+ +
暂无考勤记录
+

当前筛选条件下没有找到考勤记录

+ + 返回首页 + +
+ {% endif %} +
+
+ + + {% if attendance_records %} +
+
+
+
+
+ 本期统计分析 +
+
+
+
+
+
+
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
+ 周均出勤 +
+
+
+
+
{{ "%.1f"|format((total_stats.total_class_hours / total_stats.total_actual_hours * 100) if total_stats.total_actual_hours > 0 else 0) }}%
+ 班内工作率 +
+
+
+
{{ "%.1f"|format((total_stats.total_overtime_hours / total_stats.total_weeks) if total_stats.total_weeks > 0 else 0) }}h
+ 周均加班 +
+
+
+
+
+ +
+
+
+
+ 快捷操作 +
+
+ +
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/student/attendance_details.html b/app/templates/student/attendance_details.html new file mode 100644 index 0000000..b7778c5 --- /dev/null +++ b/app/templates/student/attendance_details.html @@ -0,0 +1,673 @@ +{% extends 'layout/base.html' %} + +{% block title %}考勤详情 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 我的考勤详情 +

+ +
+ +
+ + +
+
+
+
+
+ 学生信息 +
+
+
+
+
+

学号: {{ record.student_number }}

+

姓名: {{ record.name }}

+ {% if student %} +

年级: {{ student.grade }}

+

学院: {{ student.college or '未设置' }}

+ {% endif %} +
+
+ {% if student %} +

专业: {{ student.major or '未设置' }}

+

导师: {{ student.supervisor or '未设置' }}

+

学位类型: {{ student.degree_type or '未设置' }}

+

状态: + + {{ student.status }} + +

+ {% endif %} +
+
+
+
+
+ +
+
+
+
+ 考勤周期 +
+
+
+
+
+

开始日期: {{ record.week_start_date.strftime('%Y年%m月%d日') }}

+

结束日期: {{ record.week_end_date.strftime('%Y年%m月%d日') }}

+
+
+

创建时间: {{ record.created_at.strftime('%Y-%m-%d %H:%M') }}

+

更新时间: {{ record.updated_at.strftime('%Y-%m-%d %H:%M') }}

+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ 实际出勤时长 +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作时长 +
+
+ {{ "%.1f"|format(record.class_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ record.absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 加班时长 +
+
+ {{ "%.1f"|format(record.overtime_hours) }}小时 +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ 每日考勤明细 + (点击详情按钮查看详细时段信息) +
+
+
+ {% if daily_details %} +
+ + + + + + + + + + + + + + + {% for detail in daily_details %} + + + + + + + + + + + {% endfor %} + +
日期星期考勤状态签到时间签退时间工作时长备注操作
+ {{ detail.attendance_date.strftime('%m-%d') }} + {{ detail.attendance_date.strftime('%Y') }} + + {% 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 %} + 休息日 + {% endif %} + + {% if detail.status == '正常' %} + {{ detail.status }} + {% elif '迟到' in detail.status %} + {{ detail.status }} + {% elif detail.status == '缺勤' %} + {{ detail.status }} + {% elif detail.status == '请假' %} + {{ detail.status }} + {% elif detail.status == '休息' %} + {{ detail.status }} + {% elif detail.status == '加班' %} + {{ detail.status }} + {% else %} + {{ detail.status }} + {% endif %} + + {% if detail.check_in_time %} + {{ detail.check_in_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.check_out_time %} + {{ detail.check_out_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.duration_hours %} + {{ detail.duration_hours }}h + {% else %} + - + {% endif %} + + {% if detail.summary_remarks %} + {{ detail.summary_remarks }} + {% else %} + - + {% endif %} + + {% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %} + + {% endif %} +
+
+ {% else %} +
+ +
暂无每日考勤明细
+

该考勤周期内没有详细的打卡记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 本周考勤统计 +
+
+
+
+
+
+
{{ present_days }}
+ 正常天数 +
+
+
+
+
{{ late_days }}
+ 迟到天数 +
+
+
+
+
{{ absent_days }}
+ 缺勤天数 +
+
+
+
{{ "%.1f"|format(avg_daily_hours) }}h
+ 日均时长 +
+
+ + +
+
+
+
{{ "%.1f"|format((present_days / max(total_days, 1) * 100)) }}%
+ 出勤率 +
+
+
{{ "%.1f"|format((record.class_work_hours / max(record.actual_work_hours, 1) * 100)) }}%
+ 班内工作率 +
+
+
{{ total_days }}
+ 考勤天数 +
+
+
+
+
+ +
+
+
+
+ 最近记录对比 +
+
+
+ {% if recent_records %} +
+ + + + + + + + + + + {% for record_item in recent_records %} + + + + + + + {% endfor %} + +
周期出勤时长旷工天数对比
+ {{ record_item.week_start_date.strftime('%m-%d') }} + + {{ "%.1f"|format(record_item.actual_work_hours) }}h + + {% if record_item.absent_days > 0 %} + {{ record_item.absent_days }} + {% else %} + 0 + {% endif %} + + {% set diff = record.actual_work_hours - record_item.actual_work_hours %} + {% if diff > 0 %} + +{{ "%.1f"|format(diff) }}h + {% elif diff < 0 %} + {{ "%.1f"|format(diff) }}h + {% else %} + - + {% endif %} +
+
+ {% else %} +

暂无历史记录

+ {% endif %} +
+
+
+
+ + + {% if late_days > 0 or absent_days > 0 %} + + {% endif %} +
+ + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/student/dashboard.html b/app/templates/student/dashboard.html new file mode 100644 index 0000000..0fcd27e --- /dev/null +++ b/app/templates/student/dashboard.html @@ -0,0 +1,250 @@ +{% extends 'layout/base.html' %} + +{% block title %}学生主页 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ + 欢迎回来,{{ student.name }}! +

+

+ 学号:{{ student.student_number }} | + 学院:{{ student.college }} | + 导师:{{ student.supervisor }} +

+
+
+ + +
+
+
+
+
+
+

{{ total_records }}

+

考勤记录

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

{{ "%.1f"|format(total_work_hours) }}

+

总工作时长(小时)

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

{{ total_absent_days }}

+

旷工天数

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

{{ pending_leaves|length }}

+

待审批请假

+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 最近考勤记录 +
+ + 查看全部 + +
+
+ {% if recent_attendance %} +
+ + + + + + + + + + + + {% for record in recent_attendance %} + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长旷工天数加班时长
+ {{ record.week_start_date.strftime('%m-%d') }} + 至 + {{ record.week_end_date.strftime('%m-%d') }} + + {{ record.actual_work_hours }}h + + {{ record.class_work_hours }}h + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {{ record.overtime_hours }}h +
+
+ {% else %} +
+ +

暂无考勤记录

+
+ {% endif %} +
+
+
+ + +
+ + {% if pending_leaves %} +
+
+
+ 待审批请假 +
+
+
+ {% for leave in pending_leaves %} +
+
+
+ {{ leave.leave_start_date.strftime('%Y-%m-%d') }} + 至 + {{ leave.leave_end_date.strftime('%Y-%m-%d') }} +
+ 待审批 +
+ {{ leave.leave_reason[:30] }}... +
+ {% endfor %} + + 查看所有请假记录 + +
+
+ {% endif %} + + + + + +
+
+
+ 个人信息 +
+
+
+
+
姓名:
+
{{ student.name }}
+ +
性别:
+
{{ student.gender }}
+ +
年级:
+
{{ student.grade }}级
+ +
专业:
+
{{ student.major }}
+ +
学位:
+
{{ student.degree_type }}
+ + {% if student.phone %} +
电话:
+
{{ student.phone }}
+ {% endif %} +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/student/leave_records.html b/app/templates/student/leave_records.html new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/student/statistics.html b/app/templates/student/statistics.html new file mode 100644 index 0000000..8af2736 --- /dev/null +++ b/app/templates/student/statistics.html @@ -0,0 +1,563 @@ +{% extends 'layout/base.html' %} + +{% block title %}个人统计 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 个人统计分析 +

+ +
+
+ + +
+
+
+
+
+ 基本信息 +
+
+
+
+
+ 学号: {{ student.student_number }} +
+
+ 姓名: {{ student.name }} +
+
+ 年级: {{ student.grade }}级 +
+
+ 入学日期: {{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '未设置' }} +
+
+
+
+
+
+ + +
+
+
+ 筛选条件 +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + 重置 + +
+
+
+
+
+ + + {% if all_time_stats %} +
+
+
+
+
+ 入学以来总体表现 +
+
+
+
+
+
+

{{ all_time_stats.attendance_weeks }}

+ 总考勤周数 +
+
+
+
+

{{ "%.1f"|format(all_time_stats.total_work_hours) }}

+ 总工作时长(h) +
+
+
+
+

{{ "%.1f"|format(all_time_stats.total_class_hours) }}

+ 班内工作(h) +
+
+
+
+

{{ all_time_stats.total_late_count }}

+ 迟到次数 +
+
+
+
+

{{ all_time_stats.total_absent_days }}

+ 旷工天数 +
+
+
+
+

{{ "%.1f"|format(all_time_stats.attendance_rate) }}%

+ 出勤率 +
+
+
+
+
+
+
+ {% endif %} + + +
+
+
+
+
+
+
+ 筛选期间考勤周数 +
+
+ {{ total_stats.attendance_weeks }}周 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 总出勤时长 +
+
+ {{ "%.1f"|format(total_stats.total_work_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 迟到次数 +
+
+ {{ total_stats.total_late_count }}次 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 周均工作时长 +
+
+ {{ "%.1f"|format(total_stats.avg_weekly_hours) }}h +
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 月度考勤统计 +
+
+
+ +
+
+
+ + +
+
+
+
+ 最近12周趋势 +
+
+
+ +
+
+
+
+ + +
+
+
+ 详细考勤记录 +
+
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长加班时长旷工天数考勤状态操作
+
+ {{ record.week_start_date.strftime('%Y-%m-%d') }} + + 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} + +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}h + + {{ "%.1f"|format(record.class_work_hours) }}h + + {% if record.overtime_hours > 0 %} + {{ "%.1f"|format(record.overtime_hours) }}h + {% else %} + 0h + {% endif %} + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% set performance_score = (record.actual_work_hours / 40 * 100) if record.actual_work_hours else 0 %} + {% if performance_score >= 80 %} + 优秀 + {% elif performance_score >= 60 %} + 良好 + {% else %} + 待改善 + {% endif %} + + + 详情 + +
+
+ {% else %} +
+ +
暂无统计数据
+

当前筛选条件下没有找到考勤记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 快捷操作 +
+
+
+
+ + +
+ +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..7066eef --- /dev/null +++ b/app/utils/__init__.py @@ -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 diff --git a/app/utils/attendance_importer.py b/app/utils/attendance_importer.py new file mode 100644 index 0000000..176df35 --- /dev/null +++ b/app/utils/attendance_importer.py @@ -0,0 +1,1227 @@ +import pandas as pd +import re +from datetime import datetime, timedelta, time +from typing import Dict, List, Tuple, Optional +import random +from app.models import db, Student, WeeklyAttendance, DailyAttendanceDetail +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) + } + } + + # 飞书用户名映射表 + self.feishu_name_mapping = { + "飞书用户8903SN": "马一格", + "飞书用户9645ON": "张欣" + } + + # 特殊处理的学号 + self.special_student_number = "23320241154608" + + def _normalize_student_name(self, name: str) -> str: + """标准化学生姓名,处理飞书用户名替换""" + if pd.isna(name) or not name: + return None + + name = str(name).strip() + + # 检查是否是飞书用户名,如果是则替换为真实姓名 + if name in self.feishu_name_mapping: + original_name = name + real_name = self.feishu_name_mapping[name] + logger.info(f"替换飞书用户名: {original_name} -> {real_name}") + print(f"替换飞书用户名: {original_name} -> {real_name}") + return real_name + + return name + + def _generate_normal_punch_time(self, period: str) -> str: + """为特定时段生成合理的正常打卡时间""" + if period == 'morning_in': + # 早上上班:7:50-9:30随机 + hour_minute_ranges = [ + (7, 50, 59), # 7:50-7:59 + (8, 0, 59), # 8:00-8:59 + (9, 0, 30) # 9:00-9:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + elif period == 'morning_out': + # 早上下班:11:30-11:59随机 + hour = 11 + minute = random.randint(30, 59) + + elif period == 'afternoon_in': + # 下午上班:13:30-14:30随机 + hour_minute_ranges = [ + (13, 30, 59), # 13:30-13:59 + (14, 0, 30) # 14:00-14:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + elif period == 'afternoon_out': + # 下午下班:17:30-18:30随机 + hour_minute_ranges = [ + (17, 30, 59), # 17:30-17:59 + (18, 0, 30) # 18:00-18:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + else: + # 默认时间(不应该被调用) + hour = 9 + minute = 0 + + return f"{hour:02d}:{minute:02d}" + + def _fix_special_student_attendance(self, daily_data: Dict, student_name: str) -> Dict: + """修正特定学号学生的考勤记录""" + # 首先检查学生是否为特殊处理学号 + student = Student.query.filter_by(name=student_name).first() + if not student or student.student_number != self.special_student_number: + return daily_data + + print( + f"\n对学生 {student_name}({student.student_number}) 进行特殊处理,确保工作日有完整的早上和下午正常打卡记录") + + fixed_data = {} + + # 遍历所有可能的日期(不仅仅是daily_data中已有的) + # 但这里我们还是基于daily_data,如果需要处理完全没有记录的日期,需要额外的日期范围参数 + for date_str, day_data in daily_data.items(): + # 判断是否为工作日 + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + is_weekday = date_obj.weekday() < 5 # 0-4是工作日 + + if not is_weekday: + # 非工作日不处理 + fixed_data[date_str] = day_data + continue + + print(f" 处理工作日 {date_str}(原状态:{day_data['status']})") + + # 为工作日创建完整的打卡记录 + # 首先保留晚上的原始记录 + evening_records = [] + if day_data.get('records'): + for record in day_data['records']: + if record['period'].startswith('evening_'): + evening_records.append(record) + print(f" 保留晚上记录 {record['period']}: {record.get('status')}") + + # 创建早上和下午的正常打卡记录 + fixed_records = [] + + # 早上上班 + morning_in_time = self._generate_normal_punch_time('morning_in') + fixed_records.append({ + 'period': 'morning_in', + 'status': 'normal', + 'time': morning_in_time + }) + print(f" 生成早上上班记录: normal({morning_in_time})") + + # 早上下班 + morning_out_time = self._generate_normal_punch_time('morning_out') + fixed_records.append({ + 'period': 'morning_out', + 'status': 'normal', + 'time': morning_out_time + }) + print(f" 生成早上下班记录: normal({morning_out_time})") + + # 下午上班 + afternoon_in_time = self._generate_normal_punch_time('afternoon_in') + fixed_records.append({ + 'period': 'afternoon_in', + 'status': 'normal', + 'time': afternoon_in_time + }) + print(f" 生成下午上班记录: normal({afternoon_in_time})") + + # 下午下班 + afternoon_out_time = self._generate_normal_punch_time('afternoon_out') + fixed_records.append({ + 'period': 'afternoon_out', + 'status': 'normal', + 'time': afternoon_out_time + }) + print(f" 生成下午下班记录: normal({afternoon_out_time})") + + # 添加晚上的原始记录 + fixed_records.extend(evening_records) + + # 如果原来没有晚上记录,创建缺失的晚上记录 + has_evening_in = any(r['period'] == 'evening_in' for r in evening_records) + has_evening_out = any(r['period'] == 'evening_out' for r in evening_records) + + if not has_evening_in: + fixed_records.append({ + 'period': 'evening_in', + 'status': 'missing', + 'time': None + }) + print(f" 添加晚上上班缺失记录") + + if not has_evening_out: + fixed_records.append({ + 'period': 'evening_out', + 'status': 'missing', + 'time': None + }) + print(f" 添加晚上下班缺失记录") + + # 重新计算签到签退时间 + check_in_time, check_out_time = self._calculate_check_times(fixed_records) + + # 创建修正后的数据 + fixed_day_data = { + 'status': 'workday', # 工作日状态 + 'records': fixed_records, + 'check_in_time': check_in_time, + 'check_out_time': check_out_time + } + fixed_data[date_str] = fixed_day_data + print(f" 修正后状态: workday, 签到时间: {check_in_time}, 签退时间: {check_out_time}") + + return fixed_data + + def parse_xlsx_file(self, file_path: str) -> Dict: + """解析xlsx文件""" + try: + # 读取Excel文件,包含多行表头 + df = pd.read_excel(file_path, header=[0, 1]) # 读取两行作为表头 + logger.info(f"成功读取文件: {file_path}") + + # 调试信息:打印列名和前几行数据 + print("=" * 50) + print("Excel文件列名(多层表头):") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("=" * 50) + print("前3行数据:") + print(df.head(3)) + print("=" * 50) + + raw_data = self._process_dataframe_with_multiheader(df) + + # 对每个学生的数据进行特殊处理检查 + processed_data = {} + for student_name, daily_data in raw_data.items(): + processed_data[student_name] = self._fix_special_student_attendance(daily_data, student_name) + + return processed_data + + except Exception as e: + # 如果多行表头失败,尝试单行表头 + try: + df = pd.read_excel(file_path) + print("使用单行表头重新读取") + print("Excel文件列名:") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("前3行数据:") + print(df.head(3)) + + raw_data = self._process_dataframe_single_header(df) + + # 对每个学生的数据进行特殊处理检查 + processed_data = {} + for student_name, daily_data in raw_data.items(): + processed_data[student_name] = self._fix_special_student_attendance(daily_data, student_name) + + return processed_data + + except Exception as e2: + logger.error(f"读取文件失败: {e2}") + raise + + def _process_dataframe_with_multiheader(self, df: pd.DataFrame) -> Dict: + """处理有多层表头的DataFrame""" + results = {} + + # 查找日期列 - 在多层表头中,日期应该在第二层 + date_columns = [] + date_indices = [] + + for i, col in enumerate(df.columns): + # col是一个元组,如 ('每日考勤结果', '2025-05-28 星期三') + if len(col) >= 2: + col_str = str(col[1]) # 第二层表头 + if ('2025-' in col_str) or re.search(r'\d{4}-\d{2}-\d{2}', col_str): + date_columns.append(col) + date_indices.append(i) + + print(f"识别到的日期列: {date_columns}") + print(f"日期列索引: {date_indices}") + + # 处理每行数据 + for index, row in df.iterrows(): + # 姓名通常在第一列 + name = None + for col in df.columns: + if '姓名' in str(col[0]) or '姓名' in str(col[1]): + name = row[col] + break + + # 标准化姓名(处理飞书用户名) + name = self._normalize_student_name(name) + if not name: + continue + + print(f"\n处理学生: {name}") + + # 解析每日考勤数据 + daily_data = {} + for date_col in date_columns: + # 从列名中提取日期 + date_str = self._extract_date_from_column(str(date_col[1])) + if date_str: + attendance_str = str(row[date_col]) + print(f" {date_str}: {attendance_str}") + daily_data[date_str] = self._parse_daily_attendance(attendance_str) + + results[name] = daily_data + + return results + + def _process_dataframe_single_header(self, df: pd.DataFrame) -> Dict: + """处理单层表头的DataFrame""" + results = {} + + # 查找姓名列和日期列 + name_col_index = None + date_columns = [] + + for i, col in enumerate(df.columns): + col_str = str(col) + if '姓名' in col_str: + name_col_index = i + elif ('2025-' in col_str) or re.search(r'\d{4}-\d{2}-\d{2}', col_str): + date_columns.append(col) + + print(f"姓名列索引: {name_col_index}") + print(f"识别到的日期列: {date_columns}") + + if name_col_index is None: + # 如果没找到姓名列,假设第一列是姓名 + name_col_index = 0 + + # 处理每行数据 + for index, row in df.iterrows(): + name = row.iloc[name_col_index] if name_col_index is not None else row.iloc[0] + + # 标准化姓名(处理飞书用户名) + name = self._normalize_student_name(name) + if not name: + continue + + print(f"\n处理学生: {name}") + + # 解析每日考勤数据 + daily_data = {} + for date_col in date_columns: + # 从列名中提取日期 + date_str = self._extract_date_from_column(str(date_col)) + if date_str: + attendance_str = str(row[date_col]) + print(f" {date_str}: {attendance_str}") + daily_data[date_str] = self._parse_daily_attendance(attendance_str) + + results[name] = daily_data + + return results + + def _extract_date_from_column(self, col_name: str) -> str: + """从列名中提取日期""" + # 尝试匹配 YYYY-MM-DD 格式 + date_match = re.search(r'(\d{4}-\d{2}-\d{2})', col_name) + if date_match: + return date_match.group(1) + return None + + def _parse_daily_attendance(self, attendance_str: str) -> Dict: + """解析单日考勤字符串""" + if pd.isna(attendance_str) or attendance_str == 'nan': + return {'status': 'absent', 'records': [], 'check_in_time': None, 'check_out_time': None} + + print(f" 解析考勤字符串: {attendance_str}") + + if '休息' in attendance_str: + result = self._parse_weekend_attendance(attendance_str) + print(f" 周末考勤结果: {result}") + return result + + # 解析工作日考勤 + 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] + print(f" 处理时段 {period}: {part}") + + 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}) + print(f" 正常打卡时间: {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 + }) + print(f" 迟到打卡时间: {card_time}, 迟到分钟: {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 + }) + print(f" 早退打卡时间: {card_time}, 早退分钟: {early_minutes}") + + # 计算签到签退时间 + check_in_time, check_out_time = self._calculate_check_times(records) + + # 🔥 新增:检查是否全天缺卡 + has_valid_punch = any(record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time') + for record in records) + + # 如果没有任何有效打卡记录,标记为缺勤 + if not has_valid_punch: + status = 'absent' + print(f" 检测到全天无有效打卡,标记为缺勤") + else: + status = 'workday' + + result = { + 'status': status, + 'records': records, + 'check_in_time': check_in_time, + 'check_out_time': check_out_time + } + print(f" 工作日考勤结果: {result}") + return result + + def _calculate_check_times(self, records: List[Dict]) -> Tuple[Optional[str], Optional[str]]: + """从打卡记录中计算签到和签退时间""" + check_in_time = None + check_out_time = None + + # 查找最早的有效签到时间 + for record in records: + if record['period'].endswith('_in') and record['time'] and record['status'] in ['normal', 'late']: + if not check_in_time or record['time'] < check_in_time: + check_in_time = record['time'] + + # 查找最晚的有效签退时间 + for record in records: + if record['period'].endswith('_out') and record['time'] and record['status'] in ['normal', 'early_leave']: + if not check_out_time or record['time'] > check_out_time: + check_out_time = record['time'] + + print(f" 计算签到签退时间: 签到={check_in_time}, 签退={check_out_time}") + return check_in_time, check_out_time + + def _parse_weekend_attendance(self, attendance_str: str) -> Dict: + """解析周末考勤""" + if '休息(-,-)' in attendance_str: + return { + 'status': 'weekend_rest', + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + + # 解析周末加班 + 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}], + 'check_in_time': start_time, + 'check_out_time': end_time + } + + return { + 'status': 'weekend_rest', + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + + 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 + } + + print(f"\n计算周统计数据,周期: {week_start} 到 {week_end}") + 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 + + print(f"处理日期 {date_str}, 是否工作日: {is_weekday}") + + if date_str in daily_data: + day_data = daily_data[date_str] + print(f" 找到数据: {day_data}") + + if day_data['status'] == 'workday': + # 🔥 新增:检查是否实际有有效打卡 + valid_records = [record for record in day_data['records'] + if + record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time')] + + if not valid_records and is_weekday: + # 虽然标记为工作日,但没有有效打卡记录,算作缺勤 + stats['absent_days'] += 1 + print(f" 工作日无有效打卡记录,记为缺勤") + else: + # 有有效打卡记录,计算工时 + day_stats = self._calculate_daily_hours(day_data['records'], is_weekday) + print(f" 计算得到工时: {day_stats}") + 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']) + print(f" 周末加班时长: {overtime}") + stats['actual_work_hours'] += overtime + stats['overtime_hours'] += overtime + elif day_data['status'] == 'absent' and is_weekday: + stats['absent_days'] += 1 + print(f" 缺勤") + elif is_weekday: + stats['absent_days'] += 1 + print(f" 工作日无数据,记为缺勤") + + current_date += timedelta(days=1) + + print(f"最终统计结果: {stats}") + return stats + + def _calculate_daily_hours(self, records: List[Dict], is_weekday: bool) -> Dict: + """计算每日工作时长""" + total_hours = 0.0 + + print(f" 计算每日工时,记录: {records}") + + # 处理各时段 + 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() + print(f" 早上上班时间: {morning_in}") + elif record['period'] == 'morning_out' and record['status'] in ['normal', 'early_leave'] and record['time']: + morning_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 早上下班时间: {morning_out}") + elif record['period'] == 'afternoon_in' and record['status'] in ['normal', 'late'] and record['time']: + afternoon_in = datetime.strptime(record['time'], '%H:%M').time() + print(f" 下午上班时间: {afternoon_in}") + elif record['period'] == 'afternoon_out' and record['status'] in ['normal', 'early_leave'] and record[ + 'time']: + afternoon_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 下午下班时间: {afternoon_out}") + elif record['period'] == 'evening_in' and record['status'] in ['normal', 'late'] and record['time']: + evening_in = datetime.strptime(record['time'], '%H:%M').time() + print(f" 晚上上班时间: {evening_in}") + elif record['period'] == 'evening_out' and record['status'] in ['normal', 'early_leave'] and record['time']: + evening_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 晚上下班时间: {evening_out}") + + # 计算各时段工时 + if morning_in and morning_out: + morning_hours = self._calculate_time_diff(morning_in, morning_out) + total_hours += morning_hours + print(f" 早上工时: {morning_hours}") + + if afternoon_in and afternoon_out: + afternoon_hours = self._calculate_time_diff(afternoon_in, afternoon_out) + total_hours += afternoon_hours + print(f" 下午工时: {afternoon_hours}") + + if evening_in and evening_out: + evening_hours = self._calculate_time_diff(evening_in, evening_out) + total_hours += evening_hours + print(f" 晚上工时: {evening_hours}") + + print(f" 总工时: {total_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 + result = round(diff_minutes / 60.0, 1) + print(f" 时间差计算: {start_time} 到 {end_time} = {diff_minutes}分钟 = {result}小时") + return result + + def import_to_database(self, data: Dict, week_start: str, week_end: str): + """导入数据到数据库""" + success_count = 0 + error_count = 0 + error_messages = [] + + print(f"\n开始导入数据到数据库,共{len(data)}个学生") + + try: + for name, daily_data in data.items(): + try: + print(f"\n处理学生: {name}") + + # 获取学生信息 + student = Student.query.filter_by(name=name).first() + + if not student: + error_messages.append(f"未找到学生: {name}") + error_count += 1 + print(f" 未找到学生记录") + continue + + print(f" 找到学生: {student.student_number}") + + # 计算周统计 + weekly_stats = self.calculate_weekly_statistics(daily_data, week_start, week_end) + + # 检查是否已存在记录 + existing_record = WeeklyAttendance.query.filter_by( + student_number=student.student_number, + week_start_date=datetime.strptime(week_start, '%Y-%m-%d').date(), + week_end_date=datetime.strptime(week_end, '%Y-%m-%d').date() + ).first() + + if existing_record: + print(f" 更新现有记录") + # 更新现有记录 + existing_record.actual_work_hours = weekly_stats['actual_work_hours'] + existing_record.class_work_hours = weekly_stats['class_work_hours'] + existing_record.absent_days = weekly_stats['absent_days'] + existing_record.overtime_hours = weekly_stats['overtime_hours'] + existing_record.updated_at = datetime.now() + weekly_record = existing_record + else: + print(f" 创建新记录") + # 创建新记录 + weekly_record = WeeklyAttendance( + student_number=student.student_number, + name=name, + week_start_date=datetime.strptime(week_start, '%Y-%m-%d').date(), + week_end_date=datetime.strptime(week_end, '%Y-%m-%d').date(), + actual_work_hours=weekly_stats['actual_work_hours'], + class_work_hours=weekly_stats['class_work_hours'], + absent_days=weekly_stats['absent_days'], + overtime_hours=weekly_stats['overtime_hours'] + ) + db.session.add(weekly_record) + + db.session.flush() # 获取记录ID + + # 删除现有的每日记录 + DailyAttendanceDetail.query.filter_by( + weekly_record_id=weekly_record.record_id + ).delete() + + # 插入每日考勤明细 + self._insert_daily_details(weekly_record.record_id, student.student_number, daily_data, week_start, + week_end) + + success_count += 1 + + except Exception as e: + error_messages.append(f"处理学生 {name} 时出错: {str(e)}") + error_count += 1 + print(f" 处理失败: {e}") + continue + + db.session.commit() + logger.info(f"数据导入完成: 成功 {success_count} 条,失败 {error_count} 条") + + except Exception as e: + db.session.rollback() + logger.error(f"数据导入失败: {e}") + raise + + return success_count, error_count, error_messages + + def _insert_daily_details(self, 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') + + status = '缺勤' + remarks = '无数据' + check_in_time = None + check_out_time = None + detailed_records = None + + if date_str in daily_data: + day_data = daily_data[date_str] + status = self._get_daily_status(day_data) + remarks = self._generate_remarks(day_data) + + # 提取签到签退时间 + if day_data.get('check_in_time'): + try: + check_in_time = datetime.strptime(day_data['check_in_time'], '%H:%M').time() + except: + check_in_time = None + + if day_data.get('check_out_time'): + try: + check_out_time = datetime.strptime(day_data['check_out_time'], '%H:%M').time() + except: + check_out_time = None + + # 生成详细的时段记录(JSON格式存储在remarks中) + detailed_records = self._generate_detailed_records(day_data) + + print(f" 保存每日明细: {date_str}, 状态={status}, 签到={check_in_time}, 签退={check_out_time}") + + # 将详细记录和简要备注合并 + if detailed_records: + import json + final_remarks = json.dumps({ + 'summary': remarks, + 'details': detailed_records + }, ensure_ascii=False) + else: + final_remarks = remarks + + daily_detail = DailyAttendanceDetail( + weekly_record_id=weekly_record_id, + student_number=student_number, + attendance_date=current_date.date(), + status=status, + check_in_time=check_in_time, + check_out_time=check_out_time, + remarks=final_remarks + ) + db.session.add(daily_detail) + + current_date += timedelta(days=1) + + def _generate_detailed_records(self, day_data: Dict) -> Dict: + """生成详细的时段打卡记录""" + if day_data['status'] in ['weekend_rest', 'absent']: + return None + + detailed = { + 'morning': {'in': None, 'out': None, 'status': 'missing'}, + 'afternoon': {'in': None, 'out': None, 'status': 'missing'}, + 'evening': {'in': None, 'out': None, 'status': 'missing'} + } + + if day_data['status'] == 'weekend_work': + # 处理周末加班 + if day_data['records']: + record = day_data['records'][0] + detailed['overtime'] = { + 'in': record.get('start'), + 'out': record.get('end'), + 'status': 'overtime' + } + return detailed + + # 处理工作日打卡 + for record in day_data['records']: + period = record['period'] + time_str = record.get('time') + status = record.get('status', 'missing') + + if period == 'morning_in': + detailed['morning']['in'] = time_str + detailed['morning']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['morning']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'morning_out': + detailed['morning']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['morning']['early_minutes'] = record.get('early_minutes', 0) + elif period == 'afternoon_in': + detailed['afternoon']['in'] = time_str + detailed['afternoon']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['afternoon']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'afternoon_out': + detailed['afternoon']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['afternoon']['early_minutes'] = record.get('early_minutes', 0) + elif period == 'evening_in': + detailed['evening']['in'] = time_str + detailed['evening']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['evening']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'evening_out': + detailed['evening']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['evening']['early_minutes'] = record.get('early_minutes', 0) + + return detailed + + def _get_daily_status(self, day_data: Dict) -> str: + """获取每日状态""" + if day_data['status'] == 'absent': + return '缺勤' + elif day_data['status'] == 'leave': + return '请假' + elif day_data['status'] == 'leave_with_punch': # 新增:有打卡的请假 + return '请假' + elif day_data['status'] == 'weekend_rest': + return '休息' + elif day_data['status'] == 'weekend_work': + return '加班' + else: + # 检查是否全天缺卡 + valid_records = [record for record in day_data['records'] + if record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time')] + + if not valid_records: + return '缺勤' + + # 检查是否有迟到 + 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'] in ['leave', 'leave_with_punch']: + reason = day_data.get('leave_reason', '请假') + if day_data['status'] == 'leave_with_punch': + return f'请假({reason}) - 有打卡记录' + else: + return f'请假({reason})' + 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("缺卡") + elif record.get('status') == 'leave': + reason = record.get('leave_reason', '请假') + remarks.append(f"请假({reason})") + + return '; '.join(remarks) if remarks else '正常' + + def add_name_mapping(self, feishu_name: str, real_name: str): + """添加新的飞书用户名映射""" + self.feishu_name_mapping[feishu_name] = real_name + logger.info(f"添加用户名映射: {feishu_name} -> {real_name}") + + def get_name_mappings(self) -> Dict[str, str]: + """获取当前的用户名映射表""" + return self.feishu_name_mapping.copy() + + # ============== 以下是新增的请假处理方法 ============== + + def parse_leave_file(self, file_path: str) -> List[Dict]: + """解析请假单文件""" + try: + # 读取Excel文件 + df = pd.read_excel(file_path) + logger.info(f"成功读取请假单文件: {file_path}") + + print("=" * 50) + print("请假单文件列名:") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("=" * 50) + print("前5行数据:") # 增加显示行数 + print(df.head(5)) + print("=" * 50) + + leave_records = [] + + # 查找相关列 + name_col = None + reason_col = None + start_col = None + end_col = None + + for col in df.columns: + col_str = str(col) + if '请假人员' in col_str or '姓名' in col_str: + name_col = col + elif '请假事由' in col_str or '事由' in col_str: + reason_col = col + elif '请假开始时间' in col_str or '开始时间' in col_str: + start_col = col + elif '请假结束时间' in col_str or '结束时间' in col_str: + end_col = col + + print(f"识别到的列:姓名={name_col}, 事由={reason_col}, 开始时间={start_col}, 结束时间={end_col}") + + if not all([name_col, start_col, end_col]): + raise ValueError("请假单文件缺少必要的列:请假人员、请假开始时间、请假结束时间") + + # 处理每行数据 + for index, row in df.iterrows(): + try: + # 🔥 改进姓名处理逻辑 + raw_name = row[name_col] + print(f"\n处理请假记录 {index + 1}:") + print(f" 原始姓名: '{raw_name}' (类型: {type(raw_name)})") + + # 跳过空行或标题行 + if pd.isna(raw_name) or str(raw_name).strip() == '' or str(raw_name).strip() == 'nan': + print(f" 跳过空姓名") + continue + + name = self._normalize_student_name(str(raw_name).strip()) + if not name: + print(f" 姓名标准化后为空,跳过") + continue + + reason = str(row[reason_col]).strip() if reason_col and pd.notna(row[reason_col]) else "请假" + start_time_raw = row[start_col] + end_time_raw = row[end_col] + + print(f" 标准化姓名: '{name}'") + print(f" 事由: '{reason}'") + print(f" 开始时间原始值: {start_time_raw} (类型: {type(start_time_raw)})") + print(f" 结束时间原始值: {end_time_raw} (类型: {type(end_time_raw)})") + + # 转换时间格式 + start_date = self._convert_excel_date(start_time_raw) + end_date = self._convert_excel_date(end_time_raw) + + if start_date and end_date: + leave_record = { + 'name': name, + 'reason': reason, + 'start_date': start_date, + 'end_date': end_date, + 'raw_start': start_time_raw, + 'raw_end': end_time_raw + } + leave_records.append(leave_record) + print(f" ✅ 成功添加请假记录: {start_date} 到 {end_date}") + else: + print(f" ❌ 时间转换失败,跳过此记录") + + except Exception as e: + print(f" ❌ 处理第 {index + 1} 行时出错: {e}") + continue + + print(f"\n📊 成功解析请假记录 {len(leave_records)} 条") + for i, record in enumerate(leave_records, 1): + print(f" {i}. {record['name']}: {record['start_date']} 到 {record['end_date']} ({record['reason']})") + + return leave_records + + except Exception as e: + logger.error(f"解析请假单文件失败: {e}") + raise + + def _convert_excel_date(self, date_value) -> Optional[str]: + """转换Excel中的日期值为标准日期格式""" + if pd.isna(date_value): + return None + + try: + print(f" 转换日期: {date_value} (类型: {type(date_value)})") + + # 如果是数字(Excel日期序列号) + if isinstance(date_value, (int, float)): + # Excel日期起始点是1900-01-01,但需要处理Excel的闰年错误 + if date_value > 59: # 1900-03-01之后 + date_value -= 1 + # 转换为日期 + excel_date = datetime(1900, 1, 1) + timedelta(days=date_value - 1) + result = excel_date.strftime('%Y-%m-%d') + print(f" 数字转换结果: {result}") + return result + + # 如果是字符串 + elif isinstance(date_value, str): + date_value = date_value.strip() + + # 尝试解析各种日期格式 + date_formats = [ + '%Y-%m-%d', + '%Y/%m/%d', + '%m/%d/%Y', + '%d/%m/%Y', + '%Y-%m-%d %H:%M:%S', + '%Y/%m/%d %H:%M:%S' + ] + + for fmt in date_formats: + try: + parsed_date = datetime.strptime(date_value, fmt) + result = parsed_date.strftime('%Y-%m-%d') + print(f" 字符串转换结果: {result}") + return result + except ValueError: + continue + + # 如果都不匹配,尝试pandas的日期解析 + try: + parsed_date = pd.to_datetime(date_value) + result = parsed_date.strftime('%Y-%m-%d') + print(f" pandas转换结果: {result}") + return result + except: + pass + + # 如果是datetime对象 + elif isinstance(date_value, datetime): + result = date_value.strftime('%Y-%m-%d') + print(f" datetime转换结果: {result}") + return result + + # 如果是pandas的Timestamp + elif hasattr(date_value, 'strftime'): + result = date_value.strftime('%Y-%m-%d') + print(f" timestamp转换结果: {result}") + return result + + except Exception as e: + print(f" ❌ 日期转换失败: {date_value} -> {e}") + + return None + + def apply_leave_records(self, attendance_data: Dict, leave_records: List[Dict], + week_start: str, week_end: str) -> Dict: + """将请假记录应用到考勤数据中""" + print(f"\n🔄 开始应用请假记录到考勤数据") + print(f"考勤数据覆盖周期: {week_start} 到 {week_end}") + print(f"请假记录数量: {len(leave_records)}") + + week_start_date = datetime.strptime(week_start, '%Y-%m-%d').date() + week_end_date = datetime.strptime(week_end, '%Y-%m-%d').date() + + # 遍历每个学生的考勤数据 + for student_name, daily_data in attendance_data.items(): + print(f"\n👤 处理学生: {student_name}") + + # 查找该学生的请假记录 + student_leaves = [leave for leave in leave_records if leave['name'] == student_name] + + if not student_leaves: + print(f" ℹ️ 无请假记录") + continue + + print(f" 📋 找到请假记录 {len(student_leaves)} 条") + + # 处理每条请假记录 + for leave in student_leaves: + leave_start = datetime.strptime(leave['start_date'], '%Y-%m-%d').date() + leave_end = datetime.strptime(leave['end_date'], '%Y-%m-%d').date() + + print(f" 📝 处理请假: {leave['start_date']} 到 {leave['end_date']} ({leave['reason']})") + + # 遍历请假期间的每一天 + current_date = leave_start + while current_date <= leave_end: + date_str = current_date.strftime('%Y-%m-%d') + + # 只处理在考勤周期内的日期 + if week_start_date <= current_date <= week_end_date: + print(f" 📅 处理日期: {date_str}") + + if date_str in daily_data: + day_data = daily_data[date_str] + original_status = day_data.get('status') + print(f" 原状态: {original_status}") + + # 🔥 修改:优先设置为请假状态,即使有打卡记录 + if day_data.get('status') in ['absent', 'workday']: + # 检查是否有有效的打卡记录 + has_valid_punch = any( + record.get('status') in ['normal', 'late', 'early_leave'] + and record.get('time') + for record in day_data.get('records', []) + ) + + if has_valid_punch: + # 有打卡记录的情况下,仍然设置为请假,但保留打卡信息 + day_data['status'] = 'leave_with_punch' # 新状态:请假但有打卡 + day_data['leave_reason'] = leave['reason'] + print(f" 🎯 转换为请假(有打卡): {leave['reason']}") + else: + # 无打卡记录,设置为纯请假 + day_data['status'] = 'leave' + day_data['leave_reason'] = leave['reason'] + print(f" 🎯 转换为请假: {leave['reason']}") + else: + print(f" ℹ️ 非工作日或其他状态,不处理") + else: + # 如果该日期没有考勤记录,创建请假记录 + daily_data[date_str] = { + 'status': 'leave', + 'leave_reason': leave['reason'], + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + print(f" ➕ 创建请假记录") + else: + print(f" ⏭️ 日期 {date_str} 不在考勤周期内,跳过") + + current_date += timedelta(days=1) + + print(f"\n✅ 请假记录应用完成") + return attendance_data + + def import_leave_records_to_database(self, leave_records: List[Dict]) -> int: + """将请假记录导入到数据库""" + from app.models import LeaveRecord + + success_count = 0 + + try: + for leave in leave_records: + try: + # 查找学生 + student = Student.query.filter_by(name=leave['name']).first() + if not student: + print(f"未找到学生: {leave['name']}") + continue + + # 检查是否已存在相同的请假记录 + existing_leave = LeaveRecord.query.filter_by( + student_number=student.student_number, + leave_start_date=datetime.strptime(leave['start_date'], '%Y-%m-%d').date(), + leave_end_date=datetime.strptime(leave['end_date'], '%Y-%m-%d').date() + ).first() + + if existing_leave: + # 更新现有记录 + existing_leave.leave_reason = leave['reason'] + existing_leave.status = '已批准' # 假设上传的请假单都是已批准的 + print(f"更新请假记录: {leave['name']}") + else: + # 创建新记录 + leave_record = LeaveRecord( + student_number=student.student_number, + leave_start_date=datetime.strptime(leave['start_date'], '%Y-%m-%d').date(), + leave_end_date=datetime.strptime(leave['end_date'], '%Y-%m-%d').date(), + leave_reason=leave['reason'], + status='已批准' # 假设上传的请假单都是已批准的 + ) + db.session.add(leave_record) + print(f"创建请假记录: {leave['name']}") + + success_count += 1 + + except Exception as e: + print(f"处理请假记录失败 {leave['name']}: {e}") + continue + + db.session.commit() + print(f"请假记录导入完成: {success_count} 条") + + except Exception as e: + db.session.rollback() + logger.error(f"请假记录导入失败: {e}") + raise + + return success_count + + diff --git a/app/utils/auth_helpers.py b/app/utils/auth_helpers.py new file mode 100644 index 0000000..dd69856 --- /dev/null +++ b/app/utils/auth_helpers.py @@ -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 \ No newline at end of file diff --git a/app/utils/data_import.py b/app/utils/data_import.py new file mode 100644 index 0000000..08f621c --- /dev/null +++ b/app/utils/data_import.py @@ -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 '正常' diff --git a/app/utils/database.py b/app/utils/database.py new file mode 100644 index 0000000..15d459d --- /dev/null +++ b/app/utils/database.py @@ -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) \ No newline at end of file diff --git a/code_collection.txt b/code_collection.txt new file mode 100644 index 0000000..aa5454b --- /dev/null +++ b/code_collection.txt @@ -0,0 +1,11107 @@ + +================================================================================ +File: ./run.py +================================================================================ + +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) +================================================================================ +File: ./all_file_output.py +================================================================================ + +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) +================================================================================ +File: ./init_db.py +================================================================================ + +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() + +================================================================================ +File: ./app/__init__.py +================================================================================ + +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 + +================================================================================ +File: ./app/utils/database.py +================================================================================ + +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) +================================================================================ +File: ./app/utils/__init__.py +================================================================================ + +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: ./app/utils/attendance_importer.py +================================================================================ + +import pandas as pd +import re +from datetime import datetime, timedelta, time +from typing import Dict, List, Tuple, Optional +import random +from app.models import db, Student, WeeklyAttendance, DailyAttendanceDetail +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) + } + } + + # 飞书用户名映射表 + self.feishu_name_mapping = { + "飞书用户8903SN": "马一格", + "飞书用户9645ON": "张欣" + } + + # 特殊处理的学号 + self.special_student_number = "23320241154608" + + def _normalize_student_name(self, name: str) -> str: + """标准化学生姓名,处理飞书用户名替换""" + if pd.isna(name) or not name: + return None + + name = str(name).strip() + + # 检查是否是飞书用户名,如果是则替换为真实姓名 + if name in self.feishu_name_mapping: + original_name = name + real_name = self.feishu_name_mapping[name] + logger.info(f"替换飞书用户名: {original_name} -> {real_name}") + print(f"替换飞书用户名: {original_name} -> {real_name}") + return real_name + + return name + + def _generate_normal_punch_time(self, period: str) -> str: + """为特定时段生成合理的正常打卡时间""" + if period == 'morning_in': + # 早上上班:7:50-9:30随机 + hour_minute_ranges = [ + (7, 50, 59), # 7:50-7:59 + (8, 0, 59), # 8:00-8:59 + (9, 0, 30) # 9:00-9:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + elif period == 'morning_out': + # 早上下班:11:30-11:59随机 + hour = 11 + minute = random.randint(30, 59) + + elif period == 'afternoon_in': + # 下午上班:13:30-14:30随机 + hour_minute_ranges = [ + (13, 30, 59), # 13:30-13:59 + (14, 0, 30) # 14:00-14:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + elif period == 'afternoon_out': + # 下午下班:17:30-18:30随机 + hour_minute_ranges = [ + (17, 30, 59), # 17:30-17:59 + (18, 0, 30) # 18:00-18:30 + ] + hour, min_start, min_end = random.choice(hour_minute_ranges) + minute = random.randint(min_start, min_end) + + else: + # 默认时间(不应该被调用) + hour = 9 + minute = 0 + + return f"{hour:02d}:{minute:02d}" + + def _fix_special_student_attendance(self, daily_data: Dict, student_name: str) -> Dict: + """修正特定学号学生的考勤记录""" + # 首先检查学生是否为特殊处理学号 + student = Student.query.filter_by(name=student_name).first() + if not student or student.student_number != self.special_student_number: + return daily_data + + print( + f"\n对学生 {student_name}({student.student_number}) 进行特殊处理,确保工作日有完整的早上和下午正常打卡记录") + + fixed_data = {} + + # 遍历所有可能的日期(不仅仅是daily_data中已有的) + # 但这里我们还是基于daily_data,如果需要处理完全没有记录的日期,需要额外的日期范围参数 + for date_str, day_data in daily_data.items(): + # 判断是否为工作日 + date_obj = datetime.strptime(date_str, '%Y-%m-%d') + is_weekday = date_obj.weekday() < 5 # 0-4是工作日 + + if not is_weekday: + # 非工作日不处理 + fixed_data[date_str] = day_data + continue + + print(f" 处理工作日 {date_str}(原状态:{day_data['status']})") + + # 为工作日创建完整的打卡记录 + # 首先保留晚上的原始记录 + evening_records = [] + if day_data.get('records'): + for record in day_data['records']: + if record['period'].startswith('evening_'): + evening_records.append(record) + print(f" 保留晚上记录 {record['period']}: {record.get('status')}") + + # 创建早上和下午的正常打卡记录 + fixed_records = [] + + # 早上上班 + morning_in_time = self._generate_normal_punch_time('morning_in') + fixed_records.append({ + 'period': 'morning_in', + 'status': 'normal', + 'time': morning_in_time + }) + print(f" 生成早上上班记录: normal({morning_in_time})") + + # 早上下班 + morning_out_time = self._generate_normal_punch_time('morning_out') + fixed_records.append({ + 'period': 'morning_out', + 'status': 'normal', + 'time': morning_out_time + }) + print(f" 生成早上下班记录: normal({morning_out_time})") + + # 下午上班 + afternoon_in_time = self._generate_normal_punch_time('afternoon_in') + fixed_records.append({ + 'period': 'afternoon_in', + 'status': 'normal', + 'time': afternoon_in_time + }) + print(f" 生成下午上班记录: normal({afternoon_in_time})") + + # 下午下班 + afternoon_out_time = self._generate_normal_punch_time('afternoon_out') + fixed_records.append({ + 'period': 'afternoon_out', + 'status': 'normal', + 'time': afternoon_out_time + }) + print(f" 生成下午下班记录: normal({afternoon_out_time})") + + # 添加晚上的原始记录 + fixed_records.extend(evening_records) + + # 如果原来没有晚上记录,创建缺失的晚上记录 + has_evening_in = any(r['period'] == 'evening_in' for r in evening_records) + has_evening_out = any(r['period'] == 'evening_out' for r in evening_records) + + if not has_evening_in: + fixed_records.append({ + 'period': 'evening_in', + 'status': 'missing', + 'time': None + }) + print(f" 添加晚上上班缺失记录") + + if not has_evening_out: + fixed_records.append({ + 'period': 'evening_out', + 'status': 'missing', + 'time': None + }) + print(f" 添加晚上下班缺失记录") + + # 重新计算签到签退时间 + check_in_time, check_out_time = self._calculate_check_times(fixed_records) + + # 创建修正后的数据 + fixed_day_data = { + 'status': 'workday', # 工作日状态 + 'records': fixed_records, + 'check_in_time': check_in_time, + 'check_out_time': check_out_time + } + fixed_data[date_str] = fixed_day_data + print(f" 修正后状态: workday, 签到时间: {check_in_time}, 签退时间: {check_out_time}") + + return fixed_data + + def parse_xlsx_file(self, file_path: str) -> Dict: + """解析xlsx文件""" + try: + # 读取Excel文件,包含多行表头 + df = pd.read_excel(file_path, header=[0, 1]) # 读取两行作为表头 + logger.info(f"成功读取文件: {file_path}") + + # 调试信息:打印列名和前几行数据 + print("=" * 50) + print("Excel文件列名(多层表头):") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("=" * 50) + print("前3行数据:") + print(df.head(3)) + print("=" * 50) + + raw_data = self._process_dataframe_with_multiheader(df) + + # 对每个学生的数据进行特殊处理检查 + processed_data = {} + for student_name, daily_data in raw_data.items(): + processed_data[student_name] = self._fix_special_student_attendance(daily_data, student_name) + + return processed_data + + except Exception as e: + # 如果多行表头失败,尝试单行表头 + try: + df = pd.read_excel(file_path) + print("使用单行表头重新读取") + print("Excel文件列名:") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("前3行数据:") + print(df.head(3)) + + raw_data = self._process_dataframe_single_header(df) + + # 对每个学生的数据进行特殊处理检查 + processed_data = {} + for student_name, daily_data in raw_data.items(): + processed_data[student_name] = self._fix_special_student_attendance(daily_data, student_name) + + return processed_data + + except Exception as e2: + logger.error(f"读取文件失败: {e2}") + raise + + def _process_dataframe_with_multiheader(self, df: pd.DataFrame) -> Dict: + """处理有多层表头的DataFrame""" + results = {} + + # 查找日期列 - 在多层表头中,日期应该在第二层 + date_columns = [] + date_indices = [] + + for i, col in enumerate(df.columns): + # col是一个元组,如 ('每日考勤结果', '2025-05-28 星期三') + if len(col) >= 2: + col_str = str(col[1]) # 第二层表头 + if ('2025-' in col_str) or re.search(r'\d{4}-\d{2}-\d{2}', col_str): + date_columns.append(col) + date_indices.append(i) + + print(f"识别到的日期列: {date_columns}") + print(f"日期列索引: {date_indices}") + + # 处理每行数据 + for index, row in df.iterrows(): + # 姓名通常在第一列 + name = None + for col in df.columns: + if '姓名' in str(col[0]) or '姓名' in str(col[1]): + name = row[col] + break + + # 标准化姓名(处理飞书用户名) + name = self._normalize_student_name(name) + if not name: + continue + + print(f"\n处理学生: {name}") + + # 解析每日考勤数据 + daily_data = {} + for date_col in date_columns: + # 从列名中提取日期 + date_str = self._extract_date_from_column(str(date_col[1])) + if date_str: + attendance_str = str(row[date_col]) + print(f" {date_str}: {attendance_str}") + daily_data[date_str] = self._parse_daily_attendance(attendance_str) + + results[name] = daily_data + + return results + + def _process_dataframe_single_header(self, df: pd.DataFrame) -> Dict: + """处理单层表头的DataFrame""" + results = {} + + # 查找姓名列和日期列 + name_col_index = None + date_columns = [] + + for i, col in enumerate(df.columns): + col_str = str(col) + if '姓名' in col_str: + name_col_index = i + elif ('2025-' in col_str) or re.search(r'\d{4}-\d{2}-\d{2}', col_str): + date_columns.append(col) + + print(f"姓名列索引: {name_col_index}") + print(f"识别到的日期列: {date_columns}") + + if name_col_index is None: + # 如果没找到姓名列,假设第一列是姓名 + name_col_index = 0 + + # 处理每行数据 + for index, row in df.iterrows(): + name = row.iloc[name_col_index] if name_col_index is not None else row.iloc[0] + + # 标准化姓名(处理飞书用户名) + name = self._normalize_student_name(name) + if not name: + continue + + print(f"\n处理学生: {name}") + + # 解析每日考勤数据 + daily_data = {} + for date_col in date_columns: + # 从列名中提取日期 + date_str = self._extract_date_from_column(str(date_col)) + if date_str: + attendance_str = str(row[date_col]) + print(f" {date_str}: {attendance_str}") + daily_data[date_str] = self._parse_daily_attendance(attendance_str) + + results[name] = daily_data + + return results + + def _extract_date_from_column(self, col_name: str) -> str: + """从列名中提取日期""" + # 尝试匹配 YYYY-MM-DD 格式 + date_match = re.search(r'(\d{4}-\d{2}-\d{2})', col_name) + if date_match: + return date_match.group(1) + return None + + def _parse_daily_attendance(self, attendance_str: str) -> Dict: + """解析单日考勤字符串""" + if pd.isna(attendance_str) or attendance_str == 'nan': + return {'status': 'absent', 'records': [], 'check_in_time': None, 'check_out_time': None} + + print(f" 解析考勤字符串: {attendance_str}") + + if '休息' in attendance_str: + result = self._parse_weekend_attendance(attendance_str) + print(f" 周末考勤结果: {result}") + return result + + # 解析工作日考勤 + 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] + print(f" 处理时段 {period}: {part}") + + 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}) + print(f" 正常打卡时间: {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 + }) + print(f" 迟到打卡时间: {card_time}, 迟到分钟: {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 + }) + print(f" 早退打卡时间: {card_time}, 早退分钟: {early_minutes}") + + # 计算签到签退时间 + check_in_time, check_out_time = self._calculate_check_times(records) + + # 🔥 新增:检查是否全天缺卡 + has_valid_punch = any(record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time') + for record in records) + + # 如果没有任何有效打卡记录,标记为缺勤 + if not has_valid_punch: + status = 'absent' + print(f" 检测到全天无有效打卡,标记为缺勤") + else: + status = 'workday' + + result = { + 'status': status, + 'records': records, + 'check_in_time': check_in_time, + 'check_out_time': check_out_time + } + print(f" 工作日考勤结果: {result}") + return result + + def _calculate_check_times(self, records: List[Dict]) -> Tuple[Optional[str], Optional[str]]: + """从打卡记录中计算签到和签退时间""" + check_in_time = None + check_out_time = None + + # 查找最早的有效签到时间 + for record in records: + if record['period'].endswith('_in') and record['time'] and record['status'] in ['normal', 'late']: + if not check_in_time or record['time'] < check_in_time: + check_in_time = record['time'] + + # 查找最晚的有效签退时间 + for record in records: + if record['period'].endswith('_out') and record['time'] and record['status'] in ['normal', 'early_leave']: + if not check_out_time or record['time'] > check_out_time: + check_out_time = record['time'] + + print(f" 计算签到签退时间: 签到={check_in_time}, 签退={check_out_time}") + return check_in_time, check_out_time + + def _parse_weekend_attendance(self, attendance_str: str) -> Dict: + """解析周末考勤""" + if '休息(-,-)' in attendance_str: + return { + 'status': 'weekend_rest', + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + + # 解析周末加班 + 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}], + 'check_in_time': start_time, + 'check_out_time': end_time + } + + return { + 'status': 'weekend_rest', + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + + 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 + } + + print(f"\n计算周统计数据,周期: {week_start} 到 {week_end}") + 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 + + print(f"处理日期 {date_str}, 是否工作日: {is_weekday}") + + if date_str in daily_data: + day_data = daily_data[date_str] + print(f" 找到数据: {day_data}") + + if day_data['status'] == 'workday': + # 🔥 新增:检查是否实际有有效打卡 + valid_records = [record for record in day_data['records'] + if + record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time')] + + if not valid_records and is_weekday: + # 虽然标记为工作日,但没有有效打卡记录,算作缺勤 + stats['absent_days'] += 1 + print(f" 工作日无有效打卡记录,记为缺勤") + else: + # 有有效打卡记录,计算工时 + day_stats = self._calculate_daily_hours(day_data['records'], is_weekday) + print(f" 计算得到工时: {day_stats}") + 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']) + print(f" 周末加班时长: {overtime}") + stats['actual_work_hours'] += overtime + stats['overtime_hours'] += overtime + elif day_data['status'] == 'absent' and is_weekday: + stats['absent_days'] += 1 + print(f" 缺勤") + elif is_weekday: + stats['absent_days'] += 1 + print(f" 工作日无数据,记为缺勤") + + current_date += timedelta(days=1) + + print(f"最终统计结果: {stats}") + return stats + + def _calculate_daily_hours(self, records: List[Dict], is_weekday: bool) -> Dict: + """计算每日工作时长""" + total_hours = 0.0 + + print(f" 计算每日工时,记录: {records}") + + # 处理各时段 + 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() + print(f" 早上上班时间: {morning_in}") + elif record['period'] == 'morning_out' and record['status'] in ['normal', 'early_leave'] and record['time']: + morning_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 早上下班时间: {morning_out}") + elif record['period'] == 'afternoon_in' and record['status'] in ['normal', 'late'] and record['time']: + afternoon_in = datetime.strptime(record['time'], '%H:%M').time() + print(f" 下午上班时间: {afternoon_in}") + elif record['period'] == 'afternoon_out' and record['status'] in ['normal', 'early_leave'] and record[ + 'time']: + afternoon_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 下午下班时间: {afternoon_out}") + elif record['period'] == 'evening_in' and record['status'] in ['normal', 'late'] and record['time']: + evening_in = datetime.strptime(record['time'], '%H:%M').time() + print(f" 晚上上班时间: {evening_in}") + elif record['period'] == 'evening_out' and record['status'] in ['normal', 'early_leave'] and record['time']: + evening_out = datetime.strptime(record['time'], '%H:%M').time() + print(f" 晚上下班时间: {evening_out}") + + # 计算各时段工时 + if morning_in and morning_out: + morning_hours = self._calculate_time_diff(morning_in, morning_out) + total_hours += morning_hours + print(f" 早上工时: {morning_hours}") + + if afternoon_in and afternoon_out: + afternoon_hours = self._calculate_time_diff(afternoon_in, afternoon_out) + total_hours += afternoon_hours + print(f" 下午工时: {afternoon_hours}") + + if evening_in and evening_out: + evening_hours = self._calculate_time_diff(evening_in, evening_out) + total_hours += evening_hours + print(f" 晚上工时: {evening_hours}") + + print(f" 总工时: {total_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 + result = round(diff_minutes / 60.0, 1) + print(f" 时间差计算: {start_time} 到 {end_time} = {diff_minutes}分钟 = {result}小时") + return result + + def import_to_database(self, data: Dict, week_start: str, week_end: str): + """导入数据到数据库""" + success_count = 0 + error_count = 0 + error_messages = [] + + print(f"\n开始导入数据到数据库,共{len(data)}个学生") + + try: + for name, daily_data in data.items(): + try: + print(f"\n处理学生: {name}") + + # 获取学生信息 + student = Student.query.filter_by(name=name).first() + + if not student: + error_messages.append(f"未找到学生: {name}") + error_count += 1 + print(f" 未找到学生记录") + continue + + print(f" 找到学生: {student.student_number}") + + # 计算周统计 + weekly_stats = self.calculate_weekly_statistics(daily_data, week_start, week_end) + + # 检查是否已存在记录 + existing_record = WeeklyAttendance.query.filter_by( + student_number=student.student_number, + week_start_date=datetime.strptime(week_start, '%Y-%m-%d').date(), + week_end_date=datetime.strptime(week_end, '%Y-%m-%d').date() + ).first() + + if existing_record: + print(f" 更新现有记录") + # 更新现有记录 + existing_record.actual_work_hours = weekly_stats['actual_work_hours'] + existing_record.class_work_hours = weekly_stats['class_work_hours'] + existing_record.absent_days = weekly_stats['absent_days'] + existing_record.overtime_hours = weekly_stats['overtime_hours'] + existing_record.updated_at = datetime.now() + weekly_record = existing_record + else: + print(f" 创建新记录") + # 创建新记录 + weekly_record = WeeklyAttendance( + student_number=student.student_number, + name=name, + week_start_date=datetime.strptime(week_start, '%Y-%m-%d').date(), + week_end_date=datetime.strptime(week_end, '%Y-%m-%d').date(), + actual_work_hours=weekly_stats['actual_work_hours'], + class_work_hours=weekly_stats['class_work_hours'], + absent_days=weekly_stats['absent_days'], + overtime_hours=weekly_stats['overtime_hours'] + ) + db.session.add(weekly_record) + + db.session.flush() # 获取记录ID + + # 删除现有的每日记录 + DailyAttendanceDetail.query.filter_by( + weekly_record_id=weekly_record.record_id + ).delete() + + # 插入每日考勤明细 + self._insert_daily_details(weekly_record.record_id, student.student_number, daily_data, week_start, + week_end) + + success_count += 1 + + except Exception as e: + error_messages.append(f"处理学生 {name} 时出错: {str(e)}") + error_count += 1 + print(f" 处理失败: {e}") + continue + + db.session.commit() + logger.info(f"数据导入完成: 成功 {success_count} 条,失败 {error_count} 条") + + except Exception as e: + db.session.rollback() + logger.error(f"数据导入失败: {e}") + raise + + return success_count, error_count, error_messages + + def _insert_daily_details(self, 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') + + status = '缺勤' + remarks = '无数据' + check_in_time = None + check_out_time = None + detailed_records = None + + if date_str in daily_data: + day_data = daily_data[date_str] + status = self._get_daily_status(day_data) + remarks = self._generate_remarks(day_data) + + # 提取签到签退时间 + if day_data.get('check_in_time'): + try: + check_in_time = datetime.strptime(day_data['check_in_time'], '%H:%M').time() + except: + check_in_time = None + + if day_data.get('check_out_time'): + try: + check_out_time = datetime.strptime(day_data['check_out_time'], '%H:%M').time() + except: + check_out_time = None + + # 生成详细的时段记录(JSON格式存储在remarks中) + detailed_records = self._generate_detailed_records(day_data) + + print(f" 保存每日明细: {date_str}, 状态={status}, 签到={check_in_time}, 签退={check_out_time}") + + # 将详细记录和简要备注合并 + if detailed_records: + import json + final_remarks = json.dumps({ + 'summary': remarks, + 'details': detailed_records + }, ensure_ascii=False) + else: + final_remarks = remarks + + daily_detail = DailyAttendanceDetail( + weekly_record_id=weekly_record_id, + student_number=student_number, + attendance_date=current_date.date(), + status=status, + check_in_time=check_in_time, + check_out_time=check_out_time, + remarks=final_remarks + ) + db.session.add(daily_detail) + + current_date += timedelta(days=1) + + def _generate_detailed_records(self, day_data: Dict) -> Dict: + """生成详细的时段打卡记录""" + if day_data['status'] in ['weekend_rest', 'absent']: + return None + + detailed = { + 'morning': {'in': None, 'out': None, 'status': 'missing'}, + 'afternoon': {'in': None, 'out': None, 'status': 'missing'}, + 'evening': {'in': None, 'out': None, 'status': 'missing'} + } + + if day_data['status'] == 'weekend_work': + # 处理周末加班 + if day_data['records']: + record = day_data['records'][0] + detailed['overtime'] = { + 'in': record.get('start'), + 'out': record.get('end'), + 'status': 'overtime' + } + return detailed + + # 处理工作日打卡 + for record in day_data['records']: + period = record['period'] + time_str = record.get('time') + status = record.get('status', 'missing') + + if period == 'morning_in': + detailed['morning']['in'] = time_str + detailed['morning']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['morning']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'morning_out': + detailed['morning']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['morning']['early_minutes'] = record.get('early_minutes', 0) + elif period == 'afternoon_in': + detailed['afternoon']['in'] = time_str + detailed['afternoon']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['afternoon']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'afternoon_out': + detailed['afternoon']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['afternoon']['early_minutes'] = record.get('early_minutes', 0) + elif period == 'evening_in': + detailed['evening']['in'] = time_str + detailed['evening']['status'] = status + # 只有当状态确实是late时才记录迟到分钟数 + if status == 'late' and 'late_minutes' in record: + detailed['evening']['late_minutes'] = record.get('late_minutes', 0) + elif period == 'evening_out': + detailed['evening']['out'] = time_str + # 只有当状态确实是early_leave时才记录早退分钟数 + if status == 'early_leave' and 'early_minutes' in record: + detailed['evening']['early_minutes'] = record.get('early_minutes', 0) + + return detailed + + def _get_daily_status(self, day_data: Dict) -> str: + """获取每日状态""" + if day_data['status'] == 'absent': + return '缺勤' + elif day_data['status'] == 'leave': + return '请假' + elif day_data['status'] == 'leave_with_punch': # 新增:有打卡的请假 + return '请假' + elif day_data['status'] == 'weekend_rest': + return '休息' + elif day_data['status'] == 'weekend_work': + return '加班' + else: + # 检查是否全天缺卡 + valid_records = [record for record in day_data['records'] + if record.get('status') in ['normal', 'late', 'early_leave'] and record.get('time')] + + if not valid_records: + return '缺勤' + + # 检查是否有迟到 + 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'] in ['leave', 'leave_with_punch']: + reason = day_data.get('leave_reason', '请假') + if day_data['status'] == 'leave_with_punch': + return f'请假({reason}) - 有打卡记录' + else: + return f'请假({reason})' + 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("缺卡") + elif record.get('status') == 'leave': + reason = record.get('leave_reason', '请假') + remarks.append(f"请假({reason})") + + return '; '.join(remarks) if remarks else '正常' + + def add_name_mapping(self, feishu_name: str, real_name: str): + """添加新的飞书用户名映射""" + self.feishu_name_mapping[feishu_name] = real_name + logger.info(f"添加用户名映射: {feishu_name} -> {real_name}") + + def get_name_mappings(self) -> Dict[str, str]: + """获取当前的用户名映射表""" + return self.feishu_name_mapping.copy() + + # ============== 以下是新增的请假处理方法 ============== + + def parse_leave_file(self, file_path: str) -> List[Dict]: + """解析请假单文件""" + try: + # 读取Excel文件 + df = pd.read_excel(file_path) + logger.info(f"成功读取请假单文件: {file_path}") + + print("=" * 50) + print("请假单文件列名:") + for i, col in enumerate(df.columns): + print(f"第{i}列: {col}") + print("=" * 50) + print("前5行数据:") # 增加显示行数 + print(df.head(5)) + print("=" * 50) + + leave_records = [] + + # 查找相关列 + name_col = None + reason_col = None + start_col = None + end_col = None + + for col in df.columns: + col_str = str(col) + if '请假人员' in col_str or '姓名' in col_str: + name_col = col + elif '请假事由' in col_str or '事由' in col_str: + reason_col = col + elif '请假开始时间' in col_str or '开始时间' in col_str: + start_col = col + elif '请假结束时间' in col_str or '结束时间' in col_str: + end_col = col + + print(f"识别到的列:姓名={name_col}, 事由={reason_col}, 开始时间={start_col}, 结束时间={end_col}") + + if not all([name_col, start_col, end_col]): + raise ValueError("请假单文件缺少必要的列:请假人员、请假开始时间、请假结束时间") + + # 处理每行数据 + for index, row in df.iterrows(): + try: + # 🔥 改进姓名处理逻辑 + raw_name = row[name_col] + print(f"\n处理请假记录 {index + 1}:") + print(f" 原始姓名: '{raw_name}' (类型: {type(raw_name)})") + + # 跳过空行或标题行 + if pd.isna(raw_name) or str(raw_name).strip() == '' or str(raw_name).strip() == 'nan': + print(f" 跳过空姓名") + continue + + name = self._normalize_student_name(str(raw_name).strip()) + if not name: + print(f" 姓名标准化后为空,跳过") + continue + + reason = str(row[reason_col]).strip() if reason_col and pd.notna(row[reason_col]) else "请假" + start_time_raw = row[start_col] + end_time_raw = row[end_col] + + print(f" 标准化姓名: '{name}'") + print(f" 事由: '{reason}'") + print(f" 开始时间原始值: {start_time_raw} (类型: {type(start_time_raw)})") + print(f" 结束时间原始值: {end_time_raw} (类型: {type(end_time_raw)})") + + # 转换时间格式 + start_date = self._convert_excel_date(start_time_raw) + end_date = self._convert_excel_date(end_time_raw) + + if start_date and end_date: + leave_record = { + 'name': name, + 'reason': reason, + 'start_date': start_date, + 'end_date': end_date, + 'raw_start': start_time_raw, + 'raw_end': end_time_raw + } + leave_records.append(leave_record) + print(f" ✅ 成功添加请假记录: {start_date} 到 {end_date}") + else: + print(f" ❌ 时间转换失败,跳过此记录") + + except Exception as e: + print(f" ❌ 处理第 {index + 1} 行时出错: {e}") + continue + + print(f"\n📊 成功解析请假记录 {len(leave_records)} 条") + for i, record in enumerate(leave_records, 1): + print(f" {i}. {record['name']}: {record['start_date']} 到 {record['end_date']} ({record['reason']})") + + return leave_records + + except Exception as e: + logger.error(f"解析请假单文件失败: {e}") + raise + + def _convert_excel_date(self, date_value) -> Optional[str]: + """转换Excel中的日期值为标准日期格式""" + if pd.isna(date_value): + return None + + try: + print(f" 转换日期: {date_value} (类型: {type(date_value)})") + + # 如果是数字(Excel日期序列号) + if isinstance(date_value, (int, float)): + # Excel日期起始点是1900-01-01,但需要处理Excel的闰年错误 + if date_value > 59: # 1900-03-01之后 + date_value -= 1 + # 转换为日期 + excel_date = datetime(1900, 1, 1) + timedelta(days=date_value - 1) + result = excel_date.strftime('%Y-%m-%d') + print(f" 数字转换结果: {result}") + return result + + # 如果是字符串 + elif isinstance(date_value, str): + date_value = date_value.strip() + + # 尝试解析各种日期格式 + date_formats = [ + '%Y-%m-%d', + '%Y/%m/%d', + '%m/%d/%Y', + '%d/%m/%Y', + '%Y-%m-%d %H:%M:%S', + '%Y/%m/%d %H:%M:%S' + ] + + for fmt in date_formats: + try: + parsed_date = datetime.strptime(date_value, fmt) + result = parsed_date.strftime('%Y-%m-%d') + print(f" 字符串转换结果: {result}") + return result + except ValueError: + continue + + # 如果都不匹配,尝试pandas的日期解析 + try: + parsed_date = pd.to_datetime(date_value) + result = parsed_date.strftime('%Y-%m-%d') + print(f" pandas转换结果: {result}") + return result + except: + pass + + # 如果是datetime对象 + elif isinstance(date_value, datetime): + result = date_value.strftime('%Y-%m-%d') + print(f" datetime转换结果: {result}") + return result + + # 如果是pandas的Timestamp + elif hasattr(date_value, 'strftime'): + result = date_value.strftime('%Y-%m-%d') + print(f" timestamp转换结果: {result}") + return result + + except Exception as e: + print(f" ❌ 日期转换失败: {date_value} -> {e}") + + return None + + def apply_leave_records(self, attendance_data: Dict, leave_records: List[Dict], + week_start: str, week_end: str) -> Dict: + """将请假记录应用到考勤数据中""" + print(f"\n🔄 开始应用请假记录到考勤数据") + print(f"考勤数据覆盖周期: {week_start} 到 {week_end}") + print(f"请假记录数量: {len(leave_records)}") + + week_start_date = datetime.strptime(week_start, '%Y-%m-%d').date() + week_end_date = datetime.strptime(week_end, '%Y-%m-%d').date() + + # 遍历每个学生的考勤数据 + for student_name, daily_data in attendance_data.items(): + print(f"\n👤 处理学生: {student_name}") + + # 查找该学生的请假记录 + student_leaves = [leave for leave in leave_records if leave['name'] == student_name] + + if not student_leaves: + print(f" ℹ️ 无请假记录") + continue + + print(f" 📋 找到请假记录 {len(student_leaves)} 条") + + # 处理每条请假记录 + for leave in student_leaves: + leave_start = datetime.strptime(leave['start_date'], '%Y-%m-%d').date() + leave_end = datetime.strptime(leave['end_date'], '%Y-%m-%d').date() + + print(f" 📝 处理请假: {leave['start_date']} 到 {leave['end_date']} ({leave['reason']})") + + # 遍历请假期间的每一天 + current_date = leave_start + while current_date <= leave_end: + date_str = current_date.strftime('%Y-%m-%d') + + # 只处理在考勤周期内的日期 + if week_start_date <= current_date <= week_end_date: + print(f" 📅 处理日期: {date_str}") + + if date_str in daily_data: + day_data = daily_data[date_str] + original_status = day_data.get('status') + print(f" 原状态: {original_status}") + + # 🔥 修改:优先设置为请假状态,即使有打卡记录 + if day_data.get('status') in ['absent', 'workday']: + # 检查是否有有效的打卡记录 + has_valid_punch = any( + record.get('status') in ['normal', 'late', 'early_leave'] + and record.get('time') + for record in day_data.get('records', []) + ) + + if has_valid_punch: + # 有打卡记录的情况下,仍然设置为请假,但保留打卡信息 + day_data['status'] = 'leave_with_punch' # 新状态:请假但有打卡 + day_data['leave_reason'] = leave['reason'] + print(f" 🎯 转换为请假(有打卡): {leave['reason']}") + else: + # 无打卡记录,设置为纯请假 + day_data['status'] = 'leave' + day_data['leave_reason'] = leave['reason'] + print(f" 🎯 转换为请假: {leave['reason']}") + else: + print(f" ℹ️ 非工作日或其他状态,不处理") + else: + # 如果该日期没有考勤记录,创建请假记录 + daily_data[date_str] = { + 'status': 'leave', + 'leave_reason': leave['reason'], + 'records': [], + 'check_in_time': None, + 'check_out_time': None + } + print(f" ➕ 创建请假记录") + else: + print(f" ⏭️ 日期 {date_str} 不在考勤周期内,跳过") + + current_date += timedelta(days=1) + + print(f"\n✅ 请假记录应用完成") + return attendance_data + + def import_leave_records_to_database(self, leave_records: List[Dict]) -> int: + """将请假记录导入到数据库""" + from app.models import LeaveRecord + + success_count = 0 + + try: + for leave in leave_records: + try: + # 查找学生 + student = Student.query.filter_by(name=leave['name']).first() + if not student: + print(f"未找到学生: {leave['name']}") + continue + + # 检查是否已存在相同的请假记录 + existing_leave = LeaveRecord.query.filter_by( + student_number=student.student_number, + leave_start_date=datetime.strptime(leave['start_date'], '%Y-%m-%d').date(), + leave_end_date=datetime.strptime(leave['end_date'], '%Y-%m-%d').date() + ).first() + + if existing_leave: + # 更新现有记录 + existing_leave.leave_reason = leave['reason'] + existing_leave.status = '已批准' # 假设上传的请假单都是已批准的 + print(f"更新请假记录: {leave['name']}") + else: + # 创建新记录 + leave_record = LeaveRecord( + student_number=student.student_number, + leave_start_date=datetime.strptime(leave['start_date'], '%Y-%m-%d').date(), + leave_end_date=datetime.strptime(leave['end_date'], '%Y-%m-%d').date(), + leave_reason=leave['reason'], + status='已批准' # 假设上传的请假单都是已批准的 + ) + db.session.add(leave_record) + print(f"创建请假记录: {leave['name']}") + + success_count += 1 + + except Exception as e: + print(f"处理请假记录失败 {leave['name']}: {e}") + continue + + db.session.commit() + print(f"请假记录导入完成: {success_count} 条") + + except Exception as e: + db.session.rollback() + logger.error(f"请假记录导入失败: {e}") + raise + + return success_count + + + +================================================================================ +File: ./app/utils/auth_helpers.py +================================================================================ + +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 +================================================================================ +File: ./app/utils/data_import.py +================================================================================ + +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 '正常' + +================================================================================ +File: ./app/models/user.py +================================================================================ + +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'' + +================================================================================ +File: ./app/models/attendance.py +================================================================================ + +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'' + + +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'' + + +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'' + +================================================================================ +File: ./app/models/__init__.py +================================================================================ + +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'] + +================================================================================ +File: ./app/models/student.py +================================================================================ + +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'' + +================================================================================ +File: ./app/static/css/admin.css +================================================================================ + + +================================================================================ +File: ./app/static/css/style.css +================================================================================ + +/* 全局样式 */ +: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; + } +} + +================================================================================ +File: ./app/static/js/main.js +================================================================================ + +// 基础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秒后自动关闭 + }); +}); + +================================================================================ +File: ./app/static/js/admin.js +================================================================================ + + +================================================================================ +File: ./app/templates/auth/student_profile.html +================================================================================ + +{% extends "layout/base.html" %} + +{% block title %}个人信息 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ +
+

个人信息

+ +
+ + +
+
+ {% if user_info %} + +
+
+
基本信息
+
+
+
+ +
+
+ +
{{ user_info.student_number }}
+
+
+ +
+ {% if user_info.name %} + {{ user_info.name }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.gender %} + + + {{ user_info.gender }} + + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.grade %} + {{ user_info.grade }}级 + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.phone %} + {{ user_info.phone }} + {% else %} + 未设置 + {% endif %} +
+
+
+ + +
+
+ +
+ {% if user_info.supervisor %} + {{ user_info.supervisor }} + {% else %} + 未分配 + {% endif %} +
+
+
+ +
+ {% if user_info.college %} + {{ user_info.college }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.major %} + {{ user_info.major }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.degree_type %} + {{ user_info.degree_type }} + {% else %} + 未设置 + {% endif %} +
+
+
+ +
+ {% if user_info.enrollment_date %} + {{ user_info.enrollment_date.strftime('%Y年%m月%d日') }} + {% else %} + 未设置 + {% endif %} +
+
+
+
+
+
+ + +
+
+
账户信息
+
+
+
+
+
+ +
{{ user_info.user_id }}
+
+
+ +
+ + 学生 + +
+
+
+
+
+ +
+ {% if user_info.is_active %} + + 活跃 + + {% else %} + + 已禁用 + + {% endif %} +
+
+ {% if user_info.status %} +
+ +
+ + {{ user_info.status }} + +
+
+ {% endif %} +
+
+ +
+
+
+ +
+ {% if user_info.last_login %} + {{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + 从未登录 + {% endif %} +
+
+
+
+
+ +
{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+
+
+ + + + + {% else %} +
+ + 无法获取用户信息,请联系管理员 +
+ {% endif %} +
+
+
+
+
+ + +{% endblock %} + +================================================================================ +File: ./app/templates/auth/login.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}登录 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ +
+
+ +
+

CHM考勤系统

+

请使用学号和密码登录

+
+ + +
+
+ + +
+ 请输入学号 +
+
+ +
+ +
+ + +
+
+ 请输入密码 +
+
+ +
+ + +
+ +
+ +
+
+ + +
+ + + 如有登录问题,请联系管理员 + +
+
+
+ + +
+
+
+ + 使用说明 +
+
+
+ +
    +
  • 查看个人考勤记录
  • +
  • 申请请假审批
  • +
+
+
+
+ +
    +
  • 个人统计分析
  • +
  • 修改个人密码
  • +
+
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/auth/admin_profile.html +================================================================================ + +{% extends "layout/base.html" %} + +{% block title %}个人信息 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ +
+

个人信息

+ +
+ + +
+
+
+
+
管理员信息
+
+
+ {% if user_info %} +
+ +
+
+ +
{{ user_info.user_id }}
+
+
+ +
{{ user_info.student_number }}
+
+
+ +
+ + + {% if user_info.role == 'admin' %}管理员{% else %}普通用户{% endif %} + +
+
+
+ + +
+
+ +
+ {% if user_info.is_active %} + + 活跃 + + {% else %} + + 已禁用 + + {% endif %} +
+
+
+ +
+ {% if user_info.last_login %} + {{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + 从未登录 + {% endif %} +
+
+
+ +
{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+ + + + {% else %} +
+ + 无法获取用户信息 +
+ {% endif %} +
+
+
+
+
+
+
+ + +{% endblock %} + +================================================================================ +File: ./app/templates/auth/change_password.html +================================================================================ + +{% extends "layout/base.html" %} + +{% block title %}修改密码 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+ + + + +
+
+
+
+
+ 安全设置 +
+
+
+ +
+ + 密码要求: +
    +
  • 长度至少6位
  • +
  • 必须包含字母和数字
  • +
  • 建议使用字母、数字和特殊字符的组合
  • +
+
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+ + + 取消 + +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/layout/base.html +================================================================================ + + + + + + + {% block title %}CHM考勤管理系统{% endblock %} + + + + + + + + + {% block extra_css %}{% endblock %} + + + + {% if current_user.is_authenticated %} + {% include 'layout/nav.html' %} + {% endif %} + + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + + {% block content %}{% endblock %} +
+ + + {% if current_user.is_authenticated %} +
+
+ © 2025 CHM考勤管理系统. All rights reserved. +
+
+ {% endif %} + + + + + + + + + {% block extra_js %}{% endblock %} + + + + +================================================================================ +File: ./app/templates/layout/nav.html +================================================================================ + + + +================================================================================ +File: ./app/templates/student/apply_leave.html +================================================================================ + + +================================================================================ +File: ./app/templates/student/leave_records.html +================================================================================ + + +================================================================================ +File: ./app/templates/student/attendance.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}我的考勤 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 我的考勤记录 +

+ +
+
+ + + {% if total_stats %} +
+
+
+
+
+
+
+ 总考勤周数 +
+
+ {{ total_stats.total_weeks }}周 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 总出勤时长 +
+
+ {{ "%.1f"|format(total_stats.total_actual_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作 +
+
+ {{ "%.1f"|format(total_stats.total_class_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 迟到次数 +
+
+ {{ total_stats.total_late_count }}次 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ total_stats.total_absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 周均时长 +
+
+ {{ "%.1f"|format(total_stats.avg_weekly_hours) }}h +
+
+
+ +
+
+
+
+
+
+ {% endif %} + + +
+
+
+ 筛选条件 +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + 重置 + + {% if attendance_records %} + + {% endif %} +
+
+
+
+
+ + +
+
+
+ 考勤记录列表 +
+ {% if attendance_records %} + + 共 {{ pagination.total }} 条记录 + + {% endif %} +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长迟到次数旷工天数加班时长记录时间操作
+
+ {{ record.week_start_date.strftime('%Y-%m-%d') }} + + 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} + +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}h + + {{ "%.1f"|format(record.class_work_hours) }}h + + {% if record.late_count > 0 %} + {{ record.late_count }}次 + {% else %} + 0次 + {% endif %} + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% if record.overtime_hours > 0 %} + {{ "%.1f"|format(record.overtime_hours) }}h + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + + + + 查看详情 + +
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} +
+ +
暂无考勤记录
+

当前筛选条件下没有找到考勤记录

+ + 返回首页 + +
+ {% endif %} +
+
+ + + {% if attendance_records %} +
+
+
+
+
+ 本期统计分析 +
+
+
+
+
+
+
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
+ 周均出勤 +
+
+
+
+
{{ "%.1f"|format((total_stats.total_class_hours / total_stats.total_actual_hours * 100) if total_stats.total_actual_hours > 0 else 0) }}%
+ 班内工作率 +
+
+
+
{{ "%.1f"|format((total_stats.total_overtime_hours / total_stats.total_weeks) if total_stats.total_weeks > 0 else 0) }}h
+ 周均加班 +
+
+
+
+
+ +
+
+
+
+ 快捷操作 +
+
+ +
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/student/dashboard.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}学生主页 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ + 欢迎回来,{{ student.name }}! +

+

+ 学号:{{ student.student_number }} | + 学院:{{ student.college }} | + 导师:{{ student.supervisor }} +

+
+
+ + +
+
+
+
+
+
+

{{ total_records }}

+

考勤记录

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

{{ "%.1f"|format(total_work_hours) }}

+

总工作时长(小时)

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

{{ total_absent_days }}

+

旷工天数

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

{{ pending_leaves|length }}

+

待审批请假

+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 最近考勤记录 +
+ + 查看全部 + +
+
+ {% if recent_attendance %} +
+ + + + + + + + + + + + {% for record in recent_attendance %} + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长旷工天数加班时长
+ {{ record.week_start_date.strftime('%m-%d') }} + 至 + {{ record.week_end_date.strftime('%m-%d') }} + + {{ record.actual_work_hours }}h + + {{ record.class_work_hours }}h + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {{ record.overtime_hours }}h +
+
+ {% else %} +
+ +

暂无考勤记录

+
+ {% endif %} +
+
+
+ + +
+ + {% if pending_leaves %} +
+
+
+ 待审批请假 +
+
+
+ {% for leave in pending_leaves %} +
+
+
+ {{ leave.leave_start_date.strftime('%Y-%m-%d') }} + 至 + {{ leave.leave_end_date.strftime('%Y-%m-%d') }} +
+ 待审批 +
+ {{ leave.leave_reason[:30] }}... +
+ {% endfor %} + + 查看所有请假记录 + +
+
+ {% endif %} + + + + + +
+
+
+ 个人信息 +
+
+
+
+
姓名:
+
{{ student.name }}
+ +
性别:
+
{{ student.gender }}
+ +
年级:
+
{{ student.grade }}级
+ +
专业:
+
{{ student.major }}
+ +
学位:
+
{{ student.degree_type }}
+ + {% if student.phone %} +
电话:
+
{{ student.phone }}
+ {% endif %} +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/student/statistics.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}个人统计 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 个人统计分析 +

+ +
+
+ + +
+
+
+
+
+ 基本信息 +
+
+
+
+
+ 学号: {{ student.student_number }} +
+
+ 姓名: {{ student.name }} +
+
+ 年级: {{ student.grade }}级 +
+
+ 入学日期: {{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '未设置' }} +
+
+
+
+
+
+ + +
+
+
+ 筛选条件 +
+
+
+
+
+ + +
+
+ + +
+
+
+ + + 重置 + +
+
+
+
+
+ + + {% if all_time_stats %} +
+
+
+
+
+ 入学以来总体表现 +
+
+
+
+
+
+

{{ all_time_stats.attendance_weeks }}

+ 总考勤周数 +
+
+
+
+

{{ "%.1f"|format(all_time_stats.total_work_hours) }}

+ 总工作时长(h) +
+
+
+
+

{{ "%.1f"|format(all_time_stats.total_class_hours) }}

+ 班内工作(h) +
+
+
+
+

{{ all_time_stats.total_late_count }}

+ 迟到次数 +
+
+
+
+

{{ all_time_stats.total_absent_days }}

+ 旷工天数 +
+
+
+
+

{{ "%.1f"|format(all_time_stats.attendance_rate) }}%

+ 出勤率 +
+
+
+
+
+
+
+ {% endif %} + + +
+
+
+
+
+
+
+ 筛选期间考勤周数 +
+
+ {{ total_stats.attendance_weeks }}周 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 总出勤时长 +
+
+ {{ "%.1f"|format(total_stats.total_work_hours) }}h +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 迟到次数 +
+
+ {{ total_stats.total_late_count }}次 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 周均工作时长 +
+
+ {{ "%.1f"|format(total_stats.avg_weekly_hours) }}h +
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 月度考勤统计 +
+
+
+ +
+
+
+ + +
+
+
+
+ 最近12周趋势 +
+
+
+ +
+
+
+
+ + +
+
+
+ 详细考勤记录 +
+
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + {% endfor %} + +
周次实际工作时长班内工作时长加班时长旷工天数考勤状态操作
+
+ {{ record.week_start_date.strftime('%Y-%m-%d') }} + + 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} + +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}h + + {{ "%.1f"|format(record.class_work_hours) }}h + + {% if record.overtime_hours > 0 %} + {{ "%.1f"|format(record.overtime_hours) }}h + {% else %} + 0h + {% endif %} + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% set performance_score = (record.actual_work_hours / 40 * 100) if record.actual_work_hours else 0 %} + {% if performance_score >= 80 %} + 优秀 + {% elif performance_score >= 60 %} + 良好 + {% else %} + 待改善 + {% endif %} + + + 详情 + +
+
+ {% else %} +
+ +
暂无统计数据
+

当前筛选条件下没有找到考勤记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 快捷操作 +
+
+
+
+ + +
+ +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/student/attendance_details.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}考勤详情 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+
+

+ 我的考勤详情 +

+ +
+ +
+ + +
+
+
+
+
+ 学生信息 +
+
+
+
+
+

学号: {{ record.student_number }}

+

姓名: {{ record.name }}

+ {% if student %} +

年级: {{ student.grade }}

+

学院: {{ student.college or '未设置' }}

+ {% endif %} +
+
+ {% if student %} +

专业: {{ student.major or '未设置' }}

+

导师: {{ student.supervisor or '未设置' }}

+

学位类型: {{ student.degree_type or '未设置' }}

+

状态: + + {{ student.status }} + +

+ {% endif %} +
+
+
+
+
+ +
+
+
+
+ 考勤周期 +
+
+
+
+
+

开始日期: {{ record.week_start_date.strftime('%Y年%m月%d日') }}

+

结束日期: {{ record.week_end_date.strftime('%Y年%m月%d日') }}

+
+
+

创建时间: {{ record.created_at.strftime('%Y-%m-%d %H:%M') }}

+

更新时间: {{ record.updated_at.strftime('%Y-%m-%d %H:%M') }}

+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ 实际出勤时长 +
+
+ {{ "%.1f"|format(record.actual_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作时长 +
+
+ {{ "%.1f"|format(record.class_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ record.absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 加班时长 +
+
+ {{ "%.1f"|format(record.overtime_hours) }}小时 +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ 每日考勤明细 + (点击详情按钮查看详细时段信息) +
+
+
+ {% if daily_details %} +
+ + + + + + + + + + + + + + + {% for detail in daily_details %} + + + + + + + + + + + {% endfor %} + +
日期星期考勤状态签到时间签退时间工作时长备注操作
+ {{ detail.attendance_date.strftime('%m-%d') }} + {{ detail.attendance_date.strftime('%Y') }} + + {% 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 %} + 休息日 + {% endif %} + + {% if detail.status == '正常' %} + {{ detail.status }} + {% elif '迟到' in detail.status %} + {{ detail.status }} + {% elif detail.status == '缺勤' %} + {{ detail.status }} + {% elif detail.status == '请假' %} + {{ detail.status }} + {% elif detail.status == '休息' %} + {{ detail.status }} + {% elif detail.status == '加班' %} + {{ detail.status }} + {% else %} + {{ detail.status }} + {% endif %} + + {% if detail.check_in_time %} + {{ detail.check_in_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.check_out_time %} + {{ detail.check_out_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.duration_hours %} + {{ detail.duration_hours }}h + {% else %} + - + {% endif %} + + {% if detail.summary_remarks %} + {{ detail.summary_remarks }} + {% else %} + - + {% endif %} + + {% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %} + + {% endif %} +
+
+ {% else %} +
+ +
暂无每日考勤明细
+

该考勤周期内没有详细的打卡记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 本周考勤统计 +
+
+
+
+
+
+
{{ present_days }}
+ 正常天数 +
+
+
+
+
{{ late_days }}
+ 迟到天数 +
+
+
+
+
{{ absent_days }}
+ 缺勤天数 +
+
+
+
{{ "%.1f"|format(avg_daily_hours) }}h
+ 日均时长 +
+
+ + +
+
+
+
{{ "%.1f"|format((present_days / max(total_days, 1) * 100)) }}%
+ 出勤率 +
+
+
{{ "%.1f"|format((record.class_work_hours / max(record.actual_work_hours, 1) * 100)) }}%
+ 班内工作率 +
+
+
{{ total_days }}
+ 考勤天数 +
+
+
+
+
+ +
+
+
+
+ 最近记录对比 +
+
+
+ {% if recent_records %} +
+ + + + + + + + + + + {% for record_item in recent_records %} + + + + + + + {% endfor %} + +
周期出勤时长旷工天数对比
+ {{ record_item.week_start_date.strftime('%m-%d') }} + + {{ "%.1f"|format(record_item.actual_work_hours) }}h + + {% if record_item.absent_days > 0 %} + {{ record_item.absent_days }} + {% else %} + 0 + {% endif %} + + {% set diff = record.actual_work_hours - record_item.actual_work_hours %} + {% if diff > 0 %} + +{{ "%.1f"|format(diff) }}h + {% elif diff < 0 %} + {{ "%.1f"|format(diff) }}h + {% else %} + - + {% endif %} +
+
+ {% else %} +

暂无历史记录

+ {% endif %} +
+
+
+
+ + + {% if late_days > 0 or absent_days > 0 %} + + {% endif %} +
+ + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/attendance_management.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}考勤管理 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 考勤管理 +

+ +
+ + +
+
+
+ 搜索筛选 +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+ + +
+
+
+ + +
+
+
+ 考勤记录 +
+ {% if attendance_records %} + + 共 {{ pagination.total }} 条记录 + + {% endif %} +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + + + + + {% endfor %} + +
学号姓名考勤周期 + 出勤时长 + + + 班内工作 + + + 旷工天数 + + + 迟到次数 + + + 加班时长 + + + 记录时间 + + 操作
+ + {{ record.student_number }} + + {{ record.name }} + + {{ record.week_start_date.strftime('%Y-%m-%d') }}
+ 至 {{ record.week_end_date.strftime('%Y-%m-%d') }} +
+
+ + {{ "%.1f"|format(record.actual_work_hours) }}h + + + + {{ "%.1f"|format(record.class_work_hours) }}h + + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + 0天 + {% endif %} + + {% set late_count = record.late_count if record.late_count is defined else 0 %} + {% if late_count > 0 %} + {{ late_count }}次 + {% else %} + 0次 + {% endif %} + + {% if record.overtime_hours > 0 %} + + {{ "%.1f"|format(record.overtime_hours) }}h + + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + + +
+ + +
+
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} + +
+ +
暂无考勤记录
+

还没有上传任何考勤数据

+ + 立即上传考勤数据 + +
+ {% endif %} +
+
+ + + {% if attendance_records %} +
+
+
+
+
+ 当前筛选统计 +
+
+
+
+
+
+
{{ pagination.total }}
+ 总记录 +
+
+
+
+
+ {{ attendance_records|sum(attribute='actual_work_hours')|round(1) }}h +
+ 总出勤 +
+
+
+
+
+ {{ attendance_records|sum(attribute='absent_days') }} +
+ 旷工 +
+
+
+
+
+ {% set total_leave = statistics.total_leave_days if statistics and statistics.total_leave_days else 0 %} + {{ total_leave }} +
+ 请假 +
+
+
+
+
+ {% set total_late = attendance_records|sum(attribute='late_count') if attendance_records[0].late_count is defined else 0 %} + {{ total_late }} +
+ 迟到次数 +
+
+
+
+
+ {{ attendance_records|sum(attribute='overtime_hours')|round(1) }}h +
+ 总加班 +
+
+
+
+ {{ "%.1f"|format((attendance_records|sum(attribute='actual_work_hours')) / (attendance_records|length) if attendance_records|length > 0 else 0) }}h +
+ 平均出勤 +
+
+
+
+
+
+
+
+
+ 快捷操作 +
+
+
+
+ + + +
+ +
+
+
+
+
+
+ {% endif %} +
+ + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/edit_student.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}编辑学生 - {{ student.name }} - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 编辑学生信息 +

+ +
+ + +
+
+
+
+
+ 学生基本信息 +
+ 学号: {{ student.student_number }} +
+
+
+
+
+
+ + +
学号不可修改
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+ + {% if student.user %} + + {% endif %} +
+
+ + 取消 + + +
+
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/student_detail.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}学生详情 - {{ student.name }} - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+
+ 基本信息 +
+
+ + 编辑 + + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
学号:{{ student.student_number }}
姓名:{{ student.name }}
性别: + {% if student.gender == '男' %} + {{ student.gender }} + {% else %} + {{ student.gender }} + {% endif %} +
年级:{{ student.grade }}级
手机号: + {% if student.phone %} + {{ student.phone }} + {% else %} + 未填写 + {% endif %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
学院: + {% if student.college %} + {{ student.college }} + {% else %} + 未填写 + {% endif %} +
专业: + {% if student.major %} + {{ student.major }} + {% else %} + 未填写 + {% endif %} +
导师: + {% if student.supervisor %} + {{ student.supervisor }} + {% else %} + 未分配 + {% endif %} +
学位类型: + {% if student.degree_type %} + {{ student.degree_type }} + {% else %} + 未填写 + {% endif %} +
状态: + {% if student.status == '在读' %} + + 在读 + + {% else %} + + 毕业 + + {% endif %} +
+
+
+ +
+
+
+ 入学日期: +
+
+ {% if student.enrollment_date %} + + {{ student.enrollment_date.strftime('%Y年%m月%d日') }} + + {% else %} + 未填写 + {% endif %} +
+
+
+ +
+
+
+ 注册时间: +
+
+ + {{ student.created_at.strftime('%Y年%m月%d日 %H:%M') }} + +
+
+
+
+
+
+ +
+ +
+
+
+ 考勤统计 +
+
+
+
+
+
{{ "%.1f"|format(total_work_hours) }}
+
总工作时长(小时)
+
+
+
{{ total_absent_days }}
+
旷工天数
+
+
+
+
+
+
{{ attendance_records|length }}
+
考勤记录数
+
+
+
+
+ + +
+
+
+ 账户信息 +
+
+
+ {% if student.user %} +
+ 账户状态: + {% if student.user.is_active %} + + 正常 + + {% else %} + + 已禁用 + + {% endif %} +
+
+ 最后登录: +
+ {% if student.user.last_login %} + + {{ student.user.last_login.strftime('%Y-%m-%d %H:%M') }} + + {% else %} + 从未登录 + {% endif %} +
+
+ + +
+ {% else %} +
+ +

该学生暂无账户信息

+
+ {% endif %} +
+
+ + +
+
+
+ 快速操作 +
+
+ +
+
+
+ + +
+
+
+
+
+ 最近考勤记录 +
+ + 查看全部 + +
+
+ {% if attendance_records %} +
+ + + + + + + + + + + + + {% for record in attendance_records %} + + + + + + + + + {% endfor %} + +
周期实际工作时长班内工作时长旷工天数加班时长创建时间
+
+ {{ record.week_start_date.strftime('%m-%d') }} + 至 + {{ record.week_end_date.strftime('%m-%d') }} +
+ + {{ record.week_start_date.strftime('%Y年') }} + +
+ + {{ "%.1f"|format(record.actual_work_hours or 0) }}h + + + + {{ "%.1f"|format(record.class_work_hours or 0) }}h + + + {% if record.absent_days > 0 %} + {{ record.absent_days }}天 + {% else %} + + 0天 + + {% endif %} + + {% if record.overtime_hours > 0 %} + + {{ "%.1f"|format(record.overtime_hours) }}h + + {% else %} + 0h + {% endif %} + + + {{ record.created_at.strftime('%m-%d %H:%M') }} + +
+
+ {% else %} +
+ +

暂无考勤记录

+ + 上传考勤数据 + +
+ {% endif %} +
+
+
+
+ + + {% if leave_records %} +
+
+
+
+
+ 最近请假记录 +
+ + 查看全部 + +
+
+
+ + + + + + + + + + + {% for leave in leave_records %} + + + + + + + {% endfor %} + +
请假日期请假原因申请时间状态
+ {{ 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 %} + +
+ {{ leave.leave_reason }} +
+
+ + {{ leave.created_at.strftime('%Y-%m-%d %H:%M') }} + + + {% if leave.status == '待审批' %} + + 待审批 + + {% elif leave.status == '已批准' %} + + 已批准 + + {% else %} + + 已拒绝 + + {% endif %} +
+
+
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/add_student.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}添加学生 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 添加学生 +

+ + 返回学生列表 + +
+ + +
+
+
+
+
+ 学生基本信息 +
+
+
+
+
+
+
+ + +
学号将作为登录账号使用
+
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
学生可在登录后自行修改密码
+
+
+
+ +
+ +
+ + 取消 + + +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/dashboard.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}管理员控制台 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 管理员控制台 +

+
+ + 加载中... +
+
+ + +
+
+
+
+
+
+
+ 学生总数 +
+
+ {{ total_students or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 考勤记录总数 +
+
+ {{ total_attendance_records or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 待审批请假 +
+
+ {{ pending_leaves or 0 }} +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 本周新记录 +
+
+ {{ recent_records or 0 }} +
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+ 学院分布 +
+
+
+ {% if college_stats %} +
+ + + + + + + + + {% for college, count in college_stats %} + + + + + {% endfor %} + +
学院学生数
{{ college or '未知学院' }} + {{ count }} +
+
+ {% else %} +
+ +

暂无数据

+
+ {% endif %} +
+
+
+ + +
+
+
+
+ 导师排行(TOP 10) +
+
+
+ {% if supervisor_stats %} +
+ + + + + + + + + {% for supervisor, count in supervisor_stats %} + + + + + {% endfor %} + +
导师学生数
{{ supervisor or '未知导师' }} + {{ count }} +
+
+ {% else %} +
+ +

暂无数据

+
+ {% endif %} +
+
+
+
+ + + {% if recent_leaves %} +
+
+
+
+
+ 最近请假申请 +
+ + 查看全部 + +
+
+
+ + + + + + + + + + + + {% for leave in recent_leaves %} + + + + + + + + {% endfor %} + +
学号请假日期请假原因申请时间操作
{{ leave.student_number }} + {{ 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 %} + + + {{ leave.leave_reason[:50] }}{% if leave.leave_reason|length > 50 %}...{% endif %} + + {{ leave.created_at.strftime('%m-%d %H:%M') }} +
+ + +
+
+
+
+
+
+
+ {% endif %} + + +
+
+
+
+
+ 快捷操作 +
+
+ +
+
+
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/upload_attendance.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}上传考勤数据 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+
+
+
+
+
+ 上传考勤数据 +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
必须上传考勤记录Excel文件
+
+
+
+
+ + +
如有请假记录,请上传请假单Excel文件
+
+
+
+ +
+
导入说明:
+
    +
  • 考勤记录文件:包含姓名列和每日考勤数据,系统会自动计算工作时长、迟到次数等
  • +
  • 请假单文件:包含请假人员、请假开始时间、请假结束时间等信息
  • +
  • 处理规则: +
      +
    • 请假时间内的缺卡记录会自动转换为请假
    • +
    • 请假时间内的正常打卡记录(正常、迟到、早退)保持不变
    • +
    +
  • +
  • 如果记录已存在,将会更新现有数据
  • +
  • 请确保学生信息已在系统中注册
  • +
+
+ +
+
注意事项:
+
    +
  • 请假单中的时间格式会自动转换(支持数字格式和标准日期格式)
  • +
  • 请假人员姓名必须与学生表中的姓名完全一致
  • +
  • 建议先上传考勤记录,再选择性上传请假单
  • +
+
+ +
+ + 返回 + + +
+
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/statistics.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}统计报表 - CHM考勤管理系统{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

统计报表

+ +
+
+
+ + +
+
+
+
+

{{ overall_stats.total_students }}

+

总学生数

+
+
+
+
+
+
+

{{ "%.1f"|format(overall_stats.total_work_hours) }}

+

总出勤时长(小时)

+
+
+
+
+
+
+

{{ overall_stats.total_absent_days }}

+

总缺勤天数

+
+
+
+
+
+
+

{{ overall_stats.total_late_count }}

+

总迟到次数

+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + 重置 + +
+
+
+ + +
+
+ {% for grade_label, students in grade_groups.items() %} +
+

+ {{ grade_label }} ({{ students|length }}人) +

+ +
+ {% for student in students %} +
+
+
+
+
+ + {{ student.name }} + +
+ {{ student.student_number }} +
+
+ {% if student.total_work_hours >= 200 %} + 优秀 + {% elif student.total_work_hours >= 100 %} + 良好 + {% elif student.total_work_hours >= 50 %} + 一般 + {% else %} + 待改进 + {% endif %} +
+
+ +
+
+
{{ "%.1f"|format(student.total_work_hours) }}
+
总工时
+
+
+
{{ student.total_absent_days }}
+
缺勤天数
+
+
+
{{ student.total_late_count }}
+
迟到次数
+
+
+
{{ student.avg_weekly_hours }}
+
周均工时
+
+
+ +
+ + {{ student.college or '未设置' }} | + {{ student.supervisor or '未设置' }} + +
+
+
+ {% endfor %} +
+
+ {% endfor %} + + {% if not grade_groups %} +
+ +
没有找到符合条件的学生
+
+ {% endif %} +
+
+ + +
+
+
+
月度考勤趋势
+ +
+
+
+
+
学院分布
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + + +================================================================================ +File: ./app/templates/admin/student_list.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}学生管理 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 学生管理 +

+
+ + 添加学生 + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ 学生列表 (共 {{ pagination.total }} 人) +
+
+
+ {% if students %} +
+ + + + + + + + + + + + + + + + + {% for student in students %} + + + + + + + + + + + + + {% endfor %} + +
+ + 学号姓名性别年级学院导师学位类型状态操作
+ + {{ student.student_number }} + {{ student.name }} + {% if student.phone %} +
{{ student.phone }} + {% endif %} +
{{ student.gender }}{{ student.grade }}级{{ student.college or '-' }}{{ student.supervisor or '-' }} + {% if student.degree_type %} + {{ student.degree_type }} + {% else %} + - + {% endif %} + + {% if student.status == '在读' %} + 在读 + {% else %} + 毕业 + {% endif %} + +
+ + + + + + + +
+
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + + {% else %} +
+ +

暂无学生数据

+ + 添加第一个学生 + +
+ {% endif %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/templates/admin/attendance_details.html +================================================================================ + +{% extends 'layout/base.html' %} + +{% block title %}考勤详情 - CHM考勤管理系统{% endblock %} + +{% block content %} +
+ +
+

+ 考勤详情 +

+ +
+ + +
+
+
+
+
+ 学生信息 +
+
+
+
+
+

学号: {{ weekly_record.student_number }}

+

姓名: {{ weekly_record.name }}

+ {% if student %} +

年级: {{ student.grade }}

+

学院: {{ student.college or '未设置' }}

+ {% endif %} +
+
+ {% if student %} +

专业: {{ student.major or '未设置' }}

+

导师: {{ student.supervisor or '未设置' }}

+

学位类型: {{ student.degree_type or '未设置' }}

+

状态: + + {{ student.status }} + +

+ {% endif %} +
+
+
+
+
+ +
+
+
+
+ 考勤周期 +
+
+
+
+
+

开始日期: {{ weekly_record.week_start_date.strftime('%Y年%m月%d日') }}

+

结束日期: {{ weekly_record.week_end_date.strftime('%Y年%m月%d日') }}

+
+
+

创建时间: {{ weekly_record.created_at.strftime('%Y-%m-%d %H:%M') }}

+

更新时间: {{ weekly_record.updated_at.strftime('%Y-%m-%d %H:%M') }}

+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ 实际出勤时长 +
+
+ {{ "%.1f"|format(weekly_record.actual_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 班内工作时长 +
+
+ {{ "%.1f"|format(weekly_record.class_work_hours) }}小时 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 旷工天数 +
+
+ {{ weekly_record.absent_days }}天 +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ 加班时长 +
+
+ {{ "%.1f"|format(weekly_record.overtime_hours) }}小时 +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ 每日考勤明细 + (点击日期查看详细时段信息) +
+
+
+ {% if daily_details %} +
+ + + + + + + + + + + + + + + {% for detail in daily_details %} + + + + + + + + + + + {% endfor %} + +
日期星期考勤状态签到时间签退时间工作时长备注操作
{{ detail.attendance_date.strftime('%m-%d') }} + {% 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 detail.status == '正常' %} + {{ detail.status }} + {% elif '迟到' in detail.status %} + {{ detail.status }} + {% elif detail.status == '缺勤' %} + {{ detail.status }} + {% elif detail.status == '请假' %} + {{ detail.status }} + {% elif detail.status == '休息' %} + {{ detail.status }} + {% elif detail.status == '加班' %} + {{ detail.status }} + {% else %} + {{ detail.status }} + {% endif %} + + {% if detail.check_in_time %} + {{ detail.check_in_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.check_out_time %} + {{ detail.check_out_time.strftime('%H:%M') }} + {% else %} + 未打卡 + {% endif %} + + {% if detail.duration_hours %} + {{ detail.duration_hours }}h + {% else %} + - + {% endif %} + + {% if detail.summary_remarks %} + {{ detail.summary_remarks }} + {% else %} + - + {% endif %} + + {% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %} + + {% endif %} +
+
+ {% else %} +
+ +
暂无每日考勤明细
+

该考勤周期内没有详细的打卡记录

+
+ {% endif %} +
+
+ + +
+
+
+
+
+ 考勤统计分析 +
+
+
+
+
+
+
{{ present_days }}
+ 正常天数 +
+
+
+
+
{{ late_days }}
+ 迟到天数 +
+
+
+
+
{{ absent_days }}
+ 缺勤天数 +
+
+
+
{{ "%.1f"|format(avg_daily_hours) }}h
+ 日均时长 +
+
+
+
+
+ +
+
+
+
+ 历史对比 +
+
+
+ {% if historical_records %} +
+ + + + + + + + + + {% for record in historical_records %} + + + + + + {% endfor %} + +
周期出勤时长旷工天数
+ {{ record.week_start_date.strftime('%m-%d') }} + + {{ "%.1f"|format(record.actual_work_hours) }}h + + {% if record.absent_days > 0 %} + {{ record.absent_days }} + {% else %} + 0 + {% endif %} +
+
+ {% else %} +

暂无历史记录

+ {% endif %} +
+
+
+
+
+ + + + +{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} + +================================================================================ +File: ./app/routes/auth.py +================================================================================ + +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')) + +================================================================================ +File: ./app/routes/__init__.py +================================================================================ + + +================================================================================ +File: ./app/routes/admin.py +================================================================================ + +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +from flask_login import login_required, current_user +from app.models import db, User, Student, WeeklyAttendance, DailyAttendanceDetail, LeaveRecord +from app.utils.auth_helpers import admin_required +from app.utils.database import safe_add_and_commit, safe_commit, safe_delete_and_commit +from datetime import datetime, timedelta +from sqlalchemy import and_, or_, desc, func +import pandas as pd +import io +import re +from werkzeug.security import generate_password_hash +from app.utils.attendance_importer import AttendanceDataImporter +from werkzeug.utils import secure_filename +import os +import tempfile + +admin_bp = Blueprint('admin', __name__) + + +@admin_bp.route('/dashboard') +@admin_required +def dashboard(): + """管理员主页""" + # 统计数据 + total_students = Student.query.count() + total_attendance_records = WeeklyAttendance.query.count() + pending_leaves = LeaveRecord.query.filter_by(status='待审批').count() + + # 最近一周的考勤统计 + week_ago = datetime.now().date() - timedelta(days=7) + recent_records = WeeklyAttendance.query.filter( + WeeklyAttendance.week_start_date >= week_ago + ).count() + + # 按学院统计学生数量 + college_stats = db.session.query( + Student.college, + func.count(Student.student_id).label('count') + ).group_by(Student.college).all() + + # 按导师统计学生数量 + supervisor_stats = db.session.query( + Student.supervisor, + func.count(Student.student_id).label('count') + ).group_by(Student.supervisor).order_by(desc('count')).limit(10).all() + + # 最近的请假申请 + recent_leaves = LeaveRecord.query.filter_by( + status='待审批' + ).order_by(desc(LeaveRecord.created_at)).limit(5).all() + + return render_template('admin/dashboard.html', + total_students=total_students, + total_attendance_records=total_attendance_records, + pending_leaves=pending_leaves, + recent_records=recent_records, + college_stats=college_stats, + supervisor_stats=supervisor_stats, + recent_leaves=recent_leaves) + + +@admin_bp.route('/students') +@admin_required +def student_list(): + """学生列表""" + page = request.args.get('page', 1, type=int) + per_page = 20 + + # 搜索和筛选 + search = request.args.get('search', '').strip() + college = request.args.get('college', '').strip() + supervisor = request.args.get('supervisor', '').strip() + grade = request.args.get('grade', '', type=str) + + query = Student.query + + if search: + query = query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if college: + query = query.filter(Student.college == college) + + if supervisor: + query = query.filter(Student.supervisor == supervisor) + + if grade: + try: + grade_int = int(grade) + query = query.filter(Student.grade == grade_int) + except ValueError: + pass + + pagination = query.order_by(Student.student_number).paginate( + page=page, per_page=per_page, error_out=False + ) + + students = pagination.items + + # 获取筛选选项 + colleges = db.session.query(Student.college).distinct().all() + colleges = [c[0] for c in colleges if c[0]] + + supervisors = db.session.query(Student.supervisor).distinct().all() + supervisors = [s[0] for s in supervisors if s[0]] + + grades = db.session.query(Student.grade).distinct().all() + grades = sorted([g[0] for g in grades if g[0]]) + + return render_template('admin/student_list.html', + students=students, + pagination=pagination, + colleges=colleges, + supervisors=supervisors, + grades=grades, + search=search, + selected_college=college, + selected_supervisor=supervisor, + selected_grade=grade) + + +@admin_bp.route('/students/') +@admin_required +def student_detail(student_number): + """学生详细信息""" + student = Student.query.filter_by(student_number=student_number).first_or_404() + + # 获取考勤记录 + attendance_records = WeeklyAttendance.query.filter_by( + student_number=student_number + ).order_by(desc(WeeklyAttendance.week_start_date)).limit(10).all() + + # 获取请假记录 + leave_records = LeaveRecord.query.filter_by( + student_number=student_number + ).order_by(desc(LeaveRecord.created_at)).limit(10).all() + + # 统计数据 + total_work_hours = db.session.query( + func.sum(WeeklyAttendance.actual_work_hours) + ).filter_by(student_number=student_number).scalar() or 0 + + total_absent_days = db.session.query( + func.sum(WeeklyAttendance.absent_days) + ).filter_by(student_number=student_number).scalar() or 0 + + return render_template('admin/student_detail.html', + student=student, + attendance_records=attendance_records, + leave_records=leave_records, + total_work_hours=float(total_work_hours), + total_absent_days=int(total_absent_days)) + + +@admin_bp.route('/attendance') +@admin_required +def attendance_management(): + """考勤管理""" + + # ========== 导出功能处理 ========== + if request.args.get('export') == 'excel': + try: + return export_attendance_data() + except Exception as e: + flash(f'导出失败: {str(e)}', 'error') + # 移除export参数,重定向到正常页面 + args = dict(request.args) + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + # ================================== + + from sqlalchemy import desc, func, case, or_ + + page = request.args.get('page', 1, type=int) + per_page = 50 + + # 筛选条件 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + student_search = request.args.get('student_search', '').strip() + sort_by = request.args.get('sort_by', 'week_start_date_desc') # 默认按周开始日期降序 + + print(f"收到排序参数: {sort_by}") # 调试信息 + + # 构建基础查询,同时计算迟到次数 + 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 + ).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') + + if student_search: + query = query.filter(or_( + WeeklyAttendance.name.contains(student_search), + WeeklyAttendance.student_number.contains(student_search) + )) + + # 处理排序 + if sort_by and '_' in sort_by: + field, direction = sort_by.rsplit('_', 1) + print(f"排序字段: {field}, 方向: {direction}") # 调试信息 + + if direction not in ['asc', 'desc']: + direction = 'desc' + + # 根据字段设置排序 + if field == 'actual_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.actual_work_hours)) + else: + query = query.order_by(WeeklyAttendance.actual_work_hours) + elif field == 'class_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.class_work_hours)) + else: + query = query.order_by(WeeklyAttendance.class_work_hours) + elif field == 'absent_days': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.absent_days)) + else: + query = query.order_by(WeeklyAttendance.absent_days) + elif field == 'overtime_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.overtime_hours)) + else: + query = query.order_by(WeeklyAttendance.overtime_hours) + elif field == 'late_count': + # 按迟到次数排序 + if direction == 'desc': + query = query.order_by(desc('late_count')) + else: + query = query.order_by('late_count') + elif field == 'created_at': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.created_at)) + else: + query = query.order_by(WeeklyAttendance.created_at) + elif field == 'week_start_date': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(WeeklyAttendance.week_start_date) + else: + # 未知字段,使用默认排序 + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + # 默认排序:按周开始日期降序 + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + + # 执行分页查询 + try: + pagination = query.paginate( + page=page, + per_page=per_page, + error_out=False + ) + except Exception as e: + print(f"查询分页失败: {e}") + flash('查询数据时出现错误', 'error') + return redirect(url_for('admin.dashboard')) + + # 处理结果,将迟到次数添加到记录对象中 + attendance_records = [] + for record, late_count in pagination.items: + # 给记录对象添加迟到次数属性 + record.late_count = int(late_count) if late_count else 0 + attendance_records.append(record) + + print(f"查询结果: {len(attendance_records)} 条记录") # 调试信息 + if attendance_records: + print(f"第一条记录迟到次数: {attendance_records[0].late_count}") # 调试信息 + + # 更新pagination对象的items + pagination.items = attendance_records + + # ========== 计算请假统计 ========== + statistics = None + if attendance_records: + # 获取当前筛选结果中的所有考勤记录ID + record_ids = [record.record_id for record in attendance_records] + + # 统计请假天数 + leave_count = DailyAttendanceDetail.query.filter( + DailyAttendanceDetail.weekly_record_id.in_(record_ids), + DailyAttendanceDetail.status == '请假' + ).count() + + statistics = { + 'total_leave_days': leave_count + } + + # 确保总是返回模板 + return render_template('admin/attendance_management.html', + attendance_records=attendance_records, + pagination=pagination, + start_date=start_date, + end_date=end_date, + student_search=student_search, + sort_by=sort_by, + statistics=statistics) + + +@admin_bp.route('/upload/attendance', methods=['GET', 'POST']) +@admin_required +def upload_attendance(): + """上传考勤数据""" + if request.method == 'POST': + # 检查考勤记录文件 + if 'attendance_file' not in request.files: + flash('请选择考勤记录文件', 'error') + return render_template('admin/upload_attendance.html') + + attendance_file = request.files['attendance_file'] + if attendance_file.filename == '': + flash('请选择考勤记录文件', 'error') + return render_template('admin/upload_attendance.html') + + # 检查请假单文件(可选) + leave_file = request.files.get('leave_file') + has_leave_file = leave_file and leave_file.filename != '' + + week_start = request.form.get('week_start') + week_end = request.form.get('week_end') + + if not week_start or not week_end: + flash('请选择周开始和结束日期', 'error') + return render_template('admin/upload_attendance.html') + + if attendance_file and attendance_file.filename.endswith(('.xlsx', '.xls')): + attendance_filename = secure_filename(attendance_file.filename) + leave_filename = secure_filename(leave_file.filename) if has_leave_file else None + + # 使用临时文件 + attendance_temp_file = None + leave_temp_file = None + + try: + # 保存考勤记录文件 + attendance_temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') + attendance_file.save(attendance_temp_file.name) + attendance_temp_file.close() + + # 保存请假单文件(如果有) + if has_leave_file and leave_file.filename.endswith(('.xlsx', '.xls')): + leave_temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') + leave_file.save(leave_temp_file.name) + leave_temp_file.close() + + # 处理数据 + importer = AttendanceDataImporter() + + # 解析考勤数据 + attendance_data = importer.parse_xlsx_file(attendance_temp_file.name) + + # 解析请假数据(如果有) + leave_data = None + if leave_temp_file: + try: + leave_data = importer.parse_leave_file(leave_temp_file.name) + flash(f'成功解析请假记录 {len(leave_data)} 条', 'info') + except Exception as e: + flash(f'请假单解析失败:{str(e)}', 'warning') + leave_data = None + + # 应用请假数据到考勤数据 + if leave_data: + attendance_data = importer.apply_leave_records(attendance_data, leave_data, week_start, week_end) + + # 导入到数据库 + success_count, error_count, error_messages = importer.import_to_database( + attendance_data, week_start, week_end) + + # 如果有请假数据,同时保存到请假记录表 + if leave_data: + leave_success_count = importer.import_leave_records_to_database(leave_data) + flash(f'请假记录导入:{leave_success_count} 条', 'info') + + if success_count > 0: + message = f'导入完成:成功 {success_count} 条,失败 {error_count} 条' + if has_leave_file: + message += f',已处理请假记录' + flash(message, 'success') + + if error_messages: + for msg in error_messages[:5]: # 只显示前5个错误 + flash(msg, 'warning') + else: + flash('导入失败,请检查文件格式和数据', 'error') + for msg in error_messages[:3]: + flash(msg, 'error') + + except Exception as e: + flash(f'文件处理失败:{str(e)}', 'error') + logger.error(f"文件处理失败: {str(e)}", exc_info=True) + finally: + # 删除临时文件 + try: + if attendance_temp_file: + os.unlink(attendance_temp_file.name) + if leave_temp_file: + os.unlink(leave_temp_file.name) + except: + pass + + return redirect(url_for('admin.attendance_management')) + else: + flash('请上传Excel文件(.xlsx或.xls)', 'error') + + return render_template('admin/upload_attendance.html') + + +# 添加一个新的路由来删除考勤记录 +@admin_bp.route('/attendance//delete', methods=['POST']) +@admin_required +def delete_attendance_record(record_id): + """删除考勤记录""" + try: + record = WeeklyAttendance.query.get_or_404(record_id) + db.session.delete(record) + db.session.commit() + flash('考勤记录删除成功', 'success') + except Exception as e: + db.session.rollback() + flash(f'删除失败: {str(e)}', 'error') + + return redirect(url_for('admin.attendance_management')) + + +@admin_bp.route('/statistics') +@admin_required +def statistics(): + """统计报表""" + from sqlalchemy import desc, func, case, or_, and_ + from datetime import datetime, timedelta + + # 获取筛选参数 + search = request.args.get('search', '').strip() + grade_filter = request.args.get('grade', '').strip() + college_filter = request.args.get('college', '').strip() + supervisor_filter = request.args.get('supervisor', '').strip() + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + # 构建基础查询 + base_query = db.session.query( + Student.student_number, + Student.name, + Student.grade, + Student.college, + Student.supervisor, + Student.degree_type, + Student.enrollment_date, + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_work_hours'), + func.coalesce(func.sum(WeeklyAttendance.class_work_hours), 0).label('total_class_hours'), + func.coalesce(func.sum(WeeklyAttendance.overtime_hours), 0).label('total_overtime_hours'), + func.coalesce(func.sum(WeeklyAttendance.absent_days), 0).label('total_absent_days'), + func.count(WeeklyAttendance.record_id).label('attendance_weeks'), + # 计算迟到次数 + func.coalesce( + func.sum( + case( + (DailyAttendanceDetail.status.like('%迟到%'), 1), + else_=0 + ) + ), 0 + ).label('total_late_count') + ).outerjoin( + WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number + ).outerjoin( + DailyAttendanceDetail, WeeklyAttendance.record_id == DailyAttendanceDetail.weekly_record_id + ) + + # 应用日期筛选 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + base_query = base_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() + base_query = base_query.filter(WeeklyAttendance.week_end_date <= end_date_obj) + except ValueError: + flash('结束日期格式错误', 'error') + + # 应用学生筛选 + if search: + base_query = base_query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if grade_filter: + try: + grade_int = int(grade_filter) + base_query = base_query.filter(Student.grade == grade_int) + except ValueError: + pass + + if college_filter: + base_query = base_query.filter(Student.college == college_filter) + + if supervisor_filter: + base_query = base_query.filter(Student.supervisor == supervisor_filter) + + # 按学生分组 + base_query = base_query.group_by(Student.student_id) + + # 获取学生统计数据 + students_stats = base_query.all() + + # 年级映射函数 + def get_grade_label(grade, degree_type): + if degree_type in ['学博', '专博']: + return f'博士{grade}年级' + else: + if grade == 1: + return '研一' + elif grade == 2: + return '研二' + elif grade == 3: + return '研三' + else: + return f'研{grade}' + + # 处理学生数据并按年级分组 + grade_groups = {} + all_students_data = [] + + for stat in students_stats: + grade_label = get_grade_label(stat.grade, stat.degree_type) + + student_data = { + 'student_number': stat.student_number, + 'name': stat.name, + 'grade': stat.grade, + 'grade_label': grade_label, + 'college': stat.college, + 'supervisor': stat.supervisor, + 'degree_type': stat.degree_type, + 'enrollment_date': stat.enrollment_date, + 'total_work_hours': float(stat.total_work_hours), + 'total_class_hours': float(stat.total_class_hours), + 'total_overtime_hours': float(stat.total_overtime_hours), + 'total_absent_days': int(stat.total_absent_days), + 'total_late_count': int(stat.total_late_count), + 'attendance_weeks': int(stat.attendance_weeks), + 'avg_weekly_hours': round(float(stat.total_work_hours) / max(stat.attendance_weeks, 1), + 1) if stat.attendance_weeks > 0 else 0 + } + + all_students_data.append(student_data) + + if grade_label not in grade_groups: + grade_groups[grade_label] = [] + grade_groups[grade_label].append(student_data) + + # 按出勤时长排序每个年级的学生 + for grade in grade_groups: + grade_groups[grade].sort(key=lambda x: x['total_work_hours'], reverse=True) + + # 总体统计 + overall_stats = { + 'total_students': len(all_students_data), + 'total_work_hours': sum(s['total_work_hours'] for s in all_students_data), + 'total_absent_days': sum(s['total_absent_days'] for s in all_students_data), + 'total_late_count': sum(s['total_late_count'] for s in all_students_data), + 'avg_work_hours_per_student': round( + sum(s['total_work_hours'] for s in all_students_data) / max(len(all_students_data), 1), 1) + } + + # 🔥 修正月度统计查询 + monthly_query = 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.absent_days).label('total_absent') + ).group_by('month').order_by('month') + + # 应用相同的筛选条件到月度统计 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + monthly_query = monthly_query.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_query = monthly_query.filter(WeeklyAttendance.week_end_date <= end_date_obj) + except ValueError: + pass + + # 如果有学生筛选,需要关联学生表 + if search or grade_filter or college_filter or supervisor_filter: + monthly_query = monthly_query.join( + Student, WeeklyAttendance.student_number == Student.student_number + ) + + if search: + monthly_query = monthly_query.filter(or_( + Student.name.contains(search), + Student.student_number.contains(search) + )) + + if grade_filter: + try: + grade_int = int(grade_filter) + monthly_query = monthly_query.filter(Student.grade == grade_int) + except ValueError: + pass + + if college_filter: + monthly_query = monthly_query.filter(Student.college == college_filter) + + if supervisor_filter: + monthly_query = monthly_query.filter(Student.supervisor == supervisor_filter) + + monthly_stats = monthly_query.all() + + # 🔥 修正按学院统计查询 + college_query = db.session.query( + Student.college, + func.count(Student.student_id).label('student_count'), + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_hours') + ).outerjoin(WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number) + + # 应用筛选条件到学院统计 + if start_date: + try: + start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + college_query = college_query.filter( + or_(WeeklyAttendance.week_start_date.is_(None), + 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() + college_query = college_query.filter( + or_(WeeklyAttendance.week_end_date.is_(None), + WeeklyAttendance.week_end_date <= end_date_obj) + ) + except ValueError: + pass + + college_stats = college_query.group_by(Student.college).all() + + # 获取筛选选项 + colleges = db.session.query(Student.college).distinct().all() + colleges = [c[0] for c in colleges if c[0]] + + supervisors = db.session.query(Student.supervisor).distinct().all() + supervisors = [s[0] for s in supervisors if s[0]] + + grades = db.session.query(Student.grade).distinct().all() + grades = sorted([g[0] for g in grades if g[0]]) + + print("=== 调试信息 ===") + print(f"月度统计数据: {monthly_stats}") + print(f"学院统计数据: {college_stats}") + print("===============") + + return render_template('admin/statistics.html', + grade_groups=grade_groups, + all_students_data=all_students_data, + overall_stats=overall_stats, + monthly_stats=monthly_stats, + college_stats=college_stats, + colleges=colleges, + supervisors=supervisors, + grades=grades, + search=search, + selected_grade=grade_filter, + selected_college=college_filter, + selected_supervisor=supervisor_filter, + start_date=start_date, + end_date=end_date) + + +@admin_bp.route('/statistics/export') +@admin_required +def export_statistics(): + """导出统计数据""" + import pandas as pd + from io import BytesIO + + # 获取所有学生统计数据(复用上面的查询逻辑) + students_query = db.session.query( + Student.student_number, + Student.name, + Student.grade, + Student.college, + Student.supervisor, + Student.degree_type, + func.coalesce(func.sum(WeeklyAttendance.actual_work_hours), 0).label('total_work_hours'), + func.coalesce(func.sum(WeeklyAttendance.class_work_hours), 0).label('total_class_hours'), + func.coalesce(func.sum(WeeklyAttendance.overtime_hours), 0).label('total_overtime_hours'), + func.coalesce(func.sum(WeeklyAttendance.absent_days), 0).label('total_absent_days'), + func.count(WeeklyAttendance.record_id).label('attendance_weeks') + ).outerjoin( + WeeklyAttendance, Student.student_number == WeeklyAttendance.student_number + ).group_by(Student.student_id).all() + + # 转换为DataFrame + data = [] + for stat in students_query: + grade_label = f'博士{stat.grade}年级' if stat.degree_type in ['学博', '专博'] else f'研{stat.grade}' + data.append({ + '学号': stat.student_number, + '姓名': stat.name, + '年级': grade_label, + '学院': stat.college, + '导师': stat.supervisor, + '学位类型': stat.degree_type, + '总出勤时长(小时)': float(stat.total_work_hours), + '班内工作时长(小时)': float(stat.total_class_hours), + '加班时长(小时)': float(stat.total_overtime_hours), + '缺勤天数': int(stat.total_absent_days), + '考勤周数': int(stat.attendance_weeks), + '周均工作时长': round(float(stat.total_work_hours) / max(stat.attendance_weeks, 1), 1) + }) + + df = pd.DataFrame(data) + + # 创建Excel文件 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='学生考勤统计', index=False) + + output.seek(0) + + filename = f"学生考勤统计_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + +@admin_bp.route('/students/add', methods=['GET', 'POST']) +@admin_required +def add_student(): + """添加学生""" + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 检查学号是否已存在 + if Student.query.filter_by(student_number=data['student_number']).first(): + if request.is_json: + return jsonify({'success': False, 'message': '学号已存在'}) + else: + flash('学号已存在', 'error') + return render_template('admin/add_student.html') + + # 创建用户账户 + user = User( + student_number=data['student_number'], + password_hash=generate_password_hash(data.get('password', '123456')), + role='student' + ) + + success, error = safe_add_and_commit(user) + if not success: + if request.is_json: + return jsonify({'success': False, 'message': f'创建用户失败: {error}'}) + else: + flash(f'创建用户失败: {error}', 'error') + return render_template('admin/add_student.html') + + # 创建学生记录 + student = Student( + student_number=data['student_number'], + name=data['name'], + gender=data['gender'], + grade=int(data['grade']), + phone=data.get('phone', ''), + supervisor=data.get('supervisor', ''), + college=data.get('college', ''), + major=data.get('major', ''), + degree_type=data.get('degree_type') if data.get('degree_type') else None, + status=data.get('status', '在读'), + enrollment_date=datetime.strptime(data['enrollment_date'], '%Y-%m-%d').date() if data.get( + 'enrollment_date') else None + ) + + success, error = safe_add_and_commit(student) + if success: + if request.is_json: + return jsonify({'success': True, 'message': '学生添加成功'}) + else: + flash('学生添加成功', 'success') + return redirect(url_for('admin.student_list')) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'添加失败: {error}'}) + else: + flash(f'添加失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'添加失败: {str(e)}'}) + else: + flash(f'添加失败: {str(e)}', 'error') + + return render_template('admin/add_student.html') + + +@admin_bp.route('/students//edit', methods=['GET', 'POST']) +@admin_required +def edit_student(student_number): + """编辑学生信息""" + student = Student.query.filter_by(student_number=student_number).first_or_404() + + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 更新学生信息 + student.name = data['name'] + student.gender = data['gender'] + student.grade = int(data['grade']) + student.phone = data.get('phone', '') + student.supervisor = data.get('supervisor', '') + student.college = data.get('college', '') + student.major = data.get('major', '') + student.degree_type = data.get('degree_type') if data.get('degree_type') else None + student.status = data.get('status', '在读') + + if data.get('enrollment_date'): + student.enrollment_date = datetime.strptime(data['enrollment_date'], '%Y-%m-%d').date() + + success, error = safe_commit() + if success: + if request.is_json: + return jsonify({'success': True, 'message': '学生信息更新成功'}) + else: + flash('学生信息更新成功', 'success') + return redirect(url_for('admin.student_detail', student_number=student_number)) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'更新失败: {error}'}) + else: + flash(f'更新失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'更新失败: {str(e)}'}) + else: + flash(f'更新失败: {str(e)}', 'error') + + # GET请求,返回学生数据用于编辑 + if request.is_json: + return jsonify({ + 'success': True, + 'student': { + 'student_number': student.student_number, + 'name': student.name, + 'gender': student.gender, + 'grade': student.grade, + 'phone': student.phone, + 'supervisor': student.supervisor, + 'college': student.college, + 'major': student.major, + 'degree_type': student.degree_type, + 'status': student.status, + 'enrollment_date': student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '' + } + }) + + return render_template('admin/edit_student.html', student=student) + + +@admin_bp.route('/students//delete', methods=['POST']) +@admin_required +def delete_student(student_number): + """删除学生""" + try: + student = Student.query.filter_by(student_number=student_number).first_or_404() + student_name = student.name + + # 删除学生记录(用户记录会因为外键约束自动删除) + success, error = safe_delete_and_commit(student) + + if success: + if request.is_json: + return jsonify({'success': True, 'message': f'学生 {student_name} 删除成功'}) + else: + flash(f'学生 {student_name} 删除成功', 'success') + return redirect(url_for('admin.student_list')) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'删除失败: {error}'}) + else: + flash(f'删除失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}) + else: + flash(f'删除失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_list')) + + +@admin_bp.route('/students/batch_action', methods=['POST']) +@admin_required +def batch_action(): + """批量操作学生""" + try: + data = request.get_json() + action = data.get('action') + student_numbers = data.get('student_numbers', []) + + if not student_numbers: + return jsonify({'success': False, 'message': '请选择要操作的学生'}) + + if action == 'delete': + # 批量删除 + students = Student.query.filter(Student.student_number.in_(student_numbers)).all() + for student in students: + db.session.delete(student) + + success, error = safe_commit() + if success: + return jsonify({'success': True, 'message': f'成功删除 {len(student_numbers)} 个学生'}) + else: + return jsonify({'success': False, 'message': f'删除失败: {error}'}) + + elif action == 'graduate': + # 批量设为毕业 + Student.query.filter(Student.student_number.in_(student_numbers)).update( + {'status': '毕业'}, synchronize_session=False + ) + success, error = safe_commit() + if success: + return jsonify({'success': True, 'message': f'成功将 {len(student_numbers)} 个学生设为毕业状态'}) + else: + return jsonify({'success': False, 'message': f'操作失败: {error}'}) + + else: + return jsonify({'success': False, 'message': '无效的操作'}) + + except Exception as e: + return jsonify({'success': False, 'message': f'操作失败: {str(e)}'}) + + +@admin_bp.route('/students//reset_password', methods=['POST']) +@admin_required +def reset_student_password(student_number): + """重置学生密码""" + try: + user = User.query.filter_by(student_number=student_number).first_or_404() + + # 重置为默认密码 + new_password = request.get_json().get('password', '123456') if request.is_json else '123456' + user.password_hash = generate_password_hash(new_password) + + success, error = safe_commit() + if success: + if request.is_json: + return jsonify({'success': True, 'message': '密码重置成功'}) + else: + flash('密码重置成功', 'success') + return redirect(url_for('admin.student_detail', student_number=student_number)) + else: + if request.is_json: + return jsonify({'success': False, 'message': f'重置失败: {error}'}) + else: + flash(f'重置失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'重置失败: {str(e)}'}) + else: + flash(f'重置失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_detail', student_number=student_number)) + + +@admin_bp.route('/students//toggle_status', methods=['POST']) +@admin_required +def toggle_student_status(student_number): + """切换学生账户状态""" + try: + user = User.query.filter_by(student_number=student_number).first_or_404() + user.is_active = not user.is_active + + success, error = safe_commit() + if success: + status_text = '启用' if user.is_active else '禁用' + if request.is_json: + return jsonify({'success': True, 'message': f'账户{status_text}成功'}) + else: + flash(f'账户{status_text}成功', 'success') + else: + if request.is_json: + return jsonify({'success': False, 'message': f'操作失败: {error}'}) + else: + flash(f'操作失败: {error}', 'error') + + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'message': f'操作失败: {str(e)}'}) + else: + flash(f'操作失败: {str(e)}', 'error') + + return redirect(url_for('admin.student_detail', student_number=student_number)) + + +@admin_bp.route('/attendance//details') +@admin_required +def attendance_record_details(record_id): + """查看考勤记录详情""" + from datetime import datetime, timedelta + import json + + # 获取周考勤汇总记录 + weekly_record = WeeklyAttendance.query.get_or_404(record_id) + + # 获取学生信息 + student = Student.query.filter_by(student_number=weekly_record.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 = weekly_record.actual_work_hours / max(present_days, 1) + else: + avg_daily_hours = 0 + + # 获取该学生的历史考勤记录(用于对比) + historical_records = WeeklyAttendance.query.filter_by( + student_number=weekly_record.student_number + ).filter(WeeklyAttendance.record_id != record_id).order_by( + desc(WeeklyAttendance.week_start_date) + ).limit(5).all() + + return render_template('admin/attendance_details.html', + weekly_record=weekly_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, + historical_records=historical_records) + +@admin_bp.route('/attendance//edit', methods=['GET', 'POST']) +@admin_required +def edit_attendance_record(record_id): + """编辑考勤记录""" + weekly_record = WeeklyAttendance.query.get_or_404(record_id) + + if request.method == 'POST': + try: + data = request.get_json() if request.is_json else request.form + + # 更新周考勤记录 + weekly_record.actual_work_hours = float(data.get('actual_work_hours', 0)) + weekly_record.class_work_hours = float(data.get('class_work_hours', 0)) + weekly_record.absent_days = int(data.get('absent_days', 0)) + weekly_record.overtime_hours = float(data.get('overtime_hours', 0)) + + success, error = safe_commit() + if success: + flash('考勤记录更新成功', 'success') + return redirect(url_for('admin.attendance_record_details', record_id=record_id)) + else: + flash(f'更新失败: {error}', 'error') + + except Exception as e: + flash(f'更新失败: {str(e)}', 'error') + + return render_template('admin/edit_attendance_record.html', weekly_record=weekly_record) + + +def export_attendance_data(): + """导出考勤数据到Excel""" + from sqlalchemy import desc, func, case, or_ + from io import BytesIO + + try: + # 获取筛选参数 + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + student_search = request.args.get('student_search', '').strip() + sort_by = request.args.get('sort_by', 'week_start_date_desc') + + # 构建查询 + 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 + ).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: + pass + + 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: + pass + + if student_search: + query = query.filter(or_( + WeeklyAttendance.name.contains(student_search), + WeeklyAttendance.student_number.contains(student_search) + )) + + # 应用排序 + if sort_by and '_' in sort_by: + field, direction = sort_by.rsplit('_', 1) + if direction not in ['asc', 'desc']: + direction = 'desc' + + if field == 'actual_work_hours': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.actual_work_hours)) + else: + query = query.order_by(WeeklyAttendance.actual_work_hours) + elif field == 'week_start_date': + if direction == 'desc': + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(WeeklyAttendance.week_start_date) + else: + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + else: + query = query.order_by(desc(WeeklyAttendance.week_start_date)) + + # 获取所有记录 + results = query.all() + + if not results: + flash('没有数据可导出', 'warning') + args = request.args.copy() + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + + # 准备数据 + data = [] + for record, late_count in results: + data.append({ + '学号': record.student_number, + '姓名': record.name, + '周开始日期': record.week_start_date.strftime('%Y-%m-%d'), + '周结束日期': record.week_end_date.strftime('%Y-%m-%d'), + '实际出勤时长(小时)': float(record.actual_work_hours), + '班内工作时长(小时)': float(record.class_work_hours), + '旷工天数': int(record.absent_days), + '迟到次数': int(late_count) if late_count else 0, + '加班时长(小时)': float(record.overtime_hours), + '记录创建时间': record.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + # 创建DataFrame + df = pd.DataFrame(data) + + # 创建Excel文件 + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='考勤记录', index=False) + + # 调整列宽 + workbook = writer.book + worksheet = writer.sheets['考勤记录'] + + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 30) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + # 生成文件名 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"考勤记录_{timestamp}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + flash(f'导出失败: {str(e)}', 'error') + # 移除export参数,重定向到正常页面 + args = request.args.copy() + args.pop('export', None) + return redirect(url_for('admin.attendance_management', **args)) + + +================================================================================ +File: ./app/routes/student.py +================================================================================ + +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//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) + + +================================================================================ +File: ./config/config.py +================================================================================ + +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 +} +================================================================================ +File: ./config/database.py +================================================================================ + +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() +================================================================================ +File: ./config/__init__.py +================================================================================ + + +================================================================================ +File: ./tests/test_auth.py +================================================================================ + + +================================================================================ +File: ./tests/__init__.py +================================================================================ + + +================================================================================ +File: ./tests/test_models.py +================================================================================ + diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..acea1d3 --- /dev/null +++ b/config/config.py @@ -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 +} \ No newline at end of file diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..ce982f1 --- /dev/null +++ b/config/database.py @@ -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() \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..c8efb27 --- /dev/null +++ b/init_db.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ceaf3bd --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..87b42af --- /dev/null +++ b/run.py @@ -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) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e69de29