优化考勤记录

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
async def get_student_records(student_id: int, month: str = None) -> List[Dict[str, Any]]:
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
WHERE student_id = %s
"""
@@ -37,7 +37,8 @@ class AttendanceModel:
@staticmethod
async def get_class_records(
date: str = None,
student_id: int = None
student_id: int = None,
slot: str = None
) -> List[Dict[str, Any]]:
sql = """
SELECT ar.*, s.name as student_name, s.student_no
@@ -55,6 +56,10 @@ class AttendanceModel:
sql += " AND ar.student_id = %s"
params.append(student_id)
if slot:
sql += " AND ar.slot = %s"
params.append(slot)
sql += " ORDER BY ar.date DESC, s.student_no"
return await execute_query(sql, tuple(params))
@@ -65,12 +70,13 @@ class AttendanceModel:
date: str,
status: str,
reason: str = None,
recorder_id: int = None
recorder_id: int = None,
slot: str = 'morning'
) -> int:
# 检查是否已存在当天记录
# 检查是否已存在当天同时段记录
existing = await execute_one(
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s",
(student_id, date)
"SELECT attendance_id FROM attendance_records WHERE student_id = %s AND date = %s AND slot = %s",
(student_id, date, slot)
)
if existing:
@@ -78,17 +84,17 @@ class AttendanceModel:
sql = """
UPDATE attendance_records
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"]
else:
# 插入新记录
sql = """
INSERT INTO attendance_records (student_id, date, status, reason, recorder_id)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO attendance_records (student_id, date, slot, status, reason, recorder_id)
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
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,
apply_deduction=req.apply_deduction,
recorder_id=user["user_id"],
custom_deduction=req.custom_deduction
custom_deduction=req.custom_deduction,
slot=req.slot
)
if result["success"]:
await LogService.write_operation_log(
@@ -410,14 +411,16 @@ async def add_attendance(request: Request, req: AddAttendanceRequest):
async def get_attendance_records(
request: Request,
date: Optional[str] = None,
student_id: Optional[int] = None
student_id: Optional[int] = None,
slot: Optional[str] = None
):
"""获取考勤记录"""
user = await get_current_user(request)
result = await AttendanceService.get_records(
user_id=user["user_id"],
date=date,
student_id=student_id
student_id=student_id,
slot=slot
)
return success_response(data=result)

View File

@@ -16,8 +16,8 @@ from datetime import date, datetime
class AddPointsRequest(BaseModel):
"""加减分请求"""
student_ids: List[int] = Field(..., description="学生ID列表")
points_change: int = Field(..., description="分数变动")
student_ids: List[int] = Field(..., min_length=1, max_length=200, description="学生ID列表")
points_change: int = Field(..., gt=-100, lt=100, description="分数变动")
reason: str = Field(..., min_length=1, max_length=255, description="原因")
@@ -48,11 +48,11 @@ class ImportResult(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="真实姓名")
password: Optional[str] = Field(None, description="密码(不填则自动生成)")
role_type: str = Field(..., description="角色类型")
subject_id: Optional[int] = Field(None, description="科目ID科代表需要")
password: Optional[str] = Field(None, min_length=6, max_length=50, description="密码(不填则自动生成)")
role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
subject_id: Optional[int] = Field(None, gt=0, description="科目ID科代表需要")
class AddAdminResponse(BaseModel):
@@ -65,34 +65,35 @@ class AddAdminResponse(BaseModel):
class UpdateHomeworkStatusRequest(BaseModel):
"""更新作业状态请求"""
submission_id: int
status: str
comments: Optional[str] = None
submission_id: int = Field(..., gt=0, description="提交记录ID")
status: str = Field(..., pattern=r'^(submitted|not_submitted|late|excused)$', description="状态")
comments: Optional[str] = Field(None, max_length=500, description="评语")
apply_deduction: bool = False
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="姓名")
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):
"""添加考勤请求"""
student_id: int
student_id: int = Field(..., gt=0, description="学生ID")
date: date
status: str
reason: Optional[str] = None
slot: str = Field(default="morning", pattern=r'^(morning|afternoon|evening)$', description="时段")
status: str = Field(..., pattern=r'^(present|absent|late|leave)$', description="考勤状态")
reason: Optional[str] = Field(None, max_length=255, description="原因")
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):
"""更新管理员请求"""
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="真实姓名")
role_type: str = Field(..., description="角色类型")
role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
class DeleteAdminRequest(BaseModel):
@@ -108,4 +109,4 @@ class ResetPasswordRequest(BaseModel):
class UpdateStudentRequest(BaseModel):
"""更新学生请求"""
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],
apply_deduction: bool,
recorder_id: int,
custom_deduction: Optional[int] = None
custom_deduction: Optional[int] = None,
slot: str = 'morning'
) -> 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)
if role not in ["班主任", "考勤委员"]:
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(
@@ -58,7 +71,8 @@ class AttendanceService:
date=date,
status=status,
reason=reason,
recorder_id=recorder_id
recorder_id=recorder_id,
slot=slot
)
if not attendance_id:
@@ -107,7 +121,8 @@ class AttendanceService:
async def get_records(
user_id: int,
date: Optional[str] = None,
student_id: Optional[int] = None
student_id: Optional[int] = None,
slot: Optional[str] = None
) -> Dict[str, Any]:
"""获取考勤记录"""
role = await PermissionChecker.get_user_role(user_id)
@@ -115,7 +130,8 @@ class AttendanceService:
if role in ["班主任", "考勤委员"]:
records = await AttendanceModel.get_class_records(
date=date,
student_id=student_id
student_id=student_id,
slot=slot
)
elif student_id:
# 管理员可查看指定学生

View File

@@ -33,12 +33,18 @@ class ConductService:
recorder_id: int,
recorder_name: str
) -> 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:
return {"success": False, "message": "分值不能为0"}
if abs(points_change) > 100:
return {"success": False, "message": "单次加减分不能超过100分"}
# 获取操作人角色
role = await PermissionChecker.get_user_role(recorder_id)
@@ -119,6 +125,9 @@ class ConductService:
@staticmethod
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)
if not can_revoke:
@@ -155,6 +164,9 @@ class ConductService:
@staticmethod
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)
if role != "班主任":
@@ -207,6 +219,8 @@ class ConductService:
end_date = None
if related_type == "":
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)
offset = (page - 1) * page_size

View File

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

View File

@@ -267,6 +267,36 @@ PREPARE stmt FROM @sql;
EXECUTE 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 字段
SET @column_exists = (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS