优化考勤记录

This commit is contained in:
2026-04-27 01:36:23 +08:00
parent 439c074534
commit 17cc08071c
7 changed files with 119 additions and 47 deletions

View File

@@ -20,7 +20,7 @@ class AttendanceModel:
@staticmethod @staticmethod
async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]: async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]:
sql = """ sql = """
SELECT attendance_id, date, status, reason, deduction_applied, created_at SELECT attendance_id, date, slot, status, reason, deduction_applied, created_at
FROM attendance_records FROM attendance_records
WHERE student_id = %s WHERE student_id = %s
""" """
@@ -37,7 +37,8 @@ class AttendanceModel:
@staticmethod @staticmethod
async def get_class_records( async def get_class_records(
date: str = None, date: str = None,
student_id: int = None student_id: int = None,
slot: str = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
sql = """ sql = """
SELECT ar.*, s.name as student_name, s.student_no SELECT ar.*, s.name as student_name, s.student_no
@@ -55,6 +56,10 @@ class AttendanceModel:
sql += " AND ar.student_id = %s" sql += " AND ar.student_id = %s"
params.append(student_id) params.append(student_id)
if slot:
sql += " AND ar.slot = %s"
params.append(slot)
sql += " ORDER BY ar.date DESC, s.student_no" sql += " ORDER BY ar.date DESC, s.student_no"
return await execute_query(sql, tuple(params)) return await execute_query(sql, tuple(params))
@@ -65,30 +70,31 @@ class AttendanceModel:
date: str, date: str,
status: str, status: str,
reason: str = None, reason: str = None,
recorder_id: int = None recorder_id: int = None,
slot: str = 'morning'
) -> int: ) -> int:
# 检查是否已存在当天记录 # 检查是否已存在当天同时段记录
existing = await execute_one( existing = await execute_one(
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s", "SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s AND slot = %s",
(student_id, date) (student_id, date, slot)
) )
if existing: if existing:
# 更新已有记录 # 更新已有记录
sql = """ sql = """
UPDATE attendance_records UPDATE attendance_records
SET status = %s, reason = %s, recorder_id = %s SET status = %s, reason = %s, recorder_id = %s
WHERE student_id = %s AND date = %s WHERE student_id = %s AND date = %s AND slot = %s
""" """
await execute_update(sql, (status, reason, recorder_id, student_id, date)) await execute_update(sql, (status, reason, recorder_id, student_id, date, slot))
return existing["attendance_id"] return existing["attendance_id"]
else: else:
# 插入新记录 # 插入新记录
sql = """ sql = """
INSERT INTO attendance_records (student_id, date, status, reason, recorder_id) INSERT INTO attendance_records (student_id, date, slot, status, reason, recorder_id)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
""" """
return await execute_insert(sql, (student_id, date, status, reason, recorder_id)) return await execute_insert(sql, (student_id, date, slot, status, reason, recorder_id))
@staticmethod @staticmethod
async def mark_deduction_applied(attendance_id: int) -> bool: async def mark_deduction_applied(attendance_id: int) -> bool:

View File

@@ -391,7 +391,8 @@ async def add_attendance(request: Request, req: AddAttendanceRequest):
reason=req.reason, reason=req.reason,
apply_deduction=req.apply_deduction, apply_deduction=req.apply_deduction,
recorder_id=user["user_id"], recorder_id=user["user_id"],
custom_deduction=req.custom_deduction custom_deduction=req.custom_deduction,
slot=req.slot
) )
if result["success"]: if result["success"]:
await LogService.write_operation_log( await LogService.write_operation_log(
@@ -410,14 +411,16 @@ async def add_attendance(request: Request, req: AddAttendanceRequest):
async def get_attendance_records( async def get_attendance_records(
request: Request, request: Request,
date: Optional[str] = None, date: Optional[str] = None,
student_id: Optional[int] = None student_id: Optional[int] = None,
slot: Optional[str] = None
): ):
"""获取考勤记录""" """获取考勤记录"""
user = await get_current_user(request) user = await get_current_user(request)
result = await AttendanceService.get_records( result = await AttendanceService.get_records(
user_id=user["user_id"], user_id=user["user_id"],
date=date, date=date,
student_id=student_id student_id=student_id,
slot=slot
) )
return success_response(data=result) return success_response(data=result)

View File

@@ -16,8 +16,8 @@ from datetime import date, datetime
class AddPointsRequest(BaseModel): class AddPointsRequest(BaseModel):
"""加减分请求""" """加减分请求"""
student_ids: List[int] = Field(..., description="学生ID列表") student_ids: List[int] = Field(..., min_length=1, max_length=200, description="学生ID列表")
points_change: int = Field(..., description="分数变动") points_change: int = Field(..., gt=-100, lt=100, description="分数变动")
reason: str = Field(..., min_length=1, max_length=255, description="原因") reason: str = Field(..., min_length=1, max_length=255, description="原因")
@@ -48,11 +48,11 @@ class ImportResult(BaseModel):
class AddAdminRequest(BaseModel): class AddAdminRequest(BaseModel):
"""添加管理员请求""" """添加管理员请求"""
username: str = Field(..., min_length=1, max_length=50, description="登录账号") username: str = Field(..., min_length=2, max_length=50, pattern=r'^[a-zA-Z0-9_\u4e00-\u9fa]+$', description="登录账号")
real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名")
password: Optional[str] = Field(None, description="密码(不填则自动生成)") password: Optional[str] = Field(None, min_length=6, max_length=50, description="密码(不填则自动生成)")
role_type: str = Field(..., description="角色类型") role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
subject_id: Optional[int] = Field(None, description="科目ID科代表需要") subject_id: Optional[int] = Field(None, gt=0, description="科目ID科代表需要")
class AddAdminResponse(BaseModel): class AddAdminResponse(BaseModel):
@@ -65,34 +65,35 @@ class AddAdminResponse(BaseModel):
class UpdateHomeworkStatusRequest(BaseModel): class UpdateHomeworkStatusRequest(BaseModel):
"""更新作业状态请求""" """更新作业状态请求"""
submission_id: int submission_id: int = Field(..., gt=0, description="提交记录ID")
status: str status: str = Field(..., pattern=r'^(submitted|not_submitted|late|excused)$', description="状态")
comments: Optional[str] = None comments: Optional[str] = Field(None, max_length=500, description="评语")
apply_deduction: bool = False apply_deduction: bool = False
class AddStudentRequest(BaseModel): class AddStudentRequest(BaseModel):
"""新增学生请求""" """新增学生请求"""
student_no: str = Field(..., min_length=1, max_length=20, description="学号") student_no: str = Field(..., min_length=1, max_length=20, pattern=r'^[a-zA-Z0-9]+$', description="学号")
name: str = Field(..., min_length=1, max_length=50, description="姓名") name: str = Field(..., min_length=1, max_length=50, description="姓名")
parent_phone: Optional[str] = Field(None, max_length=11, description="家长手机号") parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号")
class AddAttendanceRequest(BaseModel): class AddAttendanceRequest(BaseModel):
"""添加考勤请求""" """添加考勤请求"""
student_id: int student_id: int = Field(..., gt=0, description="学生ID")
date: date date: date
status: str slot: str = Field(default="morning", pattern=r'^(morning|afternoon|evening)$', description="时段")
reason: Optional[str] = None status: str = Field(..., pattern=r'^(present|absent|late|leave)$', description="考勤状态")
reason: Optional[str] = Field(None, max_length=255, description="原因")
apply_deduction: bool = True apply_deduction: bool = True
custom_deduction: Optional[int] = Field(default=None, gt=0, description="自定义扣分值") custom_deduction: Optional[int] = Field(default=None, gt=0, le=20, description="自定义扣分值")
class UpdateAdminRequest(BaseModel): class UpdateAdminRequest(BaseModel):
"""更新管理员请求""" """更新管理员请求"""
user_id: int = Field(..., description="用户ID") user_id: int = Field(..., gt=0, description="用户ID")
real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名") real_name: str = Field(..., min_length=1, max_length=50, description="真实姓名")
role_type: str = Field(..., description="角色类型") role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
class DeleteAdminRequest(BaseModel): class DeleteAdminRequest(BaseModel):
@@ -108,4 +109,4 @@ class ResetPasswordRequest(BaseModel):
class UpdateStudentRequest(BaseModel): class UpdateStudentRequest(BaseModel):
"""更新学生请求""" """更新学生请求"""
name: Optional[str] = Field(None, min_length=1, max_length=50, description="姓名") name: Optional[str] = Field(None, min_length=1, max_length=50, description="姓名")
parent_phone: Optional[str] = Field(None, max_length=20, description="家长手机号") parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号")

View File

@@ -41,16 +41,29 @@ class AttendanceService:
reason: Optional[str], reason: Optional[str],
apply_deduction: bool, apply_deduction: bool,
recorder_id: int, recorder_id: int,
custom_deduction: Optional[int] = None custom_deduction: Optional[int] = None,
slot: str = 'morning'
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""添加考勤记录""" """添加考勤记录"""
# 校验时段
if slot not in ('morning', 'afternoon', 'evening'):
return {"success": False, "message": "无效的考勤时段"}
# 校验状态
if status not in ('present', 'absent', 'late', 'leave'):
return {"success": False, "message": "无效的考勤状态"}
# 校验自定义扣分范围
if custom_deduction is not None and (custom_deduction < 1 or custom_deduction > 20):
return {"success": False, "message": "自定义扣分必须在1-20之间"}
# 检查权限 # 检查权限
role = await PermissionChecker.get_user_role(recorder_id) role = await PermissionChecker.get_user_role(recorder_id)
if role not in ["班主任", "考勤委员"]: if role not in ["班主任", "考勤委员"]:
return {"success": False, "message": "无权进行此操作"} return {"success": False, "message": "无权进行此操作"}
# 检查是否同班级 # 考勤委员扣分上限
# 单班级系统,管理员均可操作 if role == "考勤委员" and apply_deduction and status in ["absent", "late"]:
if custom_deduction is not None and custom_deduction > settings.ATTENDANCE_REP_MAX_POINTS:
return {"success": False, "message": f"考勤委员单次扣分上限为{settings.ATTENDANCE_REP_MAX_POINTS}"}
# 添加考勤记录 # 添加考勤记录
attendance_id = await AttendanceModel.create_record( attendance_id = await AttendanceModel.create_record(
@@ -58,7 +71,8 @@ class AttendanceService:
date=date, date=date,
status=status, status=status,
reason=reason, reason=reason,
recorder_id=recorder_id recorder_id=recorder_id,
slot=slot
) )
if not attendance_id: if not attendance_id:
@@ -107,7 +121,8 @@ class AttendanceService:
async def get_records( async def get_records(
user_id: int, user_id: int,
date: Optional[str] = None, date: Optional[str] = None,
student_id: Optional[int] = None student_id: Optional[int] = None,
slot: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""获取考勤记录""" """获取考勤记录"""
role = await PermissionChecker.get_user_role(user_id) role = await PermissionChecker.get_user_role(user_id)
@@ -115,7 +130,8 @@ class AttendanceService:
if role in ["班主任", "考勤委员"]: if role in ["班主任", "考勤委员"]:
records = await AttendanceModel.get_class_records( records = await AttendanceModel.get_class_records(
date=date, date=date,
student_id=student_id student_id=student_id,
slot=slot
) )
elif student_id: elif student_id:
# 管理员可查看指定学生 # 管理员可查看指定学生

View File

@@ -33,12 +33,18 @@ class ConductService:
recorder_id: int, recorder_id: int,
recorder_name: str recorder_name: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """批量加减分"""
批量加减分 # 输入校验
""" if not student_ids or len(student_ids) > 200:
return {"success": False, "message": "学生数量需在1-200之间"}
if not reason or not reason.strip() or len(reason) > 255:
return {"success": False, "message": "原因不能为空且不超过255字符"}
# 验证分值 # 验证分值
if points_change == 0: if points_change == 0:
return {"success": False, "message": "分值不能为0"} return {"success": False, "message": "分值不能为0"}
if abs(points_change) > 100:
return {"success": False, "message": "单次加减分不能超过100分"}
# 获取操作人角色 # 获取操作人角色
role = await PermissionChecker.get_user_role(recorder_id) role = await PermissionChecker.get_user_role(recorder_id)
@@ -119,6 +125,9 @@ class ConductService:
@staticmethod @staticmethod
async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]: async def revoke_record(record_id: int, revoker_id: int) -> Dict[str, Any]:
"""撤销扣分记录""" """撤销扣分记录"""
if not record_id or record_id <= 0:
return {"success": False, "message": "无效的记录ID"}
# 检查权限 # 检查权限
can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id) can_revoke = await PermissionChecker.check_can_revoke(revoker_id, record_id)
if not can_revoke: if not can_revoke:
@@ -155,6 +164,9 @@ class ConductService:
@staticmethod @staticmethod
async def restore_record(record_id: int, restorer_id: int) -> Dict[str, Any]: async def restore_record(record_id: int, restorer_id: int) -> Dict[str, Any]:
"""反撤销(恢复)已撤销的记录""" """反撤销(恢复)已撤销的记录"""
if not record_id or record_id <= 0:
return {"success": False, "message": "无效的记录ID"}
# 检查权限:只有班主任可以反撤销 # 检查权限:只有班主任可以反撤销
role = await PermissionChecker.get_user_role(restorer_id) role = await PermissionChecker.get_user_role(restorer_id)
if role != "班主任": if role != "班主任":
@@ -207,6 +219,8 @@ class ConductService:
end_date = None end_date = None
if related_type == "": if related_type == "":
related_type = None related_type = None
if related_type and related_type not in ('manual', 'homework', 'attendance'):
return {"records": [], "page": page, "page_size": page_size, "total": 0, "total_pages": 0}
role = await PermissionChecker.get_user_role(user_id) role = await PermissionChecker.get_user_role(user_id)
offset = (page - 1) * page_size offset = (page - 1) * page_size

View File

@@ -41,9 +41,9 @@ include __DIR__ . '/../includes/header.php';
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label>时段</label> <label>时段</label>
<select id="attendanceSlot"> <select id="attendanceSlot">
<option value="morning">早 8:15</option> <option value="morning">早上 7:15</option>
<option value="afternoon">午 14:00</option> <option value="afternoon">午 14:00</option>
<option value="evening">晚 19:30</option> <option value="evening">晚 19:30</option>
</select> </select>
</div> </div>
<div class="status-group"> <div class="status-group">
@@ -124,7 +124,7 @@ function renderStudentGrid() {
const currentSlot = document.getElementById('attendanceSlot').value; const currentSlot = document.getElementById('attendanceSlot').value;
let html = ''; let html = '';
studentsData.forEach(student => { studentsData.forEach(student => {
const hasRecord = existingRecords.some(r => r.student_id === student.student_id && (!r.slot || r.slot === currentSlot)); const hasRecord = existingRecords.some(r => r.student_id === student.student_id && r.slot === currentSlot);
html += `<div class="student-cell${hasRecord ? ' has-record' : ''}" html += `<div class="student-cell${hasRecord ? ' has-record' : ''}"
data-id="${student.student_id}" data-id="${student.student_id}"
data-name="${escapeHtml(student.name)}" data-name="${escapeHtml(student.name)}"
@@ -178,17 +178,19 @@ async function submitAttendance() {
} }
const date = document.getElementById('attendanceDate').value; const date = document.getElementById('attendanceDate').value;
const slot = document.getElementById('attendanceSlot').value;
const reason = document.getElementById('attendanceReason').value; const reason = document.getElementById('attendanceReason').value;
const customDeduction = document.getElementById('customDeduction').value; const customDeduction = document.getElementById('customDeduction').value;
const customDeductionValue = customDeduction ? parseInt(customDeduction) : null; const customDeductionValue = customDeduction ? parseInt(customDeduction) : null;
// 批量提交(不再检查已有记录,允许同一学生同一天多次考勤) // 批量提交
const promises = []; const promises = [];
selectedCells.forEach(cell => { selectedCells.forEach(cell => {
const studentId = parseInt(cell.dataset.id); const studentId = parseInt(cell.dataset.id);
const payload = { const payload = {
student_id: studentId, student_id: studentId,
date: date, date: date,
slot: slot,
status: currentStatus, status: currentStatus,
reason: reason, reason: reason,
apply_deduction: true apply_deduction: true

View File

@@ -267,6 +267,36 @@ PREPARE stmt FROM @sql;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE stmt; DEALLOCATE PREPARE stmt;
-- attendance_records 表:添加 slot 字段(如不存在)
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'classmanagerdb'
AND TABLE_NAME = 'attendance_records'
AND COLUMN_NAME = 'slot'
);
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `attendance_records` ADD COLUMN `slot` VARCHAR(20) DEFAULT ''morning'' COMMENT ''时段: morning/afternoon/evening'' AFTER `date`',
'SELECT ''attendance_records.slot already exists'' AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 删除旧唯一键并添加新唯一键含slot
SET @uk_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = 'classmanagerdb'
AND TABLE_NAME = 'attendance_records'
AND CONSTRAINT_NAME = 'uk_student_date'
);
SET @sql = IF(@uk_exists > 0,
'ALTER TABLE `attendance_records` DROP INDEX `uk_student_date`, ADD UNIQUE KEY `uk_student_date_slot` (`student_id`, `date`, `slot`)',
'SELECT ''uk_student_date does not exist, skipping'' AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 迁移semester_archives 表新增 attendance_present 字段 -- 迁移semester_archives 表新增 attendance_present 字段
SET @column_exists = ( SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS