From 684adbd718e63503d57a35359c9907170988fc80 Mon Sep 17 00:00:00 2001 From: canglan Date: Thu, 23 Apr 2026 09:41:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=93=8D=E8=A1=8C=E5=88=86=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=B1=95=E7=A4=BA=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 +++- backend/models/conduct.py | 74 ++++++++++++++++++++++++++- backend/routes/admin.py | 60 ++++++++++++++++++++-- backend/schemas/admin.py | 8 ++- backend/services/admin_service.py | 67 ++++++++++++++++++++++++- backend/services/conduct_service.py | 33 +++++++++++-- docs/guide/cadre.md | 6 +++ docs/guide/parent.md | 6 +++ docs/guide/student.md | 6 +++ docs/guide/teacher.md | 6 +++ frontend/admin/conduct.php | 2 +- frontend/admin/history.php | 25 +++++++--- frontend/admin/homework.php | 36 ++++++++++++++ frontend/admin/students.php | 56 ++++++++++++++++++++- frontend/assets/js/admin.js | 77 ++++++++++++++++++++++++++++- 15 files changed, 447 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 8665ba3..694db01 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,11 @@ Thumbs.db *.bak # CoStrict -.cospec/ \ No newline at end of file +.cospec/ + +# PDF +docs/guide/cadre.pdf +docs/guide/parent.pdf +docs/guide/student.pdf +docs/guide/teacher.pdf +qrcode.png \ No newline at end of file diff --git a/backend/models/conduct.py b/backend/models/conduct.py index dd7042d..9324c1d 100644 --- a/backend/models/conduct.py +++ b/backend/models/conduct.py @@ -81,7 +81,8 @@ class ConductModel: limit: int = 100, offset: int = 0, start_date: str = None, - end_date: str = None + end_date: str = None, + student_id: int = None ) -> List[Dict[str, Any]]: """获取所有记录(班主任/班长专用)""" # 空字符串转为None @@ -99,7 +100,9 @@ class ConductModel: """ params = [] - # 单班级系统,无需 class_id 过滤 + if student_id: + sql += " AND cr.student_id = %s" + params.append(student_id) if start_date: sql += " AND DATE(cr.created_at) >= %s" @@ -114,6 +117,73 @@ class ConductModel: return await execute_query(sql, tuple(params)) + @staticmethod + async def get_grouped_records( + student_id: int = None, + start_date: str = None, + end_date: str = None, + page: int = 1, + page_size: int = 20 + ) -> Dict[str, Any]: + """获取分组后的操行分记录(同批次合并)""" + if start_date == "": + start_date = None + if end_date == "": + end_date = None + + conditions = ["cr.is_revoked = 0"] + params = [] + + if student_id: + conditions.append("cr.student_id = %s") + params.append(student_id) + if start_date: + conditions.append("cr.created_at >= %s") + params.append(start_date) + if end_date: + conditions.append("cr.created_at <= %s") + params.append(end_date + ' 23:59:59') + + where_clause = " AND ".join(conditions) + + count_sql = f""" + SELECT COUNT(DISTINCT CONCAT(cr.points_change, '|', cr.reason, '|', cr.recorder_id, '|', DATE_FORMAT(cr.created_at, '%Y-%m-%d %H:%i'))) as total + FROM conduct_records cr + WHERE {where_clause} + """ + + data_sql = f""" + SELECT + cr.points_change, + cr.reason, + cr.recorder_name, + DATE_FORMAT(MIN(cr.created_at), '%Y-%m-%d %H:%i:%s') as created_at, + GROUP_CONCAT(s.name ORDER BY s.student_id SEPARATOR ', ') as student_names, + COUNT(*) as student_count + FROM conduct_records cr + JOIN students s ON cr.student_id = s.student_id + WHERE {where_clause} + GROUP BY cr.points_change, cr.reason, cr.recorder_id, DATE_FORMAT(cr.created_at, '%Y-%m-%d %H:%i') + ORDER BY MIN(cr.created_at) DESC + LIMIT %s OFFSET %s + """ + + params_for_count = list(params) + params_for_data = list(params) + [page_size, (page - 1) * page_size] + + total_result = await execute_one(count_sql, tuple(params_for_count)) + total = total_result['total'] if total_result else 0 + + records = await execute_query(data_sql, tuple(params_for_data)) + + return { + "records": records, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + @staticmethod async def get_record_by_id(record_id: int) -> Optional[Dict[str, Any]]: """根据ID获取记录""" diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 4da04b6..22a7dd0 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -25,7 +25,7 @@ from services.attendance_service import AttendanceService from services.log_service import LogService from schemas.admin import ( AddPointsRequest, RevokeRequest, AddAdminRequest, - AddStudentRequest, + AddStudentRequest, UpdateStudentRequest, UpdateHomeworkStatusRequest, AddAttendanceRequest, UpdateAdminRequest, DeleteAdminRequest, ResetPasswordRequest ) @@ -124,6 +124,58 @@ async def add_student(request: Request, req: AddStudentRequest): return error_response(message=result["message"]) +@router.put("/students/{student_id}") +async def update_student(request: Request, student_id: int, req: UpdateStudentRequest): + """编辑学生信息(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可编辑学生信息", code=403) + + result = await AdminService.update_student( + student_id=student_id, + name=req.name, + parent_phone=req.parent_phone + ) + if result["success"]: + return success_response(message=result["message"]) + else: + return error_response(message=result["message"]) + + +@router.delete("/students/{student_id}") +async def delete_student(request: Request, student_id: int): + """删除学生(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可删除学生", code=403) + + result = await AdminService.delete_student(student_id=student_id) + if result["success"]: + return success_response(message=result["message"]) + else: + return error_response(message=result["message"]) + + +@router.post("/students/reset-password/{student_id}") +async def reset_student_password(request: Request, student_id: int, req: ResetPasswordRequest): + """重置学生密码(班主任)""" + user = await get_current_user(request) + is_teacher = await PermissionChecker.check_is_teacher(user["user_id"]) + if not is_teacher: + return error_response(message="仅班主任可重置学生密码", code=403) + + result = await AdminService.reset_student_password( + student_id=student_id, + new_password=req.new_password + ) + if result["success"]: + return success_response(message=result["message"]) + else: + return error_response(message=result["message"]) + + # ========== 操行分管理 ========== @router.post("/conduct/add") @@ -186,7 +238,8 @@ async def get_conduct_history( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=1000), start_date: Optional[str] = None, - end_date: Optional[str] = None + end_date: Optional[str] = None, + grouped: bool = Query(False) ): """获取操行分历史记录""" try: @@ -197,7 +250,8 @@ async def get_conduct_history( page=page, page_size=page_size, start_date=start_date, - end_date=end_date + end_date=end_date, + grouped=grouped ) return success_response(data=result) except Exception as e: diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index 9faff5e..6902bf4 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -102,4 +102,10 @@ class DeleteAdminRequest(BaseModel): class ResetPasswordRequest(BaseModel): """重置密码请求""" - new_password: str = Field(..., min_length=6, max_length=50, description="新密码") \ No newline at end of file + new_password: str = Field(..., min_length=6, max_length=50, description="新密码") + + +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="家长手机号") \ No newline at end of file diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py index 8134ea4..8538d59 100644 --- a/backend/services/admin_service.py +++ b/backend/services/admin_service.py @@ -10,7 +10,7 @@ # =========================================== from typing import Dict, Any, List, Optional -from utils.database import execute_query, execute_one +from utils.database import execute_query, execute_one, execute_update from models.user import UserModel from models.student import StudentModel from models.admin_role import AdminRoleModel @@ -245,4 +245,67 @@ class AdminService: async def get_admins() -> Dict[str, Any]: """获取管理员列表""" admins = await AdminRoleModel.get_all() - return {"admins": admins} \ No newline at end of file + return {"admins": admins} + + @staticmethod + async def update_student(student_id: int, name: str = None, parent_phone: str = None) -> Dict[str, Any]: + """编辑学生信息""" + try: + student = await StudentModel.get_by_id(student_id) + if not student: + return {"success": False, "message": "学生不存在"} + + result = await StudentModel.update(student_id, name=name, parent_phone=parent_phone) + if result: + return {"success": True, "message": "学生信息更新成功"} + return {"success": False, "message": "更新失败"} + except Exception as e: + logger.error(f"更新学生信息失败: {e}") + return {"success": False, "message": f"更新失败: {str(e)}"} + + @staticmethod + async def delete_student(student_id: int) -> Dict[str, Any]: + """删除学生(软删除)""" + try: + student = await StudentModel.get_by_id(student_id) + if not student: + return {"success": False, "message": "学生不存在"} + + result = await StudentModel.delete(student_id) + if result: + user = await execute_one( + "SELECT user_id FROM users WHERE student_id = %s AND user_type = 'student'", + (student_id,) + ) + if user: + await UserModel.update_status(user['user_id'], 0) + return {"success": True, "message": "学生删除成功"} + return {"success": False, "message": "删除失败"} + except Exception as e: + logger.error(f"删除学生失败: {e}") + return {"success": False, "message": f"删除失败: {str(e)}"} + + @staticmethod + async def reset_student_password(student_id: int, new_password: str) -> Dict[str, Any]: + """重置学生密码""" + try: + user = await execute_one( + "SELECT user_id FROM users WHERE student_id = %s AND user_type = 'student'", + (student_id,) + ) + if not user: + return {"success": False, "message": "未找到对应的用户账号"} + + password_hash = security.sha1_md5_password(new_password) + + result = await UserModel.update_password(user['user_id'], password_hash) + if result: + await execute_update( + "UPDATE users SET need_change_password = 1 WHERE user_id = %s", + (user['user_id'],) + ) + return {"success": True, "message": "密码重置成功"} + return {"success": False, "message": "密码重置失败"} + except Exception as e: + logger.error(f"重置学生密码失败: {e}") + return {"success": False, "message": f"重置失败: {str(e)}"} \ No newline at end of file diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py index 7d0e53f..c6c0d43 100644 --- a/backend/services/conduct_service.py +++ b/backend/services/conduct_service.py @@ -159,7 +159,8 @@ class ConductService: page: int = 1, page_size: int = 20, start_date: Optional[str] = None, - end_date: Optional[str] = None + end_date: Optional[str] = None, + grouped: bool = False ) -> Dict[str, Any]: """获取历史记录""" # 空字符串转为None @@ -173,21 +174,43 @@ class ConductService: # 班主任/班长/志愿委员可查看全班 if role in ["班主任", "班长", "志愿委员"]: + if grouped: + return await ConductModel.get_grouped_records( + student_id=student_id, + start_date=start_date, + end_date=end_date, + page=page, + page_size=page_size + ) + records = await ConductModel.get_all_records( limit=page_size, offset=offset, start_date=start_date, - end_date=end_date + end_date=end_date, + student_id=student_id ) # 获取总数 from utils.database import execute_one - count_sql = """ + count_conditions = ["cr.is_revoked = 0"] + count_params = [] + if student_id: + count_conditions.append("cr.student_id = %s") + count_params.append(student_id) + if start_date: + count_conditions.append("DATE(cr.created_at) >= %s") + count_params.append(start_date) + if end_date: + count_conditions.append("DATE(cr.created_at) <= %s") + count_params.append(end_date) + count_where = " AND ".join(count_conditions) + count_sql = f""" SELECT COUNT(*) as total FROM conduct_records cr JOIN students s ON cr.student_id = s.student_id - WHERE cr.is_revoked = 0 + WHERE {count_where} """ - total_result = await execute_one(count_sql) + total_result = await execute_one(count_sql, tuple(count_params)) total = total_result["total"] if total_result else 0 elif student_id: diff --git a/docs/guide/cadre.md b/docs/guide/cadre.md index b69f320..47149e0 100644 --- a/docs/guide/cadre.md +++ b/docs/guide/cadre.md @@ -1,5 +1,11 @@ # 班干部使用说明 +## 登录网址 + +![二维码](./qrcode.png) + +### 或访问https://class.sea-studio.top/ + ## 登录 输入**用户名**和**密码**登录。首次登录需强制修改密码。 diff --git a/docs/guide/parent.md b/docs/guide/parent.md index 15d2c94..c9b3230 100644 --- a/docs/guide/parent.md +++ b/docs/guide/parent.md @@ -1,5 +1,11 @@ # 家长端使用说明 +## 登录网址 + +![二维码](./qrcode.png) + +### 或访问https://class.sea-studio.top/ + ## 登录 输入**手机号**和**密码**登录。账号由系统自动创建,与子女信息关联。 diff --git a/docs/guide/student.md b/docs/guide/student.md index 296587d..f61f913 100644 --- a/docs/guide/student.md +++ b/docs/guide/student.md @@ -1,5 +1,11 @@ # 学生端使用说明 +## 登录网址 + +![二维码](./qrcode.png) + +### 或访问https://class.sea-studio.top/ + ## 登录 输入**长学号**和**密码**登录。首次登录需强制修改密码。 diff --git a/docs/guide/teacher.md b/docs/guide/teacher.md index cf86977..2c6cb5e 100644 --- a/docs/guide/teacher.md +++ b/docs/guide/teacher.md @@ -1,5 +1,11 @@ # 班主任使用说明 +## 登录网址 + +![二维码](./qrcode.png) + +### 或访问https://class.sea-studio.top/ + ## 登录 输入**用户名**和**密码**登录。首次登录需强制修改密码。 diff --git a/frontend/admin/conduct.php b/frontend/admin/conduct.php index acd0708..8aca8db 100644 --- a/frontend/admin/conduct.php +++ b/frontend/admin/conduct.php @@ -69,7 +69,7 @@ async function loadStudents() { html += ` ${escapeHtml(student.student_no)} - ${escapeHtml(student.name)} + ${escapeHtml(student.name)} ${student.total_points} `; diff --git a/frontend/admin/history.php b/frontend/admin/history.php index fd80192..ec415cb 100644 --- a/frontend/admin/history.php +++ b/frontend/admin/history.php @@ -53,6 +53,7 @@ include __DIR__ . '/../includes/header.php'; 时间 学生 + 人数 分数变动 原因 操作人 @@ -82,7 +83,6 @@ async function loadStudentsForSelect() { document.getElementById('historyStudentId').innerHTML = html; } } - async function loadHistory(page = 1) { currentHistoryPage = page; const startDate = document.getElementById('historyStartDate').value; @@ -92,7 +92,8 @@ async function loadHistory(page = 1) { const params = { page, page_size: 20, start_date: startDate, - end_date: endDate + end_date: endDate, + grouped: true }; if (studentId) params.student_id = studentId; @@ -104,18 +105,19 @@ async function loadHistory(page = 1) { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; html += ` ${formatDateTime(record.created_at)} - ${escapeHtml(record.student_name)} + ${escapeHtml(record.student_names)} + ${record.student_count || 1} ${record.points_change > 0 ? '+' : ''}${record.points_change} ${escapeHtml(record.reason)} ${escapeHtml(record.recorder_name)}`; - html += ``; + html += `-`; html += ``; }); if (res.data.records.length === 0) { - const colSpan = ; + const colSpan = ; html = `暂无记录`; } @@ -125,6 +127,7 @@ async function loadHistory(page = 1) { renderHistoryPagination(); } } +} function renderHistoryPagination() { const container = document.getElementById('historyPagination'); @@ -194,8 +197,16 @@ async function exportHistoryRecords() { } } -loadStudentsForSelect(); -loadHistory(); +loadStudentsForSelect().then(() => { + const urlParams = new URLSearchParams(window.location.search); + const preStudentId = urlParams.get('student_id'); + if (preStudentId) { + document.getElementById('historyStudentId').value = preStudentId; + loadHistory(); + } else { + loadHistory(); + } +}); diff --git a/frontend/admin/homework.php b/frontend/admin/homework.php index 8f3ad43..2cf6218 100644 --- a/frontend/admin/homework.php +++ b/frontend/admin/homework.php @@ -65,6 +65,14 @@ include __DIR__ . '/../includes/header.php';
未选择学生
+ +
+ + +
+
@@ -92,6 +100,7 @@ include __DIR__ . '/../includes/header.php'; diff --git a/frontend/admin/students.php b/frontend/admin/students.php index ec98e7a..df797bc 100644 --- a/frontend/admin/students.php +++ b/frontend/admin/students.php @@ -110,6 +110,57 @@ include __DIR__ . '/../includes/header.php';
+ + + + + +