v2.9update

This commit is contained in:
2026-06-08 10:40:59 +08:00
parent 8d497d73d2
commit 70e7ad8e5e
20 changed files with 162 additions and 74 deletions

View File

@@ -26,7 +26,7 @@
- 作业管理:发布作业、查看提交情况 - 作业管理:发布作业、查看提交情况
- 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30记录考勤、自定义考勤扣分值 - 考勤管理:按时段(早上 7:15/中午 14:00/晚修 19:30记录考勤、自定义考勤扣分值
- 科目管理:动态增删学科 - 科目管理:动态增删学科
- 管理员管理:添加/编辑/删除/重置密码班长/科代表/考勤委员/劳动委员/志愿委员 - 管理员管理:添加/编辑/禁用-启用/重置密码班长/科代表/考勤委员/劳动委员/志愿委员
- 学期管理:创建/编辑/删除学期、激活学期、归档学期(含考勤/作业统计快照)、关联历史记录、归档后可选重置分数 - 学期管理:创建/编辑/删除学期、激活学期、归档学期(含考勤/作业统计快照)、关联历史记录、归档后可选重置分数
- 排行榜百分比筛选在排行榜上方输入百分比筛选显示前N%的学生(抹零法) - 排行榜百分比筛选在排行榜上方输入百分比筛选显示前N%的学生(抹零法)
- 数据导出:导出历史记录、导出德育分记录(含加分/减分历史) - 数据导出:导出历史记录、导出德育分记录(含加分/减分历史)
@@ -279,6 +279,8 @@ classmanager/
| v2.5.1 | 2026.5.29 | 筛选学生时自动取消合并记录、合并记录选项样式修复竖排显示、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 | | v2.5.1 | 2026.5.29 | 筛选学生时自动取消合并记录、合并记录选项样式修复竖排显示、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 |
| v2.6 | 2026.5.29 | 历史记录筛选面板重构(学生筛选移入面板、合并按钮始终可见)、新增科目下拉筛选、新增原因关键词搜索、科目删除后默认仅显示启用科目、筛选面板展开修复 | | v2.6 | 2026.5.29 | 历史记录筛选面板重构(学生筛选移入面板、合并按钮始终可见)、新增科目下拉筛选、新增原因关键词搜索、科目删除后默认仅显示启用科目、筛选面板展开修复 |
| v2.7 | 2026.5.29 | 操作菜单定位修复、历史记录显示修复、筛选面板修复、科目删除修复、学期周数列修复、escapeHtml 转义修复 | | v2.7 | 2026.5.29 | 操作菜单定位修复、历史记录显示修复、筛选面板修复、科目删除修复、学期周数列修复、escapeHtml 转义修复 |
| v2.8 | 2026.6.2 | 管理员软删除改为禁用/启用切换保留admin_roles记录、科目管理面板显示全部科目禁用/启用均可见、添加管理员支持重新激活已禁用账号、升级系统补全v2.8版本号 |
| v2.9 | 2026.6.8 | 历史记录表头优化:合并/非合并视图统一新增"类型"列(手动/作业/考勤),列顺序统一为"类型、分值、原因、学生、操作人、时间" |
## 许可证 ## 许可证

View File

@@ -1 +1 @@
2.8 2.9

View File

@@ -30,9 +30,9 @@ class AdminRoleModel:
@staticmethod @staticmethod
async def get_all() -> List[Dict[str, Any]]: async def get_all() -> List[Dict[str, Any]]:
sql = """ 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 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 LEFT JOIN subjects s ON ar.subject_id = s.subject_id
ORDER BY ar.role_type ORDER BY ar.role_type
""" """

View File

@@ -289,6 +289,7 @@ class ConductModel:
cr.points_change, cr.points_change,
cr.reason, cr.reason,
cr.recorder_name, 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, 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, GROUP_CONCAT(s.name ORDER BY s.student_id SEPARATOR ', ') as student_names,
COUNT(*) as student_count, COUNT(*) as student_count,

View File

@@ -93,6 +93,17 @@ class UserModel:
""" """
await execute_update(sql, (ip, user_id)) 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 @staticmethod
async def check_username_exists(username: str) -> bool: async def check_username_exists(username: str) -> bool:
"""检查用户名是否存在""" """检查用户名是否存在"""

View File

@@ -500,11 +500,13 @@ async def add_admin(request: Request, req: AddAdminRequest):
operator_id=user["user_id"] operator_id=user["user_id"]
) )
if result["success"]: if result["success"]:
is_reactivation = result.get("password") is None
await LogService.write_operation_log( await LogService.write_operation_log(
operator_id=user["user_id"], operator_name=user["real_name"], 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", 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 ip=request.client.host
) )
return success_response(data=result, message="管理员添加成功") 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="更新失败或管理员不存在") return error_response(message="更新失败或管理员不存在")
@router.delete("/delete/{user_id}") @router.put("/toggle-status/{user_id}")
async def delete_admin(request: Request, user_id: int): async def toggle_admin_status(request: Request, user_id: int):
"""删除管理员(班主任)""" """启用/禁用管理员(班主任),保留角色记录"""
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)
# 防止删除自己 # 防止禁用自己
if user_id == user["user_id"]: 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.admin_role import AdminRoleModel
from models.user import UserModel from models.user import UserModel
# 先删除角色记录 target_user = await UserModel.get_by_user_id(user_id)
role_deleted = await AdminRoleModel.delete(user_id) if not target_user or target_user.get("user_type") != "admin":
if role_deleted: return error_response(message="管理员不存在", code=404)
# 再删除用户账号(软删除,将状态设为禁用)
await UserModel.update_status(user_id, 0) # 检查是否有管理员角色
await LogService.write_operation_log( admin_role = await AdminRoleModel.get_by_user_id(user_id)
operator_id=user["user_id"], operator_name=user["real_name"], if not admin_role:
operator_role="班主任", operation_type="delete_admin", return error_response(message="该用户无管理员角色", code=400)
target_type="admin", target_id=user_id,
details=f"删除管理员: ID={user_id}", new_status = 0 if target_user["status"] == 1 else 1
ip=request.client.host action_text = "禁用" if new_status == 0 else "启用"
)
return success_response(message="管理员删除成功") await UserModel.update_status(user_id, new_status)
else:
return error_response(message="删除失败或管理员不存在") 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}") @router.post("/reset-password/{user_id}")

View File

@@ -41,6 +41,7 @@ ALL_VERSIONS = {
'2.6': 'v2.6.sql', '2.6': 'v2.6.sql',
'2.7': 'v2.7.sql', '2.7': 'v2.7.sql',
'2.8': 'v2.8.sql', '2.8': 'v2.8.sql',
'2.9': 'v2.9.sql',
} }
# 版本特征标记(按优先级从高到低) # 版本特征标记(按优先级从高到低)
VERSION_MARKERS = [ VERSION_MARKERS = [

View File

@@ -245,10 +245,30 @@ class AdminService:
role_type: str, role_type: str,
operator_id: int operator_id: int
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""添加管理员""" """添加管理员(支持重新激活已删除的管理员)"""
existing = await UserModel.get_by_username(username) # 检查用户名是否存在(含已禁用用户,因 username 有 UNIQUE 约束)
existing = await UserModel.get_by_username_any(username)
if existing: 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: if not password:
password = security.generate_random_password() password = security.generate_random_password()
@@ -274,7 +294,6 @@ class AdminService:
"password": password, "password": password,
"role_type": role_type "role_type": role_type
} }
@staticmethod @staticmethod
async def get_admins() -> Dict[str, Any]: async def get_admins() -> Dict[str, Any]:
"""获取管理员列表""" """获取管理员列表"""

View File

@@ -37,7 +37,7 @@ include __DIR__ . '/../includes/header.php';
<div class="table-wrapper"> <div class="table-wrapper">
<table class="table"> <table class="table">
<thead> <thead>
<tr><th>用户名</th><th>姓名</th><th>角色</th><th>操作</th></tr> <tr><th>用户名</th><th>姓名</th><th>角色</th><th>状态</th><th>操作</th></tr>
</thead> </thead>
<tbody id="adminList"></tbody> <tbody id="adminList"></tbody>
</table> </table>

View File

@@ -93,11 +93,12 @@ include __DIR__ . '/../includes/header.php';
<table class="table"> <table class="table">
<thead> <thead>
<tr id="historyTableHead"> <tr id="historyTableHead">
<th>时间</th> <th>类型</th>
<th>学生</th> <th>分值</th>
<th>分数变动</th>
<th>原因</th> <th>原因</th>
<th>学生</th>
<th style="white-space: nowrap; min-width: 80px;">操作人</th> <th style="white-space: nowrap; min-width: 80px;">操作人</th>
<th>时间</th>
<?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员'): ?> <?php if ($role === '班主任' || $role === '班长' || $role === '考勤委员'): ?>
<th>操作</th> <th>操作</th>
<?php endif; ?> <?php endif; ?>

View File

@@ -18,10 +18,14 @@ async function loadAdmins() {
if (res && res.success) { if (res && res.success) {
let html = ''; let html = '';
res.data.admins.forEach(admin => { res.data.admins.forEach(admin => {
const isActive = admin.status === 1;
const statusClass = isActive ? 'subject-status-active' : 'subject-status-inactive';
const statusText = isActive ? '启用' : '禁用';
html += `<tr> html += `<tr>
<td>${escapeHtml(admin.username)}</td> <td>${escapeHtml(admin.username)}</td>
<td>${escapeHtml(admin.real_name)}</td> <td>${escapeHtml(admin.real_name)}</td>
<td>${escapeHtml(admin.role_type)}</td> <td>${escapeHtml(admin.role_type)}</td>
<td><span class="subject-status ${statusClass}">${statusText}</span></td>
<td> <td>
<div class="action-dropdown"> <div class="action-dropdown">
<button class="btn btn-sm action-dropdown-toggle" onclick="toggleActionDropdown(this)">操作 ▼</button> <button class="btn btn-sm action-dropdown-toggle" onclick="toggleActionDropdown(this)">操作 ▼</button>
@@ -29,14 +33,14 @@ async function loadAdmins() {
<a onclick="showEditAdminModal(${admin.user_id}, '${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}', '${escapeHtml(admin.role_type)}')">编辑</a> <a onclick="showEditAdminModal(${admin.user_id}, '${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}', '${escapeHtml(admin.role_type)}')">编辑</a>
<a onclick="resetAdminPassword(${admin.user_id}, '${escapeHtml(admin.real_name)}')">重置密码</a> <a onclick="resetAdminPassword(${admin.user_id}, '${escapeHtml(admin.real_name)}')">重置密码</a>
<a onclick="unlockUser('${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}')">解锁</a> <a onclick="unlockUser('${escapeHtml(admin.username)}', '${escapeHtml(admin.real_name)}')">解锁</a>
<a class="danger" onclick="deleteAdmin(${admin.user_id}, '${escapeHtml(admin.real_name)}')">删除</a> <a class="${isActive ? 'danger' : ''}" onclick="toggleAdminStatus(${admin.user_id}, '${escapeHtml(admin.real_name)}', ${isActive ? 1 : 0})">${isActive ? '禁用' : '启用'}</a>
</div> </div>
</div> </div>
</td> </td>
</tr>`; </tr>`;
}); });
if (res.data.admins.length === 0) { if (res.data.admins.length === 0) {
html = '<tr><td colspan="4" style="text-align:center;">暂无管理员</td></tr>'; html = '<tr><td colspan="5" style="text-align:center;">暂无管理员</td></tr>';
} }
document.getElementById('adminList').innerHTML = html; document.getElementById('adminList').innerHTML = html;
} }
@@ -74,17 +78,21 @@ async function submitEditAdmin() {
} }
} }
async function deleteAdmin(userId, realName) { async function toggleAdminStatus(userId, realName, currentStatus) {
if (!confirm(`确定要删除管理员 "${realName}" 吗?此操作不可恢复。`)) { const action = currentStatus === 1 ? '禁用' : '启用';
const warnMsg = currentStatus === 1
? `禁用后该管理员将无法登录,确定要禁用 "${realName}" 吗?`
: `确定要重新启用管理员 "${realName}" 吗?`;
if (!confirm(warnMsg)) {
return; return;
} }
const res = await apiDelete(`/api/admin/delete/${userId}`); const res = await apiPut(`/api/admin/toggle-status/${userId}`);
if (res && res.success) { if (res && res.success) {
showToast('管理员删除成功'); showToast(res.message || `管理员已${action}`);
loadAdmins(); loadAdmins();
} else { } else {
showToast(res?.message || '删除失败', 'error'); showToast(res?.message || '操作失败', 'error');
} }
} }
@@ -138,7 +146,7 @@ loadAdmins();
window.loadAdmins = loadAdmins; window.loadAdmins = loadAdmins;
window.showEditAdminModal = showEditAdminModal; window.showEditAdminModal = showEditAdminModal;
window.submitEditAdmin = submitEditAdmin; window.submitEditAdmin = submitEditAdmin;
window.deleteAdmin = deleteAdmin; window.toggleAdminStatus = toggleAdminStatus;
window.resetAdminPassword = resetAdminPassword; window.resetAdminPassword = resetAdminPassword;
window.unlockUser = unlockUser; window.unlockUser = unlockUser;
window.submitResetPassword = submitResetPassword; window.submitResetPassword = submitResetPassword;

View File

@@ -22,6 +22,11 @@ function escapeHtml(str) {
return el.innerHTML; return el.innerHTML;
} }
function typeMap(relatedType) {
var map = { manual: '手动', homework: '作业', attendance: '考勤' };
return map[relatedType] || '手动';
}
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});
if (res && res.success) { if (res && res.success) {
@@ -117,12 +122,12 @@ async function loadHistory(page) {
var nowrapStyle = ' style="white-space:nowrap;min-width:80px;"'; var nowrapStyle = ' style="white-space:nowrap;min-width:80px;"';
var headHtml = ''; var headHtml = '';
if (isGrouped) { if (isGrouped) {
headHtml = '<th>时间</th><th>原因</th><th>分值</th><th' + nowrapStyle + '>操作人</th><th>涉及学生</th>'; headHtml = '<th>类型</th><th>分值</th><th>原因</th><th>学生名单</th><th' + nowrapStyle + '>操作人</th><th>时间</th>';
if (role === '班主任' || role === '班长') { if (role === '班主任' || role === '班长') {
headHtml += '<th>操作</th>'; headHtml += '<th>操作</th>';
} }
} else { } else {
headHtml = '<th>时间</th><th>学生</th><th>分数变动</th><th>原因</th><th' + nowrapStyle + '>操作人</th>'; headHtml = '<th>类型</th><th>分值</th><th>原因</th><th>学生</th><th' + nowrapStyle + '>操作人</th><th>时间</th>';
if (role === '班主任' || role === '班长' || role === '考勤委员') { if (role === '班主任' || role === '班长' || role === '考勤委员') {
headHtml += '<th>操作</th>'; headHtml += '<th>操作</th>';
} }
@@ -133,15 +138,17 @@ async function loadHistory(page) {
if (isGrouped) { if (isGrouped) {
res.data.records.forEach(function(record) { res.data.records.forEach(function(record) {
var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; var pointsClass = record.points_change > 0 ? 'plus' : 'minus';
var typeLabel = typeMap(record.related_type);
var names = record.student_names || ''; var names = record.student_names || '';
var allRevoked = record.all_revoked; var allRevoked = record.all_revoked;
var revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : ''; var revokedStyle = allRevoked ? ' style="opacity:0.5;text-decoration:line-through;"' : '';
html += '<tr' + revokedStyle + '>' + html += '<tr' + revokedStyle + '>' +
'<td class="history-time">' + formatDateTime(record.created_at) + '</td>' + '<td>' + typeLabel + '</td>' +
'<td class="history-reason">' + escapeHtml(record.reason) + '</td>' +
'<td class="' + pointsClass + '">' + (record.points_change > 0 ? '+' : '') + record.points_change + '&times;' + record.student_count + '</td>' + '<td class="' + pointsClass + '">' + (record.points_change > 0 ? '+' : '') + record.points_change + '&times;' + record.student_count + '</td>' +
'<td class="history-reason">' + escapeHtml(record.reason) + '</td>' +
'<td class="history-students">' + escapeHtml(names) + '</td>' +
'<td>' + escapeHtml(record.recorder_name || '') + '</td>' + '<td>' + escapeHtml(record.recorder_name || '') + '</td>' +
'<td class="history-students">' + escapeHtml(names) + '</td>'; '<td class="history-time">' + formatDateTime(record.created_at) + '</td>';
if (role === '班主任' || role === '班长') { if (role === '班主任' || role === '班长') {
if (allRevoked) { if (allRevoked) {
html += '<td><span class="text-muted">已撤销</span></td>'; html += '<td><span class="text-muted">已撤销</span></td>';
@@ -152,19 +159,21 @@ async function loadHistory(page) {
html += '</tr>'; html += '</tr>';
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
var colSpan = (role === '班主任' || role === '班长') ? 6 : 5; var colSpan = (role === '班主任' || role === '班长') ? 7 : 6;
html = '<tr><td colspan="' + colSpan + '" style="text-align:center;">暂无记录</td></tr>'; html = '<tr><td colspan="' + colSpan + '" style="text-align:center;">暂无记录</td></tr>';
} }
} else { } else {
res.data.records.forEach(function(record) { res.data.records.forEach(function(record) {
var pointsClass = record.points_change > 0 ? 'plus' : 'minus'; 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;"' : ''; var revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5;text-decoration:line-through;"' : '';
html += '<tr' + revokedStyle + '>' + html += '<tr' + revokedStyle + '>' +
'<td class="history-time">' + formatDateTime(record.created_at) + '</td>' + '<td>' + typeLabel + '</td>' +
'<td>' + escapeHtml(record.student_name) + '</td>' +
'<td class="' + pointsClass + '">' + (record.points_change > 0 ? '+' : '') + record.points_change + '</td>' + '<td class="' + pointsClass + '">' + (record.points_change > 0 ? '+' : '') + record.points_change + '</td>' +
'<td class="history-reason">' + escapeHtml(record.reason) + '</td>' + '<td class="history-reason">' + escapeHtml(record.reason) + '</td>' +
'<td>' + escapeHtml(record.recorder_name) + '</td>'; '<td>' + escapeHtml(record.student_name) + '</td>' +
'<td>' + escapeHtml(record.recorder_name) + '</td>' +
'<td class="history-time">' + formatDateTime(record.created_at) + '</td>';
if (role === '班主任') { if (role === '班主任') {
if (record.is_revoked == 1) { if (record.is_revoked == 1) {
var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销'; var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销';
@@ -192,7 +201,7 @@ async function loadHistory(page) {
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 6 : 5; var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 7 : 6;
html = '<tr><td colspan="' + colSpan + '" style="text-align:center;">暂无记录</td></tr>'; html = '<tr><td colspan="' + colSpan + '" style="text-align:center;">暂无记录</td></tr>';
} }
} }

View File

@@ -30,7 +30,8 @@ document.getElementById('pointsChange').setAttribute('max', hwMaxPoints);
async function loadSubjectsForHomework() { async function loadSubjectsForHomework() {
const subjectSelect = document.getElementById('hwSubjectSelect'); const subjectSelect = document.getElementById('hwSubjectSelect');
if (!subjectSelect) return; 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) { if (res && res.success && res.data && res.data.subjects) {
let html = '<option value="">不选择科目</option>'; let html = '<option value="">不选择科目</option>';
res.data.subjects.forEach(s => { res.data.subjects.forEach(s => {
@@ -126,7 +127,7 @@ function toggleSubjectPanel() {
} }
async function loadSubjectList() { 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) { if (res && res.success && res.data) {
let html = ''; let html = '';
const subjects = res.data.subjects || []; const subjects = res.data.subjects || [];

View File

@@ -38,8 +38,12 @@
}); });
if (res && res.success) { if (res && res.success) {
let msg = `管理员 ${res.data.username} 添加成功`; let msg;
if (res.data.password) msg += `,密码: ${res.data.password}`; if (res.data.password) {
msg = `管理员 ${res.data.username} 添加成功,密码: ${res.data.password}`;
} else {
msg = `管理员 ${res.data.username} 已重新激活(原密码不变)`;
}
showToast(msg); showToast(msg);
closeModal('addAdminModal'); closeModal('addAdminModal');
loadAdmins(); loadAdmins();

View File

@@ -19,15 +19,17 @@ async function loadHomework() {
res.data.homework.forEach(record => { res.data.homework.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const pointsColor = record.points_change > 0 ? '#38a169' : '#e53e3e'; const pointsColor = record.points_change > 0 ? '#38a169' : '#e53e3e';
const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '作业';
html += `<tr> html += `<tr>
<td>${formatDateTime(record.created_at)}</td> <td>${typeLabel}</td>
<td style="color: ${pointsColor}; font-weight: bold;">${record.points_change > 0 ? '+' : ''}${record.points_change}</td> <td style="color: ${pointsColor}; font-weight: bold;">${record.points_change > 0 ? '+' : ''}${record.points_change}</td>
<td>${escapeHtml(record.reason)}</td> <td>${escapeHtml(record.reason)}</td>
<td>${escapeHtml(record.recorder_name || '-')}</td> <td>${escapeHtml(record.recorder_name || '-')}</td>
<td>${formatDateTime(record.created_at)}</td>
</tr>`; </tr>`;
}); });
if (res.data.homework.length === 0) { if (res.data.homework.length === 0) {
html = '<tr><td colspan="4" style="text-align:center; padding: 40px; color: #999;">📝 暂无作业扣分记录</td></tr>'; html = '<tr><td colspan="5" style="text-align:center; padding: 40px; color: #999;">📝 暂无作业扣分记录</td></tr>';
} }
document.getElementById('homeworkList').innerHTML = html; document.getElementById('homeworkList').innerHTML = html;
} }

View File

@@ -34,14 +34,15 @@ include __DIR__ . '/../includes/header.php';
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>日期</th> <th>类型</th>
<th>原因</th>
<th>分值</th> <th>分值</th>
<th>原因</th>
<th>记录人</th> <th>记录人</th>
<th>日期</th>
</tr> </tr>
</thead> </thead>
<tbody id="historyList"> <tbody id="historyList">
<tr><td colspan="4" style="text-align:center;">加载中...</td></tr> <tr><td colspan="5" style="text-align:center;">加载中...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -73,18 +74,20 @@ const pageSize = 20;
async function loadHistory(page) { async function loadHistory(page) {
const res = await apiGet('/api/parent/child/history', { page: page, page_size: pageSize }); const res = await apiGet('/api/parent/child/history', { page: page, page_size: pageSize });
if (res && res.success) { if (res && res.success) {
let html = '';
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
html = '<tr><td colspan="4" style="text-align:center;">暂无记录</td></tr>'; html = '<tr><td colspan="5" style="text-align:center;">暂无记录</td></tr>';
} else { } else {
res.data.records.forEach(record => { res.data.records.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const pointsText = record.points_change > 0 ? `+${record.points_change}` : record.points_change; const pointsText = record.points_change > 0 ? `+${record.points_change}` : record.points_change;
const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '手动';
html += `<tr> html += `<tr>
<td>${formatDateTime(record.created_at)}</td> <td>${typeLabel}</td>
<td class="history-reason">${escapeHtml(record.reason || '-')}</td>
<td><span class="record-points ${pointsClass}">${pointsText}</span></td> <td><span class="record-points ${pointsClass}">${pointsText}</span></td>
<td class="history-reason">${escapeHtml(record.reason || '-')}</td>
<td>班主任</td> <td>班主任</td>
<td>${formatDateTime(record.created_at)}</td>
</tr>`;
</tr>`; </tr>`;
}); });
} }

View File

@@ -140,10 +140,11 @@ include __DIR__ . '/../includes/header.php';
<table> <table>
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>类型</th>
<th>分值</th> <th>分值</th>
<th>原因</th> <th>原因</th>
<th>操作人</th> <th>操作人</th>
<th>时间</th>
</tr> </tr>
</thead> </thead>
<tbody id="homeworkList"></tbody> <tbody id="homeworkList"></tbody>
@@ -351,21 +352,23 @@ include __DIR__ . '/../includes/header.php';
if (res && res.success) { if (res && res.success) {
document.getElementById('conductTotalPoints').textContent = res.data.total_points; document.getElementById('conductTotalPoints').textContent = res.data.total_points;
let html = '<div class="table-wrapper"><table><thead><tr><th>时间</th><th>分数变动</th><th>原因</th><th>操作人</th></tr></thead><tbody>'; let html = '<div class="table-wrapper"><table><thead><tr><th>类型</th><th>分</th><th>原因</th><th>操作人</th><th>时间</th></tr></thead><tbody>';
res.data.records.forEach(record => { res.data.records.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const recorderDisplay = record.points_change < 0 ? '班主任' : escapeHtml(record.recorder_name || '班主任'); const recorderDisplay = record.points_change < 0 ? '班主任' : escapeHtml(record.recorder_name || '班主任');
const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '手动';
html += ` html += `
<tr> <tr>
<td>${formatDateTime(record.created_at)}</td> <td>${typeLabel}</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>${escapeHtml(record.reason)}</td> <td>${escapeHtml(record.reason)}</td>
<td>${recorderDisplay}</td> <td>${recorderDisplay}</td>
<td>${formatDateTime(record.created_at)}</td>
</tr> </tr>
`; `;
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
html += '<tr><td colspan="4" style="text-align:center;">暂无记录</td></tr>'; html += '<tr><td colspan="5" style="text-align:center;">暂无记录</td></tr>';
} }
html += '</tbody></table></div>'; html += '</tbody></table></div>';
document.getElementById('conductRecords').innerHTML = html; document.getElementById('conductRecords').innerHTML = html;
@@ -391,20 +394,22 @@ include __DIR__ . '/../includes/header.php';
try { try {
const res = await apiGet(`/api/student/homework/${STUDENT_ID}`); const res = await apiGet(`/api/student/homework/${STUDENT_ID}`);
if (res && res.success) { if (res && res.success) {
let html = '';
res.data.homework.forEach(record => { res.data.homework.forEach(record => {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const typeLabel = { manual: '手动', homework: '作业', attendance: '考勤' }[record.related_type] || '作业';
html += ` html += `
<tr> <tr>
<td>${formatDateTime(record.created_at)}</td> <td>${typeLabel}</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>${escapeHtml(record.reason)}</td> <td>${escapeHtml(record.reason)}</td>
<td>${escapeHtml(record.recorder_name || '-')}</td> <td>${escapeHtml(record.recorder_name || '-')}</td>
<td>${formatDateTime(record.created_at)}</td>
</tr> </tr>
`; `;
}); });
if (res.data.homework.length === 0) { if (res.data.homework.length === 0) {
html = '<tr><td colspan="4" style="text-align:center;">暂无作业扣分记录</td></tr>'; html = '<tr><td colspan="5" style="text-align:center;">暂无作业扣分记录</td></tr>';
}
} }
document.getElementById('homeworkList').innerHTML = html; document.getElementById('homeworkList').innerHTML = html;
} }

View File

@@ -36,7 +36,7 @@ include __DIR__ . '/../includes/header.php';
<div class="table-wrapper"> <div class="table-wrapper">
<table class="table"> <table class="table">
<thead> <thead>
<tr><th>时间</th><th>分值</th><th>原因</th><th>操作人</th></tr> <tr><th>类型</th><th>分值</th><th>原因</th><th>操作人</th><th>时间</th></tr>
</thead> </thead>
<tbody id="homeworkList"></tbody> <tbody id="homeworkList"></tbody>
</table> </table>

11
sql/upgrades/v2.9.sql Normal file
View File

@@ -0,0 +1,11 @@
-- ===========================================
-- 班级操行分管理系统 - v2.8 → v2.9 升级脚本
-- 字符集: utf8mb4
--
-- 说明: v2.9 为UI优化版本无数据库 schema 变更。
-- 主要变更:
-- 1. 优化历史记录表头顺序(合并视图 + 非合并视图)
-- 2. 新增"类型"列显示 manual/homework/attendance
-- 3. 管理员"删除"改为"禁用/启用"切换
-- 4. 科目管理面板显示全部科目(禁用/启用均可见)
-- ===========================================

View File

@@ -32,6 +32,7 @@ $UPGRADE_VERSIONS = [
'2.6' => __DIR__ . '/sql/upgrades/v2.6.sql', '2.6' => __DIR__ . '/sql/upgrades/v2.6.sql',
'2.7' => __DIR__ . '/sql/upgrades/v2.7.sql', '2.7' => __DIR__ . '/sql/upgrades/v2.7.sql',
'2.8' => __DIR__ . '/sql/upgrades/v2.8.sql', '2.8' => __DIR__ . '/sql/upgrades/v2.8.sql',
'2.9' => __DIR__ . '/sql/upgrades/v2.9.sql',
]; ];
/** /**