更新v1.4版本,修复了一些已知问题

This commit is contained in:
2026-04-28 03:16:17 +08:00
parent 76088b0dd4
commit 3aac2395a0
26 changed files with 342 additions and 151 deletions

View File

@@ -265,6 +265,7 @@ classmanager/
| v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 | | v1.1 | 2026.4.20 | 更新家长端查看加减分记录功能 |
| v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 | | v1.2 | 2026.4.22 | 学期管理、env配置加减分上限、排行榜百分比筛选、撤销操作日志、调试入口开关 |
| v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 | | v1.3 | 2026.4.27 | 考勤时段系统(早上/中午/晚修三时段)、历史记录扣分类型筛选、管理员/科目信息编辑、全链路输入安全校验 |
| v1.4 | 2026.4.28 | 全量代码审查修复双重密码哈希bug、学生端XSS漏洞、性能优化COUNT替代全量加载、Pydantic schema统一、权限检查补全、考勤委员撤销权限 |
## 许可证 ## 许可证

View File

@@ -81,13 +81,13 @@ DEDUCTION_HOMEWORK_NOT_SUBMIT=2
DEDUCTION_HOMEWORK_LATE=1 DEDUCTION_HOMEWORK_LATE=1
# 缺勤扣分 - 学生无故缺勤时扣除的操行分 # 缺勤扣分 - 学生无故缺勤时扣除的操行分
DEDUCTION_ATTENDANCE_ABSENT=5 DEDUCTION_ATTENDANCE_ABSENT=3
# 迟到扣分 - 学生迟到时扣除的操行分 # 迟到扣分 - 学生迟到时扣除的操行分
DEDUCTION_ATTENDANCE_LATE=2 DEDUCTION_ATTENDANCE_LATE=1
# 请假扣分 - 学生请假时扣除的操行分(设为0表示不扣分 # 请假扣分 - 学生请假时扣除的操行分设为0表示不扣分
DEDUCTION_ATTENDANCE_LEAVE=1 DEDUCTION_ATTENDANCE_LEAVE=0
# =========================================== # ===========================================
# 劳动委员固定分值配置 # 劳动委员固定分值配置

View File

@@ -53,9 +53,9 @@ class Settings:
DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2")) DEDUCTION_HOMEWORK_NOT_SUBMIT: int = int(os.getenv("DEDUCTION_HOMEWORK_NOT_SUBMIT", "2"))
DEDUCTION_HOMEWORK_LATE: int = int(os.getenv("DEDUCTION_HOMEWORK_LATE", "1")) DEDUCTION_HOMEWORK_LATE: int = int(os.getenv("DEDUCTION_HOMEWORK_LATE", "1"))
DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "5")) DEDUCTION_ATTENDANCE_ABSENT: int = int(os.getenv("DEDUCTION_ATTENDANCE_ABSENT", "3"))
DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "2")) DEDUCTION_ATTENDANCE_LATE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LATE", "1"))
DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "1")) DEDUCTION_ATTENDANCE_LEAVE: int = int(os.getenv("DEDUCTION_ATTENDANCE_LEAVE", "0"))
LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1")) LABOR_POINTS_ADD: int = int(os.getenv("LABOR_POINTS_ADD", "1"))
LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1")) LABOR_POINTS_SUBTRACT: int = int(os.getenv("LABOR_POINTS_SUBTRACT", "-1"))

View File

@@ -92,7 +92,6 @@ class AuthMiddleware(BaseHTTPMiddleware):
logger.warning(f"[Auth] {path} - Redis Token不匹配, user_id={user_id}, stored={'' if stored_token else ''}") logger.warning(f"[Auth] {path} - Redis Token不匹配, user_id={user_id}, stored={'' if stored_token else ''}")
return self._cors_response(request, 401, "令牌已失效,请重新登录") return self._cors_response(request, 401, "令牌已失效,请重新登录")
# 将用户信息存储到request.state
# 将用户信息存储到request.state # 将用户信息存储到request.state
request.state.user_id = payload.get("user_id") request.state.user_id = payload.get("user_id")
request.state.username = payload.get("username") request.state.username = payload.get("username")
@@ -142,20 +141,3 @@ class AuthMiddleware(BaseHTTPMiddleware):
}, },
headers=headers headers=headers
) )
async def get_current_user(request: Request) -> Dict[str, Any]:
"""获取当前登录用户信息"""
return {
"user_id": request.state.user_id,
"username": request.state.username,
"real_name": getattr(request.state, 'real_name', None) or request.state.username,
"user_type": request.state.user_type,
"student_id": request.state.student_id,
"role": request.state.role
}
async def get_current_user_id(request: Request) -> int:
"""获取当前用户ID"""
return request.state.user_id

View File

@@ -26,6 +26,7 @@ async def get_current_user(request: Request) -> Dict[str, Any]:
return { return {
"user_id": getattr(request.state, 'user_id', None), "user_id": getattr(request.state, 'user_id', None),
"username": getattr(request.state, 'username', None), "username": getattr(request.state, 'username', None),
"real_name": getattr(request.state, 'real_name', None),
"user_type": getattr(request.state, 'user_type', None), "user_type": getattr(request.state, 'user_type', None),
"student_id": getattr(request.state, 'student_id', None), "student_id": getattr(request.state, 'student_id', None),
"role": getattr(request.state, 'role', None) "role": getattr(request.state, 'role', None)
@@ -124,19 +125,23 @@ class PermissionChecker:
async def check_can_revoke(user_id: int, record_id: int) -> bool: async def check_can_revoke(user_id: int, record_id: int) -> bool:
""" """
检查是否可以撤销扣分记录 检查是否可以撤销扣分记录
班主任:可以撤销任何记录 班主任:可以撤销/反撤销任何记录
班长:可以撤销任何记录 班长:可以撤销/反撤销任何记录
考勤委员:可以撤销自己的记录 考勤委员:可以撤销自己创建的记录
其他:只能撤销自己的记录 其他角色:无撤销权限
""" """
sql = "SELECT recorder_id FROM conduct_records WHERE record_id = %s" record = await execute_one(
record = await execute_one(sql, (record_id,)) "SELECT record_id, recorder_id FROM conduct_records WHERE record_id = %s",
(record_id,)
)
if not record: if not record:
return False return False
role = await PermissionChecker.get_user_role(user_id) role = await PermissionChecker.get_user_role(user_id)
if role in ["班主任", "班长", "志愿委员"]: if role in ["班主任", "班长"]:
return True return True
return record["recorder_id"] == user_id if role == "考勤委员" and record.get("recorder_id") == user_id:
return True
return False
def require_auth(func: Callable): def require_auth(func: Callable):

View File

@@ -40,6 +40,21 @@ class ConductModel:
student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id student_id, points_change, reason, recorder_id, recorder_name, related_type, related_id
)) ))
@staticmethod
async def count_student_records(student_id: int, include_revoked: bool = False) -> int:
"""统计学生操行分记录总数"""
revoked_condition = "" if include_revoked else " AND is_revoked = 0"
sql = f"SELECT COUNT(*) as total FROM conduct_records WHERE student_id = %s{revoked_condition}"
result = await execute_one(sql, (student_id,))
return result["total"] if result else 0
@staticmethod
async def count_records_by_recorder(recorder_id: int) -> int:
"""统计记录人提交的操行分记录总数"""
sql = "SELECT COUNT(*) as total FROM conduct_records WHERE recorder_id = %s"
result = await execute_one(sql, (recorder_id,))
return result["total"] if result else 0
@staticmethod @staticmethod
async def get_student_records( async def get_student_records(
student_id: int, student_id: int,

View File

@@ -27,7 +27,8 @@ from schemas.admin import (
AddPointsRequest, RevokeRequest, AddAdminRequest, AddPointsRequest, RevokeRequest, AddAdminRequest,
AddStudentRequest, UpdateStudentRequest, AddStudentRequest, UpdateStudentRequest,
UpdateHomeworkStatusRequest, AddAttendanceRequest, UpdateHomeworkStatusRequest, AddAttendanceRequest,
UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest,
CreateAssignmentRequest
) )
from utils.response import success_response, error_response from utils.response import success_response, error_response
from utils.logger import get_logger from utils.logger import get_logger
@@ -48,6 +49,8 @@ async def get_students(
): ):
"""获取所有学生列表(单班级)""" """获取所有学生列表(单班级)"""
user = await get_current_user(request) user = await get_current_user(request)
if user["user_type"] != "admin":
return error_response(message="仅管理员可查看学生列表", code=403)
result = await AdminService.get_students(page=page, page_size=page_size, search=search) result = await AdminService.get_students(page=page, page_size=page_size, search=search)
return success_response(data=result) return success_response(data=result)
@@ -138,6 +141,13 @@ async def update_student(request: Request, student_id: int, req: UpdateStudentRe
parent_phone=req.parent_phone parent_phone=req.parent_phone
) )
if result["success"]: if result["success"]:
await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"],
operator_role="班主任", operation_type="update_student",
target_type="student", target_id=student_id,
details=f"编辑学生ID: {student_id}",
ip=request.client.host
)
return success_response(message=result["message"]) return success_response(message=result["message"])
else: else:
return error_response(message=result["message"]) return error_response(message=result["message"])
@@ -153,6 +163,13 @@ async def delete_student(request: Request, student_id: int):
result = await AdminService.delete_student(student_id=student_id) result = await AdminService.delete_student(student_id=student_id)
if result["success"]: if result["success"]:
await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"],
operator_role="班主任", operation_type="delete_student",
target_type="student", target_id=student_id,
details=f"删除学生ID: {student_id}",
ip=request.client.host
)
return success_response(message=result["message"]) return success_response(message=result["message"])
else: else:
return error_response(message=result["message"]) return error_response(message=result["message"])
@@ -171,6 +188,13 @@ async def reset_student_password(request: Request, student_id: int, req: ResetPa
new_password=req.new_password new_password=req.new_password
) )
if result["success"]: if result["success"]:
await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"],
operator_role="班主任", operation_type="reset_student_password",
target_type="student", target_id=student_id,
details=f"重置学生密码, 学生ID: {student_id}",
ip=request.client.host
)
return success_response(message=result["message"]) return success_response(message=result["message"])
else: else:
return error_response(message=result["message"]) return error_response(message=result["message"])
@@ -272,6 +296,8 @@ async def get_conduct_history(
"""获取操行分历史记录""" """获取操行分历史记录"""
try: try:
user = await get_current_user(request) user = await get_current_user(request)
if user["user_type"] != "admin":
return error_response(message="仅管理员可查看历史记录", code=403)
result = await ConductService.get_history( result = await ConductService.get_history(
user_id=user["user_id"], user_id=user["user_id"],
student_id=student_id, student_id=student_id,
@@ -316,23 +342,17 @@ async def get_submissions(request: Request, assignment_id: int):
@router.post("/homework/assignment") @router.post("/homework/assignment")
async def create_assignment( async def create_assignment(request: Request, req: CreateAssignmentRequest):
request: Request,
subject_id: int,
title: str,
description: Optional[str] = None,
deadline: str = None
):
"""发布作业(班主任)""" """发布作业(班主任)"""
user = await get_current_user(request) user = await get_current_user(request)
is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) is_teacher = await PermissionChecker.check_is_teacher(user["user_id"])
if not is_teacher: if not is_teacher:
return error_response(message="仅班主任可发布作业", code=403) return error_response(message="仅班主任可发布作业", code=403)
result = await HomeworkService.create_assignment( result = await HomeworkService.create_assignment(
subject_id=subject_id, subject_id=req.subject_id,
title=title, title=req.title,
description=description, description=req.description,
deadline=deadline, deadline=req.deadline,
created_by=user["user_id"] created_by=user["user_id"]
) )
if result["success"]: if result["success"]:
@@ -416,6 +436,9 @@ async def get_attendance_records(
): ):
"""获取考勤记录""" """获取考勤记录"""
user = await get_current_user(request) user = await get_current_user(request)
role = await PermissionChecker.get_user_role(user["user_id"])
if role not in ["班主任", "考勤委员"]:
return error_response(message="无权查看考勤记录", code=403)
result = await AttendanceService.get_records( result = await AttendanceService.get_records(
user_id=user["user_id"], user_id=user["user_id"],
date=date, date=date,

View File

@@ -91,7 +91,6 @@ class AddAttendanceRequest(BaseModel):
class UpdateAdminRequest(BaseModel): class UpdateAdminRequest(BaseModel):
"""更新管理员请求""" """更新管理员请求"""
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(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型") role_type: str = Field(..., pattern=r'^(班长|学习委员|考勤委员|劳动委员|志愿委员)$', description="角色类型")
@@ -109,4 +108,12 @@ 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=11, pattern=r'^\d{0,11}$', description="家长手机号") parent_phone: Optional[str] = Field(None, max_length=11, pattern=r'^\d{0,11}$', description="家长手机号")
class CreateAssignmentRequest(BaseModel):
"""创建作业请求"""
subject_id: int = Field(..., gt=0, description="科目ID")
title: str = Field(..., min_length=1, max_length=200, description="作业标题")
description: Optional[str] = Field(None, max_length=1000, description="作业描述")
deadline: str = Field(..., min_length=1, max_length=20, description="截止日期")

View File

@@ -296,9 +296,8 @@ class AdminService:
if not user: if not user:
return {"success": False, "message": "未找到对应的用户账号"} return {"success": False, "message": "未找到对应的用户账号"}
password_hash = security.sha1_md5_password(new_password) # UserModel.update_password 内部会进行哈希,无需预先哈希
result = await UserModel.update_password(user['user_id'], new_password)
result = await UserModel.update_password(user['user_id'], password_hash)
if result: if result:
await execute_update( await execute_update(
"UPDATE users SET need_change_password = 1 WHERE user_id = %s", "UPDATE users SET need_change_password = 1 WHERE user_id = %s",

View File

@@ -89,14 +89,17 @@ class AttendanceService:
points_change = -settings.DEDUCTION_ATTENDANCE_LATE points_change = -settings.DEDUCTION_ATTENDANCE_LATE
else: else:
points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE points_change = -settings.DEDUCTION_ATTENDANCE_LEAVE
# 创建扣分记录
# 扣分为0时跳过如请假不扣分
if points_change == 0:
logger.info(f"用户[{recorder_id}] 添加考勤记录[{attendance_id}] -> {status} (不扣分)")
return {"success": True, "message": "考勤记录添加成功(不扣分)"}
student = await StudentModel.get_by_id(student_id) student = await StudentModel.get_by_id(student_id)
if student: if student:
# 获取操作人姓名 # 获取操作人姓名
user = await UserModel.get_by_user_id(recorder_id) user = await UserModel.get_by_user_id(recorder_id)
recorder_name = user.get("real_name", "班主任") if user else "班主任" recorder_name = user.get("real_name", "班主任") if user else "班主任"
# 使用中文状态 # 使用中文状态
# 使用中文状态
status_text = ATTENDANCE_STATUS_MAP.get(status, status) status_text = ATTENDANCE_STATUS_MAP.get(status, status)
await ConductModel.create_record( await ConductModel.create_record(
student_id=student_id, student_id=student_id,

View File

@@ -115,8 +115,10 @@ class ConductService:
details.append({"student_id": student_id, "error": str(e)}) details.append({"student_id": student_id, "error": str(e)})
fail_count += 1 fail_count += 1
message = "操作成功" if fail_count == 0 else f"{success_count}人成功,{fail_count}人失败"
return { return {
"success": fail_count == 0, "success": fail_count == 0,
"message": message,
"success_count": success_count, "success_count": success_count,
"fail_count": fail_count, "fail_count": fail_count,
"details": details "details": details
@@ -278,7 +280,7 @@ class ConductService:
limit=page_size, limit=page_size,
offset=offset offset=offset
) )
total = len(await ConductModel.get_student_records(student_id, limit=10000)) total = await ConductModel.count_student_records(student_id)
else: else:
# 查看自己提交的记录 # 查看自己提交的记录
records = await ConductModel.get_records_by_recorder( records = await ConductModel.get_records_by_recorder(
@@ -286,7 +288,7 @@ class ConductService:
limit=page_size, limit=page_size,
offset=offset offset=offset
) )
total = len(await ConductModel.get_records_by_recorder(user_id, limit=10000)) total = await ConductModel.count_records_by_recorder(user_id)
return { return {
"records": records, "records": records,

View File

@@ -135,9 +135,8 @@ class ParentService:
offset=offset offset=offset
) )
# 获取总数 # 使用 COUNT 查询获取总数(避免获取全部记录)
all_records = await ConductModel.get_student_records(user["student_id"], limit=10000) total = await ConductModel.count_student_records(user["student_id"])
total = len(all_records)
return { return {
"student_id": student["student_id"], "student_id": student["student_id"],

View File

@@ -48,11 +48,11 @@ HOMEWORK_MAX_POINTS=3
STUDY_COMMISSIONER_MAX_POINTS=5 STUDY_COMMISSIONER_MAX_POINTS=5
# 考勤-缺勤扣分 # 考勤-缺勤扣分
DEDUCTION_ATTENDANCE_ABSENT=5 DEDUCTION_ATTENDANCE_ABSENT=3
# 考勤-迟到扣分 # 考勤-迟到扣分
DEDUCTION_ATTENDANCE_LATE=2 DEDUCTION_ATTENDANCE_LATE=1
# 考勤-请假扣分 # 考勤-请假扣分设为0表示不扣分
DEDUCTION_ATTENDANCE_LEAVE=1 DEDUCTION_ATTENDANCE_LEAVE=0
# 学生初始操行分 # 学生初始操行分
STUDENT_INITIAL_POINTS=60 STUDENT_INITIAL_POINTS=60

View File

@@ -227,7 +227,6 @@ async function submitEditAdmin() {
} }
const res = await apiPut(`/api/admin/update/${currentEditUserId}`, { const res = await apiPut(`/api/admin/update/${currentEditUserId}`, {
user_id: currentEditUserId,
real_name: document.getElementById('editAdminRealName').value, real_name: document.getElementById('editAdminRealName').value,
role_type: roleType role_type: roleType
}); });

View File

@@ -47,10 +47,10 @@ include __DIR__ . '/../includes/header.php';
</select> </select>
</div> </div>
<div class="status-group"> <div class="status-group">
<button class="status-btn active" data-status="absent" onclick="selectStatus(this)" data-default-deduction="3">缺勤</button> <button class="status-btn active" data-status="absent" onclick="selectStatus(this)" id="btnAbsent">缺勤</button>
<button class="status-btn" data-status="late" onclick="selectStatus(this)" data-default-deduction="1">迟到</button> <button class="status-btn" data-status="late" onclick="selectStatus(this)" id="btnLate">迟到</button>
<button class="status-btn" data-status="leave" onclick="selectStatus(this)" data-default-deduction="0">请假</button> <button class="status-btn" data-status="leave" onclick="selectStatus(this)" id="btnLeave">请假</button>
<input type="number" id="customDeduction" placeholder="自定义扣分" min="0" max="10" style="width:100px;margin-left:10px;" title="留空或0使用默认值"> <input type="number" id="customDeduction" placeholder="自定义扣分" min="0" max="20" style="width:100px;margin-left:10px;" title="留空或0使用默认值">
</div> </div>
<input type="text" id="attendanceReason" placeholder="原因(可选)" style="flex:1;min-width:150px;"> <input type="text" id="attendanceReason" placeholder="原因(可选)" style="flex:1;min-width:150px;">
<button class="btn btn-primary" onclick="selectAllStudents()">全选</button> <button class="btn btn-primary" onclick="selectAllStudents()">全选</button>
@@ -92,14 +92,35 @@ let currentStatus = 'absent';
let studentsData = []; let studentsData = [];
let existingRecords = []; let existingRecords = [];
// 考勤扣分配置映射(从后端配置注入)
const attendanceDeductionMap = {
absent: window.DEDUCTION_ATTENDANCE_ABSENT || 3,
late: window.DEDUCTION_ATTENDANCE_LATE || 1,
leave: window.DEDUCTION_ATTENDANCE_LEAVE || 0
};
// 初始化按钮文字
function initAttendanceButtons() {
const btnAbsent = document.getElementById('btnAbsent');
const btnLate = document.getElementById('btnLate');
const btnLeave = document.getElementById('btnLeave');
if (btnAbsent) btnAbsent.textContent = '缺勤(' + attendanceDeductionMap.absent + '分)';
if (btnLate) btnLate.textContent = '迟到(' + attendanceDeductionMap.late + '分)';
if (btnLeave) btnLeave.textContent = '请假(' + (attendanceDeductionMap.leave > 0 ? attendanceDeductionMap.leave + '分' : '不扣分') + ')';
// 默认选中缺勤,自动填入默认扣分
if (attendanceDeductionMap.absent > 0) {
document.getElementById('customDeduction').value = attendanceDeductionMap.absent;
}
}
// 选择考勤状态 // 选择考勤状态
function selectStatus(btn) { function selectStatus(btn) {
document.querySelectorAll('.status-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.status-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
currentStatus = btn.dataset.status; currentStatus = btn.dataset.status;
// 自动设置默认扣分值 // 自动设置默认扣分值(从配置读取)
const defaultDeduction = btn.dataset.defaultDeduction; const defaultDeduction = attendanceDeductionMap[currentStatus] || 0;
if (defaultDeduction && defaultDeduction !== '0') { if (defaultDeduction > 0) {
document.getElementById('customDeduction').value = defaultDeduction; document.getElementById('customDeduction').value = defaultDeduction;
} else { } else {
document.getElementById('customDeduction').value = ''; document.getElementById('customDeduction').value = '';
@@ -252,6 +273,7 @@ document.getElementById('attendanceSlot').addEventListener('change', function()
}); });
// 页面初始化 // 页面初始化
initAttendanceButtons();
loadStudents(); loadStudents();
loadAttendanceRecords(); loadAttendanceRecords();
</script> </script>

View File

@@ -20,7 +20,7 @@ if (!isset($_SESSION['user_id']) || $_SESSION['user_type'] !== 'admin') {
$page_title = '操行分管理'; $page_title = '操行分管理';
$role = $_SESSION['role'] ?? ''; $role = $_SESSION['role'] ?? '';
if (!in_array($role, ['班主任', '班长', '学习委员', '劳动委员', '志愿委员'])) { if (!in_array($role, ['班主任', '班长', '学习委员', '考勤委员', '劳动委员', '志愿委员'])) {
header('Location: /admin/dashboard.php'); header('Location: /admin/dashboard.php');
exit(); exit();
} }

View File

@@ -65,7 +65,7 @@ include __DIR__ . '/../includes/header.php';
<th>分数变动</th> <th>分数变动</th>
<th>原因</th> <th>原因</th>
<th>操作人</th> <th>操作人</th>
<?php if ($role === '班主任' || $role === '班长'): ?> <?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员'): ?>
<th>操作</th> <th>操作</th>
<?php endif; ?> <?php endif; ?>
</tr> </tr>
@@ -80,6 +80,7 @@ include __DIR__ . '/../includes/header.php';
<script> <script>
var currentHistoryPage = 1; var currentHistoryPage = 1;
var totalHistoryPages = 1; var totalHistoryPages = 1;
var currentUserId = <?php echo intval($_SESSION['user_id']); ?>;
async function loadStudentsForSelect() { async function loadStudentsForSelect() {
const res = await apiGet('/api/admin/students', {page_size: 1000}); const res = await apiGet('/api/admin/students', {page_size: 1000});
@@ -134,12 +135,20 @@ async function loadHistory(page = 1) {
} else { } else {
html += `<td><button class="btn btn-sm btn-danger" onclick="revokeRecord(${record.record_id})">撤销</button></td>`; html += `<td><button class="btn btn-sm btn-danger" onclick="revokeRecord(${record.record_id})">撤销</button></td>`;
} }
<?php elseif ($role === '考勤委员'): ?>
if (record.is_revoked == 1) {
html += `<td><span class="text-muted">已撤销</span></td>`;
} else if (record.recorder_id == currentUserId) {
html += `<td><button class="btn btn-sm btn-danger" onclick="revokeRecord(${record.record_id})">撤销</button></td>`;
} else {
html += `<td><span class="text-muted">-</span></td>`;
}
<?php endif; ?> <?php endif; ?>
html += `</tr>`; html += `</tr>`;
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
const colSpan = <?php echo ($role === '班主任' || $role === '班长') ? '6' : '5'; ?>; const colSpan = <?php echo ($role === '班主任' || $role === '班长' || $role === '考勤委员') ? '6' : '5'; ?>;
html = `<tr><td colspan="${colSpan}" style="text-align:center;">暂无记录</td></tr>`; html = `<tr><td colspan="${colSpan}" style="text-align:center;">暂无记录</td></tr>`;
} }
@@ -151,22 +160,9 @@ async function loadHistory(page = 1) {
} }
function renderHistoryPagination() { function renderHistoryPagination() {
const container = document.getElementById('historyPagination'); renderSmartPagination('historyPagination', currentHistoryPage, totalHistoryPages, function(page) {
if (!container) return; loadHistory(page);
if (totalHistoryPages <= 1) { });
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalHistoryPages; i++) {
if (i === currentHistoryPage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" onclick="loadHistory(${i}); return false;">${i}</a>`;
}
}
container.innerHTML = html;
} }
// 导出历史记录 // 导出历史记录

View File

@@ -457,21 +457,9 @@ async function viewArchiveData(semesterId, semesterName, page) {
} }
function renderArchivePagination(semesterId, semesterName) { function renderArchivePagination(semesterId, semesterName) {
const container = document.getElementById('archivePagination'); renderSmartPagination('archivePagination', archivePage, archiveTotalPages, function(page) {
if (!container) return; viewArchiveData(semesterId, semesterName, page);
if (archiveTotalPages <= 1) { });
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= archiveTotalPages; i++) {
if (i === archivePage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" onclick="viewArchiveData(${semesterId}, '${escapeHtml(semesterName)}', ${i}); return false;">${i}</a>`;
}
}
container.innerHTML = html;
} }
function closeModal(modalId) { function closeModal(modalId) {

View File

@@ -201,22 +201,9 @@ async function loadStudents(page = 1) {
} }
function renderPagination() { function renderPagination() {
const container = document.getElementById('pagination'); renderSmartPagination('pagination', currentPage, totalPages, function(page) {
if (!container) return; loadStudents(page);
if (totalPages <= 1) { });
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
if (i === currentPage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" onclick="loadStudents(${i}); return false;">${i}</a>`;
}
}
container.innerHTML = html;
} }
function showSinglePointsModal(studentId, studentName) { function showSinglePointsModal(studentId, studentName) {
@@ -263,7 +250,16 @@ document.getElementById('searchInput').addEventListener('input', () => {
<div class="form-group"> <div class="form-group">
<label>分数变动</label> <label>分数变动</label>
<input type="number" id="pointsChange" required placeholder="正数为加分,负数为扣分"> <input type="number" id="pointsChange" required placeholder="正数为加分,负数为扣分">
<small><?php echo $role === '班长' ? '班长单次±5分以内' : '班主任无限制'; ?></small> <small><?php
$hints = [
'班长' => '班长单次±5分以内',
'学习委员' => '学习委员单次±5分以内',
'考勤委员' => '考勤委员仅限扣分单次最多扣8分',
'劳动委员' => '劳动委员单次±1分以内',
'志愿委员' => '志愿委员仅限加分,最多+5分',
];
echo $hints[$role] ?? '班主任无限制';
?></small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>原因</label> <label>原因</label>

View File

@@ -490,8 +490,10 @@ tr:hover {
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 8px; align-items: center;
gap: 6px;
margin-top: 20px; margin-top: 20px;
flex-wrap: wrap;
} }
.pagination a, .pagination span { .pagination a, .pagination span {
@@ -501,6 +503,16 @@ tr:hover {
text-decoration: none; text-decoration: none;
color: #666; color: #666;
cursor: pointer; cursor: pointer;
min-width: 36px;
text-align: center;
box-sizing: border-box;
transition: all 0.2s;
}
.pagination a:hover {
background: #f0f0ff;
border-color: #667eea;
color: #667eea;
} }
.pagination .active { .pagination .active {
@@ -509,6 +521,43 @@ tr:hover {
border-color: #667eea; border-color: #667eea;
} }
.pagination .ellipsis {
border: none;
cursor: default;
padding: 6px 4px;
color: #999;
min-width: auto;
}
.pagination .page-jump {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
font-size: 13px;
color: #666;
}
.pagination .page-jump input {
width: 50px;
padding: 5px 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
font-size: 13px;
outline: none;
}
.pagination .page-jump input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.pagination .page-nav {
padding: 6px 10px;
font-size: 13px;
}
/* ========== 提示消息 ========== */ /* ========== 提示消息 ========== */
.toast { .toast {
position: fixed; position: fixed;

View File

@@ -205,6 +205,125 @@ function escapeHtml(str) {
}); });
} }
/**
* 智能分页渲染最多显示7个页码 + 跳转输入框)
* @param {string|HTMLElement} container - 分页容器ID或DOM元素
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {function} onPageChange - 页码变化回调函数,参数为新的页码
*/
function renderSmartPagination(container, currentPage, totalPages, onPageChange) {
if (typeof container === 'string') {
container = document.getElementById(container);
}
if (!container || totalPages <= 1) {
if (container) container.innerHTML = '';
return;
}
const MAX_VISIBLE = 7;
let html = '';
// 上一页按钮
if (currentPage > 1) {
html += `<a href="#" class="page-nav" data-page="${currentPage - 1}">&laquo; 上一页</a>`;
}
if (totalPages <= MAX_VISIBLE) {
// 总页数不超过最大显示数,全部显示
for (let i = 1; i <= totalPages; i++) {
if (i === currentPage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" data-page="${i}">${i}</a>`;
}
}
} else {
// 需要省略号
// 始终显示第1页
if (currentPage === 1) {
html += `<span class="active">1</span>`;
} else {
html += `<a href="#" data-page="1">1</a>`;
}
// 计算中间页码范围
let start = Math.max(2, currentPage - 2);
let end = Math.min(totalPages - 1, currentPage + 2);
// 调整确保中间至少有3个页码加上首尾共5-7个
if (currentPage <= 3) {
end = Math.min(5, totalPages - 1);
}
if (currentPage >= totalPages - 2) {
start = Math.max(2, totalPages - 4);
}
// 前省略号
if (start > 2) {
html += `<span class="ellipsis">...</span>`;
}
// 中间页码
for (let i = start; i <= end; i++) {
if (i === currentPage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" data-page="${i}">${i}</a>`;
}
}
// 后省略号
if (end < totalPages - 1) {
html += `<span class="ellipsis">...</span>`;
}
// 始终显示最后一页
if (currentPage === totalPages) {
html += `<span class="active">${totalPages}</span>`;
} else {
html += `<a href="#" data-page="${totalPages}">${totalPages}</a>`;
}
}
// 下一页按钮
if (currentPage < totalPages) {
html += `<a href="#" class="page-nav" data-page="${currentPage + 1}">下一页 &raquo;</a>`;
}
// 页码跳转
html += `<span class="page-jump">跳至 <input type="number" min="1" max="${totalPages}" placeholder="页码"> / ${totalPages}页</span>`;
container.innerHTML = html;
// 绑定页码点击事件
container.querySelectorAll('a[data-page]').forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = parseInt(this.dataset.page);
if (page && page !== currentPage && page >= 1 && page <= totalPages) {
onPageChange(page);
}
});
});
// 绑定跳转输入框事件
const jumpInput = container.querySelector('.page-jump input');
if (jumpInput) {
jumpInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const page = parseInt(this.value);
if (page && page >= 1 && page <= totalPages) {
onPageChange(page);
} else {
showToast(`请输入1-${totalPages}之间的页码`, 'warning');
}
}
});
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const user = getUserInfo(); const user = getUserInfo();
const userNameSpan = document.getElementById('userName'); const userNameSpan = document.getElementById('userName');

View File

@@ -65,9 +65,9 @@ define('ICP_NUMBER', $config['ICP_NUMBER'] ?? '');
define('DEDUCTION_HOMEWORK_NOT_SUBMIT', (int)($config['DEDUCTION_HOMEWORK_NOT_SUBMIT'] ?? 2)); define('DEDUCTION_HOMEWORK_NOT_SUBMIT', (int)($config['DEDUCTION_HOMEWORK_NOT_SUBMIT'] ?? 2));
define('DEDUCTION_HOMEWORK_LATE', (int)($config['DEDUCTION_HOMEWORK_LATE'] ?? 1)); define('DEDUCTION_HOMEWORK_LATE', (int)($config['DEDUCTION_HOMEWORK_LATE'] ?? 1));
define('HOMEWORK_MAX_POINTS', (int)($config['HOMEWORK_MAX_POINTS'] ?? 3)); define('HOMEWORK_MAX_POINTS', (int)($config['HOMEWORK_MAX_POINTS'] ?? 3));
define('DEDUCTION_ATTENDANCE_ABSENT', (int)($config['DEDUCTION_ATTENDANCE_ABSENT'] ?? 5)); define('DEDUCTION_ATTENDANCE_ABSENT', (int)($config['DEDUCTION_ATTENDANCE_ABSENT'] ?? 3));
define('DEDUCTION_ATTENDANCE_LATE', (int)($config['DEDUCTION_ATTENDANCE_LATE'] ?? 2)); define('DEDUCTION_ATTENDANCE_LATE', (int)($config['DEDUCTION_ATTENDANCE_LATE'] ?? 1));
define('DEDUCTION_ATTENDANCE_LEAVE', (int)($config['DEDUCTION_ATTENDANCE_LEAVE'] ?? 1)); define('DEDUCTION_ATTENDANCE_LEAVE', (int)($config['DEDUCTION_ATTENDANCE_LEAVE'] ?? 0));
// 学生初始操行分 // 学生初始操行分
define('STUDENT_INITIAL_POINTS', (int)($config['STUDENT_INITIAL_POINTS'] ?? 60)); define('STUDENT_INITIAL_POINTS', (int)($config['STUDENT_INITIAL_POINTS'] ?? 60));

View File

@@ -1,7 +1,7 @@
<div class="nav"> <div class="nav">
<a href="/admin/dashboard.php" class="nav-item<?php echo $current_page === 'dashboard' ? ' active' : ''; ?>">首页</a> <a href="/admin/dashboard.php" class="nav-item<?php echo $current_page === 'dashboard' ? ' active' : ''; ?>">首页</a>
<a href="/admin/students.php" class="nav-item<?php echo $current_page === 'students' ? ' active' : ''; ?>">学生管理</a> <a href="/admin/students.php" class="nav-item<?php echo $current_page === 'students' ? ' active' : ''; ?>">学生管理</a>
<?php if ($role === '班主任' || $role === '班长' || $role === '劳动委员' || $role === '志愿委员'): ?> <?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员' || $role === '劳动委员' || $role === '志愿委员'): ?>
<a href="/admin/conduct.php" class="nav-item<?php echo $current_page === 'conduct' ? ' active' : ''; ?>">操行分管理</a> <a href="/admin/conduct.php" class="nav-item<?php echo $current_page === 'conduct' ? ' active' : ''; ?>">操行分管理</a>
<?php endif; ?> <?php endif; ?>
<?php if ($role === '班主任' || $role === '学习委员'): ?> <?php if ($role === '班主任' || $role === '学习委员'): ?>

View File

@@ -133,6 +133,5 @@ if (isset($_SESSION['user_id']) && isset($_SESSION['user_type'])) {
}, 3000); }, 3000);
} }
</script> </script>
</script>
</body> </body>
</html> </html>

View File

@@ -73,7 +73,6 @@ async function loadDashboard() {
document.getElementById('totalPoints').textContent = res.data.total_points; document.getElementById('totalPoints').textContent = res.data.total_points;
} }
// 加载排名信息
// 加载排名信息 // 加载排名信息
const rankRes = await apiGet('/api/parent/child/ranking'); const rankRes = await apiGet('/api/parent/child/ranking');
if (rankRes && rankRes.success) { if (rankRes && rankRes.success) {

View File

@@ -285,7 +285,7 @@ include __DIR__ . '/../includes/header.php';
html += ` html += `
<div class="record-item"> <div class="record-item">
<span class="record-points ${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}</span> <span class="record-points ${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}</span>
<span class="record-reason">${record.reason}</span> <span class="record-reason">${escapeHtml(record.reason)}</span>
<span class="record-time">${formatDate(record.created_at)}</span> <span class="record-time">${formatDate(record.created_at)}</span>
</div> </div>
`; `;
@@ -347,8 +347,8 @@ include __DIR__ . '/../includes/header.php';
<tr> <tr>
<td>${formatDateTime(record.created_at)}</td> <td>${formatDateTime(record.created_at)}</td>
<td class="record-points ${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}</td> <td class="record-points ${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}</td>
<td>${record.reason}</td> <td>${escapeHtml(record.reason)}</td>
<td>${record.recorder_name}</td> <td>${escapeHtml(record.recorder_name)}</td>
</tr> </tr>
`; `;
}); });
@@ -369,21 +369,9 @@ include __DIR__ . '/../includes/header.php';
} }
function renderConductPagination() { function renderConductPagination() {
const container = document.getElementById('conductPagination'); renderSmartPagination('conductPagination', conductPage, conductTotalPages, function(page) {
if (!container) return; loadConductHistory(page);
if (conductTotalPages <= 1) { });
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= conductTotalPages; i++) {
if (i === conductPage) {
html += `<span class="active">${i}</span>`;
} else {
html += `<a href="#" onclick="loadConductHistory(${i}); return false;">${i}</a>`;
}
}
container.innerHTML = html;
} }
// 加载作业 // 加载作业
@@ -395,11 +383,11 @@ include __DIR__ . '/../includes/header.php';
res.data.homework.forEach(hw => { res.data.homework.forEach(hw => {
html += ` html += `
<tr> <tr>
<td>${hw.subject_name}</td> <td>${escapeHtml(hw.subject_name)}</td>
<td>${hw.title}</td> <td>${escapeHtml(hw.title)}</td>
<td>${hw.deadline}</td> <td>${escapeHtml(hw.deadline)}</td>
<td>${getStatusBadge(hw.status, 'homework')}</td> <td>${getStatusBadge(hw.status, 'homework')}</td>
<td>${hw.comments || '-'}</td> <td>${escapeHtml(hw.comments || '-')}</td>
</tr> </tr>
`; `;
}); });
@@ -428,9 +416,9 @@ include __DIR__ . '/../includes/header.php';
res.data.records.forEach(record => { res.data.records.forEach(record => {
html += ` html += `
<tr> <tr>
<td>${record.date}</td> <td>${escapeHtml(record.date)}</td>
<td>${getStatusBadge(record.status, 'attendance')}</td> <td>${getStatusBadge(record.status, 'attendance')}</td>
<td>${record.reason || '-'}</td> <td>${escapeHtml(record.reason || '-')}</td>
</tr> </tr>
`; `;
}); });