From 77b57a9876efec799a955d3ec4ccf0ec136fc255 Mon Sep 17 00:00:00 2001 From: superlishunqin <852326703@qq.com> Date: Sun, 14 Sep 2025 01:28:47 +0800 Subject: [PATCH] 0914 --- app/models/attendance.py | 8 +- app/models/student.py | 2 +- app/routes/admin.py | 787 ++++++++++++--- app/static/css/admin.css | 70 ++ app/static/js/admin.js | 206 ++++ app/templates/admin/add_student.html | 584 ++++++++--- app/templates/admin/attendance_details.html | 12 +- .../admin/attendance_management.html | 94 +- app/templates/admin/edit_student.html | 3 +- app/templates/admin/student_detail.html | 13 +- app/templates/admin/student_list.html | 8 +- app/utils/attendance_importer.py | 910 +++++++++++++----- requirements.txt | 1 + update_foreign_keys.py | 51 + 14 files changed, 2169 insertions(+), 580 deletions(-) create mode 100644 update_foreign_keys.py diff --git a/app/models/attendance.py b/app/models/attendance.py index 2b9db4b..0dd7d7f 100644 --- a/app/models/attendance.py +++ b/app/models/attendance.py @@ -6,7 +6,7 @@ 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) + student_number = db.Column(db.String(20), db.ForeignKey('students.student_number', ondelete='CASCADE'), 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) @@ -30,8 +30,8 @@ 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) + weekly_record_id = db.Column(db.Integer, db.ForeignKey('weekly_attendance.record_id', ondelete='CASCADE')) + student_number = db.Column(db.String(20), db.ForeignKey('students.student_number', ondelete='CASCADE'), 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) @@ -53,7 +53,7 @@ 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) + student_number = db.Column(db.String(20), db.ForeignKey('students.student_number', ondelete='CASCADE'), 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) diff --git a/app/models/student.py b/app/models/student.py index 3d7b5ee..e51eb40 100644 --- a/app/models/student.py +++ b/app/models/student.py @@ -6,7 +6,7 @@ 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) + student_number = db.Column(db.String(20), db.ForeignKey('users.student_number', ondelete='CASCADE'), 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) diff --git a/app/routes/admin.py b/app/routes/admin.py index 3679185..ed46c6d 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -125,33 +125,96 @@ def student_list(): @admin_required def student_detail(student_number): """学生详细信息""" - student = Student.query.filter_by(student_number=student_number).first_or_404() + import json + 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() + # 🔥 新增:为每个考勤记录计算真实的缺勤次数和迟到次数 + def calculate_record_counts(record): + """为单个考勤记录计算真实的缺勤次数和迟到次数""" + # 获取该记录的每日明细 + daily_details = DailyAttendanceDetail.query.filter_by( + weekly_record_id=record.record_id + ).all() + + late_count = 0 + absent_count = 0 + + for detail in daily_details: + # 🔥 处理完全缺勤的情况 + if detail.status == '缺勤' and (not detail.remarks or not detail.remarks.startswith('{')): + # 完全缺勤的天数,早上+下午都缺勤 + absent_count += 2 # 早上缺勤1次 + 下午缺勤1次 + continue + + if detail.remarks and detail.remarks.startswith('{'): + try: + remarks_data = json.loads(detail.remarks) + details_info = remarks_data.get('details', {}) + + # 统计迟到次数 + for period in ['morning', 'afternoon', 'evening']: + period_data = details_info.get(period, {}) + if period_data.get('status') == 'late': + late_count += 1 + + # 统计缺勤次数 + # 检查早上缺勤:morning_in AND morning_out 都missing + morning_data = details_info.get('morning', {}) + morning_in_time = morning_data.get('in') + morning_out_time = morning_data.get('out') + morning_in_status = morning_data.get('status', 'missing') + + if ((not morning_in_time or morning_in_status == 'missing') and + (not morning_out_time)): + absent_count += 1 + + # 检查下午缺勤:afternoon_in AND afternoon_out 都missing + afternoon_data = details_info.get('afternoon', {}) + afternoon_in_time = afternoon_data.get('in') + afternoon_out_time = afternoon_data.get('out') + afternoon_in_status = afternoon_data.get('status', 'missing') + + if ((not afternoon_in_time or afternoon_in_status == 'missing') and + (not afternoon_out_time)): + absent_count += 1 + + except (json.JSONDecodeError, KeyError, AttributeError): + continue + + return late_count, absent_count + + # 🔥 为每个考勤记录添加计算属性 + total_absent_count = 0 + total_late_count = 0 + + for record in attendance_records: + late_count, absent_count = calculate_record_counts(record) + record.late_count = late_count + record.absent_count = absent_count + total_absent_count += absent_count + total_late_count += late_count # 获取请假记录 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 - + # 🔥 修改:使用计算的缺勤次数而不是数据库的absent_days + print(f"学生 {student.name} - 总缺勤次数: {total_absent_count}, 总迟到次数: {total_late_count}") 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)) + total_absent_count=int(total_absent_count), # 🔥 新字段 + total_late_count=int(total_late_count)) # 🔥 新字段 @admin_bp.route('/attendance') @@ -172,6 +235,7 @@ def attendance_management(): # ================================== from sqlalchemy import desc, func, case, or_ + import json page = request.args.get('page', 1, type=int) per_page = 50 @@ -181,24 +245,15 @@ def attendance_management(): 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') # 默认按周开始日期降序 + + # 🔥 新增:高级搜索参数 + supervisors = request.args.getlist('supervisor') # 多选导师 + grades = request.args.getlist('grade') # 多选年级 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) + # 🔥 修改:构建基础查询,不再在SQL中计算迟到次数 + query = WeeklyAttendance.query # 应用筛选条件 if start_date: @@ -220,8 +275,129 @@ def attendance_management(): WeeklyAttendance.name.contains(student_search), WeeklyAttendance.student_number.contains(student_search) )) + + # 🔥 新增:应用高级搜索筛选条件 + if supervisors or grades: + # 需要连接Student表来筛选导师和年级 + query = query.join(Student, WeeklyAttendance.student_number == Student.student_number) + + if supervisors: + # 导师多选筛选 + query = query.filter(Student.supervisor.in_(supervisors)) + + if grades: + # 年级多选筛选,转换为整数 + grade_ints = [] + for grade in grades: + try: + grade_ints.append(int(grade)) + except ValueError: + continue + if grade_ints: + query = query.filter(Student.grade.in_(grade_ints)) - # 处理排序 + # 🔥 修改:先获取基础记录,稍后在Python中计算迟到次数和缺勤次数 + base_records = query.all() + + # 🔥 性能优化:批量查询所有每日明细数据 + print("🚀 批量查询每日明细数据...") + record_ids = [r.record_id for r in base_records] + + if record_ids: + # 一次性查询所有相关的每日明细 + all_daily_details = DailyAttendanceDetail.query.filter( + DailyAttendanceDetail.weekly_record_id.in_(record_ids) + ).all() + + # 按record_id分组 + daily_details_by_record = {} + for detail in all_daily_details: + record_id = detail.weekly_record_id + if record_id not in daily_details_by_record: + daily_details_by_record[record_id] = [] + daily_details_by_record[record_id].append(detail) + + print(f"批量查询完成,共获取 {len(all_daily_details)} 条每日明细") + else: + daily_details_by_record = {} + + # 🔥 优化:批量计算迟到次数和缺勤次数 + def calculate_counts_batch(daily_details_dict: dict) -> dict: + """批量计算所有记录的迟到次数和缺勤次数 - 修复版本""" + counts = {} + + for record_id, daily_details in daily_details_dict.items(): + late_count = 0 + absent_count = 0 + + for detail in daily_details: + # 🔥 新增:处理完全缺勤的情况 + if detail.status == '缺勤' and (not detail.remarks or not detail.remarks.startswith('{')): + # 完全缺勤的天数,早上+下午都缺勤 + absent_count += 2 # 早上缺勤1次 + 下午缺勤1次 + print(f" 完全缺勤日期 {detail.attendance_date}: +2次") + continue + + if detail.remarks and detail.remarks.startswith('{'): + try: + remarks_data = json.loads(detail.remarks) + details = remarks_data.get('details', {}) + + # 统计迟到次数 + for period in ['morning', 'afternoon', 'evening']: + period_data = details.get(period, {}) + if period_data.get('status') == 'late': + late_count += 1 + + # 统计缺勤次数 + # 检查早上缺勤:morning_in AND morning_out 都missing + morning_data = details.get('morning', {}) + morning_in_time = morning_data.get('in') + morning_out_time = morning_data.get('out') + morning_in_status = morning_data.get('status', 'missing') + + if ((not morning_in_time or morning_in_status == 'missing') and + (not morning_out_time)): + absent_count += 1 + print(f" 部分缺勤日期 {detail.attendance_date}: 早上缺勤 +1次") + + # 检查下午缺勤:afternoon_in AND afternoon_out 都missing + afternoon_data = details.get('afternoon', {}) + afternoon_in_time = afternoon_data.get('in') + afternoon_out_time = afternoon_data.get('out') + afternoon_in_status = afternoon_data.get('status', 'missing') + + if ((not afternoon_in_time or afternoon_in_status == 'missing') and + (not afternoon_out_time)): + absent_count += 1 + print(f" 部分缺勤日期 {detail.attendance_date}: 下午缺勤 +1次") + + except (json.JSONDecodeError, KeyError, AttributeError): + continue + + counts[record_id] = { + 'late_count': late_count, + 'absent_count': absent_count + } + print(f" 记录{record_id}最终缺勤次数: {absent_count}") + + return counts + + # 🔥 执行批量计算 + print("🔍 开始批量计算迟到次数和缺勤次数...") + all_counts = calculate_counts_batch(daily_details_by_record) + + # 🔥 为每个记录添加计算属性 + records_with_counts = [] + for record in base_records: + counts = all_counts.get(record.record_id, {'late_count': 0, 'absent_count': 0}) + record.late_count = counts['late_count'] + record.absent_count = counts['absent_count'] + records_with_counts.append(record) + + print(f"批量计算完成,处理了 {len(records_with_counts)} 条记录") + + # 🔥 修改:处理排序(现在基于Python计算的迟到次数和缺勤次数) if sort_by and '_' in sort_by: field, direction = sort_by.rsplit('_', 1) print(f"排序字段: {field}, 方向: {direction}") # 调试信息 @@ -230,74 +406,96 @@ def attendance_management(): 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) + if field == 'late_count': + # 🔥 按真实迟到次数排序 + records_with_counts.sort( + key=lambda x: x.late_count, + reverse=(direction == 'desc') + ) + elif field == 'absent_count' or field == 'absent_days': # 兼容旧的absent_days参数 + # 🔥 新增:按真实缺勤次数排序 + records_with_counts.sort( + key=lambda x: x.absent_count, + reverse=(direction == 'desc') + ) + elif field == 'actual_work_hours': + records_with_counts.sort( + key=lambda x: x.actual_work_hours, + reverse=(direction == 'desc') + ) 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) + records_with_counts.sort( + key=lambda x: x.class_work_hours, + reverse=(direction == 'desc') + ) 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') + records_with_counts.sort( + key=lambda x: x.overtime_hours, + reverse=(direction == 'desc') + ) elif field == 'created_at': - if direction == 'desc': - query = query.order_by(desc(WeeklyAttendance.created_at)) - else: - query = query.order_by(WeeklyAttendance.created_at) + records_with_counts.sort( + key=lambda x: x.created_at, + reverse=(direction == 'desc') + ) 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) + records_with_counts.sort( + key=lambda x: x.week_start_date, + reverse=(direction == 'desc') + ) else: # 未知字段,使用默认排序 - query = query.order_by(desc(WeeklyAttendance.week_start_date)) + records_with_counts.sort( + key=lambda x: x.week_start_date, + reverse=True + ) else: # 默认排序:按周开始日期降序 - query = query.order_by(desc(WeeklyAttendance.week_start_date)) - - # 执行分页查询 - try: - pagination = query.paginate( - page=page, - per_page=per_page, - error_out=False + records_with_counts.sort( + key=lambda x: x.week_start_date, + reverse=True ) - 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) + # 🔥 修改:手动实现分页 + total_count = len(records_with_counts) + start_index = (page - 1) * per_page + end_index = start_index + per_page + + attendance_records = records_with_counts[start_index:end_index] + + # 🔥 完善分页对象,添加 iter_pages 方法 + class SimplePagination: + def __init__(self, page, per_page, total, items): + self.page = page + self.per_page = per_page + self.total = total + self.items = items + self.pages = (total + per_page - 1) // per_page # 总页数 + self.prev_num = page - 1 if page > 1 else None + self.next_num = page + 1 if page < self.pages else None + self.has_prev = page > 1 + self.has_next = page < self.pages + + def iter_pages(self, left_edge=2, left_current=2, right_current=3, right_edge=2): + """生成分页页码列表,与Flask-SQLAlchemy的Pagination兼容""" + last = self.pages + for num in range(1, last + 1): + if (num <= left_edge or + (num > self.page - left_current - 1 and num < self.page + right_current) or + num > last - right_edge): + yield num + + pagination = SimplePagination( + page=page, + per_page=per_page, + total=total_count, + items=attendance_records + ) print(f"查询结果: {len(attendance_records)} 条记录") # 调试信息 if attendance_records: print(f"第一条记录迟到次数: {attendance_records[0].late_count}") # 调试信息 - - # 更新pagination对象的items - pagination.items = attendance_records + print(f"第一条记录缺勤次数: {attendance_records[0].absent_count}") # 调试信息 # ========== 计算请假统计 ========== statistics = None @@ -315,6 +513,17 @@ def attendance_management(): 'total_leave_days': leave_count } + # 🔥 新增:获取所有导师和年级用于高级搜索选项 + all_supervisors = db.session.query(Student.supervisor).distinct().filter( + Student.supervisor.isnot(None), Student.supervisor != '' + ).order_by(Student.supervisor).all() + all_supervisors = [s[0] for s in all_supervisors] + + all_grades = db.session.query(Student.grade).distinct().filter( + Student.grade.isnot(None) + ).order_by(Student.grade).all() + all_grades = [g[0] for g in all_grades] + # 确保总是返回模板 return render_template('admin/attendance_management.html', attendance_records=attendance_records, @@ -323,7 +532,11 @@ def attendance_management(): end_date=end_date, student_search=student_search, sort_by=sort_by, - statistics=statistics) + statistics=statistics, + all_supervisors=all_supervisors, + all_grades=all_grades, + selected_supervisors=supervisors, + selected_grades=grades) @admin_bp.route('/upload/attendance', methods=['GET', 'POST']) @@ -417,7 +630,7 @@ def upload_attendance(): except Exception as e: flash(f'文件处理失败:{str(e)}', 'error') - logger.error(f"文件处理失败: {str(e)}", exc_info=True) + print(f"文件处理失败: {str(e)}") finally: # 删除临时文件 try: @@ -930,25 +1143,32 @@ def edit_student(student_number): def delete_student(student_number): """删除学生""" try: + # 首先删除相关的考勤记录 + WeeklyAttendance.query.filter_by(student_number=student_number).delete() + DailyAttendanceDetail.query.filter_by(student_number=student_number).delete() + LeaveRecord.query.filter_by(student_number=student_number).delete() + + # 然后删除学生记录 student = Student.query.filter_by(student_number=student_number).first_or_404() student_name = student.name + db.session.delete(student) + + # 最后删除用户记录 + user = User.query.filter_by(student_number=student_number).first() + if user: + db.session.delete(user) + + # 提交事务 + db.session.commit() - # 删除学生记录(用户记录会因为外键约束自动删除) - 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')) + if request.is_json: + return jsonify({'success': True, 'message': f'学生 {student_name} 删除成功'}) else: - if request.is_json: - return jsonify({'success': False, 'message': f'删除失败: {error}'}) - else: - flash(f'删除失败: {error}', 'error') + flash(f'学生 {student_name} 删除成功', 'success') + return redirect(url_for('admin.student_list')) except Exception as e: + db.session.rollback() if request.is_json: return jsonify({'success': False, 'message': f'删除失败: {str(e)}'}) else: @@ -970,32 +1190,29 @@ def batch_action(): 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}'}) + # 批量删除:先删除相关记录 + WeeklyAttendance.query.filter(WeeklyAttendance.student_number.in_(student_numbers)).delete(synchronize_session=False) + DailyAttendanceDetail.query.filter(DailyAttendanceDetail.student_number.in_(student_numbers)).delete(synchronize_session=False) + LeaveRecord.query.filter(LeaveRecord.student_number.in_(student_numbers)).delete(synchronize_session=False) + Student.query.filter(Student.student_number.in_(student_numbers)).delete(synchronize_session=False) + User.query.filter(User.student_number.in_(student_numbers)).delete(synchronize_session=False) + + db.session.commit() + return jsonify({'success': True, 'message': f'成功删除 {len(student_numbers)} 个学生'}) 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}'}) + db.session.commit() + return jsonify({'success': True, 'message': f'成功将 {len(student_numbers)} 个学生设为毕业状态'}) else: return jsonify({'success': False, 'message': '无效的操作'}) except Exception as e: + db.session.rollback() return jsonify({'success': False, 'message': f'操作失败: {str(e)}'}) @@ -1068,18 +1285,75 @@ 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() + # 🔥 新增:计算真实的缺勤次数和迟到次数 + def calculate_real_counts(daily_details_list): + """计算真实的缺勤次数和迟到次数""" + late_count = 0 + absent_count = 0 + + for detail in daily_details_list: + # 🔥 处理完全缺勤的情况 + if detail.status == '缺勤' and (not detail.remarks or not detail.remarks.startswith('{')): + # 完全缺勤的天数,早上+下午都缺勤 + absent_count += 2 # 早上缺勤1次 + 下午缺勤1次 + print(f" 完全缺勤日期 {detail.attendance_date}: +2次") + continue + + if detail.remarks and detail.remarks.startswith('{'): + try: + remarks_data = json.loads(detail.remarks) + details_info = remarks_data.get('details', {}) + + # 统计迟到次数 + for period in ['morning', 'afternoon', 'evening']: + period_data = details_info.get(period, {}) + if period_data.get('status') == 'late': + late_count += 1 + + # 统计缺勤次数 + # 检查早上缺勤:morning_in AND morning_out 都missing + morning_data = details_info.get('morning', {}) + morning_in_time = morning_data.get('in') + morning_out_time = morning_data.get('out') + morning_in_status = morning_data.get('status', 'missing') + + if ((not morning_in_time or morning_in_status == 'missing') and + (not morning_out_time)): + absent_count += 1 + print(f" 部分缺勤日期 {detail.attendance_date}: 早上缺勤 +1次") + + # 检查下午缺勤:afternoon_in AND afternoon_out 都missing + afternoon_data = details_info.get('afternoon', {}) + afternoon_in_time = afternoon_data.get('in') + afternoon_out_time = afternoon_data.get('out') + afternoon_in_status = afternoon_data.get('status', 'missing') + + if ((not afternoon_in_time or afternoon_in_status == 'missing') and + (not afternoon_out_time)): + absent_count += 1 + print(f" 部分缺勤日期 {detail.attendance_date}: 下午缺勤 +1次") + + except (json.JSONDecodeError, KeyError, AttributeError): + continue + + print(f" 详情页面计算结果 - 迟到次数: {late_count}, 缺勤次数: {absent_count}") + return late_count, absent_count + + # 🔥 计算真实的次数 + real_late_count, real_absent_count = calculate_real_counts(daily_details) + + # 🔥 将计算结果添加到weekly_record对象中 + weekly_record.late_count = real_late_count + weekly_record.absent_count = real_absent_count # 处理每日详情,计算工作时长和解析详细信息 processed_daily_details = [] for detail in daily_details: @@ -1093,24 +1367,20 @@ def attendance_record_details(record_id): '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: @@ -1122,28 +1392,23 @@ def attendance_record_details(record_id): 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, @@ -1219,26 +1484,22 @@ def export_attendance_data(): 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)) @@ -1253,16 +1514,13 @@ def export_attendance_data(): 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: @@ -1278,19 +1536,15 @@ def export_attendance_data(): '加班时长(小时)': 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 @@ -1302,20 +1556,16 @@ def export_attendance_data(): 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参数,重定向到正常页面 @@ -1323,3 +1573,270 @@ def export_attendance_data(): args.pop('export', None) return redirect(url_for('admin.attendance_management', **args)) +@admin_bp.route('/students/batch_import', methods=['POST']) +@admin_required +def batch_import_students(): + """批量导入学生""" + try: + if 'excel_file' not in request.files: + return jsonify({'success': False, 'message': '请上传Excel文件'}) + + file = request.files['excel_file'] + if file.filename == '': + return jsonify({'success': False, 'message': '请选择文件'}) + + if not file.filename.lower().endswith(('.xlsx', '.xls')): + return jsonify({'success': False, 'message': '请上传Excel文件(.xlsx或.xls)'}) + + overwrite = request.form.get('overwrite_existing') == 'on' + + # 使用临时文件处理上传的Excel + import tempfile + import os + + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') + file.save(temp_file.name) + temp_file.close() + + try: + # 解析Excel文件 + import pandas as pd + df = pd.read_excel(temp_file.name) + + print("Excel文件列名:", df.columns.tolist()) + print("数据行数:", len(df)) + + # 验证必要的列 + required_columns = ['姓名', '性别', '年级', '学号'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + return jsonify({ + 'success': False, + 'message': f'Excel文件缺少必要的列: {", ".join(missing_columns)}' + }) + + success_count = 0 + error_count = 0 + skip_count = 0 + details = [] + + # 遍历每行数据 + for index, row in df.iterrows(): + try: + # 提取数据 + name = str(row['姓名']).strip() if pd.notna(row['姓名']) else '' + gender = str(row['性别']).strip() if pd.notna(row['性别']) else '' + grade = row['年级'] if pd.notna(row['年级']) else None + student_number = str(row['学号']).strip() if pd.notna(row['学号']) else '' + phone = str(row['手机号']).strip() if pd.notna(row.get('手机号', '')) and str(row.get('手机号', '')) != 'nan' else '' + supervisor = str(row['导师']).strip() if pd.notna(row.get('导师', '')) else '' + college = str(row['学院']).strip() if pd.notna(row.get('学院', '')) else '' + major = str(row['专业']).strip() if pd.notna(row.get('专业', '')) else '' + degree_type = str(row['学位类型']).strip() if pd.notna(row.get('学位类型', '')) else '' + + # 验证必填字段 + if not all([name, gender, student_number]) or grade is None: + details.append({ + 'name': name or '未知', + 'student_number': student_number or '未知', + 'status': 'error', + 'message': '缺少必填字段' + }) + error_count += 1 + continue + + # 验证年级格式 + try: + grade = int(grade) + if grade < 20 or grade > 30: + raise ValueError("年级应在20-30之间") + except (ValueError, TypeError): + details.append({ + 'name': name, + 'student_number': student_number, + 'status': 'error', + 'message': f'年级格式错误: {grade}' + }) + error_count += 1 + continue + + # 验证性别 + if gender not in ['男', '女']: + details.append({ + 'name': name, + 'student_number': student_number, + 'status': 'error', + 'message': f'性别格式错误: {gender}' + }) + error_count += 1 + continue + + # 检查学号是否已存在 + existing_student = Student.query.filter_by(student_number=student_number).first() + if existing_student: + if not overwrite: + details.append({ + 'name': name, + 'student_number': student_number, + 'status': 'skip', + 'message': '学号已存在,跳过' + }) + skip_count += 1 + continue + else: + # 更新现有学生信息 + existing_student.name = name + existing_student.gender = gender + existing_student.grade = grade + existing_student.phone = phone + existing_student.supervisor = supervisor + existing_student.college = college + existing_student.major = major + existing_student.degree_type = degree_type if degree_type else None + + details.append({ + 'name': name, + 'student_number': student_number, + 'status': 'success', + 'message': '更新成功' + }) + success_count += 1 + continue + + # 创建用户账户 + user = User( + student_number=student_number, + password_hash=generate_password_hash('123456'), + role='student' + ) + db.session.add(user) + db.session.flush() # 获取用户ID + + # 创建学生记录 + student = Student( + student_number=student_number, + name=name, + gender=gender, + grade=grade, + phone=phone, + supervisor=supervisor, + college=college, + major=major, + degree_type=degree_type if degree_type else None, + status='在读' + ) + db.session.add(student) + + details.append({ + 'name': name, + 'student_number': student_number, + 'status': 'success', + 'message': '创建成功' + }) + success_count += 1 + + except Exception as e: + details.append({ + 'name': name if 'name' in locals() else '未知', + 'student_number': student_number if 'student_number' in locals() else '未知', + 'status': 'error', + 'message': f'处理失败: {str(e)}' + }) + error_count += 1 + continue + + # 提交事务 + db.session.commit() + + return jsonify({ + 'success': True, + 'success_count': success_count, + 'error_count': error_count, + 'skip_count': skip_count, + 'details': details + }) + + finally: + # 删除临时文件 + try: + os.unlink(temp_file.name) + except: + pass + + except Exception as e: + db.session.rollback() + return jsonify({'success': False, 'message': f'导入失败: {str(e)}'}) + + +@admin_bp.route('/students/download_template') +@admin_required +def download_student_template(): + """下载学生信息Excel模板""" + import pandas as pd + from io import BytesIO + + # 创建模板数据 + template_data = { + '姓名': ['张三', '李四', '王五'], + '性别': ['男', '女', '男'], + '年级': [25, 25, 24], + '学号': ['23320251154001', '23320251154002', '23320241154003'], + '手机号': ['13800138001', '13800138002', '13800138003'], + '导师': ['张教授', '李教授', '王教授'], + '学院': ['信息学院', '信息学院', '计算机学院'], + '专业': ['人工智能', '通信工程', '计算机科学'], + '学位类型': ['专硕', '学硕', '专硕'], + '备注': ['', '', ''] + } + + df = pd.DataFrame(template_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['学生信息模板'] + + # 设置列宽 + column_widths = { + 'A': 10, # 姓名 + 'B': 6, # 性别 + 'C': 8, # 年级 + 'D': 18, # 学号 + 'E': 15, # 手机号 + 'F': 12, # 导师 + 'G': 15, # 学院 + 'H': 15, # 专业 + 'I': 10, # 学位类型 + 'J': 10 # 备注 + } + + for col, width in column_widths.items(): + worksheet.column_dimensions[col].width = width + + # 设置表头格式 + from openpyxl.styles import Font, PatternFill, Alignment + + header_font = Font(bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_alignment = Alignment(horizontal='center', vertical='center') + + for cell in worksheet[1]: + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + output.seek(0) + + filename = f"学生信息导入模板_{datetime.now().strftime('%Y%m%d')}.xlsx" + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) diff --git a/app/static/css/admin.css b/app/static/css/admin.css index e69de29..f66c760 100644 --- a/app/static/css/admin.css +++ b/app/static/css/admin.css @@ -0,0 +1,70 @@ + +/* 高级搜索样式 */ +.supervisor-checkboxes, .grade-checkboxes { + background-color: #f8f9fa; +} + +.supervisor-checkboxes::-webkit-scrollbar, +.grade-checkboxes::-webkit-scrollbar { + width: 6px; +} + +.supervisor-checkboxes::-webkit-scrollbar-track, +.grade-checkboxes::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.supervisor-checkboxes::-webkit-scrollbar-thumb, +.grade-checkboxes::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.supervisor-checkboxes::-webkit-scrollbar-thumb:hover, +.grade-checkboxes::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +.form-check { + padding: 2px 0; +} + +.form-check-label { + font-size: 0.9rem; + cursor: pointer; +} + +.form-check-input:checked + .form-check-label { + font-weight: 600; + color: #0066cc; +} + +/* 高级搜索区域动画 */ +#advancedSearchArea { + transition: all 0.3s ease-in-out; + opacity: 0; + max-height: 0; + overflow: hidden; +} + +#advancedSearchArea[style*="block"] { + opacity: 1; + max-height: 500px; +} + +/* 搜索标题样式 */ +.text-primary { + font-weight: 600 !important; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .supervisor-checkboxes, .grade-checkboxes { + max-height: 150px !important; + } + + .col-md-6 { + margin-bottom: 1rem; + } +} diff --git a/app/static/js/admin.js b/app/static/js/admin.js index e69de29..a86f384 100644 --- a/app/static/js/admin.js +++ b/app/static/js/admin.js @@ -0,0 +1,206 @@ +// 高级搜索功能 +let isAdvancedSearchVisible = false; + +function toggleAdvancedSearch() { + console.log('toggleAdvancedSearch called'); // 调试信息 + + const advancedArea = document.getElementById('advancedSearchArea'); + const toggleButton = document.querySelector('button[onclick="toggleAdvancedSearch()"]'); + + if (!advancedArea) { + console.error('找不到高级搜索区域元素'); + return; + } + + if (!toggleButton) { + console.error('找不到高级搜索切换按钮'); + return; + } + + if (isAdvancedSearchVisible) { + // 隐藏高级搜索 + advancedArea.style.display = 'none'; + toggleButton.innerHTML = ''; + toggleButton.title = '高级搜索'; + toggleButton.classList.remove('btn-info'); + toggleButton.classList.add('btn-outline-info'); + isAdvancedSearchVisible = false; + console.log('高级搜索已隐藏'); + } else { + // 显示高级搜索 + advancedArea.style.display = 'block'; + toggleButton.innerHTML = ''; + toggleButton.title = '关闭高级搜索'; + toggleButton.classList.remove('btn-outline-info'); + toggleButton.classList.add('btn-info'); + isAdvancedSearchVisible = true; + console.log('高级搜索已显示'); + } +} + +// 导师选择功能 +function selectAllSupervisors() { + const checkboxes = document.querySelectorAll('.supervisor-checkboxes input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + }); + updateSupervisorCount(); +} + +function clearAllSupervisors() { + const checkboxes = document.querySelectorAll('.supervisor-checkboxes input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + }); + updateSupervisorCount(); +} + +// 年级选择功能 +function selectAllGrades() { + const checkboxes = document.querySelectorAll('.grade-checkboxes input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + }); + updateGradeCount(); +} + +function clearAllGrades() { + const checkboxes = document.querySelectorAll('.grade-checkboxes input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + }); + updateGradeCount(); +} + +// 更新导师选择计数 +function updateSupervisorCount() { + const checkedCount = document.querySelectorAll('.supervisor-checkboxes input[type="checkbox"]:checked').length; + const label = document.querySelector('.supervisor-checkboxes').previousElementSibling; + if (label) { + if (checkedCount > 0) { + label.innerHTML = `导师(已选${checkedCount}个)`; + label.classList.add('text-primary'); + } else { + label.innerHTML = '导师(多选)'; + label.classList.remove('text-primary'); + } + } +} + +// 更新年级选择计数 +function updateGradeCount() { + const checkedCount = document.querySelectorAll('.grade-checkboxes input[type="checkbox"]:checked').length; + const label = document.querySelector('.grade-checkboxes').previousElementSibling; + if (label) { + if (checkedCount > 0) { + label.innerHTML = `年级(已选${checkedCount}个)`; + label.classList.add('text-primary'); + } else { + label.innerHTML = '年级(多选)'; + label.classList.remove('text-primary'); + } + } +} + +// 检查是否有高级搜索条件被选中 +function hasAdvancedSearchFilters() { + const supervisorChecked = document.querySelectorAll('.supervisor-checkboxes input[type="checkbox"]:checked').length > 0; + const gradeChecked = document.querySelectorAll('.grade-checkboxes input[type="checkbox"]:checked').length > 0; + return supervisorChecked || gradeChecked; +} + +// 智能搜索提示 +function setupSearchHints() { + const supervisorCheckboxes = document.querySelectorAll('.supervisor-checkboxes input[type="checkbox"]'); + const gradeCheckboxes = document.querySelectorAll('.grade-checkboxes input[type="checkbox"]'); + + // 导师搜索计数提示 + supervisorCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', updateSupervisorCount); + }); + + // 年级搜索计数提示 + gradeCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', updateGradeCount); + }); +} + +// 清空所有筛选条件 +function clearAllFilters() { + // 清空基本搜索 + const startDate = document.getElementById('start_date'); + const endDate = document.getElementById('end_date'); + const studentSearch = document.getElementById('student_search'); + const sortBy = document.getElementById('sort_by'); + + if (startDate) startDate.value = ''; + if (endDate) endDate.value = ''; + if (studentSearch) studentSearch.value = ''; + if (sortBy) sortBy.value = 'week_start_date_desc'; + + // 清空高级搜索 + clearAllSupervisors(); + clearAllGrades(); + + // 提交表单 + const form = document.getElementById('searchForm'); + if (form) { + form.submit(); + } +} + +// 导出当前筛选结果 +function exportCurrentFilter() { + const form = document.getElementById('searchForm'); + if (!form) { + console.error('找不到搜索表单'); + return; + } + + const formData = new FormData(form); + const params = new URLSearchParams(); + + for (let [key, value] of formData.entries()) { + params.append(key, value); + } + + params.set('export', 'excel'); + + const exportUrl = window.location.pathname + '?' + params.toString(); + console.log('导出URL:', exportUrl); + + window.location.href = exportUrl; +} + +// 页面加载完成后的初始化 +document.addEventListener('DOMContentLoaded', function() { + console.log('admin.js: 页面加载完成,开始初始化'); + + // 检查高级搜索区域是否存在 + const advancedArea = document.getElementById('advancedSearchArea'); + if (!advancedArea) { + console.warn('高级搜索区域不存在,可能模板未正确更新'); + return; + } + + // 设置搜索提示 + setupSearchHints(); + + // 检查URL参数,如果有高级搜索参数则自动展开 + const urlParams = new URLSearchParams(window.location.search); + const hasSupervisorParam = urlParams.getAll('supervisor').length > 0; + const hasGradeParam = urlParams.getAll('grade').length > 0; + + if (hasSupervisorParam || hasGradeParam || hasAdvancedSearchFilters()) { + console.log('检测到高级搜索参数,自动展开'); + toggleAdvancedSearch(); + } + + // 初始化计数显示 + setTimeout(() => { + updateSupervisorCount(); + updateGradeCount(); + }, 100); + + console.log('admin.js: 初始化完成'); +}); diff --git a/app/templates/admin/add_student.html b/app/templates/admin/add_student.html index 2a491e3..90a07ee 100644 --- a/app/templates/admin/add_student.html +++ b/app/templates/admin/add_student.html @@ -14,141 +14,257 @@ - -