CHM_attendance/code_collection.txt
superlishunqin e7fa4bc030 first commit
2025-06-11 19:56:34 +08:00

11108 lines
450 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

================================================================================
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">&copy; 2025 CHM考勤管理系统. All rights reserved.</span>
</div>
</footer>
{% endif %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Chart.js (用于统计图表) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
================================================================================
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">&nbsp;</label>
<div>
<button type="submit" class="btn btn-primary">搜索</button>
</div>
</div>
</form>
</div>
</div>
<!-- 学生列表 -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
学生列表 (共 {{ pagination.total }} 人)
</h6>
</div>
<div class="card-body">
{% if students %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" id="selectAll" onchange="toggleSelectAll()">
</th>
<th>学号</th>
<th>姓名</th>
<th>性别</th>
<th>年级</th>
<th>学院</th>
<th>导师</th>
<th>学位类型</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for student in students %}
<tr>
<td>
<input type="checkbox" class="student-checkbox"
value="{{ student.student_number }}">
</td>
<td>{{ student.student_number }}</td>
<td>
<strong>{{ student.name }}</strong>
{% if student.phone %}
<br><small class="text-muted">{{ student.phone }}</small>
{% endif %}
</td>
<td>{{ student.gender }}</td>
<td>{{ student.grade }}级</td>
<td>{{ student.college or '-' }}</td>
<td>{{ student.supervisor or '-' }}</td>
<td>
{% if student.degree_type %}
<span class="badge bg-info">{{ student.degree_type }}</span>
{% else %}
-
{% endif %}
</td>
<td>
{% if student.status == '在读' %}
<span class="badge bg-success">在读</span>
{% else %}
<span class="badge bg-secondary">毕业</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.student_detail', student_number=student.student_number) }}"
class="btn btn-outline-info" title="查看详情">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('admin.edit_student', student_number=student.student_number) }}"
class="btn btn-outline-primary" title="编辑">
<i class="fas fa-edit"></i>
</a>
<button class="btn btn-outline-danger"
onclick="deleteStudent('{{ student.student_number }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.prev_num, **request.args) }}">
上一页
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.student_list', page=page_num, **request.args) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">…</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.student_list', page=pagination.next_num, **request.args) }}">
下一页
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无学生数据</p>
<a href="{{ url_for('admin.add_student') }}" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>添加第一个学生
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 全选/取消全选
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.student-checkbox');
checkboxes.forEach(checkbox => {
checkbox.checked = selectAll.checked;
});
}
// 删除学生
function deleteStudent(studentNumber) {
if (confirm('确认删除这个学生吗?此操作不可恢复!')) {
fetch(`/admin/students/${studentNumber}/delete`, {
method: 'POST'
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
alert(result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('删除失败,请稍后重试');
});
}
}
// 批量操作
function batchAction(action) {
const selectedStudents = Array.from(document.querySelectorAll('.student-checkbox:checked'))
.map(checkbox => checkbox.value);
if (selectedStudents.length === 0) {
alert('请选择要操作的学生');
return;
}
let confirmMessage = '';
if (action === 'delete') {
confirmMessage = `确认删除选中的 ${selectedStudents.length} 个学生吗?此操作不可恢复!`;
} else if (action === 'graduate') {
confirmMessage = `确认将选中的 ${selectedStudents.length} 个学生设为毕业状态吗?`;
}
if (confirm(confirmMessage)) {
fetch('/admin/students/batch_action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: action,
student_numbers: selectedStudents
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
alert(result.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('操作失败,请稍后重试');
});
}
}
</script>
{% endblock %}
================================================================================
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
================================================================================