11108 lines
450 KiB
Plaintext
11108 lines
450 KiB
Plaintext
|
||
================================================================================
|
||
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'<User {self.student_number}>'
|
||
|
||
================================================================================
|
||
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'<WeeklyAttendance {self.name} {self.week_start_date}>'
|
||
|
||
|
||
class DailyAttendanceDetail(db.Model):
|
||
__tablename__ = 'daily_attendance_details'
|
||
|
||
detail_id = db.Column(db.Integer, primary_key=True)
|
||
weekly_record_id = db.Column(db.Integer, db.ForeignKey('weekly_attendance.record_id'))
|
||
student_number = db.Column(db.String(20), db.ForeignKey('students.student_number'), nullable=False)
|
||
attendance_date = db.Column(db.Date, nullable=False, index=True)
|
||
status = db.Column(db.String(20), default='正常')
|
||
check_in_time = db.Column(db.Time)
|
||
check_out_time = db.Column(db.Time)
|
||
remarks = db.Column(db.Text)
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||
|
||
# 关系
|
||
weekly_record = db.relationship('WeeklyAttendance', backref='daily_details')
|
||
student_info = db.relationship('Student', backref='daily_attendance',
|
||
foreign_keys=[student_number],
|
||
primaryjoin="DailyAttendanceDetail.student_number==Student.student_number")
|
||
|
||
def __repr__(self):
|
||
return f'<DailyAttendance {self.student_number} {self.attendance_date}>'
|
||
|
||
|
||
class LeaveRecord(db.Model):
|
||
__tablename__ = 'leave_records'
|
||
|
||
leave_id = db.Column(db.Integer, primary_key=True)
|
||
student_number = db.Column(db.String(20), db.ForeignKey('students.student_number'), nullable=False)
|
||
leave_start_date = db.Column(db.Date, nullable=False)
|
||
leave_end_date = db.Column(db.Date, nullable=False)
|
||
leave_reason = db.Column(db.Text)
|
||
status = db.Column(db.Enum('待审批', '已批准', '已拒绝'), default='待审批')
|
||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||
|
||
# 学生关系
|
||
student_info = db.relationship('Student', backref='leave_records',
|
||
foreign_keys=[student_number],
|
||
primaryjoin="LeaveRecord.student_number==Student.student_number")
|
||
|
||
def __repr__(self):
|
||
return f'<LeaveRecord {self.student_number} {self.leave_start_date}>'
|
||
|
||
================================================================================
|
||
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'<Student {self.name}({self.student_number})>'
|
||
|
||
================================================================================
|
||
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 %}
|
||
<div class="container-fluid py-4">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2><i class="fas fa-user-circle me-2"></i>个人信息</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||
<li class="breadcrumb-item active">个人信息</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- 个人信息卡片 -->
|
||
<div class="row">
|
||
<div class="col-lg-10 mx-auto">
|
||
{% if user_info %}
|
||
<!-- 基本信息卡片 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header bg-primary text-white">
|
||
<h5 class="mb-0"><i class="fas fa-id-card me-2"></i>基本信息</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<!-- 左侧个人信息 -->
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">学号</label>
|
||
<div class="info-value">{{ user_info.student_number }}</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">姓名</label>
|
||
<div class="info-value">
|
||
{% if user_info.name %}
|
||
{{ user_info.name }}
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">性别</label>
|
||
<div class="info-value">
|
||
{% if user_info.gender %}
|
||
<span class="badge bg-{{ 'info' if user_info.gender == '男' else 'warning' }}">
|
||
<i class="fas fa-{{ 'mars' if user_info.gender == '男' else 'venus' }} me-1"></i>
|
||
{{ user_info.gender }}
|
||
</span>
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">年级</label>
|
||
<div class="info-value">
|
||
{% if user_info.grade %}
|
||
{{ user_info.grade }}级
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">手机号</label>
|
||
<div class="info-value">
|
||
{% if user_info.phone %}
|
||
{{ user_info.phone }}
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧学术信息 -->
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">导师</label>
|
||
<div class="info-value">
|
||
{% if user_info.supervisor %}
|
||
{{ user_info.supervisor }}
|
||
{% else %}
|
||
<span class="text-muted">未分配</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">学院</label>
|
||
<div class="info-value">
|
||
{% if user_info.college %}
|
||
{{ user_info.college }}
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">专业</label>
|
||
<div class="info-value">
|
||
{% if user_info.major %}
|
||
{{ user_info.major }}
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">学位类型</label>
|
||
<div class="info-value">
|
||
{% if user_info.degree_type %}
|
||
<span class="badge bg-success">{{ user_info.degree_type }}</span>
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">入学日期</label>
|
||
<div class="info-value">
|
||
{% if user_info.enrollment_date %}
|
||
{{ user_info.enrollment_date.strftime('%Y年%m月%d日') }}
|
||
{% else %}
|
||
<span class="text-muted">未设置</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 账户信息卡片 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header bg-secondary text-white">
|
||
<h5 class="mb-0"><i class="fas fa-user-cog me-2"></i>账户信息</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">用户ID</label>
|
||
<div class="info-value">{{ user_info.user_id }}</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">角色</label>
|
||
<div class="info-value">
|
||
<span class="badge bg-primary">
|
||
<i class="fas fa-user me-1"></i>学生
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">账户状态</label>
|
||
<div class="info-value">
|
||
{% if user_info.is_active %}
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check-circle me-1"></i>活跃
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-times-circle me-1"></i>已禁用
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% if user_info.status %}
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">在读状态</label>
|
||
<div class="info-value">
|
||
<span class="badge bg-{{ 'success' if user_info.status == '在读' else 'secondary' }}">
|
||
{{ user_info.status }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">最后登录</label>
|
||
<div class="info-value">
|
||
{% if user_info.last_login %}
|
||
{{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||
{% else %}
|
||
<span class="text-muted">从未登录</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">账户创建时间</label>
|
||
<div class="info-value">{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="text-center">
|
||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-warning btn-lg me-3">
|
||
<i class="fas fa-key me-1"></i>修改密码
|
||
</a>
|
||
<a href="{{ url_for('student.dashboard') }}" class="btn btn-secondary btn-lg">
|
||
<i class="fas fa-arrow-left me-1"></i>返回首页
|
||
</a>
|
||
</div>
|
||
|
||
{% else %}
|
||
<div class="alert alert-warning">
|
||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||
无法获取用户信息,请联系管理员
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.info-group {
|
||
border-left: 3px solid #007bff;
|
||
padding-left: 15px;
|
||
}
|
||
|
||
.info-value {
|
||
font-weight: 500;
|
||
font-size: 1.1em;
|
||
color: #333;
|
||
}
|
||
|
||
.card {
|
||
border: none;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.card-header {
|
||
border-radius: 10px 10px 0 0 !important;
|
||
}
|
||
|
||
.text-muted {
|
||
font-style: italic;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.info-group {
|
||
margin-bottom: 1rem !important;
|
||
}
|
||
|
||
.btn-lg {
|
||
width: 100%;
|
||
margin-bottom: 10px;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/auth/login.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}登录 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="min-vh-100 d-flex align-items-center bg-light">
|
||
<div class="container">
|
||
<div class="row justify-content-center">
|
||
<div class="col-md-6 col-lg-5">
|
||
<div class="card shadow">
|
||
<div class="card-body p-5">
|
||
<!-- Logo和标题 -->
|
||
<div class="text-center mb-4">
|
||
<div class="mb-3">
|
||
<i class="fas fa-clock fa-4x text-primary"></i>
|
||
</div>
|
||
<h2 class="fw-bold mb-2">CHM考勤系统</h2>
|
||
<p class="text-muted">请使用学号和密码登录</p>
|
||
</div>
|
||
|
||
<!-- 登录表单 -->
|
||
<form method="POST" action="{{ url_for('auth.login') }}" novalidate>
|
||
<div class="mb-3">
|
||
<label for="student_number" class="form-label">
|
||
<i class="fas fa-user me-1"></i>学号
|
||
</label>
|
||
<input type="text"
|
||
class="form-control form-control-lg"
|
||
id="student_number"
|
||
name="student_number"
|
||
placeholder="请输入学号"
|
||
value="{{ request.form.get('student_number', '') }}"
|
||
required
|
||
autocomplete="username">
|
||
<div class="invalid-feedback">
|
||
请输入学号
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label for="password" class="form-label">
|
||
<i class="fas fa-lock me-1"></i>密码
|
||
</label>
|
||
<div class="input-group">
|
||
<input type="password"
|
||
class="form-control form-control-lg"
|
||
id="password"
|
||
name="password"
|
||
placeholder="请输入密码"
|
||
required
|
||
autocomplete="current-password">
|
||
<button class="btn btn-outline-secondary" type="button" id="togglePassword">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
</div>
|
||
<div class="invalid-feedback">
|
||
请输入密码
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3 form-check">
|
||
<input type="checkbox" class="form-check-input" id="remember" name="remember" value="true">
|
||
<label class="form-check-label" for="remember">
|
||
记住我
|
||
</label>
|
||
</div>
|
||
|
||
<div class="d-grid">
|
||
<button type="submit" class="btn btn-primary btn-lg">
|
||
<i class="fas fa-sign-in-alt me-2"></i>登录
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<!-- 帮助信息 -->
|
||
<div class="text-center mt-4">
|
||
<small class="text-muted">
|
||
<i class="fas fa-info-circle me-1"></i>
|
||
如有登录问题,请联系管理员
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统说明 -->
|
||
<div class="card mt-3">
|
||
<div class="card-body text-center">
|
||
<h6 class="card-title">
|
||
<i class="fas fa-lightbulb me-1 text-warning"></i>
|
||
使用说明
|
||
</h6>
|
||
<div class="row text-start">
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<ul class="list-unstyled mb-0">
|
||
<li><i class="fas fa-check text-success me-1"></i> 查看个人考勤记录</li>
|
||
<li><i class="fas fa-check text-success me-1"></i> 申请请假审批</li>
|
||
</ul>
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<ul class="list-unstyled mb-0">
|
||
<li><i class="fas fa-check text-success me-1"></i> 个人统计分析</li>
|
||
<li><i class="fas fa-check text-success me-1"></i> 修改个人密码</li>
|
||
</ul>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 密码显示/隐藏切换
|
||
const togglePassword = document.getElementById('togglePassword');
|
||
const passwordInput = document.getElementById('password');
|
||
|
||
togglePassword.addEventListener('click', function() {
|
||
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
|
||
passwordInput.setAttribute('type', type);
|
||
|
||
const icon = this.querySelector('i');
|
||
icon.className = type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
|
||
});
|
||
|
||
// 表单验证
|
||
const form = document.querySelector('form');
|
||
form.addEventListener('submit', function(event) {
|
||
const studentNumber = document.getElementById('student_number').value.trim();
|
||
const password = document.getElementById('password').value;
|
||
|
||
if (!studentNumber || !password) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
if (!studentNumber) {
|
||
document.getElementById('student_number').classList.add('is-invalid');
|
||
}
|
||
if (!password) {
|
||
document.getElementById('password').classList.add('is-invalid');
|
||
}
|
||
}
|
||
|
||
form.classList.add('was-validated');
|
||
});
|
||
|
||
// 清除验证状态
|
||
document.getElementById('student_number').addEventListener('input', function() {
|
||
this.classList.remove('is-invalid');
|
||
});
|
||
|
||
document.getElementById('password').addEventListener('input', function() {
|
||
this.classList.remove('is-invalid');
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/auth/admin_profile.html
|
||
================================================================================
|
||
|
||
{% extends "layout/base.html" %}
|
||
|
||
{% block title %}个人信息 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid py-4">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2><i class="fas fa-user-circle me-2"></i>个人信息</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">控制台</a></li>
|
||
<li class="breadcrumb-item active">个人信息</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- 个人信息卡片 -->
|
||
<div class="row">
|
||
<div class="col-lg-8 mx-auto">
|
||
<div class="card shadow">
|
||
<div class="card-header bg-primary text-white">
|
||
<h5 class="mb-0"><i class="fas fa-user-cog me-2"></i>管理员信息</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if user_info %}
|
||
<div class="row">
|
||
<!-- 基本信息 -->
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">用户ID</label>
|
||
<div class="info-value">{{ user_info.user_id }}</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">账号</label>
|
||
<div class="info-value">{{ user_info.student_number }}</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">角色</label>
|
||
<div class="info-value">
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-crown me-1"></i>
|
||
{% if user_info.role == 'admin' %}管理员{% else %}普通用户{% endif %}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态信息 -->
|
||
<div class="col-md-6">
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">账户状态</label>
|
||
<div class="info-value">
|
||
{% if user_info.is_active %}
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check-circle me-1"></i>活跃
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-times-circle me-1"></i>已禁用
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">最后登录</label>
|
||
<div class="info-value">
|
||
{% if user_info.last_login %}
|
||
{{ user_info.last_login.strftime('%Y-%m-%d %H:%M:%S') }}
|
||
{% else %}
|
||
<span class="text-muted">从未登录</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="info-group mb-3">
|
||
<label class="form-label text-muted">账户创建时间</label>
|
||
<div class="info-value">{{ user_info.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="text-center mt-4">
|
||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-primary me-2">
|
||
<i class="fas fa-key me-1"></i>修改密码
|
||
</a>
|
||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">
|
||
<i class="fas fa-arrow-left me-1"></i>返回控制台
|
||
</a>
|
||
</div>
|
||
{% else %}
|
||
<div class="alert alert-warning">
|
||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||
无法获取用户信息
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.info-group {
|
||
border-left: 3px solid #007bff;
|
||
padding-left: 15px;
|
||
}
|
||
|
||
.info-value {
|
||
font-weight: 500;
|
||
font-size: 1.1em;
|
||
color: #333;
|
||
}
|
||
|
||
.card {
|
||
border: none;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.card-header {
|
||
border-radius: 10px 10px 0 0 !important;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/auth/change_password.html
|
||
================================================================================
|
||
|
||
{% extends "layout/base.html" %}
|
||
|
||
{% block title %}修改密码 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid py-4">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2><i class="fas fa-key me-2"></i>修改密码</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item">
|
||
<a href="{{ url_for('admin.dashboard' if current_user.is_admin() else 'student.dashboard') }}">
|
||
{% if current_user.is_admin() %}控制台{% else %}首页{% endif %}
|
||
</a>
|
||
</li>
|
||
<li class="breadcrumb-item"><a href="{{ url_for('auth.profile') }}">个人信息</a></li>
|
||
<li class="breadcrumb-item active">修改密码</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- 修改密码表单 -->
|
||
<div class="row">
|
||
<div class="col-lg-6 mx-auto">
|
||
<div class="card shadow">
|
||
<div class="card-header bg-warning text-dark">
|
||
<h5 class="mb-0">
|
||
<i class="fas fa-shield-alt me-2"></i>安全设置
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- 安全提示 -->
|
||
<div class="alert alert-info">
|
||
<i class="fas fa-info-circle me-2"></i>
|
||
<strong>密码要求:</strong>
|
||
<ul class="mb-0 mt-2">
|
||
<li>长度至少6位</li>
|
||
<li>必须包含字母和数字</li>
|
||
<li>建议使用字母、数字和特殊字符的组合</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<form method="POST" id="changePasswordForm">
|
||
<!-- 当前密码 -->
|
||
<div class="mb-3">
|
||
<label for="current_password" class="form-label">
|
||
<i class="fas fa-lock me-1"></i>当前密码 <span class="text-danger">*</span>
|
||
</label>
|
||
<div class="input-group">
|
||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('current_password')">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新密码 -->
|
||
<div class="mb-3">
|
||
<label for="new_password" class="form-label">
|
||
<i class="fas fa-key me-1"></i>新密码 <span class="text-danger">*</span>
|
||
</label>
|
||
<div class="input-group">
|
||
<input type="password" class="form-control" id="new_password" name="new_password" required>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('new_password')">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
</div>
|
||
<div class="form-text">
|
||
<div id="password-strength" class="mt-2"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 确认密码 -->
|
||
<div class="mb-4">
|
||
<label for="confirm_password" class="form-label">
|
||
<i class="fas fa-check-double me-1"></i>确认新密码 <span class="text-danger">*</span>
|
||
</label>
|
||
<div class="input-group">
|
||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('confirm_password')">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
</div>
|
||
<div id="password-match" class="form-text"></div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||
<button type="submit" class="btn btn-primary btn-lg me-md-2">
|
||
<i class="fas fa-save me-1"></i>保存密码
|
||
</button>
|
||
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary btn-lg">
|
||
<i class="fas fa-times me-1"></i>取消
|
||
</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.card {
|
||
border: none;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.card-header {
|
||
border-radius: 10px 10px 0 0 !important;
|
||
}
|
||
|
||
.input-group .btn {
|
||
border-left: none;
|
||
}
|
||
|
||
.password-strength-weak { color: #dc3545; }
|
||
.password-strength-medium { color: #ffc107; }
|
||
.password-strength-strong { color: #28a745; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 切换密码显示/隐藏
|
||
function togglePassword(fieldId) {
|
||
const field = document.getElementById(fieldId);
|
||
const button = field.nextElementSibling;
|
||
const icon = button.querySelector('i');
|
||
|
||
if (field.type === 'password') {
|
||
field.type = 'text';
|
||
icon.className = 'fas fa-eye-slash';
|
||
} else {
|
||
field.type = 'password';
|
||
icon.className = 'fas fa-eye';
|
||
}
|
||
}
|
||
|
||
// 密码强度检查
|
||
document.getElementById('new_password').addEventListener('input', function() {
|
||
const password = this.value;
|
||
const strengthDiv = document.getElementById('password-strength');
|
||
|
||
if (password.length === 0) {
|
||
strengthDiv.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let score = 0;
|
||
let feedback = [];
|
||
|
||
// 长度检查
|
||
if (password.length >= 6) score += 1;
|
||
else feedback.push('至少6位字符');
|
||
|
||
// 包含字母
|
||
if (/[a-zA-Z]/.test(password)) score += 1;
|
||
else feedback.push('包含字母');
|
||
|
||
// 包含数字
|
||
if (/\d/.test(password)) score += 1;
|
||
else feedback.push('包含数字');
|
||
|
||
// 包含特殊字符
|
||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 1;
|
||
|
||
let strengthText = '';
|
||
let strengthClass = '';
|
||
|
||
if (score < 2) {
|
||
strengthText = '弱';
|
||
strengthClass = 'password-strength-weak';
|
||
} else if (score < 3) {
|
||
strengthText = '中等';
|
||
strengthClass = 'password-strength-medium';
|
||
} else {
|
||
strengthText = '强';
|
||
strengthClass = 'password-strength-strong';
|
||
}
|
||
|
||
strengthDiv.innerHTML = `<span class="${strengthClass}">密码强度: ${strengthText}</span>`;
|
||
if (feedback.length > 0) {
|
||
strengthDiv.innerHTML += `<br><small class="text-muted">建议: ${feedback.join(', ')}</small>`;
|
||
}
|
||
});
|
||
|
||
// 密码匹配检查
|
||
document.getElementById('confirm_password').addEventListener('input', function() {
|
||
const newPassword = document.getElementById('new_password').value;
|
||
const confirmPassword = this.value;
|
||
const matchDiv = document.getElementById('password-match');
|
||
|
||
if (confirmPassword.length === 0) {
|
||
matchDiv.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
if (newPassword === confirmPassword) {
|
||
matchDiv.innerHTML = '<span class="text-success"><i class="fas fa-check me-1"></i>密码匹配</span>';
|
||
} else {
|
||
matchDiv.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>密码不匹配</span>';
|
||
}
|
||
});
|
||
|
||
// 表单提交验证
|
||
document.getElementById('changePasswordForm').addEventListener('submit', function(e) {
|
||
const newPassword = document.getElementById('new_password').value;
|
||
const confirmPassword = document.getElementById('confirm_password').value;
|
||
|
||
if (newPassword !== confirmPassword) {
|
||
e.preventDefault();
|
||
alert('新密码与确认密码不匹配!');
|
||
return false;
|
||
}
|
||
|
||
if (newPassword.length < 6) {
|
||
e.preventDefault();
|
||
alert('新密码长度至少6位!');
|
||
return false;
|
||
}
|
||
|
||
if (!/^(?=.*[a-zA-Z])(?=.*\d).+$/.test(newPassword)) {
|
||
e.preventDefault();
|
||
alert('新密码必须包含字母和数字!');
|
||
return false;
|
||
}
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/layout/base.html
|
||
================================================================================
|
||
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}CHM考勤管理系统{% endblock %}</title>
|
||
|
||
<!-- Bootstrap CSS -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<!-- Font Awesome -->
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||
<!-- Custom CSS -->
|
||
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body>
|
||
<!-- 导航栏 -->
|
||
{% if current_user.is_authenticated %}
|
||
{% include 'layout/nav.html' %}
|
||
{% endif %}
|
||
|
||
<!-- 主要内容区域 -->
|
||
<main class="{% if current_user.is_authenticated %}main-content{% else %}full-page{% endif %}">
|
||
<!-- Flash消息 -->
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% if messages %}
|
||
<div class="container-fluid mt-3">
|
||
{% for category, message in messages %}
|
||
<div class="alert alert-{{ 'danger' if category == 'error' else 'info' if category == 'info' else 'success' if category == 'success' else 'warning' }} alert-dismissible fade show" role="alert">
|
||
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' if category == 'info' else 'check-circle' if category == 'success' else 'exclamation-circle' }}"></i>
|
||
{{ message }}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% endwith %}
|
||
|
||
<!-- 页面内容 -->
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
|
||
<!-- Footer -->
|
||
{% if current_user.is_authenticated %}
|
||
<footer class="bg-light text-center py-3 mt-auto">
|
||
<div class="container">
|
||
<span class="text-muted">© 2025 CHM考勤管理系统. All rights reserved.</span>
|
||
</div>
|
||
</footer>
|
||
{% endif %}
|
||
|
||
<!-- Bootstrap JS -->
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<!-- Chart.js (用于统计图表) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<!-- Custom JS -->
|
||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||
|
||
{% block extra_js %}{% endblock %}
|
||
</body>
|
||
</html>
|
||
|
||
|
||
================================================================================
|
||
File: ./app/templates/layout/nav.html
|
||
================================================================================
|
||
|
||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||
<div class="container-fluid">
|
||
<!-- Logo -->
|
||
<a class="navbar-brand" href="{{ url_for('student.dashboard' if not current_user.is_admin() else 'admin.dashboard') }}">
|
||
<i class="fas fa-clock me-2"></i>
|
||
CHM考勤系统
|
||
</a>
|
||
|
||
<!-- 移动端切换按钮 -->
|
||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||
<span class="navbar-toggler-icon"></span>
|
||
</button>
|
||
|
||
<div class="collapse navbar-collapse" id="navbarNav">
|
||
<!-- 左侧导航菜单 -->
|
||
<ul class="navbar-nav me-auto">
|
||
{% if current_user.is_admin() %}
|
||
<!-- 管理员菜单 -->
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">
|
||
<i class="fas fa-tachometer-alt me-1"></i>控制台
|
||
</a>
|
||
</li>
|
||
<li class="nav-item dropdown">
|
||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||
<i class="fas fa-users me-1"></i>学生管理
|
||
</a>
|
||
<ul class="dropdown-menu">
|
||
<li><a class="dropdown-item" href="{{ url_for('admin.student_list') }}">
|
||
<i class="fas fa-list me-2"></i>学生列表
|
||
</a></li>
|
||
</ul>
|
||
</li>
|
||
<li class="nav-item dropdown">
|
||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||
<i class="fas fa-calendar-check me-1"></i>考勤管理
|
||
</a>
|
||
<ul class="dropdown-menu">
|
||
<li><a class="dropdown-item" href="{{ url_for('admin.attendance_management') }}">
|
||
<i class="fas fa-table me-2"></i>考勤记录
|
||
</a></li>
|
||
<li><a class="dropdown-item" href="{{ url_for('admin.upload_attendance') }}">
|
||
<i class="fas fa-upload me-2"></i>上传数据
|
||
</a></li>
|
||
</ul>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.statistics' }}" href="{{ url_for('admin.statistics') }}">
|
||
<i class="fas fa-chart-bar me-1"></i>统计报表
|
||
</a>
|
||
</li>
|
||
{% else %}
|
||
<!-- 学生菜单 -->
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'student.dashboard' }}" href="{{ url_for('student.dashboard') }}">
|
||
<i class="fas fa-home me-1"></i>首页
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'student.attendance' }}" href="{{ url_for('student.attendance') }}">
|
||
<i class="fas fa-calendar-check me-1"></i>我的考勤
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link {{ 'active' if request.endpoint == 'student.statistics' }}" href="{{ url_for('student.statistics') }}">
|
||
<i class="fas fa-chart-line me-1"></i>个人统计
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
|
||
<!-- 右侧用户菜单 -->
|
||
<ul class="navbar-nav">
|
||
<li class="nav-item dropdown">
|
||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown">
|
||
<i class="fas fa-user-circle me-2"></i>
|
||
<span>
|
||
{% if current_user.is_admin() %}
|
||
管理员
|
||
{% else %}
|
||
{% set student = get_current_student() %}
|
||
{{ student.name if student else current_user.student_number }}
|
||
{% endif %}
|
||
</span>
|
||
</a>
|
||
<ul class="dropdown-menu dropdown-menu-end">
|
||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||
<i class="fas fa-user me-2"></i>个人信息
|
||
</a></li>
|
||
<li><a class="dropdown-item" href="{{ url_for('auth.change_password') }}">
|
||
<i class="fas fa-key me-2"></i>修改密码
|
||
</a></li>
|
||
<li><hr class="dropdown-divider"></li>
|
||
<li><a class="dropdown-item text-danger" href="{{ url_for('auth.logout') }}">
|
||
<i class="fas fa-sign-out-alt me-2"></i>退出登录
|
||
</a></li>
|
||
</ul>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
================================================================================
|
||
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 %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<h2 class="fw-bold text-primary">
|
||
<i class="fas fa-calendar-check me-2"></i>我的考勤记录
|
||
</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||
<li class="breadcrumb-item active">考勤记录</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
{% if total_stats %}
|
||
<div class="row mb-4">
|
||
<div class="col-md-2">
|
||
<div class="card border-left-primary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||
总考勤周数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_stats.total_weeks }}周
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-2">
|
||
<div class="card border-left-success shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||
总出勤时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(total_stats.total_actual_hours) }}h
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-2">
|
||
<div class="card border-left-info shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||
班内工作
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(total_stats.total_class_hours) }}h
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-2">
|
||
<div class="card border-left-warning shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||
迟到次数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_stats.total_late_count }}次
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-2">
|
||
<div class="card border-left-danger shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">
|
||
旷工天数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_stats.total_absent_days }}天
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-times-circle fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-2">
|
||
<div class="card border-left-secondary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
|
||
周均时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 筛选器 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-filter me-2"></i>筛选条件
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<form method="GET" action="{{ url_for('student.attendance') }}" class="row g-3">
|
||
<div class="col-md-4">
|
||
<label for="start_date" class="form-label">开始日期</label>
|
||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||
value="{{ start_date or '' }}">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label for="end_date" class="form-label">结束日期</label>
|
||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||
value="{{ end_date or '' }}">
|
||
</div>
|
||
<div class="col-md-4 d-flex align-items-end">
|
||
<div class="d-grid gap-2 d-md-flex w-100">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="fas fa-search me-1"></i>筛选
|
||
</button>
|
||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-refresh me-1"></i>重置
|
||
</a>
|
||
{% if attendance_records %}
|
||
<button type="button" class="btn btn-outline-info" onclick="exportData()">
|
||
<i class="fas fa-download me-1"></i>导出
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 考勤记录表格 -->
|
||
<div class="card shadow">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-table me-2"></i>考勤记录列表
|
||
</h6>
|
||
{% if attendance_records %}
|
||
<span class="badge bg-info">
|
||
共 {{ pagination.total }} 条记录
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if attendance_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>周次</th>
|
||
<th>实际工作时长</th>
|
||
<th>班内工作时长</th>
|
||
<th>迟到次数</th>
|
||
<th>旷工天数</th>
|
||
<th>加班时长</th>
|
||
<th>记录时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in attendance_records %}
|
||
<tr>
|
||
<td>
|
||
<div>
|
||
<strong>{{ record.week_start_date.strftime('%Y-%m-%d') }}</strong>
|
||
<small class="d-block text-muted">
|
||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||
</small>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-success">{{ "%.1f"|format(record.class_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
{% if record.late_count > 0 %}
|
||
<span class="badge bg-warning">{{ record.late_count }}次</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0次</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0天</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if record.overtime_hours > 0 %}
|
||
<span class="badge bg-info">{{ "%.1f"|format(record.overtime_hours) }}h</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">0h</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<small class="text-muted">
|
||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||
</small>
|
||
</td>
|
||
<td>
|
||
<a href="{{ url_for('student.attendance_details', record_id=record.record_id) }}"
|
||
class="btn btn-sm btn-outline-primary">
|
||
<i class="fas fa-eye me-1"></i>查看详情
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
{% if pagination.pages > 1 %}
|
||
<nav aria-label="考勤记录分页" class="mt-4">
|
||
<ul class="pagination justify-content-center">
|
||
{% if pagination.has_prev %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('student.attendance', page=pagination.prev_num, start_date=start_date, end_date=end_date) }}">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
|
||
{% for page_num in pagination.iter_pages() %}
|
||
{% if page_num %}
|
||
{% if page_num != pagination.page %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('student.attendance', page=page_num, start_date=start_date, end_date=end_date) }}">
|
||
{{ page_num }}
|
||
</a>
|
||
</li>
|
||
{% else %}
|
||
<li class="page-item active">
|
||
<span class="page-link">{{ page_num }}</span>
|
||
</li>
|
||
{% endif %}
|
||
{% else %}
|
||
<li class="page-item disabled">
|
||
<span class="page-link">…</span>
|
||
</li>
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
{% if pagination.has_next %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('student.attendance', page=pagination.next_num, start_date=start_date, end_date=end_date) }}">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
</nav>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-calendar-times fa-4x text-muted mb-3"></i>
|
||
<h5 class="text-muted">暂无考勤记录</h5>
|
||
<p class="text-muted">当前筛选条件下没有找到考勤记录</p>
|
||
<a href="{{ url_for('student.dashboard') }}" class="btn btn-primary">
|
||
<i class="fas fa-home me-2"></i>返回首页
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷操作 -->
|
||
{% if attendance_records %}
|
||
<div class="row mt-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-pie me-2"></i>本期统计分析
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-4">
|
||
<div class="border-end">
|
||
<h6 class="text-primary">{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h</h6>
|
||
<small class="text-muted">周均出勤</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-4">
|
||
<div class="border-end">
|
||
<h6 class="text-success">{{ "%.1f"|format((total_stats.total_class_hours / total_stats.total_actual_hours * 100) if total_stats.total_actual_hours > 0 else 0) }}%</h6>
|
||
<small class="text-muted">班内工作率</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-4">
|
||
<h6 class="text-info">{{ "%.1f"|format((total_stats.total_overtime_hours / total_stats.total_weeks) if total_stats.total_weeks > 0 else 0) }}h</h6>
|
||
<small class="text-muted">周均加班</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-tools me-2"></i>快捷操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-primary btn-block">
|
||
<i class="fas fa-chart-bar me-2"></i>统计报表
|
||
</a>
|
||
</div>
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary btn-block">
|
||
<i class="fas fa-user me-2"></i>个人资料
|
||
</a>
|
||
</div>
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-outline-warning btn-block">
|
||
<i class="fas fa-key me-2"></i>修改密码
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-left-primary {
|
||
border-left: 0.25rem solid #4e73df !important;
|
||
}
|
||
|
||
.border-left-success {
|
||
border-left: 0.25rem solid #1cc88a !important;
|
||
}
|
||
|
||
.border-left-info {
|
||
border-left: 0.25rem solid #36b9cc !important;
|
||
}
|
||
|
||
.border-left-warning {
|
||
border-left: 0.25rem solid #f6c23e !important;
|
||
}
|
||
|
||
.border-left-danger {
|
||
border-left: 0.25rem solid #e74a3b !important;
|
||
}
|
||
|
||
.border-left-secondary {
|
||
border-left: 0.25rem solid #858796 !important;
|
||
}
|
||
|
||
.border-end {
|
||
border-right: 1px solid #dee2e6;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.card {
|
||
border: 0;
|
||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||
}
|
||
|
||
.btn-block {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
|
||
.table th {
|
||
background-color: #f8f9fc;
|
||
border-top: none;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
color: #5a5c69;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
/* 响应式调整 */
|
||
@media (max-width: 768px) {
|
||
.col-md-2 {
|
||
flex: 0 0 50%;
|
||
max-width: 50%;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.table-responsive {
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 0.65rem;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
function exportData() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.set('export', 'excel');
|
||
|
||
// 构建导出URL
|
||
const exportUrl = '{{ url_for("student.attendance") }}?' + params.toString();
|
||
|
||
// 创建临时链接并触发下载
|
||
const link = document.createElement('a');
|
||
link.href = exportUrl;
|
||
link.download = '我的考勤记录.xlsx';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
|
||
// 自动设置结束日期为开始日期的一个月后
|
||
document.getElementById('start_date').addEventListener('change', function() {
|
||
const startDate = new Date(this.value);
|
||
if (startDate && !document.getElementById('end_date').value) {
|
||
const endDate = new Date(startDate);
|
||
endDate.setMonth(startDate.getMonth() + 1);
|
||
document.getElementById('end_date').value = endDate.toISOString().split('T')[0];
|
||
}
|
||
});
|
||
|
||
// 页面加载完成后的初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('学生考勤记录页面已加载');
|
||
|
||
// 显示统计信息的提示
|
||
{% if total_stats and total_stats.total_weeks > 0 %}
|
||
console.log('统计信息:', {
|
||
总周数: {{ total_stats.total_weeks }},
|
||
总出勤时长: {{ total_stats.total_actual_hours }},
|
||
迟到次数: {{ total_stats.total_late_count }},
|
||
周均时长: {{ total_stats.avg_weekly_hours }}
|
||
});
|
||
{% endif %}
|
||
});
|
||
|
||
// 如果有迟到记录,显示温馨提示
|
||
{% if total_stats and total_stats.total_late_count > 0 %}
|
||
setTimeout(function() {
|
||
if ({{ total_stats.total_late_count }} > 5) {
|
||
const toast = `
|
||
<div class="toast show position-fixed bottom-0 end-0 m-3" role="alert" style="z-index: 1055;">
|
||
<div class="toast-header">
|
||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||
<strong class="me-auto">考勤提醒</strong>
|
||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||
</div>
|
||
<div class="toast-body">
|
||
您的迟到次数较多({{ total_stats.total_late_count }}次),请注意准时上班。
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.insertAdjacentHTML('beforeend', toast);
|
||
|
||
// 5秒后自动关闭
|
||
setTimeout(function() {
|
||
const toastElement = document.querySelector('.toast');
|
||
if (toastElement) {
|
||
toastElement.remove();
|
||
}
|
||
}, 5000);
|
||
}
|
||
}, 1000);
|
||
{% endif %}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/student/dashboard.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}学生主页 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 欢迎标题 -->
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<h2 class="fw-bold text-primary">
|
||
<i class="fas fa-home me-2"></i>
|
||
欢迎回来,{{ student.name }}!
|
||
</h2>
|
||
<p class="text-muted mb-0">
|
||
学号:{{ student.student_number }} |
|
||
学院:{{ student.college }} |
|
||
导师:{{ student.supervisor }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3 mb-3">
|
||
<div class="card text-white bg-primary">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between">
|
||
<div>
|
||
<h4 class="card-title">{{ total_records }}</h4>
|
||
<p class="card-text">考勤记录</p>
|
||
</div>
|
||
<div class="align-self-center">
|
||
<i class="fas fa-calendar-check fa-2x opacity-75"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3 mb-3">
|
||
<div class="card text-white bg-success">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between">
|
||
<div>
|
||
<h4 class="card-title">{{ "%.1f"|format(total_work_hours) }}</h4>
|
||
<p class="card-text">总工作时长(小时)</p>
|
||
</div>
|
||
<div class="align-self-center">
|
||
<i class="fas fa-clock fa-2x opacity-75"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3 mb-3">
|
||
<div class="card text-white bg-warning">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between">
|
||
<div>
|
||
<h4 class="card-title">{{ total_absent_days }}</h4>
|
||
<p class="card-text">旷工天数</p>
|
||
</div>
|
||
<div class="align-self-center">
|
||
<i class="fas fa-exclamation-triangle fa-2x opacity-75"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3 mb-3">
|
||
<div class="card text-white bg-info">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between">
|
||
<div>
|
||
<h4 class="card-title">{{ pending_leaves|length }}</h4>
|
||
<p class="card-text">待审批请假</p>
|
||
</div>
|
||
<div class="align-self-center">
|
||
<i class="fas fa-file-alt fa-2x opacity-75"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要内容区域 -->
|
||
<div class="row">
|
||
<!-- 最近考勤记录 -->
|
||
<div class="col-lg-8 mb-4">
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h5 class="mb-0">
|
||
<i class="fas fa-calendar-check me-2"></i>最近考勤记录
|
||
</h5>
|
||
<a href="{{ url_for('student.attendance') }}" class="btn btn-sm btn-outline-primary">
|
||
查看全部 <i class="fas fa-arrow-right ms-1"></i>
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if recent_attendance %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead>
|
||
<tr>
|
||
<th>周次</th>
|
||
<th>实际工作时长</th>
|
||
<th>班内工作时长</th>
|
||
<th>旷工天数</th>
|
||
<th>加班时长</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in recent_attendance %}
|
||
<tr>
|
||
<td>
|
||
<strong>{{ record.week_start_date.strftime('%m-%d') }}</strong>
|
||
至
|
||
<strong>{{ record.week_end_date.strftime('%m-%d') }}</strong>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">{{ record.actual_work_hours }}h</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-success">{{ record.class_work_hours }}h</span>
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0天</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-info">{{ record.overtime_hours }}h</span>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-4">
|
||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||
<p class="text-muted">暂无考勤记录</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧边栏 -->
|
||
<div class="col-lg-4">
|
||
<!-- 待审批请假 -->
|
||
{% if pending_leaves %}
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6 class="mb-0">
|
||
<i class="fas fa-clock me-2"></i>待审批请假
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% for leave in pending_leaves %}
|
||
<div class="border-start border-warning border-3 ps-3 mb-3">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<strong>{{ leave.leave_start_date.strftime('%Y-%m-%d') }}</strong>
|
||
至
|
||
<strong>{{ leave.leave_end_date.strftime('%Y-%m-%d') }}</strong>
|
||
</div>
|
||
<span class="badge bg-warning text-dark">待审批</span>
|
||
</div>
|
||
<small class="text-muted">{{ leave.leave_reason[:30] }}...</small>
|
||
</div>
|
||
{% endfor %}
|
||
<a href="{{ url_for('student.leave_records') }}" class="btn btn-sm btn-outline-primary w-100">
|
||
查看所有请假记录
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 快速操作 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6 class="mb-0">
|
||
<i class="fas fa-bolt me-2"></i>快速操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="d-grid gap-2">
|
||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-primary">
|
||
<i class="fas fa-calendar-check me-2"></i>查看考勤记录
|
||
</a>
|
||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-info">
|
||
<i class="fas fa-chart-line me-2"></i>个人统计
|
||
</a>
|
||
<a href="{{ url_for('auth.change_password') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-key me-2"></i>修改密码
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 个人信息 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6 class="mb-0">
|
||
<i class="fas fa-user me-2"></i>个人信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-2">
|
||
<div class="col-4"><strong>姓名:</strong></div>
|
||
<div class="col-8">{{ student.name }}</div>
|
||
|
||
<div class="col-4"><strong>性别:</strong></div>
|
||
<div class="col-8">{{ student.gender }}</div>
|
||
|
||
<div class="col-4"><strong>年级:</strong></div>
|
||
<div class="col-8">{{ student.grade }}级</div>
|
||
|
||
<div class="col-4"><strong>专业:</strong></div>
|
||
<div class="col-8">{{ student.major }}</div>
|
||
|
||
<div class="col-4"><strong>学位:</strong></div>
|
||
<div class="col-8">{{ student.degree_type }}</div>
|
||
|
||
{% if student.phone %}
|
||
<div class="col-4"><strong>电话:</strong></div>
|
||
<div class="col-8">{{ student.phone }}</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 如果有数据,可以在这里添加图表初始化代码
|
||
console.log('Dashboard loaded');
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/student/statistics.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}个人统计 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<h2 class="fw-bold text-primary">
|
||
<i class="fas fa-chart-line me-2"></i>个人统计分析
|
||
</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||
<li class="breadcrumb-item active">个人统计</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 学生基本信息 -->
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-user me-2"></i>基本信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-3">
|
||
<strong>学号:</strong> {{ student.student_number }}
|
||
</div>
|
||
<div class="col-md-3">
|
||
<strong>姓名:</strong> {{ student.name }}
|
||
</div>
|
||
<div class="col-md-3">
|
||
<strong>年级:</strong> {{ student.grade }}级
|
||
</div>
|
||
<div class="col-md-3">
|
||
<strong>入学日期:</strong> {{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '未设置' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选器 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-filter me-2"></i>筛选条件
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<form method="GET" action="{{ url_for('student.statistics') }}" class="row g-3">
|
||
<div class="col-md-4">
|
||
<label for="start_date" class="form-label">开始日期</label>
|
||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||
value="{{ start_date or '' }}">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label for="end_date" class="form-label">结束日期</label>
|
||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||
value="{{ end_date or '' }}">
|
||
</div>
|
||
<div class="col-md-4 d-flex align-items-end">
|
||
<div class="d-grid gap-2 d-md-flex w-100">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="fas fa-search me-1"></i>筛选
|
||
</button>
|
||
<a href="{{ url_for('student.statistics') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-refresh me-1"></i>重置
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 入学以来总体统计 -->
|
||
{% if all_time_stats %}
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<div class="card shadow border-left-primary">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-graduation-cap me-2"></i>入学以来总体表现
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-primary">{{ all_time_stats.attendance_weeks }}</h4>
|
||
<small class="text-muted">总考勤周数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-success">{{ "%.1f"|format(all_time_stats.total_work_hours) }}</h4>
|
||
<small class="text-muted">总工作时长(h)</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-info">{{ "%.1f"|format(all_time_stats.total_class_hours) }}</h4>
|
||
<small class="text-muted">班内工作(h)</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-warning">{{ all_time_stats.total_late_count }}</h4>
|
||
<small class="text-muted">迟到次数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-danger">{{ all_time_stats.total_absent_days }}</h4>
|
||
<small class="text-muted">旷工天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="text-center">
|
||
<h4 class="text-secondary">{{ "%.1f"|format(all_time_stats.attendance_rate) }}%</h4>
|
||
<small class="text-muted">出勤率</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 当前筛选条件下的统计 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card border-left-primary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||
筛选期间考勤周数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_stats.attendance_weeks }}周
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-success shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||
总出勤时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(total_stats.total_work_hours) }}h
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-warning shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||
迟到次数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_stats.total_late_count }}次
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-info shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||
周均工作时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(total_stats.avg_weekly_hours) }}h
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表展示区域 -->
|
||
<div class="row mb-4">
|
||
<!-- 月度统计图表 -->
|
||
<div class="col-lg-8">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-bar me-2"></i>月度考勤统计
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<canvas id="monthlyChart" width="400" height="200"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近趋势图表 -->
|
||
<div class="col-lg-4">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-line me-2"></i>最近12周趋势
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<canvas id="trendChart" width="400" height="200"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细记录表格 -->
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-table me-2"></i>详细考勤记录
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if attendance_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>周次</th>
|
||
<th>实际工作时长</th>
|
||
<th>班内工作时长</th>
|
||
<th>加班时长</th>
|
||
<th>旷工天数</th>
|
||
<th>考勤状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in attendance_records %}
|
||
<tr>
|
||
<td>
|
||
<div>
|
||
<strong>{{ record.week_start_date.strftime('%Y-%m-%d') }}</strong>
|
||
<small class="d-block text-muted">
|
||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||
</small>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-success">{{ "%.1f"|format(record.class_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
{% if record.overtime_hours > 0 %}
|
||
<span class="badge bg-info">{{ "%.1f"|format(record.overtime_hours) }}h</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">0h</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-danger">{{ record.absent_days }}天</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0天</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% set performance_score = (record.actual_work_hours / 40 * 100) if record.actual_work_hours else 0 %}
|
||
{% if performance_score >= 80 %}
|
||
<span class="badge bg-success">优秀</span>
|
||
{% elif performance_score >= 60 %}
|
||
<span class="badge bg-warning">良好</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">待改善</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<a href="{{ url_for('student.attendance_details', record_id=record.record_id) }}"
|
||
class="btn btn-sm btn-outline-primary">
|
||
<i class="fas fa-eye me-1"></i>详情
|
||
</a>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-chart-line fa-4x text-muted mb-3"></i>
|
||
<h5 class="text-muted">暂无统计数据</h5>
|
||
<p class="text-muted">当前筛选条件下没有找到考勤记录</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷操作 -->
|
||
<div class="row mt-4">
|
||
<div class="col">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-tools me-2"></i>快捷操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-3 mb-2">
|
||
<a href="{{ url_for('student.attendance') }}" class="btn btn-outline-primary w-100">
|
||
<i class="fas fa-calendar-check me-2"></i>考勤记录
|
||
</a>
|
||
</div>
|
||
<div class="col-md-3 mb-2">
|
||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-secondary w-100">
|
||
<i class="fas fa-user me-2"></i>个人资料
|
||
</a>
|
||
</div>
|
||
<div class="col-md-3 mb-2">
|
||
<button type="button" class="btn btn-outline-success w-100" onclick="exportStatistics()">
|
||
<i class="fas fa-download me-2"></i>导出统计
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-left-primary {
|
||
border-left: 0.25rem solid #4e73df !important;
|
||
}
|
||
|
||
.border-left-success {
|
||
border-left: 0.25rem solid #1cc88a !important;
|
||
}
|
||
|
||
.border-left-info {
|
||
border-left: 0.25rem solid #36b9cc !important;
|
||
}
|
||
|
||
.border-left-warning {
|
||
border-left: 0.25rem solid #f6c23e !important;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.card {
|
||
border: 0;
|
||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||
}
|
||
|
||
.table th {
|
||
background-color: #f8f9fc;
|
||
border-top: none;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
color: #5a5c69;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
/* 图表容器样式 */
|
||
canvas {
|
||
max-height: 300px;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 月度统计图表
|
||
const monthlyData = {
|
||
labels: [
|
||
{% for stat in monthly_stats %}
|
||
'{{ stat.month }}',
|
||
{% endfor %}
|
||
],
|
||
datasets: [{
|
||
label: '总工作时长',
|
||
data: [
|
||
{% for stat in monthly_stats %}
|
||
{{ stat.total_hours or 0 }},
|
||
{% endfor %}
|
||
],
|
||
backgroundColor: 'rgba(78, 115, 223, 0.2)',
|
||
borderColor: 'rgba(78, 115, 223, 1)',
|
||
borderWidth: 2,
|
||
fill: true
|
||
}, {
|
||
label: '班内工作时长',
|
||
data: [
|
||
{% for stat in monthly_stats %}
|
||
{{ stat.class_hours or 0 }},
|
||
{% endfor %}
|
||
],
|
||
backgroundColor: 'rgba(28, 200, 138, 0.2)',
|
||
borderColor: 'rgba(28, 200, 138, 1)',
|
||
borderWidth: 2,
|
||
fill: true
|
||
}]
|
||
};
|
||
|
||
const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
|
||
const monthlyChart = new Chart(monthlyCtx, {
|
||
type: 'line',
|
||
data: monthlyData,
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: '工作时长 (小时)'
|
||
}
|
||
},
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: '月份'
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top'
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: '月度工作时长趋势'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 最近12周趋势图表
|
||
const trendData = {
|
||
labels: [
|
||
{% for week in recent_weeks %}
|
||
'{{ week.week_start_date.strftime("%m/%d") }}',
|
||
{% endfor %}
|
||
],
|
||
datasets: [{
|
||
label: '周工作时长',
|
||
data: [
|
||
{% for week in recent_weeks %}
|
||
{{ week.actual_work_hours }},
|
||
{% endfor %}
|
||
],
|
||
backgroundColor: 'rgba(54, 185, 204, 0.2)',
|
||
borderColor: 'rgba(54, 185, 204, 1)',
|
||
borderWidth: 2,
|
||
tension: 0.1
|
||
}]
|
||
};
|
||
|
||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||
const trendChart = new Chart(trendCtx, {
|
||
type: 'line',
|
||
data: trendData,
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: '时长(h)'
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: '近期趋势'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 导出统计功能
|
||
function exportStatistics() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.set('export', 'excel');
|
||
|
||
const exportUrl = '{{ url_for("student.statistics") }}?' + params.toString();
|
||
|
||
const link = document.createElement('a');
|
||
link.href = exportUrl;
|
||
link.download = '个人考勤统计.xlsx';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
|
||
// 页面加载完成后的初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('个人统计页面已加载');
|
||
|
||
// 显示统计摘要
|
||
{% if total_stats %}
|
||
console.log('统计摘要:', {
|
||
考勤周数: {{ total_stats.attendance_weeks }},
|
||
总工作时长: {{ total_stats.total_work_hours }},
|
||
迟到次数: {{ total_stats.total_late_count }},
|
||
周均时长: {{ total_stats.avg_weekly_hours }}
|
||
});
|
||
{% endif %}
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/student/attendance_details.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}考勤详情 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<div>
|
||
<h2 class="fw-bold text-primary mb-0">
|
||
<i class="fas fa-calendar-check me-2"></i>我的考勤详情
|
||
</h2>
|
||
<nav aria-label="breadcrumb">
|
||
<ol class="breadcrumb">
|
||
<li class="breadcrumb-item"><a href="{{ url_for('student.dashboard') }}">首页</a></li>
|
||
<li class="breadcrumb-item"><a href="{{ url_for('student.attendance') }}">考勤记录</a></li>
|
||
<li class="breadcrumb-item active">考勤详情</li>
|
||
</ol>
|
||
</nav>
|
||
</div>
|
||
<div>
|
||
<a href="{{ url_for('student.attendance') }}" class="btn btn-secondary">
|
||
<i class="fas fa-arrow-left me-2"></i>返回列表
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 基本信息卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-user me-2"></i>学生信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6">
|
||
<p><strong>学号:</strong> {{ record.student_number }}</p>
|
||
<p><strong>姓名:</strong> {{ record.name }}</p>
|
||
{% if student %}
|
||
<p><strong>年级:</strong> {{ student.grade }}</p>
|
||
<p><strong>学院:</strong> {{ student.college or '未设置' }}</p>
|
||
{% endif %}
|
||
</div>
|
||
<div class="col-6">
|
||
{% if student %}
|
||
<p><strong>专业:</strong> {{ student.major or '未设置' }}</p>
|
||
<p><strong>导师:</strong> {{ student.supervisor or '未设置' }}</p>
|
||
<p><strong>学位类型:</strong> {{ student.degree_type or '未设置' }}</p>
|
||
<p><strong>状态:</strong>
|
||
<span class="badge bg-{{ 'success' if student.status == '在读' else 'secondary' }}">
|
||
{{ student.status }}
|
||
</span>
|
||
</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-calendar-week me-2"></i>考勤周期
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6">
|
||
<p><strong>开始日期:</strong> {{ record.week_start_date.strftime('%Y年%m月%d日') }}</p>
|
||
<p><strong>结束日期:</strong> {{ record.week_end_date.strftime('%Y年%m月%d日') }}</p>
|
||
</div>
|
||
<div class="col-6">
|
||
<p><strong>创建时间:</strong> {{ record.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||
<p><strong>更新时间:</strong> {{ record.updated_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计数据卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card border-left-primary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||
实际出勤时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(record.actual_work_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-success shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||
班内工作时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(record.class_work_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-warning shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||
旷工天数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ record.absent_days }}天
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-info shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||
加班时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(record.overtime_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-moon fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 每日考勤明细 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-list me-2"></i>每日考勤明细
|
||
<small class="text-muted">(点击详情按钮查看详细时段信息)</small>
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if daily_details %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>日期</th>
|
||
<th>星期</th>
|
||
<th>考勤状态</th>
|
||
<th>签到时间</th>
|
||
<th>签退时间</th>
|
||
<th>工作时长</th>
|
||
<th>备注</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for detail in daily_details %}
|
||
<tr class="{% if '迟到' in detail.status %}table-warning{% elif detail.status == '缺勤' %}table-danger{% endif %}">
|
||
<td>
|
||
<strong>{{ detail.attendance_date.strftime('%m-%d') }}</strong>
|
||
<small class="d-block text-muted">{{ detail.attendance_date.strftime('%Y') }}</small>
|
||
</td>
|
||
<td>
|
||
{% set weekday = detail.attendance_date.weekday() %}
|
||
{% if weekday == 0 %}周一
|
||
{% elif weekday == 1 %}周二
|
||
{% elif weekday == 2 %}周三
|
||
{% elif weekday == 3 %}周四
|
||
{% elif weekday == 4 %}周五
|
||
{% elif weekday == 5 %}周六
|
||
{% else %}周日
|
||
{% endif %}
|
||
|
||
{% if weekday >= 5 %}
|
||
<small class="badge bg-info">休息日</small>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.status == '正常' %}
|
||
<span class="badge bg-success">{{ detail.status }}</span>
|
||
{% elif '迟到' in detail.status %}
|
||
<span class="badge bg-warning">{{ detail.status }}</span>
|
||
{% elif detail.status == '缺勤' %}
|
||
<span class="badge bg-danger">{{ detail.status }}</span>
|
||
{% elif detail.status == '请假' %}
|
||
<span class="badge bg-orange">{{ detail.status }}</span>
|
||
{% elif detail.status == '休息' %}
|
||
<span class="badge bg-info">{{ detail.status }}</span>
|
||
{% elif detail.status == '加班' %}
|
||
<span class="badge bg-primary">{{ detail.status }}</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">{{ detail.status }}</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.check_in_time %}
|
||
<span class="badge bg-primary">{{ detail.check_in_time.strftime('%H:%M') }}</span>
|
||
{% else %}
|
||
<span class="text-muted">未打卡</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.check_out_time %}
|
||
<span class="badge bg-success">{{ detail.check_out_time.strftime('%H:%M') }}</span>
|
||
{% else %}
|
||
<span class="text-muted">未打卡</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.duration_hours %}
|
||
<span class="badge bg-primary">{{ detail.duration_hours }}h</span>
|
||
{% else %}
|
||
<span class="text-muted">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.summary_remarks %}
|
||
<small class="text-muted">{{ detail.summary_remarks }}</small>
|
||
{% else %}
|
||
<span class="text-muted">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %}
|
||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||
onclick="showDetailModal('{{ detail.detail_id }}', '{{ detail.attendance_date.strftime('%Y-%m-%d') }}', '{{ detail.remarks|escape }}')"
|
||
title="查看详细时段">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-calendar-times fa-4x text-muted mb-3"></i>
|
||
<h5 class="text-muted">暂无每日考勤明细</h5>
|
||
<p class="text-muted">该考勤周期内没有详细的打卡记录</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计分析和历史对比 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-pie me-2"></i>本周考勤统计
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-success">{{ present_days }}</h6>
|
||
<small class="text-muted">正常天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-warning">{{ late_days }}</h6>
|
||
<small class="text-muted">迟到天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-danger">{{ absent_days }}</h6>
|
||
<small class="text-muted">缺勤天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<h6 class="text-info">{{ "%.1f"|format(avg_daily_hours) }}h</h6>
|
||
<small class="text-muted">日均时长</small>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 出勤率计算 -->
|
||
<hr>
|
||
<div class="row text-center">
|
||
<div class="col-4">
|
||
<h6 class="text-primary">{{ "%.1f"|format((present_days / max(total_days, 1) * 100)) }}%</h6>
|
||
<small class="text-muted">出勤率</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h6 class="text-success">{{ "%.1f"|format((record.class_work_hours / max(record.actual_work_hours, 1) * 100)) }}%</h6>
|
||
<small class="text-muted">班内工作率</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h6 class="text-info">{{ total_days }}</h6>
|
||
<small class="text-muted">考勤天数</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-history me-2"></i>最近记录对比
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if recent_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>周期</th>
|
||
<th>出勤时长</th>
|
||
<th>旷工天数</th>
|
||
<th>对比</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record_item in recent_records %}
|
||
<tr>
|
||
<td>
|
||
<small>{{ record_item.week_start_date.strftime('%m-%d') }}</small>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">{{ "%.1f"|format(record_item.actual_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
{% if record_item.absent_days > 0 %}
|
||
<span class="badge bg-warning">{{ record_item.absent_days }}</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% set diff = record.actual_work_hours - record_item.actual_work_hours %}
|
||
{% if diff > 0 %}
|
||
<small class="text-success">+{{ "%.1f"|format(diff) }}h</small>
|
||
{% elif diff < 0 %}
|
||
<small class="text-danger">{{ "%.1f"|format(diff) }}h</small>
|
||
{% else %}
|
||
<small class="text-muted">-</small>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center mb-0">暂无历史记录</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作建议 -->
|
||
{% if late_days > 0 or absent_days > 0 %}
|
||
<div class="alert alert-warning" role="alert">
|
||
<h6 class="alert-heading"><i class="fas fa-exclamation-triangle me-2"></i>考勤提醒</h6>
|
||
<p class="mb-0">
|
||
{% if late_days > 0 %}
|
||
本周有 <strong>{{ late_days }}</strong> 天迟到,
|
||
{% endif %}
|
||
{% if absent_days > 0 %}
|
||
有 <strong>{{ absent_days }}</strong> 天缺勤,
|
||
{% endif %}
|
||
请注意调整作息时间,保持良好的考勤记录。
|
||
</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- 详细时段信息模态框 -->
|
||
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="detailModalLabel">
|
||
<i class="fas fa-clock me-2"></i>详细打卡时段 - <span id="modalDate"></span>
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="detailContent">
|
||
<!-- 内容将通过JavaScript动态填充 -->
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-left-primary {
|
||
border-left: 0.25rem solid #4e73df !important;
|
||
}
|
||
|
||
.border-left-success {
|
||
border-left: 0.25rem solid #1cc88a !important;
|
||
}
|
||
|
||
.border-left-info {
|
||
border-left: 0.25rem solid #36b9cc !important;
|
||
}
|
||
|
||
.border-left-warning {
|
||
border-left: 0.25rem solid #f6c23e !important;
|
||
}
|
||
|
||
.border-end {
|
||
border-right: 1px solid #dee2e6;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.card {
|
||
border: 0;
|
||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||
}
|
||
|
||
.period-card {
|
||
border: 1px solid #e3e6f0;
|
||
border-radius: 0.35rem;
|
||
padding: 1rem;
|
||
margin-bottom: 1rem;
|
||
background-color: #f8f9fc;
|
||
}
|
||
|
||
.period-header {
|
||
font-weight: 600;
|
||
color: #5a5c69;
|
||
margin-bottom: 0.75rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid #e3e6f0;
|
||
}
|
||
|
||
.time-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.badge.bg-orange {
|
||
background-color: #fd7e14 !important;
|
||
color: white;
|
||
}
|
||
|
||
.time-info > div {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
|
||
/* 高亮迟到和缺勤行 */
|
||
.table-warning {
|
||
--bs-table-accent-bg: rgba(255, 193, 7, 0.1);
|
||
}
|
||
|
||
.table-danger {
|
||
--bs-table-accent-bg: rgba(220, 53, 69, 0.1);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.time-info {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.time-info > div {
|
||
width: 100%;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
function showDetailModal(detailId, date, remarksJson) {
|
||
document.getElementById('modalDate').textContent = date;
|
||
|
||
console.log('调用showDetailModal', detailId, date, remarksJson);
|
||
|
||
let detailsData = null;
|
||
|
||
try {
|
||
if (remarksJson && remarksJson.startsWith('{')) {
|
||
const parsed = JSON.parse(remarksJson);
|
||
detailsData = parsed.details;
|
||
console.log('解析的详细数据:', detailsData);
|
||
}
|
||
} catch (e) {
|
||
console.error('解析详细信息失败:', e);
|
||
}
|
||
|
||
if (!detailsData) {
|
||
document.getElementById('detailContent').innerHTML =
|
||
'<div class="alert alert-info"><i class="fas fa-info-circle me-2"></i>暂无详细时段信息</div>';
|
||
} else {
|
||
let html = '';
|
||
|
||
// 显示各个时段的详情
|
||
const periods = [
|
||
{ key: 'morning', name: '早上时段', time: '09:45-11:30', icon: 'fa-sun' },
|
||
{ key: 'afternoon', name: '下午时段', time: '13:30-18:30', icon: 'fa-cloud-sun' },
|
||
{ key: 'evening', name: '晚上时段', time: '19:00-23:30', icon: 'fa-moon' }
|
||
];
|
||
|
||
periods.forEach(period => {
|
||
if (detailsData[period.key]) {
|
||
const data = detailsData[period.key];
|
||
html += `
|
||
<div class="period-card">
|
||
<div class="period-header">
|
||
<i class="fas ${period.icon} me-2"></i>${period.name}
|
||
<small class="text-muted">(${period.time})</small>
|
||
</div>
|
||
<div class="time-info">
|
||
<div>
|
||
<strong>签到:</strong>
|
||
<span class="badge ${getStatusClass(data.status, 'in')}">
|
||
${data.in || '未打卡'}
|
||
</span>
|
||
${data.late_minutes ? `<small class="text-warning d-block">(迟到${data.late_minutes}分钟)</small>` : ''}
|
||
</div>
|
||
<div>
|
||
<strong>签退:</strong>
|
||
<span class="badge ${getStatusClass(data.status, 'out')}">
|
||
${data.out || '未打卡'}
|
||
</span>
|
||
${data.early_minutes ? `<small class="text-warning d-block">(早退${data.early_minutes}分钟)</small>` : ''}
|
||
</div>
|
||
<div>
|
||
<strong>工时:</strong>
|
||
${calculatePeriodHours(data.in, data.out)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// 如果是周末加班
|
||
if (detailsData.overtime) {
|
||
html += `
|
||
<div class="period-card">
|
||
<div class="period-header">
|
||
<i class="fas fa-business-time me-2"></i>周末加班
|
||
</div>
|
||
<div class="time-info">
|
||
<div>
|
||
<strong>开始:</strong>
|
||
<span class="badge bg-info">${detailsData.overtime.in || '未记录'}</span>
|
||
</div>
|
||
<div>
|
||
<strong>结束:</strong>
|
||
<span class="badge bg-info">${detailsData.overtime.out || '未记录'}</span>
|
||
</div>
|
||
<div>
|
||
<strong>加班时长:</strong>
|
||
${calculatePeriodHours(detailsData.overtime.in, detailsData.overtime.out)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (html === '') {
|
||
html = '<div class="alert alert-warning"><i class="fas fa-exclamation-triangle me-2"></i>该日期没有详细的时段打卡信息</div>';
|
||
}
|
||
|
||
document.getElementById('detailContent').innerHTML = html;
|
||
}
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||
modal.show();
|
||
}
|
||
|
||
function getStatusClass(status, type) {
|
||
if (status === 'normal') return 'bg-success';
|
||
if (status === 'late' && type === 'in') return 'bg-warning';
|
||
if (status === 'early_leave' && type === 'out') return 'bg-warning';
|
||
if (status === 'missing') return 'bg-secondary';
|
||
return 'bg-secondary';
|
||
}
|
||
|
||
function calculatePeriodHours(startTime, endTime) {
|
||
if (!startTime || !endTime) return '<span class="text-muted">-</span>';
|
||
|
||
try {
|
||
const start = new Date(`2000-01-01 ${startTime}:00`);
|
||
const end = new Date(`2000-01-01 ${endTime}:00`);
|
||
let diff = (end - start) / (1000 * 60 * 60);
|
||
|
||
// 处理跨天情况
|
||
if (diff < 0) {
|
||
diff += 24;
|
||
}
|
||
|
||
if (diff > 0) {
|
||
return `<span class="badge bg-primary">${diff.toFixed(1)}h</span>`;
|
||
}
|
||
} catch (e) {
|
||
console.error('计算时长失败:', e);
|
||
}
|
||
|
||
return '<span class="text-muted">-</span>';
|
||
}
|
||
|
||
// 页面加载完成后初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('学生考勤详情页面已加载');
|
||
|
||
// 显示考勤统计
|
||
console.log('考勤统计:', {
|
||
正常天数: {{ present_days }},
|
||
迟到天数: {{ late_days }},
|
||
缺勤天数: {{ absent_days }},
|
||
日均时长: {{ "%.1f"|format(avg_daily_hours) }}
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/attendance_management.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}考勤管理 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-calendar-check me-2"></i>考勤管理
|
||
</h1>
|
||
<div>
|
||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-primary me-2">
|
||
<i class="fas fa-upload me-2"></i>上传数据
|
||
</a>
|
||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-secondary">
|
||
<i class="fas fa-home me-2"></i>返回首页
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索和筛选 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-search me-2"></i>搜索筛选
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<form method="GET" class="row g-3" id="searchForm">
|
||
<div class="col-md-2">
|
||
<label for="start_date" class="form-label">开始日期</label>
|
||
<input type="date" class="form-control" id="start_date" name="start_date"
|
||
value="{{ start_date or '' }}">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label for="end_date" class="form-label">结束日期</label>
|
||
<input type="date" class="form-control" id="end_date" name="end_date"
|
||
value="{{ end_date or '' }}">
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label for="student_search" class="form-label">学生姓名/学号</label>
|
||
<input type="text" class="form-control" id="student_search" name="student_search"
|
||
placeholder="输入姓名或学号" value="{{ student_search or '' }}">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label for="sort_by" class="form-label">排序方式</label>
|
||
<select class="form-select" id="sort_by" name="sort_by">
|
||
<option value="created_at_desc" {{ 'selected' if sort_by == 'created_at_desc' else '' }}>最新记录</option>
|
||
<option value="created_at_asc" {{ 'selected' if sort_by == 'created_at_asc' else '' }}>最早记录</option>
|
||
<option value="actual_work_hours_desc" {{ 'selected' if sort_by == 'actual_work_hours_desc' else '' }}>出勤时长↓</option>
|
||
<option value="actual_work_hours_asc" {{ 'selected' if sort_by == 'actual_work_hours_asc' else '' }}>出勤时长↑</option>
|
||
<option value="class_work_hours_desc" {{ 'selected' if sort_by == 'class_work_hours_desc' else '' }}>班内工作↓</option>
|
||
<option value="class_work_hours_asc" {{ 'selected' if sort_by == 'class_work_hours_asc' else '' }}>班内工作↑</option>
|
||
<option value="absent_days_desc" {{ 'selected' if sort_by == 'absent_days_desc' else '' }}>旷工天数↓</option>
|
||
<option value="absent_days_asc" {{ 'selected' if sort_by == 'absent_days_asc' else '' }}>旷工天数↑</option>
|
||
<option value="late_count_desc" {{ 'selected' if sort_by == 'late_count_desc' else '' }}>迟到次数↓</option>
|
||
<option value="late_count_asc" {{ 'selected' if sort_by == 'late_count_asc' else '' }}>迟到次数↑</option>
|
||
<option value="overtime_hours_desc" {{ 'selected' if sort_by == 'overtime_hours_desc' else '' }}>加班时长↓</option>
|
||
<option value="overtime_hours_asc" {{ 'selected' if sort_by == 'overtime_hours_asc' else '' }}>加班时长↑</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3 d-flex align-items-end">
|
||
<button type="submit" class="btn btn-primary me-2">
|
||
<i class="fas fa-search me-1"></i>搜索
|
||
</button>
|
||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-outline-secondary me-2">
|
||
<i class="fas fa-refresh"></i>
|
||
</a>
|
||
<button type="button" class="btn btn-outline-info" onclick="toggleAdvancedSearch()" title="高级搜索">
|
||
<i class="fas fa-cog"></i>
|
||
</button>
|
||
</div>
|
||
<!-- 隐藏字段保持分页状态 -->
|
||
<input type="hidden" name="page" value="{{ pagination.page if pagination else 1 }}">
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 考勤记录表格 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-table me-2"></i>考勤记录
|
||
</h6>
|
||
{% if attendance_records %}
|
||
<span class="badge bg-info">
|
||
共 {{ pagination.total }} 条记录
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="card-body">
|
||
{% if attendance_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>学号</th>
|
||
<th>姓名</th>
|
||
<th>考勤周期</th>
|
||
<th class="sortable" data-sort="actual_work_hours">
|
||
出勤时长
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="class_work_hours">
|
||
班内工作
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="absent_days">
|
||
旷工天数
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="late_count">
|
||
迟到次数
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="overtime_hours">
|
||
加班时长
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="created_at">
|
||
记录时间
|
||
<i class="fas fa-sort ms-1 text-muted sort-icon"></i>
|
||
</th>
|
||
<th class="text-center">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in attendance_records %}
|
||
<tr>
|
||
<td>
|
||
<a href="{{ url_for('admin.student_detail', student_number=record.student_number) }}"
|
||
class="text-decoration-none">
|
||
{{ record.student_number }}
|
||
</a>
|
||
</td>
|
||
<td>{{ record.name }}</td>
|
||
<td>
|
||
<small>
|
||
{{ record.week_start_date.strftime('%Y-%m-%d') }}<br>
|
||
至 {{ record.week_end_date.strftime('%Y-%m-%d') }}
|
||
</small>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">
|
||
{{ "%.1f"|format(record.actual_work_hours) }}h
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-success">
|
||
{{ "%.1f"|format(record.class_work_hours) }}h
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-warning">{{ record.absent_days }}天</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0天</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% set late_count = record.late_count if record.late_count is defined else 0 %}
|
||
{% if late_count > 0 %}
|
||
<span class="badge bg-warning">{{ late_count }}次</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0次</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if record.overtime_hours > 0 %}
|
||
<span class="badge bg-info">
|
||
{{ "%.1f"|format(record.overtime_hours) }}h
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">0h</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<small class="text-muted">
|
||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||
</small>
|
||
</td>
|
||
<td class="text-center">
|
||
<div class="btn-group btn-group-sm">
|
||
<button type="button" class="btn btn-outline-primary"
|
||
onclick="viewDetails({{ record.record_id }})"
|
||
title="查看详情">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-danger"
|
||
onclick="deleteRecord({{ record.record_id }}, '{{ record.name }}')"
|
||
title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
{% if pagination.pages > 1 %}
|
||
<nav aria-label="考勤记录分页">
|
||
<ul class="pagination justify-content-center">
|
||
{% if pagination.has_prev %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||
page=pagination.prev_num,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
student_search=student_search,
|
||
sort_by=sort_by) }}">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
|
||
{% for page_num in pagination.iter_pages() %}
|
||
{% if page_num %}
|
||
{% if page_num != pagination.page %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||
page=page_num,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
student_search=student_search,
|
||
sort_by=sort_by) }}">
|
||
{{ page_num }}
|
||
</a>
|
||
</li>
|
||
{% else %}
|
||
<li class="page-item active">
|
||
<span class="page-link">{{ page_num }}</span>
|
||
</li>
|
||
{% endif %}
|
||
{% else %}
|
||
<li class="page-item disabled">
|
||
<span class="page-link">…</span>
|
||
</li>
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
{% if pagination.has_next %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.attendance_management',
|
||
page=pagination.next_num,
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
student_search=student_search,
|
||
sort_by=sort_by) }}">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
</nav>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
<!-- 空状态 -->
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
|
||
<h5 class="text-muted">暂无考勤记录</h5>
|
||
<p class="text-muted mb-4">还没有上传任何考勤数据</p>
|
||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-primary">
|
||
<i class="fas fa-upload me-2"></i>立即上传考勤数据
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计信息卡片 -->
|
||
{% if attendance_records %}
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-bar me-2"></i>当前筛选统计
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-2">
|
||
<div class="border-end">
|
||
<h6 class="text-primary">{{ pagination.total }}</h6>
|
||
<small class="text-muted">总记录</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-2">
|
||
<div class="border-end">
|
||
<h6 class="text-success">
|
||
{{ attendance_records|sum(attribute='actual_work_hours')|round(1) }}h
|
||
</h6>
|
||
<small class="text-muted">总出勤</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-1">
|
||
<div class="border-end">
|
||
<h6 class="text-danger">
|
||
{{ attendance_records|sum(attribute='absent_days') }}
|
||
</h6>
|
||
<small class="text-muted">旷工</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-1">
|
||
<div class="border-end">
|
||
<h6 class="text-warning">
|
||
{% set total_leave = statistics.total_leave_days if statistics and statistics.total_leave_days else 0 %}
|
||
{{ total_leave }}
|
||
</h6>
|
||
<small class="text-muted">请假</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-2">
|
||
<div class="border-end">
|
||
<h6 class="text-warning">
|
||
{% set total_late = attendance_records|sum(attribute='late_count') if attendance_records[0].late_count is defined else 0 %}
|
||
{{ total_late }}
|
||
</h6>
|
||
<small class="text-muted">迟到次数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-2">
|
||
<div class="border-end">
|
||
<h6 class="text-info">
|
||
{{ attendance_records|sum(attribute='overtime_hours')|round(1) }}h
|
||
</h6>
|
||
<small class="text-muted">总加班</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-2">
|
||
<h6 class="text-secondary">
|
||
{{ "%.1f"|format((attendance_records|sum(attribute='actual_work_hours')) / (attendance_records|length) if attendance_records|length > 0 else 0) }}h
|
||
</h6>
|
||
<small class="text-muted">平均出勤</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-tools me-2"></i>快捷操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('admin.upload_attendance') }}"
|
||
class="btn btn-outline-primary btn-block">
|
||
<i class="fas fa-upload me-2"></i>上传新数据
|
||
</a>
|
||
</div>
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('admin.student_list') }}"
|
||
class="btn btn-outline-success btn-block">
|
||
<i class="fas fa-users me-2"></i>学生管理
|
||
</a>
|
||
</div>
|
||
<div class="col-6 mb-2">
|
||
<a href="{{ url_for('admin.statistics') }}"
|
||
class="btn btn-outline-info btn-block">
|
||
<i class="fas fa-chart-line me-2"></i>统计报表
|
||
</a>
|
||
</div>
|
||
<div class="col-6 mb-2">
|
||
<button class="btn btn-outline-secondary btn-block" onclick="exportData()">
|
||
<i class="fas fa-download me-2"></i>导出数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- 删除确认模态框 -->
|
||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="deleteModalLabel">
|
||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>确认删除
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p>您确定要删除 <strong id="studentName"></strong> 的这条考勤记录吗?</p>
|
||
<p class="text-danger"><small>此操作不可撤销!</small></p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-danger" id="confirmDelete">确认删除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.btn-block {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
|
||
.border-end {
|
||
border-right: 1px solid #dee2e6;
|
||
}
|
||
|
||
.table th {
|
||
background-color: #f8f9fc;
|
||
border-top: none;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
color: #5a5c69;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.btn-group-sm > .btn {
|
||
padding: 0.25rem 0.5rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
/* 排序功能样式 */
|
||
.sortable {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
position: relative;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.sortable:hover {
|
||
background-color: #e9ecef !important;
|
||
}
|
||
|
||
.sort-icon {
|
||
font-size: 0.7rem;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.sortable:hover .sort-icon {
|
||
color: #007bff !important;
|
||
}
|
||
|
||
.sortable.sort-active {
|
||
background-color: #e7f1ff !important;
|
||
}
|
||
|
||
.sortable.sort-active .sort-icon {
|
||
color: #007bff !important;
|
||
}
|
||
|
||
.sortable.sort-asc .sort-icon:before {
|
||
content: "\f0de"; /* fa-sort-up */
|
||
}
|
||
|
||
.sortable.sort-desc .sort-icon:before {
|
||
content: "\f0dd"; /* fa-sort-down */
|
||
}
|
||
|
||
/* 响应式调整 */
|
||
@media (max-width: 768px) {
|
||
.col-2 {
|
||
flex: 0 0 33.333333%;
|
||
max-width: 33.333333%;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.table-responsive {
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 0.65rem;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
let recordToDelete = null;
|
||
|
||
function viewDetails(recordId) {
|
||
window.location.href = `/admin/attendance/${recordId}/details`;
|
||
}
|
||
|
||
function editRecord(recordId) {
|
||
window.location.href = `/admin/attendance/${recordId}/edit`;
|
||
}
|
||
|
||
function deleteRecord(recordId, studentName) {
|
||
recordToDelete = recordId;
|
||
document.getElementById('studentName').textContent = studentName;
|
||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||
modal.show();
|
||
}
|
||
|
||
document.getElementById('confirmDelete').addEventListener('click', function() {
|
||
if (recordToDelete) {
|
||
const form = document.createElement('form');
|
||
form.method = 'POST';
|
||
form.action = `/admin/attendance/${recordToDelete}/delete`;
|
||
|
||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||
if (csrfToken) {
|
||
const input = document.createElement('input');
|
||
input.type = 'hidden';
|
||
input.name = 'csrf_token';
|
||
input.value = csrfToken.getAttribute('content');
|
||
form.appendChild(input);
|
||
}
|
||
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
}
|
||
});
|
||
|
||
function exportData() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.set('export', 'excel');
|
||
window.location.href = '{{ url_for("admin.attendance_management") }}?' + params.toString();
|
||
}
|
||
|
||
// 自动设置结束日期为开始日期的一周后
|
||
document.getElementById('start_date').addEventListener('change', function() {
|
||
const startDate = new Date(this.value);
|
||
const endDate = new Date(startDate);
|
||
endDate.setDate(startDate.getDate() + 6);
|
||
document.getElementById('end_date').value = endDate.toISOString().split('T')[0];
|
||
});
|
||
|
||
// 排序选择器变化时自动提交表单
|
||
document.getElementById('sort_by').addEventListener('change', function() {
|
||
// 重置到第一页
|
||
const pageInput = document.querySelector('input[name="page"]');
|
||
if (pageInput) {
|
||
pageInput.value = 1;
|
||
}
|
||
document.getElementById('searchForm').submit();
|
||
});
|
||
|
||
// 获取当前URL参数的函数
|
||
function getUrlParams() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return {
|
||
start_date: params.get('start_date') || '',
|
||
end_date: params.get('end_date') || '',
|
||
student_search: params.get('student_search') || '',
|
||
sort_by: params.get('sort_by') || 'created_at_desc'
|
||
};
|
||
}
|
||
|
||
// 构建新的排序URL
|
||
function buildSortUrl(sortField, currentParams) {
|
||
const currentSort = currentParams.sort_by;
|
||
let newSort;
|
||
|
||
if (currentSort === `${sortField}_asc`) {
|
||
newSort = `${sortField}_desc`;
|
||
} else {
|
||
newSort = `${sortField}_asc`;
|
||
}
|
||
|
||
const url = new URL(window.location.origin + window.location.pathname);
|
||
url.searchParams.set('sort_by', newSort);
|
||
if (currentParams.start_date) url.searchParams.set('start_date', currentParams.start_date);
|
||
if (currentParams.end_date) url.searchParams.set('end_date', currentParams.end_date);
|
||
if (currentParams.student_search) url.searchParams.set('student_search', currentParams.student_search);
|
||
// 重置到第一页
|
||
url.searchParams.set('page', '1');
|
||
|
||
return url.toString();
|
||
}
|
||
|
||
// 表头排序功能
|
||
function setupTableSorting() {
|
||
const sortableHeaders = document.querySelectorAll('.sortable');
|
||
const currentParams = getUrlParams();
|
||
|
||
console.log('当前URL参数:', currentParams); // 调试信息
|
||
|
||
sortableHeaders.forEach(header => {
|
||
header.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
const sortField = this.getAttribute('data-sort');
|
||
console.log('点击排序字段:', sortField); // 调试信息
|
||
|
||
const newUrl = buildSortUrl(sortField, currentParams);
|
||
console.log('新URL:', newUrl); // 调试信息
|
||
|
||
window.location.href = newUrl;
|
||
});
|
||
});
|
||
|
||
// 更新当前排序状态的显示
|
||
const currentSortBy = currentParams.sort_by;
|
||
console.log('当前排序:', currentSortBy); // 调试用
|
||
|
||
if (currentSortBy && currentSortBy.includes('_')) {
|
||
// 移除所有现有的排序状态
|
||
sortableHeaders.forEach(header => {
|
||
header.classList.remove('sort-active', 'sort-asc', 'sort-desc');
|
||
});
|
||
|
||
// 解析排序字段和方向
|
||
const lastUnderscoreIndex = currentSortBy.lastIndexOf('_');
|
||
if (lastUnderscoreIndex > 0) {
|
||
const field = currentSortBy.substring(0, lastUnderscoreIndex);
|
||
const direction = currentSortBy.substring(lastUnderscoreIndex + 1);
|
||
|
||
console.log('解析排序:', field, direction); // 调试用
|
||
|
||
const header = document.querySelector(`[data-sort="${field}"]`);
|
||
if (header) {
|
||
header.classList.add('sort-active');
|
||
if (direction === 'desc') {
|
||
header.classList.add('sort-desc');
|
||
} else {
|
||
header.classList.add('sort-asc');
|
||
}
|
||
console.log('设置排序状态成功:', header.textContent.trim()); // 调试用
|
||
} else {
|
||
console.log('未找到排序表头:', field); // 调试用
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 高级搜索切换(预留功能)
|
||
function toggleAdvancedSearch() {
|
||
alert('高级搜索功能开发中...');
|
||
}
|
||
|
||
// DOM加载完成后初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('页面加载完成,初始化排序功能'); // 调试信息
|
||
setupTableSorting();
|
||
});
|
||
</script>
|
||
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/edit_student.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}编辑学生 - {{ student.name }} - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-user-edit me-2"></i>编辑学生信息
|
||
</h1>
|
||
<div>
|
||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||
class="btn btn-outline-info me-2">
|
||
<i class="fas fa-eye me-1"></i>查看详情
|
||
</a>
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-arrow-left me-1"></i>返回列表
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编辑学生表单 -->
|
||
<div class="row justify-content-center">
|
||
<div class="col-lg-8">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-info-circle me-2"></i>学生基本信息
|
||
</h6>
|
||
<span class="badge bg-secondary">学号: {{ student.student_number }}</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<form id="editStudentForm" method="POST">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学号</label>
|
||
<input type="text" class="form-control" name="student_number"
|
||
value="{{ student.student_number }}" readonly
|
||
style="background-color: #f8f9fa;">
|
||
<div class="form-text">学号不可修改</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">姓名 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" name="name" required
|
||
value="{{ student.name }}" placeholder="请输入姓名">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">性别 <span class="text-danger">*</span></label>
|
||
<select class="form-select" name="gender" required>
|
||
<option value="">请选择性别</option>
|
||
<option value="男" {% if student.gender == '男' %}selected{% endif %}>男</option>
|
||
<option value="女" {% if student.gender == '女' %}selected{% endif %}>女</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">年级 <span class="text-danger">*</span></label>
|
||
<input type="number" class="form-control" name="grade" required
|
||
min="2020" max="2030" value="{{ student.grade }}" placeholder="如:2023">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">手机号</label>
|
||
<input type="tel" class="form-control" name="phone" maxlength="11"
|
||
pattern="1[3-9]\d{9}" value="{{ student.phone or '' }}" placeholder="请输入11位手机号">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学院</label>
|
||
<input type="text" class="form-control" name="college"
|
||
value="{{ student.college or '' }}" placeholder="请输入学院名称">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">专业</label>
|
||
<input type="text" class="form-control" name="major"
|
||
value="{{ student.major or '' }}" placeholder="请输入专业名称">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">导师</label>
|
||
<input type="text" class="form-control" name="supervisor"
|
||
value="{{ student.supervisor or '' }}" placeholder="请输入导师姓名">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学位类型</label>
|
||
<select class="form-select" name="degree_type">
|
||
<option value="">请选择学位类型</option>
|
||
<option value="专硕" {% if student.degree_type == '专硕' %}selected{% endif %}>专硕</option>
|
||
<option value="学硕" {% if student.degree_type == '学硕' %}selected{% endif %}>学硕</option>
|
||
<option value="学博" {% if student.degree_type == '学博' %}selected{% endif %}>学博</option>
|
||
<option value="专博" {% if student.degree_type == '专博' %}selected{% endif %}>专博</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">入学日期</label>
|
||
<input type="date" class="form-control" name="enrollment_date"
|
||
value="{{ student.enrollment_date.strftime('%Y-%m-%d') if student.enrollment_date else '' }}">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">状态</label>
|
||
<select class="form-select" name="status">
|
||
<option value="在读" {% if student.status == '在读' %}selected{% endif %}>在读</option>
|
||
<option value="毕业" {% if student.status == '毕业' %}selected{% endif %}>毕业</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<div class="d-flex justify-content-between">
|
||
<div>
|
||
<button type="button" class="btn btn-warning" onclick="resetPassword()">
|
||
<i class="fas fa-key me-1"></i>重置密码
|
||
</button>
|
||
{% if student.user %}
|
||
<button type="button" class="btn btn-outline-secondary ms-2" onclick="toggleAccountStatus()">
|
||
{% if student.user.is_active %}
|
||
<i class="fas fa-ban me-1"></i>禁用账户
|
||
{% else %}
|
||
<i class="fas fa-check me-1"></i>启用账户
|
||
{% endif %}
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-secondary">
|
||
<i class="fas fa-times me-1"></i>取消
|
||
</a>
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="fas fa-save me-1"></i>保存修改
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.getElementById('editStudentForm').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData(this);
|
||
const data = Object.fromEntries(formData);
|
||
|
||
// 表单验证
|
||
if (!data.name || !data.gender || !data.grade) {
|
||
alert('请填写所有必填项');
|
||
return;
|
||
}
|
||
|
||
// 验证手机号格式
|
||
if (data.phone && !/^1[3-9]\d{9}$/.test(data.phone)) {
|
||
alert('手机号格式不正确');
|
||
return;
|
||
}
|
||
|
||
// 禁用提交按钮
|
||
const submitBtn = this.querySelector('button[type="submit"]');
|
||
const originalText = submitBtn.innerHTML;
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>保存中...';
|
||
|
||
fetch(`/admin/students/{{ student.student_number }}/edit`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
// 显示成功消息
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'alert alert-success alert-dismissible fade show';
|
||
alertDiv.innerHTML = `
|
||
<i class="fas fa-check-circle me-2"></i>${result.message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
`;
|
||
|
||
const card = document.querySelector('.card-body');
|
||
card.insertBefore(alertDiv, card.firstChild);
|
||
|
||
// 滚动到顶部
|
||
window.scrollTo(0, 0);
|
||
|
||
// 恢复提交按钮
|
||
setTimeout(() => {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
}, 2000);
|
||
} else {
|
||
// 显示错误消息
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||
alertDiv.innerHTML = `
|
||
<i class="fas fa-exclamation-triangle me-2"></i>${result.message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
`;
|
||
|
||
const card = document.querySelector('.card-body');
|
||
card.insertBefore(alertDiv, card.firstChild);
|
||
|
||
// 恢复提交按钮
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('网络错误,请稍后重试');
|
||
|
||
// 恢复提交按钮
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
});
|
||
});
|
||
|
||
// 重置密码
|
||
function resetPassword() {
|
||
if (confirm('确认重置该学生的密码为默认密码(123456)吗?')) {
|
||
fetch(`/admin/students/{{ student.student_number }}/reset_password`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
alert('密码重置成功');
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 切换账户状态
|
||
function toggleAccountStatus() {
|
||
const isActive = {{ 'true' if student.user and student.user.is_active else 'false' }};
|
||
const action = isActive ? '禁用' : '启用';
|
||
|
||
if (confirm(`确认${action}该学生的账户吗?`)) {
|
||
fetch(`/admin/students/{{ student.student_number }}/toggle_status`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
alert(result.message);
|
||
location.reload(); // 重新加载页面以更新按钮状态
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/student_detail.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}学生详情 - {{ student.name }} - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 返回按钮 -->
|
||
<div class="mb-3">
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-arrow-left me-1"></i>返回学生列表
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 学生基本信息 -->
|
||
<div class="row">
|
||
<div class="col-md-8">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-user me-2"></i>基本信息
|
||
</h6>
|
||
<div class="btn-group">
|
||
<a href="{{ url_for('admin.edit_student', student_number=student.student_number) }}"
|
||
class="btn btn-primary btn-sm">
|
||
<i class="fas fa-edit me-1"></i>编辑
|
||
</a>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteStudent('{{ student.student_number }}')">
|
||
<i class="fas fa-trash me-1"></i>删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<table class="table table-borderless table-sm">
|
||
<tr>
|
||
<td class="fw-bold text-muted" style="width: 30%;">学号:</td>
|
||
<td>{{ student.student_number }}</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">姓名:</td>
|
||
<td><strong>{{ student.name }}</strong></td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">性别:</td>
|
||
<td>
|
||
{% if student.gender == '男' %}
|
||
<i class="fas fa-mars text-primary me-1"></i>{{ student.gender }}
|
||
{% else %}
|
||
<i class="fas fa-venus text-danger me-1"></i>{{ student.gender }}
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">年级:</td>
|
||
<td>{{ student.grade }}级</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">手机号:</td>
|
||
<td>
|
||
{% if student.phone %}
|
||
<i class="fas fa-phone me-1"></i>{{ student.phone }}
|
||
{% else %}
|
||
<span class="text-muted">未填写</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<table class="table table-borderless table-sm">
|
||
<tr>
|
||
<td class="fw-bold text-muted" style="width: 30%;">学院:</td>
|
||
<td>
|
||
{% if student.college %}
|
||
<i class="fas fa-university me-1"></i>{{ student.college }}
|
||
{% else %}
|
||
<span class="text-muted">未填写</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">专业:</td>
|
||
<td>
|
||
{% if student.major %}
|
||
<i class="fas fa-graduation-cap me-1"></i>{{ student.major }}
|
||
{% else %}
|
||
<span class="text-muted">未填写</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">导师:</td>
|
||
<td>
|
||
{% if student.supervisor %}
|
||
<i class="fas fa-user-tie me-1"></i>{{ student.supervisor }}
|
||
{% else %}
|
||
<span class="text-muted">未分配</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">学位类型:</td>
|
||
<td>
|
||
{% if student.degree_type %}
|
||
<span class="badge bg-info">{{ student.degree_type }}</span>
|
||
{% else %}
|
||
<span class="text-muted">未填写</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="fw-bold text-muted">状态:</td>
|
||
<td>
|
||
{% if student.status == '在读' %}
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check me-1"></i>在读
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">
|
||
<i class="fas fa-graduation-cap me-1"></i>毕业
|
||
</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="fw-bold text-muted mb-2">
|
||
<i class="fas fa-calendar-alt me-1"></i>入学日期:
|
||
</div>
|
||
<div class="ms-3">
|
||
{% if student.enrollment_date %}
|
||
<span class="badge bg-light text-dark">
|
||
{{ student.enrollment_date.strftime('%Y年%m月%d日') }}
|
||
</span>
|
||
{% else %}
|
||
<span class="text-muted">未填写</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="fw-bold text-muted mb-2">
|
||
<i class="fas fa-clock me-1"></i>注册时间:
|
||
</div>
|
||
<div class="ms-3">
|
||
<span class="badge bg-light text-dark">
|
||
{{ student.created_at.strftime('%Y年%m月%d日 %H:%M') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-4">
|
||
<!-- 考勤统计 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-bar me-2"></i>考勤统计
|
||
</h6>
|
||
</div>
|
||
<div class="card-body text-center">
|
||
<div class="row">
|
||
<div class="col-6 border-end">
|
||
<div class="h4 mb-0 text-primary">{{ "%.1f"|format(total_work_hours) }}</div>
|
||
<div class="text-muted small">总工作时长(小时)</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="h4 mb-0 text-warning">{{ total_absent_days }}</div>
|
||
<div class="text-muted small">旷工天数</div>
|
||
</div>
|
||
</div>
|
||
<hr class="my-3">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="h5 mb-0 text-info">{{ attendance_records|length }}</div>
|
||
<div class="text-muted small">考勤记录数</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 账户信息 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-key me-2"></i>账户信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if student.user %}
|
||
<div class="mb-3">
|
||
<span class="fw-bold">账户状态:</span>
|
||
{% if student.user.is_active %}
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check me-1"></i>正常
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-ban me-1"></i>已禁用
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
<div class="mb-3">
|
||
<span class="fw-bold">最后登录:</span>
|
||
<br>
|
||
{% if student.user.last_login %}
|
||
<small class="text-muted">
|
||
{{ student.user.last_login.strftime('%Y-%m-%d %H:%M') }}
|
||
</small>
|
||
{% else %}
|
||
<small class="text-muted">从未登录</small>
|
||
{% endif %}
|
||
</div>
|
||
<div class="d-grid gap-2">
|
||
<button class="btn btn-outline-warning btn-sm" onclick="resetPassword()">
|
||
<i class="fas fa-key me-1"></i>重置密码
|
||
</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="toggleAccountStatus()">
|
||
{% if student.user.is_active %}
|
||
<i class="fas fa-ban me-1"></i>禁用账户
|
||
{% else %}
|
||
<i class="fas fa-check me-1"></i>启用账户
|
||
{% endif %}
|
||
</button>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center text-muted">
|
||
<i class="fas fa-exclamation-triangle fa-2x mb-2"></i>
|
||
<p>该学生暂无账户信息</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快速操作 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-bolt me-2"></i>快速操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="d-grid gap-2">
|
||
<a href="{{ url_for('admin.attendance_management') }}?student_search={{ student.student_number }}"
|
||
class="btn btn-outline-info btn-sm">
|
||
<i class="fas fa-calendar-check me-1"></i>查看考勤详情
|
||
</a>
|
||
<a href="{{ url_for('admin.pending_leaves') }}?student_search={{ student.student_number }}"
|
||
class="btn btn-outline-success btn-sm">
|
||
<i class="fas fa-file-alt me-1"></i>查看请假记录
|
||
</a>
|
||
<button class="btn btn-outline-primary btn-sm" onclick="exportStudentData()">
|
||
<i class="fas fa-download me-1"></i>导出数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近考勤记录 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-calendar-check me-2"></i>最近考勤记录
|
||
</h6>
|
||
<a href="{{ url_for('admin.attendance_management') }}?student_search={{ student.student_number }}"
|
||
class="btn btn-primary btn-sm">
|
||
<i class="fas fa-eye me-1"></i>查看全部
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if attendance_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>周期</th>
|
||
<th>实际工作时长</th>
|
||
<th>班内工作时长</th>
|
||
<th>旷工天数</th>
|
||
<th>加班时长</th>
|
||
<th>创建时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in attendance_records %}
|
||
<tr>
|
||
<td>
|
||
<div class="fw-bold">
|
||
{{ record.week_start_date.strftime('%m-%d') }}
|
||
至
|
||
{{ record.week_end_date.strftime('%m-%d') }}
|
||
</div>
|
||
<small class="text-muted">
|
||
{{ record.week_start_date.strftime('%Y年') }}
|
||
</small>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">
|
||
{{ "%.1f"|format(record.actual_work_hours or 0) }}h
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-info">
|
||
{{ "%.1f"|format(record.class_work_hours or 0) }}h
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-warning">{{ record.absent_days }}天</span>
|
||
{% else %}
|
||
<span class="text-success">
|
||
<i class="fas fa-check"></i> 0天
|
||
</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if record.overtime_hours > 0 %}
|
||
<span class="badge bg-success">
|
||
{{ "%.1f"|format(record.overtime_hours) }}h
|
||
</span>
|
||
{% else %}
|
||
<span class="text-muted">0h</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<small class="text-muted">
|
||
{{ record.created_at.strftime('%m-%d %H:%M') }}
|
||
</small>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||
<p class="text-muted">暂无考勤记录</p>
|
||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-outline-primary">
|
||
<i class="fas fa-upload me-1"></i>上传考勤数据
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近请假记录 -->
|
||
{% if leave_records %}
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-file-alt me-2"></i>最近请假记录
|
||
</h6>
|
||
<a href="{{ url_for('admin.pending_leaves') }}?student_search={{ student.student_number }}"
|
||
class="btn btn-primary btn-sm">
|
||
<i class="fas fa-eye me-1"></i>查看全部
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>请假日期</th>
|
||
<th>请假原因</th>
|
||
<th>申请时间</th>
|
||
<th>状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for leave in leave_records %}
|
||
<tr>
|
||
<td>
|
||
{{ leave.leave_start_date.strftime('%Y-%m-%d') }}
|
||
{% if leave.leave_start_date != leave.leave_end_date %}
|
||
至 {{ leave.leave_end_date.strftime('%Y-%m-%d') }}
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<div class="text-truncate" style="max-width: 300px;"
|
||
title="{{ leave.leave_reason }}">
|
||
{{ leave.leave_reason }}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<small class="text-muted">
|
||
{{ leave.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||
</small>
|
||
</td>
|
||
<td>
|
||
{% if leave.status == '待审批' %}
|
||
<span class="badge bg-warning">
|
||
<i class="fas fa-clock me-1"></i>待审批
|
||
</span>
|
||
{% elif leave.status == '已批准' %}
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check me-1"></i>已批准
|
||
</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-times me-1"></i>已拒绝
|
||
</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 删除学生
|
||
function deleteStudent(studentNumber) {
|
||
if (confirm('确认删除这个学生吗?此操作不可恢复!\n\n学生的所有考勤记录也将被删除。')) {
|
||
fetch(`/admin/students/${studentNumber}/delete`, {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
alert(result.message);
|
||
window.location.href = '/admin/students';
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('删除失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 重置密码
|
||
function resetPassword() {
|
||
if (confirm('确认重置该学生的密码为默认密码(123456)吗?')) {
|
||
fetch(`/admin/students/{{ student.student_number }}/reset_password`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
alert('密码重置成功');
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 切换账户状态
|
||
function toggleAccountStatus() {
|
||
{% if student.user %}
|
||
const isActive = {{ 'true' if student.user.is_active else 'false' }};
|
||
const action = isActive ? '禁用' : '启用';
|
||
|
||
if (confirm(`确认${action}该学生的账户吗?`)) {
|
||
fetch(`/admin/students/{{ student.student_number }}/toggle_status`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
alert(result.message);
|
||
location.reload(); // 重新加载页面以更新按钮状态
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
{% else %}
|
||
alert('该学生暂无账户信息');
|
||
{% endif %}
|
||
}
|
||
|
||
// 导出学生数据(可选功能)
|
||
function exportStudentData() {
|
||
// 这里可以实现导出学生考勤数据的功能
|
||
alert('导出功能正在开发中...');
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-end {
|
||
border-right: 1px solid #dee2e6 !important;
|
||
}
|
||
|
||
.table-borderless td {
|
||
border: none !important;
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
|
||
.card-header {
|
||
background-color: #f8f9fc;
|
||
border-bottom: 1px solid #e3e6f0;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 0.75em;
|
||
}
|
||
|
||
.btn-group .btn {
|
||
margin-left: 0;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/add_student.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}添加学生 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-user-plus me-2"></i>添加学生
|
||
</h1>
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-secondary">
|
||
<i class="fas fa-arrow-left me-1"></i>返回学生列表
|
||
</a>
|
||
</div>
|
||
|
||
<!-- 添加学生表单 -->
|
||
<div class="row justify-content-center">
|
||
<div class="col-lg-8">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-info-circle me-2"></i>学生基本信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<form id="addStudentForm" method="POST">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学号 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" name="student_number" required
|
||
placeholder="请输入学号">
|
||
<div class="form-text">学号将作为登录账号使用</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">姓名 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" name="name" required
|
||
placeholder="请输入姓名">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">性别 <span class="text-danger">*</span></label>
|
||
<select class="form-select" name="gender" required>
|
||
<option value="">请选择性别</option>
|
||
<option value="男">男</option>
|
||
<option value="女">女</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">年级 <span class="text-danger">*</span></label>
|
||
<input type="number" class="form-control" name="grade" required
|
||
min="2020" max="2030" placeholder="如:2023">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="mb-3">
|
||
<label class="form-label">手机号</label>
|
||
<input type="tel" class="form-control" name="phone" maxlength="11"
|
||
pattern="1[3-9]\d{9}" placeholder="请输入11位手机号">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学院</label>
|
||
<input type="text" class="form-control" name="college"
|
||
placeholder="请输入学院名称">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">专业</label>
|
||
<input type="text" class="form-control" name="major"
|
||
placeholder="请输入专业名称">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">导师</label>
|
||
<input type="text" class="form-control" name="supervisor"
|
||
placeholder="请输入导师姓名">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">学位类型</label>
|
||
<select class="form-select" name="degree_type">
|
||
<option value="">请选择学位类型</option>
|
||
<option value="专硕">专硕</option>
|
||
<option value="学硕">学硕</option>
|
||
<option value="学博">学博</option>
|
||
<option value="专博">专博</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">入学日期</label>
|
||
<input type="date" class="form-control" name="enrollment_date">
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">状态</label>
|
||
<select class="form-select" name="status">
|
||
<option value="在读" selected>在读</option>
|
||
<option value="毕业">毕业</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">初始密码</label>
|
||
<input type="password" class="form-control" name="password"
|
||
placeholder="默认为123456" value="123456">
|
||
<div class="form-text">学生可在登录后自行修改密码</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-4">
|
||
|
||
<div class="d-flex justify-content-end gap-2">
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-secondary">
|
||
<i class="fas fa-times me-1"></i>取消
|
||
</a>
|
||
<button type="submit" class="btn btn-success">
|
||
<i class="fas fa-save me-1"></i>保存学生信息
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.getElementById('addStudentForm').addEventListener('submit', function(e) {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData(this);
|
||
const data = Object.fromEntries(formData);
|
||
|
||
// 表单验证
|
||
if (!data.student_number || !data.name || !data.gender || !data.grade) {
|
||
alert('请填写所有必填项');
|
||
return;
|
||
}
|
||
|
||
// 验证手机号格式
|
||
if (data.phone && !/^1[3-9]\d{9}$/.test(data.phone)) {
|
||
alert('手机号格式不正确');
|
||
return;
|
||
}
|
||
|
||
// 禁用提交按钮
|
||
const submitBtn = this.querySelector('button[type="submit"]');
|
||
const originalText = submitBtn.innerHTML;
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>保存中...';
|
||
|
||
fetch('/admin/students/add', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
// 显示成功消息
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'alert alert-success alert-dismissible fade show';
|
||
alertDiv.innerHTML = `
|
||
<i class="fas fa-check-circle me-2"></i>${result.message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
`;
|
||
|
||
// 插入到表单前面
|
||
const card = document.querySelector('.card-body');
|
||
card.insertBefore(alertDiv, card.firstChild);
|
||
|
||
// 3秒后跳转到学生列表
|
||
setTimeout(() => {
|
||
window.location.href = '/admin/students';
|
||
}, 2000);
|
||
} else {
|
||
// 显示错误消息
|
||
const alertDiv = document.createElement('div');
|
||
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||
alertDiv.innerHTML = `
|
||
<i class="fas fa-exclamation-triangle me-2"></i>${result.message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
`;
|
||
|
||
const card = document.querySelector('.card-body');
|
||
card.insertBefore(alertDiv, card.firstChild);
|
||
|
||
// 恢复提交按钮
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('网络错误,请稍后重试');
|
||
|
||
// 恢复提交按钮
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
});
|
||
});
|
||
|
||
// 学号输入框失焦时检查是否已存在
|
||
document.querySelector('input[name="student_number"]').addEventListener('blur', function() {
|
||
const studentNumber = this.value.trim();
|
||
if (studentNumber) {
|
||
// 这里可以添加AJAX检查学号是否已存在的逻辑
|
||
// 为了简化,暂时不实现
|
||
}
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/dashboard.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}管理员控制台 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-tachometer-alt me-2"></i>管理员控制台
|
||
</h1>
|
||
<div class="text-muted" id="current-time">
|
||
<i class="fas fa-clock me-1"></i>
|
||
加载中...
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-xl-3 col-md-6 mb-4">
|
||
<div class="card border-left-primary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||
学生总数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_students or 0 }}
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-3 col-md-6 mb-4">
|
||
<div class="card border-left-success shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||
考勤记录总数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ total_attendance_records or 0 }}
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-calendar-check fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-3 col-md-6 mb-4">
|
||
<div class="card border-left-warning shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||
待审批请假
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ pending_leaves or 0 }}
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-xl-3 col-md-6 mb-4">
|
||
<div class="card border-left-info shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||
本周新记录
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ recent_records or 0 }}
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-calendar-week fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 内容区域 -->
|
||
<div class="row">
|
||
<!-- 学院统计 -->
|
||
<div class="col-xl-6 col-lg-6">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-university me-2"></i>学院分布
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if college_stats %}
|
||
<div class="table-responsive">
|
||
<table class="table table-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>学院</th>
|
||
<th class="text-end">学生数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for college, count in college_stats %}
|
||
<tr>
|
||
<td>{{ college or '未知学院' }}</td>
|
||
<td class="text-end">
|
||
<span class="badge bg-primary">{{ count }}</span>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center text-muted py-4">
|
||
<i class="fas fa-chart-bar fa-3x mb-3"></i>
|
||
<p>暂无数据</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 导师统计 -->
|
||
<div class="col-xl-6 col-lg-6">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-user-tie me-2"></i>导师排行(TOP 10)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if supervisor_stats %}
|
||
<div class="table-responsive">
|
||
<table class="table table-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>导师</th>
|
||
<th class="text-end">学生数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for supervisor, count in supervisor_stats %}
|
||
<tr>
|
||
<td>{{ supervisor or '未知导师' }}</td>
|
||
<td class="text-end">
|
||
<span class="badge bg-success">{{ count }}</span>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center text-muted py-4">
|
||
<i class="fas fa-chart-bar fa-3x mb-3"></i>
|
||
<p>暂无数据</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近请假申请 -->
|
||
{% if recent_leaves %}
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-file-alt me-2"></i>最近请假申请
|
||
</h6>
|
||
<a href="{{ url_for('admin.pending_leaves') }}" class="btn btn-primary btn-sm">
|
||
<i class="fas fa-eye me-1"></i>查看全部
|
||
</a>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead>
|
||
<tr>
|
||
<th>学号</th>
|
||
<th>请假日期</th>
|
||
<th>请假原因</th>
|
||
<th>申请时间</th>
|
||
<th class="text-center">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for leave in recent_leaves %}
|
||
<tr>
|
||
<td>{{ leave.student_number }}</td>
|
||
<td>
|
||
{{ leave.leave_start_date.strftime('%Y-%m-%d') }}
|
||
{% if leave.leave_start_date != leave.leave_end_date %}
|
||
至 {{ leave.leave_end_date.strftime('%Y-%m-%d') }}
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<span class="text-truncate" style="max-width: 200px; display: inline-block;"
|
||
title="{{ leave.leave_reason }}">
|
||
{{ leave.leave_reason[:50] }}{% if leave.leave_reason|length > 50 %}...{% endif %}
|
||
</span>
|
||
</td>
|
||
<td>{{ leave.created_at.strftime('%m-%d %H:%M') }}</td>
|
||
<td class="text-center">
|
||
<div class="btn-group btn-group-sm">
|
||
<button class="btn btn-success btn-sm"
|
||
onclick="approveLeave({{ leave.leave_id }})">
|
||
<i class="fas fa-check"></i>
|
||
</button>
|
||
<button class="btn btn-danger btn-sm"
|
||
onclick="rejectLeave({{ leave.leave_id }})">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- 快捷操作 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-bolt me-2"></i>快捷操作
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-3 mb-3">
|
||
<a href="{{ url_for('admin.student_list') }}" class="btn btn-outline-primary btn-block h-100">
|
||
<i class="fas fa-users fa-2x mb-2"></i><br>
|
||
<span>学生管理</span>
|
||
</a>
|
||
</div>
|
||
<div class="col-md-3 mb-3">
|
||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-outline-success btn-block h-100">
|
||
<i class="fas fa-calendar-check fa-2x mb-2"></i><br>
|
||
<span>考勤管理</span>
|
||
</a>
|
||
</div>
|
||
<div class="col-md-3 mb-3">
|
||
<a href="{{ url_for('admin.upload_attendance') }}" class="btn btn-outline-info btn-block h-100">
|
||
<i class="fas fa-upload fa-2x mb-2"></i><br>
|
||
<span>上传数据</span>
|
||
</a>
|
||
</div>
|
||
<div class="col-md-3 mb-3">
|
||
<a href="{{ url_for('admin.statistics') }}" class="btn btn-outline-warning btn-block h-100">
|
||
<i class="fas fa-chart-bar fa-2x mb-2"></i><br>
|
||
<span>统计报表</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-left-primary {
|
||
border-left: 0.25rem solid #4e73df !important;
|
||
}
|
||
.border-left-success {
|
||
border-left: 0.25rem solid #1cc88a !important;
|
||
}
|
||
.border-left-warning {
|
||
border-left: 0.25rem solid #f6c23e !important;
|
||
}
|
||
.border-left-info {
|
||
border-left: 0.25rem solid #36b9cc !important;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: .7rem;
|
||
}
|
||
|
||
.btn-block {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: center;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.text-gray-800 {
|
||
color: #5a5c69 !important;
|
||
}
|
||
|
||
.text-gray-300 {
|
||
color: #dddfeb !important;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 显示当前时间
|
||
function updateCurrentTime() {
|
||
const now = new Date();
|
||
const timeString = `${now.getFullYear()}年${(now.getMonth()+1).toString().padStart(2,'0')}月${now.getDate().toString().padStart(2,'0')}日 ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
|
||
document.getElementById('current-time').innerHTML = `<i class="fas fa-clock me-1"></i>${timeString}`;
|
||
}
|
||
|
||
function approveLeave(leaveId) {
|
||
if (confirm('确认批准这个请假申请吗?')) {
|
||
fetch(`/admin/leave/${leaveId}/approve`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
}).then(response => {
|
||
if (response.ok) {
|
||
location.reload();
|
||
} else {
|
||
alert('操作失败,请稍后重试');
|
||
}
|
||
}).catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
function rejectLeave(leaveId) {
|
||
if (confirm('确认拒绝这个请假申请吗?')) {
|
||
fetch(`/admin/leave/${leaveId}/reject`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
}).then(response => {
|
||
if (response.ok) {
|
||
location.reload();
|
||
} else {
|
||
alert('操作失败,请稍后重试');
|
||
}
|
||
}).catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 页面加载时更新时间,然后每分钟更新一次
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
updateCurrentTime();
|
||
setInterval(updateCurrentTime, 60000); // 每分钟更新一次
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/upload_attendance.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}上传考勤数据 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container mt-4">
|
||
<div class="row">
|
||
<div class="col-md-10 mx-auto">
|
||
<div class="card shadow">
|
||
<div class="card-header">
|
||
<h5 class="card-title mb-0">
|
||
<i class="fas fa-upload me-2"></i>上传考勤数据
|
||
</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<form method="POST" enctype="multipart/form-data" id="uploadForm">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="week_start" class="form-label">周开始日期</label>
|
||
<input type="date" class="form-control" id="week_start" name="week_start" required>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="week_end" class="form-label">周结束日期</label>
|
||
<input type="date" class="form-control" id="week_end" name="week_end" required>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="attendance_file" class="form-label">
|
||
<i class="fas fa-clock me-2"></i>考勤记录文件 <span class="text-danger">*</span>
|
||
</label>
|
||
<input type="file" class="form-control" id="attendance_file" name="attendance_file"
|
||
accept=".xlsx,.xls" required>
|
||
<div class="form-text">必须上传考勤记录Excel文件</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label for="leave_file" class="form-label">
|
||
<i class="fas fa-file-alt me-2"></i>请假单文件 <span class="text-muted">(可选)</span>
|
||
</label>
|
||
<input type="file" class="form-control" id="leave_file" name="leave_file"
|
||
accept=".xlsx,.xls">
|
||
<div class="form-text">如有请假记录,请上传请假单Excel文件</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="alert alert-info">
|
||
<h6><i class="fas fa-info-circle me-2"></i>导入说明:</h6>
|
||
<ul class="mb-0">
|
||
<li><strong>考勤记录文件:</strong>包含姓名列和每日考勤数据,系统会自动计算工作时长、迟到次数等</li>
|
||
<li><strong>请假单文件:</strong>包含请假人员、请假开始时间、请假结束时间等信息</li>
|
||
<li><strong>处理规则:</strong>
|
||
<ul>
|
||
<li>请假时间内的缺卡记录会自动转换为请假</li>
|
||
<li>请假时间内的正常打卡记录(正常、迟到、早退)保持不变</li>
|
||
</ul>
|
||
</li>
|
||
<li>如果记录已存在,将会更新现有数据</li>
|
||
<li>请确保学生信息已在系统中注册</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="alert alert-warning">
|
||
<h6><i class="fas fa-exclamation-triangle me-2"></i>注意事项:</h6>
|
||
<ul class="mb-0">
|
||
<li>请假单中的时间格式会自动转换(支持数字格式和标准日期格式)</li>
|
||
<li>请假人员姓名必须与学生表中的姓名完全一致</li>
|
||
<li>建议先上传考勤记录,再选择性上传请假单</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||
<a href="{{ url_for('admin.attendance_management') }}"
|
||
class="btn btn-secondary me-md-2">
|
||
<i class="fas fa-arrow-left me-2"></i>返回
|
||
</a>
|
||
<button type="submit" class="btn btn-primary" id="submitBtn">
|
||
<i class="fas fa-upload me-2"></i>开始导入
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const weekStartInput = document.getElementById('week_start');
|
||
const weekEndInput = document.getElementById('week_end');
|
||
const form = document.getElementById('uploadForm');
|
||
const submitBtn = document.getElementById('submitBtn');
|
||
const attendanceFileInput = document.getElementById('attendance_file');
|
||
const leaveFileInput = document.getElementById('leave_file');
|
||
|
||
// 自动设置周结束日期
|
||
weekStartInput.addEventListener('change', function() {
|
||
if (this.value) {
|
||
const startDate = new Date(this.value);
|
||
const endDate = new Date(startDate);
|
||
endDate.setDate(startDate.getDate() + 6);
|
||
|
||
weekEndInput.value = endDate.toISOString().split('T')[0];
|
||
}
|
||
});
|
||
|
||
// 文件选择提示
|
||
attendanceFileInput.addEventListener('change', function() {
|
||
if (this.files.length > 0) {
|
||
console.log('已选择考勤记录文件:', this.files[0].name);
|
||
}
|
||
});
|
||
|
||
leaveFileInput.addEventListener('change', function() {
|
||
if (this.files.length > 0) {
|
||
console.log('已选择请假单文件:', this.files[0].name);
|
||
}
|
||
});
|
||
|
||
// 表单提交处理
|
||
form.addEventListener('submit', function(e) {
|
||
const hasAttendanceFile = attendanceFileInput.files.length > 0;
|
||
|
||
if (!hasAttendanceFile) {
|
||
e.preventDefault();
|
||
alert('请选择考勤记录文件!');
|
||
return false;
|
||
}
|
||
|
||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>导入中...';
|
||
submitBtn.disabled = true;
|
||
|
||
// 显示处理进度提示
|
||
const progressAlert = document.createElement('div');
|
||
progressAlert.className = 'alert alert-info mt-3';
|
||
progressAlert.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>正在处理文件,请稍候...';
|
||
form.appendChild(progressAlert);
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/statistics.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}统计报表 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
|
||
.chart-container canvas {
|
||
max-height: 300px !important;
|
||
}
|
||
|
||
.chart-container {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
min-height: 350px; /* 确保容器有足够高度 */
|
||
}
|
||
|
||
.statistics-card {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.grade-section {
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.grade-title {
|
||
color: #495057;
|
||
border-bottom: 2px solid #007bff;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.student-card {
|
||
background: white;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.student-card:hover {
|
||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.stats-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.stat-item {
|
||
text-align: center;
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.2em;
|
||
font-weight: bold;
|
||
color: #007bff;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.9em;
|
||
color: #6c757d;
|
||
}
|
||
|
||
.search-section {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.chart-container {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid">
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2><i class="fas fa-chart-bar me-2"></i>统计报表</h2>
|
||
<div>
|
||
<a href="{{ url_for('admin.export_statistics') }}" class="btn btn-success">
|
||
<i class="fas fa-download me-1"></i>导出数据
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 总体统计卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="statistics-card">
|
||
<div class="text-center">
|
||
<h3>{{ overall_stats.total_students }}</h3>
|
||
<p class="mb-0">总学生数</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="statistics-card">
|
||
<div class="text-center">
|
||
<h3>{{ "%.1f"|format(overall_stats.total_work_hours) }}</h3>
|
||
<p class="mb-0">总出勤时长(小时)</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="statistics-card">
|
||
<div class="text-center">
|
||
<h3>{{ overall_stats.total_absent_days }}</h3>
|
||
<p class="mb-0">总缺勤天数</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="statistics-card">
|
||
<div class="text-center">
|
||
<h3>{{ overall_stats.total_late_count }}</h3>
|
||
<p class="mb-0">总迟到次数</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索和筛选 -->
|
||
<div class="search-section">
|
||
<form method="GET" class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label">搜索学生</label>
|
||
<input type="text" class="form-control" name="search"
|
||
value="{{ search }}" placeholder="姓名或学号">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">年级</label>
|
||
<select class="form-select" name="grade">
|
||
<option value="">全部年级</option>
|
||
{% for grade in grades %}
|
||
<option value="{{ grade }}" {{ 'selected' if selected_grade == grade|string }}>
|
||
{% if grade == 1 %}研一{% elif grade == 2 %}研二{% elif grade == 3 %}研三{% else %}{{ grade }}年级{% endif %}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">学院</label>
|
||
<select class="form-select" name="college">
|
||
<option value="">全部学院</option>
|
||
{% for college in colleges %}
|
||
<option value="{{ college }}" {{ 'selected' if selected_college == college }}>{{ college }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">导师</label>
|
||
<select class="form-select" name="supervisor">
|
||
<option value="">全部导师</option>
|
||
{% for supervisor in supervisors %}
|
||
<option value="{{ supervisor }}" {{ 'selected' if selected_supervisor == supervisor }}>{{ supervisor }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label">时间范围</label>
|
||
<div class="row">
|
||
<div class="col-6">
|
||
<input type="date" class="form-control" name="start_date" value="{{ start_date }}">
|
||
</div>
|
||
<div class="col-6">
|
||
<input type="date" class="form-control" name="end_date" value="{{ end_date }}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-12">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="fas fa-search me-1"></i>筛选
|
||
</button>
|
||
<a href="{{ url_for('admin.statistics') }}" class="btn btn-outline-secondary ms-2">
|
||
<i class="fas fa-undo me-1"></i>重置
|
||
</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- 按年级分组的学生统计 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
{% for grade_label, students in grade_groups.items() %}
|
||
<div class="grade-section">
|
||
<h4 class="grade-title">
|
||
<i class="fas fa-graduation-cap me-2"></i>{{ grade_label }} ({{ students|length }}人)
|
||
</h4>
|
||
|
||
<div class="row">
|
||
{% for student in students %}
|
||
<div class="col-md-6 col-lg-4 mb-3">
|
||
<div class="student-card">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<h6 class="mb-1">
|
||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||
class="text-decoration-none">
|
||
{{ student.name }}
|
||
</a>
|
||
</h6>
|
||
<small class="text-muted">{{ student.student_number }}</small>
|
||
</div>
|
||
<div class="text-end">
|
||
{% if student.total_work_hours >= 200 %}
|
||
<span class="badge bg-success">优秀</span>
|
||
{% elif student.total_work_hours >= 100 %}
|
||
<span class="badge bg-primary">良好</span>
|
||
{% elif student.total_work_hours >= 50 %}
|
||
<span class="badge bg-warning">一般</span>
|
||
{% else %}
|
||
<span class="badge bg-danger">待改进</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-row mt-3">
|
||
<div class="stat-item">
|
||
<div class="stat-value">{{ "%.1f"|format(student.total_work_hours) }}</div>
|
||
<div class="stat-label">总工时</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value">{{ student.total_absent_days }}</div>
|
||
<div class="stat-label">缺勤天数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value">{{ student.total_late_count }}</div>
|
||
<div class="stat-label">迟到次数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value">{{ student.avg_weekly_hours }}</div>
|
||
<div class="stat-label">周均工时</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-2">
|
||
<small class="text-muted">
|
||
<i class="fas fa-building me-1"></i>{{ student.college or '未设置' }} |
|
||
<i class="fas fa-user-tie me-1"></i>{{ student.supervisor or '未设置' }}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
|
||
{% if not grade_groups %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||
<h5 class="text-muted">没有找到符合条件的学生</h5>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表区域 -->
|
||
<div class="row mt-4">
|
||
<div class="col-md-6">
|
||
<div class="chart-container">
|
||
<h5><i class="fas fa-chart-line me-2"></i>月度考勤趋势</h5>
|
||
<canvas id="monthlyChart" width="400" height="200"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="chart-container">
|
||
<h5><i class="fas fa-chart-pie me-2"></i>学院分布</h5>
|
||
<canvas id="collegeChart" width="400" height="200"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 检查数据
|
||
console.log('月度统计数据:', [{% for stat in monthly_stats %}{ month: '{{ stat.month }}', hours: {{ stat.total_hours or 0 }} }{% if not loop.last %},{% endif %}{% endfor %}]);
|
||
console.log('学院统计数据:', [{% for stat in college_stats %}{ college: '{{ stat.college or "未设置" }}', count: {{ stat.student_count }} }{% if not loop.last %},{% endif %}{% endfor %}]);
|
||
|
||
// 月度趋势图
|
||
const monthlyCtx = document.getElementById('monthlyChart').getContext('2d');
|
||
|
||
// 准备月度数据
|
||
const monthlyData = [{% for stat in monthly_stats %}{{ stat.total_hours or 0 }}{% if not loop.last %},{% endif %}{% endfor %}];
|
||
const monthlyLabels = [{% for stat in monthly_stats %}'{{ stat.month }}'{% if not loop.last %},{% endif %}{% endfor %}];
|
||
|
||
console.log('图表数据:', monthlyData);
|
||
console.log('图表标签:', monthlyLabels);
|
||
|
||
const monthlyChart = new Chart(monthlyCtx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: monthlyLabels,
|
||
datasets: [{
|
||
label: '总工时',
|
||
data: monthlyData,
|
||
borderColor: 'rgb(75, 192, 192)',
|
||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||
tension: 0.1,
|
||
fill: true
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: '工时(小时)'
|
||
}
|
||
},
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: '月份'
|
||
}
|
||
}
|
||
},
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: '月度工时统计'
|
||
},
|
||
legend: {
|
||
display: true,
|
||
position: 'top'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 学院分布图
|
||
const collegeCtx = document.getElementById('collegeChart').getContext('2d');
|
||
|
||
// 准备学院数据
|
||
const collegeData = [{% for stat in college_stats %}{{ stat.student_count }}{% if not loop.last %},{% endif %}{% endfor %}];
|
||
const collegeLabels = [{% for stat in college_stats %}'{{ stat.college or "未设置" }}'{% if not loop.last %},{% endif %}{% endfor %}];
|
||
|
||
console.log('学院数据:', collegeData);
|
||
console.log('学院标签:', collegeLabels);
|
||
|
||
// 只有当有数据时才创建图表
|
||
if (collegeData.length > 0 && collegeData.some(d => d > 0)) {
|
||
const collegeChart = new Chart(collegeCtx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: collegeLabels,
|
||
datasets: [{
|
||
data: collegeData,
|
||
backgroundColor: [
|
||
'#FF6384',
|
||
'#36A2EB',
|
||
'#FFCE56',
|
||
'#4BC0C0',
|
||
'#9966FF',
|
||
'#FF9F40',
|
||
'#C7C7C7',
|
||
'#FF6384'
|
||
],
|
||
borderWidth: 2,
|
||
borderColor: '#fff'
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: '学院学生分布'
|
||
},
|
||
legend: {
|
||
position: 'bottom',
|
||
labels: {
|
||
padding: 20,
|
||
usePointStyle: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
// 如果没有数据,显示提示信息
|
||
collegeCtx.font = '16px Arial';
|
||
collegeCtx.textAlign = 'center';
|
||
collegeCtx.fillText('暂无数据', collegeCtx.canvas.width / 2, collegeCtx.canvas.height / 2);
|
||
}
|
||
|
||
// 如果月度数据为空,显示提示
|
||
if (monthlyData.length === 0 || monthlyData.every(d => d === 0)) {
|
||
const monthlyCanvas = document.getElementById('monthlyChart');
|
||
const ctx = monthlyCanvas.getContext('2d');
|
||
ctx.font = '16px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('暂无月度数据', monthlyCanvas.width / 2, monthlyCanvas.height / 2);
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/student_list.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}学生管理 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-users me-2"></i>学生管理
|
||
</h1>
|
||
<div>
|
||
<a href="{{ url_for('admin.add_student') }}" class="btn btn-success">
|
||
<i class="fas fa-plus me-1"></i>添加学生
|
||
</a>
|
||
<div class="btn-group ms-2">
|
||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||
批量操作
|
||
</button>
|
||
<ul class="dropdown-menu">
|
||
<li><a class="dropdown-item" href="#" onclick="batchAction('graduate')">设为毕业</a></li>
|
||
<li><a class="dropdown-item text-danger" href="#" onclick="batchAction('delete')">批量删除</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索和筛选 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-body">
|
||
<form method="GET" class="row g-3">
|
||
<div class="col-md-3">
|
||
<label class="form-label">搜索</label>
|
||
<input type="text" class="form-control" name="search" value="{{ search }}"
|
||
placeholder="学号或姓名">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label">年级</label>
|
||
<select class="form-select" name="grade">
|
||
<option value="">全部年级</option>
|
||
{% for grade in grades %}
|
||
<option value="{{ grade }}" {% if grade|string == selected_grade %}selected{% endif %}>
|
||
{{ grade }}级
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label">学院</label>
|
||
<select class="form-select" name="college">
|
||
<option value="">全部学院</option>
|
||
{% for college in colleges %}
|
||
<option value="{{ college }}" {% if college == selected_college %}selected{% endif %}>
|
||
{{ college }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label">导师</label>
|
||
<select class="form-select" name="supervisor">
|
||
<option value="">全部导师</option>
|
||
{% for supervisor in supervisors %}
|
||
<option value="{{ supervisor }}" {% if supervisor == selected_supervisor %}selected{% endif %}>
|
||
{{ supervisor }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-1">
|
||
<label class="form-label"> </label>
|
||
<div>
|
||
<button type="submit" class="btn btn-primary">搜索</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 学生列表 -->
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
学生列表 (共 {{ pagination.total }} 人)
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if students %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead>
|
||
<tr>
|
||
<th>
|
||
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
|
||
</th>
|
||
<th>学号</th>
|
||
<th>姓名</th>
|
||
<th>性别</th>
|
||
<th>年级</th>
|
||
<th>学院</th>
|
||
<th>导师</th>
|
||
<th>学位类型</th>
|
||
<th>状态</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for student in students %}
|
||
<tr>
|
||
<td>
|
||
<input type="checkbox" class="student-checkbox"
|
||
value="{{ student.student_number }}">
|
||
</td>
|
||
<td>{{ student.student_number }}</td>
|
||
<td>
|
||
<strong>{{ student.name }}</strong>
|
||
{% if student.phone %}
|
||
<br><small class="text-muted">{{ student.phone }}</small>
|
||
{% endif %}
|
||
</td>
|
||
<td>{{ student.gender }}</td>
|
||
<td>{{ student.grade }}级</td>
|
||
<td>{{ student.college or '-' }}</td>
|
||
<td>{{ student.supervisor or '-' }}</td>
|
||
<td>
|
||
{% if student.degree_type %}
|
||
<span class="badge bg-info">{{ student.degree_type }}</span>
|
||
{% else %}
|
||
-
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if student.status == '在读' %}
|
||
<span class="badge bg-success">在读</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">毕业</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
<div class="btn-group btn-group-sm">
|
||
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
|
||
class="btn btn-outline-info" title="查看详情">
|
||
<i class="fas fa-eye"></i>
|
||
</a>
|
||
<a href="{{ url_for('admin.edit_student', student_number=student.student_number) }}"
|
||
class="btn btn-outline-primary" title="编辑">
|
||
<i class="fas fa-edit"></i>
|
||
</a>
|
||
<button class="btn btn-outline-danger"
|
||
onclick="deleteStudent('{{ student.student_number }}')" title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
{% if pagination.pages > 1 %}
|
||
<nav aria-label="Page navigation" class="mt-4">
|
||
<ul class="pagination justify-content-center">
|
||
{% if pagination.has_prev %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.prev_num, **request.args) }}">
|
||
上一页
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
|
||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
|
||
{% if page_num %}
|
||
{% if page_num != pagination.page %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.student_list', page=page_num, **request.args) }}">
|
||
{{ page_num }}
|
||
</a>
|
||
</li>
|
||
{% else %}
|
||
<li class="page-item active">
|
||
<span class="page-link">{{ page_num }}</span>
|
||
</li>
|
||
{% endif %}
|
||
{% else %}
|
||
<li class="page-item disabled">
|
||
<span class="page-link">…</span>
|
||
</li>
|
||
{% endif %}
|
||
{% endfor %}
|
||
|
||
{% if pagination.has_next %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.next_num, **request.args) }}">
|
||
下一页
|
||
</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
</nav>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
<div class="text-center py-5">
|
||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||
<p class="text-muted">暂无学生数据</p>
|
||
<a href="{{ url_for('admin.add_student') }}" class="btn btn-primary">
|
||
<i class="fas fa-plus me-1"></i>添加第一个学生
|
||
</a>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// 全选/取消全选
|
||
function toggleSelectAll() {
|
||
const selectAll = document.getElementById('selectAll');
|
||
const checkboxes = document.querySelectorAll('.student-checkbox');
|
||
checkboxes.forEach(checkbox => {
|
||
checkbox.checked = selectAll.checked;
|
||
});
|
||
}
|
||
|
||
// 删除学生
|
||
function deleteStudent(studentNumber) {
|
||
if (confirm('确认删除这个学生吗?此操作不可恢复!')) {
|
||
fetch(`/admin/students/${studentNumber}/delete`, {
|
||
method: 'POST'
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
location.reload();
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('删除失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 批量操作
|
||
function batchAction(action) {
|
||
const selectedStudents = Array.from(document.querySelectorAll('.student-checkbox:checked'))
|
||
.map(checkbox => checkbox.value);
|
||
|
||
if (selectedStudents.length === 0) {
|
||
alert('请选择要操作的学生');
|
||
return;
|
||
}
|
||
|
||
let confirmMessage = '';
|
||
if (action === 'delete') {
|
||
confirmMessage = `确认删除选中的 ${selectedStudents.length} 个学生吗?此操作不可恢复!`;
|
||
} else if (action === 'graduate') {
|
||
confirmMessage = `确认将选中的 ${selectedStudents.length} 个学生设为毕业状态吗?`;
|
||
}
|
||
|
||
if (confirm(confirmMessage)) {
|
||
fetch('/admin/students/batch_action', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
action: action,
|
||
student_numbers: selectedStudents
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(result => {
|
||
if (result.success) {
|
||
location.reload();
|
||
} else {
|
||
alert(result.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error:', error);
|
||
alert('操作失败,请稍后重试');
|
||
});
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
File: ./app/templates/admin/attendance_details.html
|
||
================================================================================
|
||
|
||
{% extends 'layout/base.html' %}
|
||
|
||
{% block title %}考勤详情 - CHM考勤管理系统{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-4">
|
||
<!-- 页面标题 -->
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0">
|
||
<i class="fas fa-calendar-check me-2"></i>考勤详情
|
||
</h1>
|
||
<div>
|
||
<a href="{{ url_for('admin.attendance_management') }}" class="btn btn-secondary">
|
||
<i class="fas fa-arrow-left me-2"></i>返回列表
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 基本信息卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-user me-2"></i>学生信息
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6">
|
||
<p><strong>学号:</strong> {{ weekly_record.student_number }}</p>
|
||
<p><strong>姓名:</strong> {{ weekly_record.name }}</p>
|
||
{% if student %}
|
||
<p><strong>年级:</strong> {{ student.grade }}</p>
|
||
<p><strong>学院:</strong> {{ student.college or '未设置' }}</p>
|
||
{% endif %}
|
||
</div>
|
||
<div class="col-6">
|
||
{% if student %}
|
||
<p><strong>专业:</strong> {{ student.major or '未设置' }}</p>
|
||
<p><strong>导师:</strong> {{ student.supervisor or '未设置' }}</p>
|
||
<p><strong>学位类型:</strong> {{ student.degree_type or '未设置' }}</p>
|
||
<p><strong>状态:</strong>
|
||
<span class="badge bg-{{ 'success' if student.status == '在读' else 'secondary' }}">
|
||
{{ student.status }}
|
||
</span>
|
||
</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-calendar-week me-2"></i>考勤周期
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-6">
|
||
<p><strong>开始日期:</strong> {{ weekly_record.week_start_date.strftime('%Y年%m月%d日') }}</p>
|
||
<p><strong>结束日期:</strong> {{ weekly_record.week_end_date.strftime('%Y年%m月%d日') }}</p>
|
||
</div>
|
||
<div class="col-6">
|
||
<p><strong>创建时间:</strong> {{ weekly_record.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||
<p><strong>更新时间:</strong> {{ weekly_record.updated_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计数据卡片 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card border-left-primary shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||
实际出勤时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(weekly_record.actual_work_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-success shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||
班内工作时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(weekly_record.class_work_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-briefcase fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-warning shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||
旷工天数
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ weekly_record.absent_days }}天
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-3">
|
||
<div class="card border-left-info shadow h-100 py-2">
|
||
<div class="card-body">
|
||
<div class="row no-gutters align-items-center">
|
||
<div class="col mr-2">
|
||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||
加班时长
|
||
</div>
|
||
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
||
{{ "%.1f"|format(weekly_record.overtime_hours) }}小时
|
||
</div>
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="fas fa-moon fa-2x text-gray-300"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 每日考勤明细 -->
|
||
<div class="card shadow mb-4">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-list me-2"></i>每日考勤明细
|
||
<small class="text-muted">(点击日期查看详细时段信息)</small>
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if daily_details %}
|
||
<div class="table-responsive">
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>日期</th>
|
||
<th>星期</th>
|
||
<th>考勤状态</th>
|
||
<th>签到时间</th>
|
||
<th>签退时间</th>
|
||
<th>工作时长</th>
|
||
<th>备注</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for detail in daily_details %}
|
||
<tr>
|
||
<td>{{ detail.attendance_date.strftime('%m-%d') }}</td>
|
||
<td>
|
||
{% set weekday = detail.attendance_date.weekday() %}
|
||
{% if weekday == 0 %}周一
|
||
{% elif weekday == 1 %}周二
|
||
{% elif weekday == 2 %}周三
|
||
{% elif weekday == 3 %}周四
|
||
{% elif weekday == 4 %}周五
|
||
{% elif weekday == 5 %}周六
|
||
{% else %}周日
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.status == '正常' %}
|
||
<span class="badge bg-success">{{ detail.status }}</span>
|
||
{% elif '迟到' in detail.status %}
|
||
<span class="badge bg-warning">{{ detail.status }}</span>
|
||
{% elif detail.status == '缺勤' %}
|
||
<span class="badge bg-danger">{{ detail.status }}</span>
|
||
{% elif detail.status == '请假' %}
|
||
<span class="badge bg-orange">{{ detail.status }}</span>
|
||
{% elif detail.status == '休息' %}
|
||
<span class="badge bg-info">{{ detail.status }}</span>
|
||
{% elif detail.status == '加班' %}
|
||
<span class="badge bg-primary">{{ detail.status }}</span>
|
||
{% else %}
|
||
<span class="badge bg-secondary">{{ detail.status }}</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.check_in_time %}
|
||
{{ detail.check_in_time.strftime('%H:%M') }}
|
||
{% else %}
|
||
<span class="text-muted">未打卡</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.check_out_time %}
|
||
{{ detail.check_out_time.strftime('%H:%M') }}
|
||
{% else %}
|
||
<span class="text-muted">未打卡</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.duration_hours %}
|
||
<span class="badge bg-primary">{{ detail.duration_hours }}h</span>
|
||
{% else %}
|
||
<span class="text-muted">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.summary_remarks %}
|
||
<small class="text-muted">{{ detail.summary_remarks }}</small>
|
||
{% else %}
|
||
<span class="text-muted">-</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>
|
||
{% if detail.status not in ['休息', '缺勤'] and detail.detailed_info %}
|
||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||
onclick="showDetailModal('{{ detail.detail_id }}', '{{ detail.attendance_date.strftime('%Y-%m-%d') }}', '{{ detail.remarks|escape }}')"
|
||
title="查看详细时段">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="text-center py-3">
|
||
<i class="fas fa-calendar-times fa-3x text-muted mb-3"></i>
|
||
<h5 class="text-muted">暂无每日考勤明细</h5>
|
||
<p class="text-muted">该考勤周期内没有详细的打卡记录</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计分析和历史对比的其他部分... -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-chart-pie me-2"></i>考勤统计分析
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-success">{{ present_days }}</h6>
|
||
<small class="text-muted">正常天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-warning">{{ late_days }}</h6>
|
||
<small class="text-muted">迟到天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<div class="border-end">
|
||
<h6 class="text-danger">{{ absent_days }}</h6>
|
||
<small class="text-muted">缺勤天数</small>
|
||
</div>
|
||
</div>
|
||
<div class="col-3">
|
||
<h6 class="text-info">{{ "%.1f"|format(avg_daily_hours) }}h</h6>
|
||
<small class="text-muted">日均时长</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-6">
|
||
<div class="card shadow">
|
||
<div class="card-header py-3">
|
||
<h6 class="m-0 font-weight-bold text-primary">
|
||
<i class="fas fa-history me-2"></i>历史对比
|
||
</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
{% if historical_records %}
|
||
<div class="table-responsive">
|
||
<table class="table table-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>周期</th>
|
||
<th>出勤时长</th>
|
||
<th>旷工天数</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for record in historical_records %}
|
||
<tr>
|
||
<td>
|
||
<small>{{ record.week_start_date.strftime('%m-%d') }}</small>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-primary">{{ "%.1f"|format(record.actual_work_hours) }}h</span>
|
||
</td>
|
||
<td>
|
||
{% if record.absent_days > 0 %}
|
||
<span class="badge bg-warning">{{ record.absent_days }}</span>
|
||
{% else %}
|
||
<span class="badge bg-success">0</span>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<p class="text-muted text-center mb-0">暂无历史记录</p>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细时段信息模态框 -->
|
||
<div class="modal fade" id="detailModal" tabindex="-1" aria-labelledby="detailModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-centered"> <!-- 添加 modal-dialog-centered -->
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="detailModalLabel">
|
||
<i class="fas fa-clock me-2"></i>详细打卡时段 - <span id="modalDate"></span>
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="detailContent">
|
||
<!-- 内容将通过JavaScript动态填充 -->
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.border-left-primary {
|
||
border-left: 0.25rem solid #4e73df !important;
|
||
}
|
||
|
||
.border-left-success {
|
||
border-left: 0.25rem solid #1cc88a !important;
|
||
}
|
||
|
||
.border-left-info {
|
||
border-left: 0.25rem solid #36b9cc !important;
|
||
}
|
||
|
||
.border-left-warning {
|
||
border-left: 0.25rem solid #f6c23e !important;
|
||
}
|
||
|
||
.border-end {
|
||
border-right: 1px solid #dee2e6;
|
||
}
|
||
|
||
.text-xs {
|
||
font-size: 0.7rem;
|
||
}
|
||
|
||
.card {
|
||
border: 0;
|
||
box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important;
|
||
}
|
||
|
||
.period-card {
|
||
border: 1px solid #e3e6f0;
|
||
border-radius: 0.35rem;
|
||
padding: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.period-header {
|
||
font-weight: 600;
|
||
color: #5a5c69;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.badge.bg-orange {
|
||
background-color: #fd7e14 !important;
|
||
}
|
||
|
||
.time-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
function showDetailModal(detailId, date, remarksJson) {
|
||
document.getElementById('modalDate').textContent = date;
|
||
|
||
console.log('调用showDetailModal', detailId, date, remarksJson); // 调试信息
|
||
|
||
let detailsData = null;
|
||
|
||
try {
|
||
if (remarksJson && remarksJson.startsWith('{')) {
|
||
const parsed = JSON.parse(remarksJson);
|
||
detailsData = parsed.details;
|
||
console.log('解析的详细数据:', detailsData); // 调试信息
|
||
}
|
||
} catch (e) {
|
||
console.error('解析详细信息失败:', e);
|
||
}
|
||
|
||
if (!detailsData) {
|
||
document.getElementById('detailContent').innerHTML =
|
||
'<p class="text-muted">暂无详细时段信息</p><p class="text-muted">原始数据: ' + remarksJson + '</p>';
|
||
} else {
|
||
let html = '';
|
||
|
||
// 显示各个时段的详情
|
||
const periods = [
|
||
{ key: 'morning', name: '早上时段', time: '09:45-11:30' },
|
||
{ key: 'afternoon', name: '下午时段', time: '13:30-18:30' },
|
||
{ key: 'evening', name: '晚上时段', time: '19:00-23:30' }
|
||
];
|
||
|
||
periods.forEach(period => {
|
||
if (detailsData[period.key]) {
|
||
const data = detailsData[period.key];
|
||
html += `
|
||
<div class="period-card">
|
||
<div class="period-header">
|
||
<i class="fas fa-clock me-2"></i>${period.name} (${period.time})
|
||
</div>
|
||
<div class="time-info">
|
||
<div>
|
||
<strong>签到:</strong>
|
||
<span class="badge ${getStatusClass(data.status, 'in')}">
|
||
${data.in || '未打卡'}
|
||
</span>
|
||
${data.late_minutes ? `<small class="text-warning">(迟到${data.late_minutes}分钟)</small>` : ''}
|
||
</div>
|
||
<div>
|
||
<strong>签退:</strong>
|
||
<span class="badge ${getStatusClass(data.status, 'out')}">
|
||
${data.out || '未打卡'}
|
||
</span>
|
||
${data.early_minutes ? `<small class="text-warning">(早退${data.early_minutes}分钟)</small>` : ''}
|
||
</div>
|
||
<div>
|
||
<strong>工时:</strong>
|
||
${calculatePeriodHours(data.in, data.out)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// 如果是周末加班
|
||
if (detailsData.overtime) {
|
||
html += `
|
||
<div class="period-card">
|
||
<div class="period-header">
|
||
<i class="fas fa-moon me-2"></i>周末加班
|
||
</div>
|
||
<div class="time-info">
|
||
<div>
|
||
<strong>开始:</strong>
|
||
<span class="badge bg-info">${detailsData.overtime.in || '未记录'}</span>
|
||
</div>
|
||
<div>
|
||
<strong>结束:</strong>
|
||
<span class="badge bg-info">${detailsData.overtime.out || '未记录'}</span>
|
||
</div>
|
||
<div>
|
||
<strong>加班时长:</strong>
|
||
${calculatePeriodHours(detailsData.overtime.in, detailsData.overtime.out)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (html === '') {
|
||
html = '<p class="text-muted">该日期没有详细的时段打卡信息</p>';
|
||
}
|
||
|
||
document.getElementById('detailContent').innerHTML = html;
|
||
}
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||
modal.show();
|
||
}
|
||
|
||
function getStatusClass(status, type) {
|
||
if (status === 'normal') return 'bg-success';
|
||
if (status === 'late' && type === 'in') return 'bg-warning';
|
||
if (status === 'early_leave' && type === 'out') return 'bg-warning';
|
||
if (status === 'missing') return 'bg-secondary';
|
||
return 'bg-secondary';
|
||
}
|
||
|
||
function calculatePeriodHours(startTime, endTime) {
|
||
if (!startTime || !endTime) return '<span class="text-muted">-</span>';
|
||
|
||
try {
|
||
const start = new Date(`2000-01-01 ${startTime}:00`);
|
||
const end = new Date(`2000-01-01 ${endTime}:00`);
|
||
const diff = (end - start) / (1000 * 60 * 60);
|
||
|
||
if (diff > 0) {
|
||
return `<span class="badge bg-primary">${diff.toFixed(1)}h</span>`;
|
||
}
|
||
} catch (e) {
|
||
console.error('计算时长失败:', e);
|
||
}
|
||
|
||
return '<span class="text-muted">-</span>';
|
||
}
|
||
|
||
// 测试函数
|
||
function testModal() {
|
||
console.log('测试模态框');
|
||
document.getElementById('modalDate').textContent = '测试日期';
|
||
document.getElementById('detailContent').innerHTML = '<p>测试内容</p>';
|
||
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
|
||
modal.show();
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
|
||
================================================================================
|
||
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/<student_number>')
|
||
@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/<int:record_id>/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/<string:student_number>/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/<string:student_number>/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/<string:student_number>/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/<string:student_number>/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/<int:record_id>/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/<int:record_id>/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/<int:record_id>/details')
|
||
@student_required
|
||
def attendance_details(record_id):
|
||
"""考勤详细信息"""
|
||
from datetime import datetime, timedelta
|
||
import json
|
||
|
||
# 获取周考勤汇总记录(确保只能查看自己的记录)
|
||
record = WeeklyAttendance.query.filter_by(
|
||
record_id=record_id,
|
||
student_number=current_user.student_number
|
||
).first_or_404()
|
||
|
||
# 获取学生信息
|
||
student = Student.query.filter_by(student_number=current_user.student_number).first()
|
||
|
||
# 获取该周的每日考勤明细
|
||
daily_details = DailyAttendanceDetail.query.filter_by(
|
||
weekly_record_id=record_id
|
||
).order_by(DailyAttendanceDetail.attendance_date).all()
|
||
|
||
# 处理每日详情,计算工作时长和解析详细信息
|
||
processed_daily_details = []
|
||
for detail in daily_details:
|
||
processed_detail = {
|
||
'detail_id': detail.detail_id,
|
||
'attendance_date': detail.attendance_date,
|
||
'status': detail.status,
|
||
'check_in_time': detail.check_in_time,
|
||
'check_out_time': detail.check_out_time,
|
||
'remarks': detail.remarks,
|
||
'duration_hours': None,
|
||
'detailed_info': None
|
||
}
|
||
|
||
# 计算工作时长
|
||
if detail.check_in_time and detail.check_out_time:
|
||
try:
|
||
# 创建完整的datetime对象
|
||
start_datetime = datetime.combine(detail.attendance_date, detail.check_in_time)
|
||
end_datetime = datetime.combine(detail.attendance_date, detail.check_out_time)
|
||
|
||
# 如果结束时间小于开始时间,说明跨天了
|
||
if end_datetime < start_datetime:
|
||
end_datetime += timedelta(days=1)
|
||
|
||
duration = (end_datetime - start_datetime).total_seconds() / 3600
|
||
processed_detail['duration_hours'] = round(duration, 1)
|
||
except Exception as e:
|
||
print(f"计算工作时长失败: {e}")
|
||
processed_detail['duration_hours'] = None
|
||
|
||
# 解析详细信息
|
||
if detail.remarks:
|
||
try:
|
||
if detail.remarks.startswith('{'):
|
||
remarks_data = json.loads(detail.remarks)
|
||
processed_detail['detailed_info'] = remarks_data.get('details')
|
||
processed_detail['summary_remarks'] = remarks_data.get('summary', detail.remarks)
|
||
else:
|
||
processed_detail['summary_remarks'] = detail.remarks
|
||
except:
|
||
processed_detail['summary_remarks'] = detail.remarks
|
||
|
||
processed_daily_details.append(processed_detail)
|
||
|
||
# 计算统计数据
|
||
total_days = len(processed_daily_details)
|
||
present_days = len([d for d in processed_daily_details if d['status'] == '正常'])
|
||
late_days = len([d for d in processed_daily_details if '迟到' in d['status']])
|
||
absent_days = len([d for d in processed_daily_details if d['status'] == '缺勤'])
|
||
|
||
# 计算平均每日工作时长
|
||
if processed_daily_details:
|
||
avg_daily_hours = record.actual_work_hours / max(present_days, 1)
|
||
else:
|
||
avg_daily_hours = 0
|
||
|
||
# 获取该学生最近的其他考勤记录(用于对比)
|
||
recent_records = WeeklyAttendance.query.filter_by(
|
||
student_number=current_user.student_number
|
||
).filter(WeeklyAttendance.record_id != record_id).order_by(
|
||
desc(WeeklyAttendance.week_start_date)
|
||
).limit(5).all()
|
||
|
||
return render_template('student/attendance_details.html',
|
||
record=record,
|
||
student=student,
|
||
daily_details=processed_daily_details,
|
||
total_days=total_days,
|
||
present_days=present_days,
|
||
late_days=late_days,
|
||
absent_days=absent_days,
|
||
avg_daily_hours=avg_daily_hours,
|
||
recent_records=recent_records)
|
||
|
||
|
||
@student_bp.route('/statistics')
|
||
@login_required
|
||
def statistics():
|
||
"""学生个人统计"""
|
||
from sqlalchemy import desc, func, case
|
||
from datetime import datetime, timedelta
|
||
|
||
# 获取当前学生信息
|
||
student = Student.query.filter_by(student_number=current_user.student_number).first_or_404()
|
||
|
||
# 获取筛选参数
|
||
start_date = request.args.get('start_date', '')
|
||
end_date = request.args.get('end_date', '')
|
||
|
||
# 构建考勤记录查询
|
||
attendance_query = WeeklyAttendance.query.filter_by(
|
||
student_number=current_user.student_number
|
||
)
|
||
|
||
# 应用日期筛选
|
||
if start_date:
|
||
try:
|
||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||
attendance_query = attendance_query.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||
except ValueError:
|
||
flash('开始日期格式错误', 'error')
|
||
|
||
if end_date:
|
||
try:
|
||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||
attendance_query = attendance_query.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||
except ValueError:
|
||
flash('结束日期格式错误', 'error')
|
||
|
||
# 获取考勤记录,按周排序
|
||
attendance_records = attendance_query.order_by(desc(WeeklyAttendance.week_start_date)).all()
|
||
|
||
# 计算统计数据
|
||
total_stats = {
|
||
'total_work_hours': sum(record.actual_work_hours for record in attendance_records),
|
||
'total_class_hours': sum(record.class_work_hours for record in attendance_records),
|
||
'total_overtime_hours': sum(record.overtime_hours for record in attendance_records),
|
||
'total_absent_days': sum(record.absent_days for record in attendance_records),
|
||
'attendance_weeks': len(attendance_records)
|
||
}
|
||
|
||
# 计算迟到次数
|
||
total_late_count = db.session.query(
|
||
func.sum(
|
||
case(
|
||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||
else_=0
|
||
)
|
||
)
|
||
).join(
|
||
WeeklyAttendance, DailyAttendanceDetail.weekly_record_id == WeeklyAttendance.record_id
|
||
).filter(WeeklyAttendance.student_number == current_user.student_number)
|
||
|
||
if start_date:
|
||
try:
|
||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||
total_late_count = total_late_count.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||
except ValueError:
|
||
pass
|
||
|
||
if end_date:
|
||
try:
|
||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||
total_late_count = total_late_count.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||
except ValueError:
|
||
pass
|
||
|
||
total_stats['total_late_count'] = int(total_late_count.scalar() or 0)
|
||
|
||
# 计算平均值
|
||
if total_stats['attendance_weeks'] > 0:
|
||
total_stats['avg_weekly_hours'] = round(total_stats['total_work_hours'] / total_stats['attendance_weeks'], 1)
|
||
total_stats['avg_weekly_class_hours'] = round(
|
||
total_stats['total_class_hours'] / total_stats['attendance_weeks'], 1)
|
||
else:
|
||
total_stats['avg_weekly_hours'] = 0
|
||
total_stats['avg_weekly_class_hours'] = 0
|
||
|
||
# 按月统计
|
||
monthly_stats = db.session.query(
|
||
func.date_format(WeeklyAttendance.week_start_date, '%Y-%m').label('month'),
|
||
func.count(WeeklyAttendance.record_id).label('record_count'),
|
||
func.sum(WeeklyAttendance.actual_work_hours).label('total_hours'),
|
||
func.sum(WeeklyAttendance.class_work_hours).label('class_hours'),
|
||
func.sum(WeeklyAttendance.overtime_hours).label('overtime_hours'),
|
||
func.sum(WeeklyAttendance.absent_days).label('absent_days')
|
||
).filter_by(student_number=current_user.student_number)
|
||
|
||
if start_date:
|
||
try:
|
||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||
monthly_stats = monthly_stats.filter(WeeklyAttendance.week_start_date >= start_date_obj)
|
||
except ValueError:
|
||
pass
|
||
|
||
if end_date:
|
||
try:
|
||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||
monthly_stats = monthly_stats.filter(WeeklyAttendance.week_end_date <= end_date_obj)
|
||
except ValueError:
|
||
pass
|
||
|
||
monthly_stats = monthly_stats.group_by('month').order_by('month').all()
|
||
|
||
# 最近几周的趋势数据
|
||
recent_weeks = attendance_query.order_by(desc(WeeklyAttendance.week_start_date)).limit(12).all()
|
||
recent_weeks.reverse() # 按时间正序排列
|
||
|
||
# 计算入学以来的总体表现
|
||
all_time_stats = None
|
||
if student.enrollment_date:
|
||
all_time_query = WeeklyAttendance.query.filter_by(
|
||
student_number=current_user.student_number
|
||
)
|
||
|
||
all_records = all_time_query.all()
|
||
if all_records:
|
||
# 计算入学以来的总统计
|
||
enrollment_weeks = (datetime.now().date() - student.enrollment_date).days // 7
|
||
all_time_stats = {
|
||
'total_work_hours': sum(record.actual_work_hours for record in all_records),
|
||
'total_class_hours': sum(record.class_work_hours for record in all_records),
|
||
'total_overtime_hours': sum(record.overtime_hours for record in all_records),
|
||
'total_absent_days': sum(record.absent_days for record in all_records),
|
||
'attendance_weeks': len(all_records),
|
||
'enrollment_weeks': enrollment_weeks,
|
||
'attendance_rate': round(len(all_records) / max(enrollment_weeks, 1) * 100,
|
||
1) if enrollment_weeks > 0 else 0
|
||
}
|
||
|
||
# 计算入学以来的迟到次数
|
||
all_time_late_count = db.session.query(
|
||
func.sum(
|
||
case(
|
||
(DailyAttendanceDetail.status.like('%迟到%'), 1),
|
||
else_=0
|
||
)
|
||
)
|
||
).join(
|
||
WeeklyAttendance, DailyAttendanceDetail.weekly_record_id == WeeklyAttendance.record_id
|
||
).filter(WeeklyAttendance.student_number == current_user.student_number).scalar()
|
||
|
||
all_time_stats['total_late_count'] = int(all_time_late_count or 0)
|
||
|
||
return render_template('student/statistics.html',
|
||
student=student,
|
||
attendance_records=attendance_records,
|
||
total_stats=total_stats,
|
||
monthly_stats=monthly_stats,
|
||
recent_weeks=recent_weeks,
|
||
all_time_stats=all_time_stats,
|
||
start_date=start_date,
|
||
end_date=end_date)
|
||
|
||
|
||
================================================================================
|
||
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
|
||
================================================================================
|
||
|