diff --git a/README.md b/README.md index 0a2c283..4865c10 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - 作业管理:发布作业、查看提交情况 - 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30)记录考勤、自定义考勤扣分值 - 科目管理:动态增删学科 -- 管理员管理:添加/编辑/删除/重置密码班长/科代表/考勤委员/劳动委员/志愿委员 +- 管理员管理:添加/编辑/禁用-启用/重置密码班长/科代表/考勤委员/劳动委员/志愿委员 - 学期管理:创建/编辑/删除学期、激活学期、归档学期(含考勤/作业统计快照)、关联历史记录、归档后可选重置分数 - 排行榜百分比筛选:在排行榜上方输入百分比,筛选显示前N%的学生(抹零法) - 数据导出:导出历史记录、导出德育分记录(含加分/减分历史) @@ -279,6 +279,8 @@ classmanager/ | v2.5.1 | 2026.5.29 | 筛选学生时自动取消合并记录、合并记录选项样式修复(竖排显示)、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 | | v2.6 | 2026.5.29 | 历史记录筛选面板重构(学生筛选移入面板、合并按钮始终可见)、新增科目下拉筛选、新增原因关键词搜索、科目删除后默认仅显示启用科目、筛选面板展开修复 | | v2.7 | 2026.5.29 | 操作菜单定位修复、历史记录显示修复、筛选面板修复、科目删除修复、学期周数列修复、escapeHtml 转义修复 | +| v2.8 | 2026.6.2 | 管理员软删除改为禁用/启用切换(保留admin_roles记录)、科目管理面板显示全部科目(禁用/启用均可见)、添加管理员支持重新激活已禁用账号、升级系统补全v2.8版本号 | +| v2.9 | 2026.6.8 | 历史记录表头优化:合并/非合并视图统一新增"类型"列(手动/作业/考勤),列顺序统一为"类型、分值、原因、学生、操作人、时间" | ## 许可证 diff --git a/VERSION b/VERSION index a4412fa..8c26915 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8 +2.9 diff --git a/backend/models/admin_role.py b/backend/models/admin_role.py index c40de9b..ff17849 100644 --- a/backend/models/admin_role.py +++ b/backend/models/admin_role.py @@ -30,9 +30,9 @@ class AdminRoleModel: @staticmethod async def get_all() -> List[Dict[str, Any]]: sql = """ - SELECT ar.*, u.real_name, u.username, s.subject_name + SELECT ar.*, u.real_name, u.username, u.status, s.subject_name FROM admin_roles ar - JOIN users u ON ar.user_id = u.user_id AND u.status = 1 + JOIN users u ON ar.user_id = u.user_id LEFT JOIN subjects s ON ar.subject_id = s.subject_id ORDER BY ar.role_type """ diff --git a/backend/models/conduct.py b/backend/models/conduct.py index 2cefb0c..610a7ca 100644 --- a/backend/models/conduct.py +++ b/backend/models/conduct.py @@ -289,6 +289,7 @@ class ConductModel: cr.points_change, cr.reason, cr.recorder_name, + MAX(cr.related_type) as related_type, 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, diff --git a/backend/models/user.py b/backend/models/user.py index a20b718..42ea95f 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -93,6 +93,17 @@ class UserModel: """ await execute_update(sql, (ip, user_id)) + @staticmethod + async def get_by_username_any(username: str) -> dict: + """根据用户名获取用户(含已禁用)""" + sql = """ + SELECT user_id, username, password_hash, real_name, user_type, + student_id, status, need_change_password, last_login_time, last_login_ip + FROM users + WHERE username = %s + """ + return await execute_one(sql, (username,)) + @staticmethod async def check_username_exists(username: str) -> bool: """检查用户名是否存在""" diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 8e4e7b0..874b3b8 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -500,11 +500,13 @@ async def add_admin(request: Request, req: AddAdminRequest): operator_id=user["user_id"] ) if result["success"]: + is_reactivation = result.get("password") is None await LogService.write_operation_log( operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="add_admin", + operator_role="班主任", + operation_type="reactivate_admin" if is_reactivation else "add_admin", target_type="admin", - details=f"新增管理员: {req.real_name}({req.username}), 角色: {req.role_type}", + details=f"{'重新激活' if is_reactivation else '新增'}管理员: {req.real_name}({req.username}), 角色: {req.role_type}", ip=request.client.host ) return success_response(data=result, message="管理员添加成功") @@ -563,36 +565,43 @@ async def update_admin(request: Request, user_id: int, req: UpdateAdminRequest): return error_response(message="更新失败或管理员不存在") -@router.delete("/delete/{user_id}") -async def delete_admin(request: Request, user_id: int): - """删除管理员(班主任)""" +@router.put("/toggle-status/{user_id}") +async def toggle_admin_status(request: Request, user_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) + return error_response(message="仅班主任可操作", code=403) - # 防止删除自己 + # 防止禁用自己 if user_id == user["user_id"]: - return error_response(message="不能删除当前登录的管理员", code=400) + return error_response(message="不能禁用当前登录的管理员", code=400) from models.admin_role import AdminRoleModel from models.user import UserModel - # 先删除角色记录 - role_deleted = await AdminRoleModel.delete(user_id) - if role_deleted: - # 再删除用户账号(软删除,将状态设为禁用) - await UserModel.update_status(user_id, 0) - await LogService.write_operation_log( - operator_id=user["user_id"], operator_name=user["real_name"], - operator_role="班主任", operation_type="delete_admin", - target_type="admin", target_id=user_id, - details=f"删除管理员: ID={user_id}", - ip=request.client.host - ) - return success_response(message="管理员删除成功") - else: - return error_response(message="删除失败或管理员不存在") + target_user = await UserModel.get_by_user_id(user_id) + if not target_user or target_user.get("user_type") != "admin": + return error_response(message="管理员不存在", code=404) + + # 检查是否有管理员角色 + admin_role = await AdminRoleModel.get_by_user_id(user_id) + if not admin_role: + return error_response(message="该用户无管理员角色", code=400) + + new_status = 0 if target_user["status"] == 1 else 1 + action_text = "禁用" if new_status == 0 else "启用" + + await UserModel.update_status(user_id, new_status) + + await LogService.write_operation_log( + operator_id=user["user_id"], operator_name=user["real_name"], + operator_role="班主任", operation_type="toggle_admin_status", + target_type="admin", target_id=user_id, + details=f"{action_text}管理员: {target_user['real_name']}({target_user['username']})", + ip=request.client.host + ) + return success_response(message=f"管理员已{action_text}") @router.post("/reset-password/{user_id}") diff --git a/backend/routes/upgrade.py b/backend/routes/upgrade.py index 0feb897..f32a356 100644 --- a/backend/routes/upgrade.py +++ b/backend/routes/upgrade.py @@ -41,6 +41,7 @@ ALL_VERSIONS = { '2.6': 'v2.6.sql', '2.7': 'v2.7.sql', '2.8': 'v2.8.sql', + '2.9': 'v2.9.sql', } # 版本特征标记(按优先级从高到低) VERSION_MARKERS = [ diff --git a/backend/services/admin_service.py b/backend/services/admin_service.py index 29f6df5..eb8a78e 100644 --- a/backend/services/admin_service.py +++ b/backend/services/admin_service.py @@ -245,10 +245,30 @@ class AdminService: role_type: str, operator_id: int ) -> Dict[str, Any]: - """添加管理员""" - existing = await UserModel.get_by_username(username) + """添加管理员(支持重新激活已删除的管理员)""" + # 检查用户名是否存在(含已禁用用户,因 username 有 UNIQUE 约束) + existing = await UserModel.get_by_username_any(username) if existing: - return {"success": False, "message": "用户名已存在"} + if existing.get('status') == 1: + return {"success": False, "message": "用户名已存在"} + # 用户已被软删除(status=0),重新激活 + await UserModel.update_status(existing['user_id'], 1) + await UserModel.update_real_name(existing['user_id'], real_name) + user_id = existing['user_id'] + # 检查是否已有管理员角色 + existing_role = await AdminRoleModel.get_by_user_id(user_id) + if existing_role: + await AdminRoleModel.update_role(user_id, role_type) + else: + await AdminRoleModel.create(user_id=user_id, role_type=role_type, subject_id=None) + logger.info(f"用户[{operator_id}] 重新激活管理员: {username} ({role_type})") + return { + "success": True, + "user_id": user_id, + "username": username, + "password": None, # 重新激活不返回密码 + "role_type": role_type + } if not password: password = security.generate_random_password() @@ -274,7 +294,6 @@ class AdminService: "password": password, "role_type": role_type } - @staticmethod async def get_admins() -> Dict[str, Any]: """获取管理员列表""" diff --git a/frontend/admin/admins.php b/frontend/admin/admins.php index 6588f68..e612e68 100644 --- a/frontend/admin/admins.php +++ b/frontend/admin/admins.php @@ -37,7 +37,7 @@ include __DIR__ . '/../includes/header.php';
- +
用户名姓名角色操作
用户名姓名角色状态操作
diff --git a/frontend/admin/history.php b/frontend/admin/history.php index a1c0a58..04613c2 100644 --- a/frontend/admin/history.php +++ b/frontend/admin/history.php @@ -93,11 +93,12 @@ include __DIR__ . '/../includes/header.php'; - - - + + + + diff --git a/frontend/assets/js/admins.js b/frontend/assets/js/admins.js index c4e2f06..6808141 100644 --- a/frontend/assets/js/admins.js +++ b/frontend/assets/js/admins.js @@ -18,10 +18,14 @@ async function loadAdmins() { if (res && res.success) { let html = ''; res.data.admins.forEach(admin => { + const isActive = admin.status === 1; + const statusClass = isActive ? 'subject-status-active' : 'subject-status-inactive'; + const statusText = isActive ? '启用' : '禁用'; html += ` + `; }); if (res.data.admins.length === 0) { - html = ''; + html = ''; } document.getElementById('adminList').innerHTML = html; } @@ -74,17 +78,21 @@ async function submitEditAdmin() { } } -async function deleteAdmin(userId, realName) { - if (!confirm(`确定要删除管理员 "${realName}" 吗?此操作不可恢复。`)) { +async function toggleAdminStatus(userId, realName, currentStatus) { + const action = currentStatus === 1 ? '禁用' : '启用'; + const warnMsg = currentStatus === 1 + ? `禁用后该管理员将无法登录,确定要禁用 "${realName}" 吗?` + : `确定要重新启用管理员 "${realName}" 吗?`; + if (!confirm(warnMsg)) { return; } - const res = await apiDelete(`/api/admin/delete/${userId}`); + const res = await apiPut(`/api/admin/toggle-status/${userId}`); if (res && res.success) { - showToast('管理员删除成功'); + showToast(res.message || `管理员已${action}`); loadAdmins(); } else { - showToast(res?.message || '删除失败', 'error'); + showToast(res?.message || '操作失败', 'error'); } } @@ -138,7 +146,7 @@ loadAdmins(); window.loadAdmins = loadAdmins; window.showEditAdminModal = showEditAdminModal; window.submitEditAdmin = submitEditAdmin; -window.deleteAdmin = deleteAdmin; +window.toggleAdminStatus = toggleAdminStatus; window.resetAdminPassword = resetAdminPassword; window.unlockUser = unlockUser; window.submitResetPassword = submitResetPassword; diff --git a/frontend/assets/js/history.js b/frontend/assets/js/history.js index 766cf2a..0985473 100644 --- a/frontend/assets/js/history.js +++ b/frontend/assets/js/history.js @@ -22,6 +22,11 @@ function escapeHtml(str) { return el.innerHTML; } +function typeMap(relatedType) { + var map = { manual: '手动', homework: '作业', attendance: '考勤' }; + return map[relatedType] || '手动'; +} + async function loadStudentsForSelect() { const res = await apiGet('/api/admin/students', {page_size: 1000}); if (res && res.success) { @@ -117,12 +122,12 @@ async function loadHistory(page) { var nowrapStyle = ' style="white-space:nowrap;min-width:80px;"'; var headHtml = ''; if (isGrouped) { - headHtml = '操作人'; + headHtml = '操作人'; if (role === '班主任' || role === '班长') { headHtml += ''; } } else { - headHtml = '操作人'; + headHtml = '操作人'; if (role === '班主任' || role === '班长' || role === '考勤委员') { headHtml += ''; } @@ -133,15 +138,17 @@ async function loadHistory(page) { if (isGrouped) { res.data.records.forEach(function(record) { var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + var typeLabel = typeMap(record.related_type); var names = record.student_names || ''; var allRevoked = record.all_revoked; var revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; html += '' + - '' + - '' + + '' + '' + + '' + + '' + '' + - ''; + ''; if (role === '班主任' || role === '班长') { if (allRevoked) { html += ''; @@ -152,19 +159,21 @@ async function loadHistory(page) { html += ''; }); if (res.data.records.length === 0) { - var colSpan = (role === '班主任' || role === '班长') ? 6 : 5; + var colSpan = (role === '班主任' || role === '班长') ? 7 : 6; html = ''; } } else { res.data.records.forEach(function(record) { var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + var typeLabel = typeMap(record.related_type); var revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; html += '' + - '' + - '' + + '' + '' + '' + - ''; + '' + + '' + + ''; if (role === '班主任') { if (record.is_revoked == 1) { var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; @@ -192,7 +201,7 @@ async function loadHistory(page) { }); if (res.data.records.length === 0) { - var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 6 : 5; + var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 7 : 6; html = ''; } } diff --git a/frontend/assets/js/homework-manage.js b/frontend/assets/js/homework-manage.js index e4256ad..681b4f9 100644 --- a/frontend/assets/js/homework-manage.js +++ b/frontend/assets/js/homework-manage.js @@ -30,7 +30,8 @@ document.getElementById('pointsChange').setAttribute('max', hwMaxPoints); async function loadSubjectsForHomework() { const subjectSelect = document.getElementById('hwSubjectSelect'); if (!subjectSelect) return; - const res = await apiGet('/api/subject/list'); + // 作业下拉只显示已启用的科目 + const res = await apiGet('/api/subject/list', { is_active: true }); if (res && res.success && res.data && res.data.subjects) { let html = ''; res.data.subjects.forEach(s => { @@ -126,7 +127,7 @@ function toggleSubjectPanel() { } async function loadSubjectList() { - const res = await apiGet('/api/subject/list', { is_active: true }); + const res = await apiGet('/api/subject/list'); if (res && res.success && res.data) { let html = ''; const subjects = res.data.subjects || []; diff --git a/frontend/assets/js/modules/admin-mgmt.js b/frontend/assets/js/modules/admin-mgmt.js index 3f709a1..48ac39b 100644 --- a/frontend/assets/js/modules/admin-mgmt.js +++ b/frontend/assets/js/modules/admin-mgmt.js @@ -38,8 +38,12 @@ }); if (res && res.success) { - let msg = `管理员 ${res.data.username} 添加成功`; - if (res.data.password) msg += `,密码: ${res.data.password}`; + let msg; + if (res.data.password) { + msg = `管理员 ${res.data.username} 添加成功,密码: ${res.data.password}`; + } else { + msg = `管理员 ${res.data.username} 已重新激活(原密码不变)`; + } showToast(msg); closeModal('addAdminModal'); loadAdmins(); diff --git a/frontend/assets/js/student-homework.js b/frontend/assets/js/student-homework.js index 4ff9d5e..e98498b 100644 --- a/frontend/assets/js/student-homework.js +++ b/frontend/assets/js/student-homework.js @@ -19,15 +19,17 @@ async function loadHomework() { res.data.homework.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsColor = record.points_change > 0 ? '#38a169' : '#e53e3e'; + const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '作业'; html += ` - + + `; }); if (res.data.homework.length === 0) { - html = ''; + html = ''; } document.getElementById('homeworkList').innerHTML = html; } diff --git a/frontend/parent/history.php b/frontend/parent/history.php index f6061b3..25be726 100644 --- a/frontend/parent/history.php +++ b/frontend/parent/history.php @@ -34,14 +34,15 @@ include __DIR__ . '/../includes/header.php';
时间学生分数变动类型分值 原因学生 操作人时间 操作
${escapeHtml(admin.username)} ${escapeHtml(admin.real_name)} ${escapeHtml(admin.role_type)}${statusText}
@@ -29,14 +33,14 @@ async function loadAdmins() { 编辑 重置密码 解锁 - 删除 + ${isActive ? '禁用' : '启用'}
暂无管理员
暂无管理员
时间原因分值涉及学生类型分值原因学生名单时间操作时间学生分数变动原因类型分值原因学生时间操作' + formatDateTime(record.created_at) + '' + escapeHtml(record.reason) + '' + typeLabel + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '×' + record.student_count + '' + escapeHtml(record.reason) + '' + escapeHtml(names) + '' + escapeHtml(record.recorder_name || '') + '' + escapeHtml(names) + '' + formatDateTime(record.created_at) + '已撤销
暂无记录
' + formatDateTime(record.created_at) + '' + escapeHtml(record.student_name) + '' + typeLabel + '' + (record.points_change > 0 ? '+' : '') + record.points_change + '' + escapeHtml(record.reason) + '' + escapeHtml(record.recorder_name) + '' + escapeHtml(record.student_name) + '' + escapeHtml(record.recorder_name) + '' + formatDateTime(record.created_at) + '
暂无记录
${formatDateTime(record.created_at)}${typeLabel} ${record.points_change > 0 ? '+' : ''}${record.points_change} ${escapeHtml(record.reason)} ${escapeHtml(record.recorder_name || '-')}${formatDateTime(record.created_at)}
📝 暂无作业扣分记录
📝 暂无作业扣分记录
- - + + + - +
日期原因类型 分值原因 记录人日期
加载中...
加载中...
@@ -73,18 +74,20 @@ const pageSize = 20; async function loadHistory(page) { const res = await apiGet('/api/parent/child/history', { page: page, page_size: pageSize }); if (res && res.success) { - let html = ''; if (res.data.records.length === 0) { - html = '暂无记录'; + html = '暂无记录'; } else { res.data.records.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsText = record.points_change > 0 ? `+${record.points_change}` : record.points_change; + const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '手动'; html += ` - ${formatDateTime(record.created_at)} - ${escapeHtml(record.reason || '-')} + ${typeLabel} ${pointsText} + ${escapeHtml(record.reason || '-')} 班主任 + ${formatDateTime(record.created_at)} + `; `; }); } diff --git a/frontend/student/dashboard.php b/frontend/student/dashboard.php index 8c3fb29..f3ad702 100644 --- a/frontend/student/dashboard.php +++ b/frontend/student/dashboard.php @@ -140,10 +140,11 @@ include __DIR__ . '/../includes/header.php'; - + + @@ -351,21 +352,23 @@ include __DIR__ . '/../includes/header.php'; if (res && res.success) { document.getElementById('conductTotalPoints').textContent = res.data.total_points; - let html = '
时间类型 分值 原因 操作人时间
'; + let html = '
时间分数变动原因操作人
'; res.data.records.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const recorderDisplay = record.points_change < 0 ? '班主任' : escapeHtml(record.recorder_name || '班主任'); + const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '手动'; html += ` - + + `; }); if (res.data.records.length === 0) { - html += ''; + html += ''; } html += '
类型分值原因操作人时间
${formatDateTime(record.created_at)}${typeLabel} ${record.points_change > 0 ? '+' : ''}${record.points_change} ${escapeHtml(record.reason)} ${recorderDisplay}${formatDateTime(record.created_at)}
暂无记录
暂无记录
'; document.getElementById('conductRecords').innerHTML = html; @@ -391,20 +394,22 @@ include __DIR__ . '/../includes/header.php'; try { const res = await apiGet(`/api/student/homework/${STUDENT_ID}`); if (res && res.success) { - let html = ''; res.data.homework.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; + const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '作业'; html += ` - ${formatDateTime(record.created_at)} + ${typeLabel} ${record.points_change > 0 ? '+' : ''}${record.points_change} ${escapeHtml(record.reason)} ${escapeHtml(record.recorder_name || '-')} + ${formatDateTime(record.created_at)} `; }); if (res.data.homework.length === 0) { - html = '暂无作业扣分记录'; + html = '暂无作业扣分记录'; + } } document.getElementById('homeworkList').innerHTML = html; } diff --git a/frontend/student/homework.php b/frontend/student/homework.php index fe7c9de..9cea791 100644 --- a/frontend/student/homework.php +++ b/frontend/student/homework.php @@ -36,7 +36,7 @@ include __DIR__ . '/../includes/header.php';
- +
时间分值原因操作人
类型分值原因操作人时间
diff --git a/sql/upgrades/v2.9.sql b/sql/upgrades/v2.9.sql new file mode 100644 index 0000000..b25cd7d --- /dev/null +++ b/sql/upgrades/v2.9.sql @@ -0,0 +1,11 @@ +-- =========================================== +-- 班级操行分管理系统 - v2.8 → v2.9 升级脚本 +-- 字符集: utf8mb4 +-- +-- 说明: v2.9 为UI优化版本,无数据库 schema 变更。 +-- 主要变更: +-- 1. 优化历史记录表头顺序(合并视图 + 非合并视图) +-- 2. 新增"类型"列显示 manual/homework/attendance +-- 3. 管理员"删除"改为"禁用/启用"切换 +-- 4. 科目管理面板显示全部科目(禁用/启用均可见) +-- =========================================== diff --git a/upgrade.php b/upgrade.php index 3316354..c8b7d9e 100644 --- a/upgrade.php +++ b/upgrade.php @@ -32,6 +32,7 @@ $UPGRADE_VERSIONS = [ '2.6' => __DIR__ . '/sql/upgrades/v2.6.sql', '2.7' => __DIR__ . '/sql/upgrades/v2.7.sql', '2.8' => __DIR__ . '/sql/upgrades/v2.8.sql', + '2.9' => __DIR__ . '/sql/upgrades/v2.9.sql', ]; /**