From fe58ee1d234e8d662bd66e4fd47f707c36185443 Mon Sep 17 00:00:00 2001 From: canglan Date: Fri, 29 May 2026 17:35:29 +0800 Subject: [PATCH] =?UTF-8?q?v2.5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/models/conduct.py | 7 ++- backend/models/subject.py | 6 ++ backend/routes/admin.py | 86 +++++++++++++++++++++++++++- backend/routes/upgrade.py | 3 +- backend/services/conduct_service.py | 9 ++- frontend/admin/history.php | 10 ++++ frontend/assets/css/style.css | 52 ++++++++++++++++- frontend/assets/js/history.js | 89 ++++++++++++++++++++++++----- sql/init.sql | 4 +- sql/upgrades/v2.5.sql | 12 ++++ upgrade.php | 2 + 12 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 sql/upgrades/v2.5.sql diff --git a/VERSION b/VERSION index 6b4950e..95e3ba8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4 +2.5 diff --git a/backend/models/conduct.py b/backend/models/conduct.py index a07514b..eb543a5 100644 --- a/backend/models/conduct.py +++ b/backend/models/conduct.py @@ -161,7 +161,8 @@ class ConductModel: student_id: int = None, include_revoked: bool = True, related_type: str = None, - reason_prefix: str = None + reason_prefix: str = None, + is_revoked: int = None ) -> List[Dict[str, Any]]: """获取所有记录(班主任/班长专用)""" # 空字符串转为None @@ -206,6 +207,10 @@ class ConductModel: sql += " AND cr.reason LIKE %s" params.append(f"{reason_prefix}%") + if is_revoked is not None: + sql += " AND cr.is_revoked = %s" + params.append(1 if is_revoked else 0) + sql += " ORDER BY cr.created_at DESC LIMIT %s OFFSET %s" params.extend([limit, offset]) diff --git a/backend/models/subject.py b/backend/models/subject.py index 7e1cb7a..16b06d1 100644 --- a/backend/models/subject.py +++ b/backend/models/subject.py @@ -76,6 +76,12 @@ class SubjectModel: @staticmethod async def delete(subject_id: int) -> bool: + """软删除科目(设置 is_active = 0),如果已禁用也返回成功""" + subject = await SubjectModel.get_by_id(subject_id) + if not subject: + return False + if subject.get("is_active") == 0: + return True # 已禁用,视为成功 sql = "UPDATE subjects SET is_active = 0 WHERE subject_id = %s" result = await execute_update(sql, (subject_id,)) return result > 0 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index f5a23bb..bda3b11 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -319,7 +319,8 @@ async def get_conduct_history( end_date: Optional[str] = None, grouped: bool = Query(False), related_type: Optional[str] = None, - reason_prefix: Optional[str] = None + reason_prefix: Optional[str] = None, + is_revoked: Optional[int] = None ): """获取操行分历史记录""" try: @@ -335,7 +336,8 @@ async def get_conduct_history( end_date=end_date, grouped=grouped, related_type=related_type, - reason_prefix=reason_prefix + reason_prefix=reason_prefix, + is_revoked=is_revoked ) return success_response(data=result) except Exception as e: @@ -343,6 +345,86 @@ async def get_conduct_history( return error_response(message=f"获取历史记录失败: {str(e)}") +@router.post("/conduct/batch-revoke") +async def batch_revoke_conduct_records(request: Request): + """批量撤销操行分记录""" + try: + user = await get_current_user(request) + if user["user_type"] != "admin": + return error_response(message="无权进行此操作", code=403) + + body = await request.json() + record_ids = body.get("record_ids", []) + if not record_ids or not isinstance(record_ids, list): + return error_response(message="请提供要撤销的记录ID列表", code=400) + if len(record_ids) > 100: + return error_response(message="单次最多撤销100条记录", code=400) + + success_count = 0 + fail_count = 0 + errors = [] + + for record_id in record_ids: + result = await ConductService.revoke_record( + record_id=record_id, + revoker_id=user["user_id"] + ) + if result["success"]: + success_count += 1 + else: + fail_count += 1 + errors.append({"record_id": record_id, "error": result["message"]}) + + return success_response(data={ + "success_count": success_count, + "fail_count": fail_count, + "errors": errors + }, message=f"批量撤销完成: {success_count}条成功, {fail_count}条失败") + except Exception as e: + logger.error(f"批量撤销失败: {e}", exc_info=True) + return error_response(message=f"批量撤销失败: {str(e)}") + + +@router.post("/conduct/batch-restore") +async def batch_restore_conduct_records(request: Request): + """批量反撤销操行分记录""" + try: + user = await get_current_user(request) + if user["user_type"] != "admin": + return error_response(message="无权进行此操作", code=403) + + body = await request.json() + record_ids = body.get("record_ids", []) + if not record_ids or not isinstance(record_ids, list): + return error_response(message="请提供要反撤销的记录ID列表", code=400) + if len(record_ids) > 100: + return error_response(message="单次最多反撤销100条记录", code=400) + + success_count = 0 + fail_count = 0 + errors = [] + + for record_id in record_ids: + result = await ConductService.restore_record( + record_id=record_id, + restorer_id=user["user_id"] + ) + if result["success"]: + success_count += 1 + else: + fail_count += 1 + errors.append({"record_id": record_id, "error": result["message"]}) + + return success_response(data={ + "success_count": success_count, + "fail_count": fail_count, + "errors": errors + }, message=f"批量反撤销完成: {success_count}条成功, {fail_count}条失败") + except Exception as e: + logger.error(f"批量反撤销失败: {e}", exc_info=True) + return error_response(message=f"批量反撤销失败: {str(e)}") + + # ========== 考勤管理 ========== diff --git a/backend/routes/upgrade.py b/backend/routes/upgrade.py index 454ad68..d6dee2c 100644 --- a/backend/routes/upgrade.py +++ b/backend/routes/upgrade.py @@ -18,7 +18,6 @@ import re logger = setup_logger() router = APIRouter() -# 版本列表(按顺序) # 版本列表(按顺序) ALL_VERSIONS = { '1.0': 'v1.0.sql', @@ -35,7 +34,9 @@ ALL_VERSIONS = { '2.1': 'v2.1.sql', '2.2': 'v2.2.sql', '2.3': 'v2.3.sql', + '2.3': 'v2.3.sql', '2.4': 'v2.4.sql', + '2.5': 'v2.5.sql', } # 版本特征标记(按优先级从高到低) diff --git a/backend/services/conduct_service.py b/backend/services/conduct_service.py index 8b1e8d7..cd32e0b 100644 --- a/backend/services/conduct_service.py +++ b/backend/services/conduct_service.py @@ -213,7 +213,8 @@ class ConductService: end_date: Optional[str] = None, grouped: bool = False, related_type: Optional[str] = None, - reason_prefix: Optional[str] = None + reason_prefix: Optional[str] = None, + is_revoked: Optional[int] = None ) -> Dict[str, Any]: """获取历史记录""" # 空字符串转为None @@ -251,7 +252,8 @@ class ConductService: end_date=end_date, student_id=student_id, related_type=related_type, - reason_prefix=reason_prefix + reason_prefix=reason_prefix, + is_revoked=is_revoked ) # 获取总数 @@ -273,6 +275,9 @@ class ConductService: if reason_prefix: count_conditions.append("cr.reason LIKE %s") count_params.append(f"{reason_prefix}%") + if is_revoked is not None: + count_conditions.append("cr.is_revoked = %s") + count_params.append(1 if is_revoked else 0) count_where = " AND ".join(count_conditions) count_sql = f""" SELECT COUNT(*) as total FROM conduct_records cr diff --git a/frontend/admin/history.php b/frontend/admin/history.php index 5265535..fac1f32 100644 --- a/frontend/admin/history.php +++ b/frontend/admin/history.php @@ -54,6 +54,16 @@ include __DIR__ . '/../includes/header.php'; + +
+ + +
+
diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index 6a36edd..6d46902 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -288,6 +288,7 @@ body { /* ========== 表格 ========== */ .table-wrapper { overflow-x: auto; + overflow-y: visible; } table { @@ -853,9 +854,9 @@ tr:hover { .action-dropdown-menu { display: none; position: absolute; - top: 100%; + bottom: 100%; right: 0; - margin-top: 4px; + margin-bottom: 4px; background: white; border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); @@ -926,3 +927,50 @@ tr:hover { .tag-danger { background: #ffebee; color: #c62828; } .tag-warning { background: #fff3e0; color: #e65100; } .tag-info { background: #e3f2fd; color: #1565c0; } + +/* ========== 历史记录页优化 ========== */ +/* 时间列:确保分两行显示(日期+时间) */ +.history-time { + white-space: pre-line; + min-width: 80px; + line-height: 1.5; + word-break: break-all; +} + +/* 原因列:每行最少7个字,自动换行 */ +.history-reason { + min-width: 7em; + max-width: 200px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; +} + +/* 学生名列:允许换行 */ +.history-students { + white-space: normal; + word-break: break-word; + min-width: 60px; + max-width: 120px; + line-height: 1.5; +} + +/* 合并记录按钮样式 */ +.btn-outline-danger { + background: transparent; + color: var(--color-danger); + border: 1px solid var(--color-danger); + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-outline-danger:hover { + background: var(--color-danger-light); + color: var(--color-danger-dark); + border-color: var(--color-danger-dark); +} + diff --git a/frontend/assets/js/history.js b/frontend/assets/js/history.js index 7486cf8..b073764 100644 --- a/frontend/assets/js/history.js +++ b/frontend/assets/js/history.js @@ -33,6 +33,7 @@ async function loadHistory(page = 1) { const studentId = document.getElementById('historyStudentId').value; const reasonFilter = document.getElementById('historyReasonFilter').value; const isGrouped = document.getElementById('historyGrouped').checked; + const statusFilter = document.getElementById('historyStatusFilter')?.value; const params = { page, page_size: 20, @@ -42,6 +43,7 @@ async function loadHistory(page = 1) { if (studentId) params.student_id = studentId; if (reasonFilter) params.reason_prefix = reasonFilter; if (isGrouped) params.grouped = true; + if (statusFilter !== undefined && statusFilter !== '') params.is_revoked = parseInt(statusFilter); const res = await apiGet('/api/admin/conduct/history', params); @@ -50,6 +52,9 @@ async function loadHistory(page = 1) { let headHtml = ''; if (isGrouped) { headHtml = '时间原因分值操作人涉及学生'; + if (role === '班主任' || role === '班长') { + headHtml += '操作'; + } } else { headHtml = '时间学生分数变动原因操作人'; if (role === '班主任' || role === '班长' || role === '考勤委员') { @@ -63,46 +68,56 @@ async function loadHistory(page = 1) { res.data.records.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const names = record.student_names || ''; - html += ` - ${formatDateTime(record.created_at)} - ${escapeHtml(record.reason)} + const allRevoked = record.all_revoked; + const revokedStyle = allRevoked ? ' style="opacity:0.5; text-decoration:line-through;"' : ''; + html += ` + ${formatDateTime(record.created_at)} + ${escapeHtml(record.reason)} ${record.points_change > 0 ? '+' : ''}${record.points_change}×${record.student_count} ${escapeHtml(record.recorder_name || '')} - ${escapeHtml(names)} - `; + ${escapeHtml(names)}`; + if (role === '班主任' || role === '班长') { + if (allRevoked) { + html += `已撤销`; + } else { + html += ``; + } + } + html += ``; }); if (res.data.records.length === 0) { - html = '暂无记录'; + const colSpan = (role === '班主任' || role === '班长') ? 6 : 5; + html = '暂无记录'; } } else { res.data.records.forEach(record => { const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; const revokedStyle = record.is_revoked == 1 ? ' style="opacity:0.5; text-decoration:line-through;"' : ''; html += ` - ${formatDateTime(record.created_at)} - ${escapeHtml(record.student_name)} + ${formatDateTime(record.created_at)} + ${escapeHtml(record.student_name)} ${record.points_change > 0 ? '+' : ''}${record.points_change} - ${escapeHtml(record.reason)} + ${escapeHtml(record.reason)} ${escapeHtml(record.recorder_name)}`; if (role === '班主任') { if (record.is_revoked == 1) { const revokerInfo = record.revoker_name ? `由 ${escapeHtml(record.revoker_name)} 撤销` : '已撤销'; - html += `${revokerInfo}`; + html += `${revokerInfo}`; } else { - html += ``; + html += ``; } } else if (role === '班长') { if (record.is_revoked == 1) { const revokerInfo = record.revoker_name ? `由 ${escapeHtml(record.revoker_name)} 撤销` : '已撤销'; html += `${revokerInfo}`; } else { - html += ``; + html += ``; } } else if (role === '考勤委员') { if (record.is_revoked == 1) { html += `已撤销`; } else if (record.recorder_id == currentUserId) { - html += ``; + html += ``; } else { html += `-`; } @@ -177,6 +192,53 @@ async function exportHistoryRecords() { } } +// 批量撤销合并记录(按条件查找并撤销) +async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) { + if (!confirm(`确定要撤销所有"${reason}"(${pointsChange > 0 ? '+' : ''}${pointsChange}分)的记录吗?`)) return; + + showToast('正在批量撤销...', 'info'); + + try { + // 先查询匹配的记录 + const params = { + page: 1, page_size: 1000, + start_date: document.getElementById('historyStartDate').value, + end_date: document.getElementById('historyEndDate').value, + reason_prefix: reason.substring(0, 4), + grouped: false + }; + + const res = await apiGet('/api/admin/conduct/history', params); + if (!res || !res.success || !res.data.records) { + showToast('查询记录失败', 'error'); + return; + } + + // 精确匹配 + const matchedIds = []; + res.data.records.forEach(r => { + if (r.reason === reason && r.points_change === pointsChange && r.is_revoked == 0) { + matchedIds.push(r.record_id); + } + }); + + if (matchedIds.length === 0) { + showToast('没有找到可撤销的记录', 'warning'); + return; + } + + const revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds }); + if (revokeRes && revokeRes.success) { + showToast(`批量撤销完成: ${revokeRes.data.success_count}条成功`); + loadHistory(currentHistoryPage); + } else { + showToast(revokeRes?.message || '批量撤销失败', 'error'); + } + } catch (err) { + showToast('批量撤销失败: ' + err.message, 'error'); + } +} + loadStudentsForSelect().then(() => { const urlParams = new URLSearchParams(window.location.search); const preStudentId = urlParams.get('student_id'); @@ -191,5 +253,6 @@ loadStudentsForSelect().then(() => { window.loadHistory = loadHistory; window.loadStudentsForSelect = loadStudentsForSelect; window.exportHistoryRecords = exportHistoryRecords; +window.batchRevokeGrouped = batchRevokeGrouped; })(); diff --git a/sql/init.sql b/sql/init.sql index f333c1d..fe9d3ed 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -232,8 +232,8 @@ INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VAL -- 初始化系统版本号 INSERT INTO `system_settings` (`setting_key`, `setting_value`) -VALUES ('db_version', '2.4') -ON DUPLICATE KEY UPDATE `setting_value` = '2.4'; +VALUES ('db_version', '2.5') +ON DUPLICATE KEY UPDATE `setting_value` = '2.5'; -- 控制台输出初始化结果(含版本号) SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message; diff --git a/sql/upgrades/v2.5.sql b/sql/upgrades/v2.5.sql new file mode 100644 index 0000000..ef4b3c0 --- /dev/null +++ b/sql/upgrades/v2.5.sql @@ -0,0 +1,12 @@ +-- =========================================== +-- 班级操行分管理系统 - v2.3 → v2.5 升级脚本 +-- 字符集: utf8mb4 +-- +-- 说明: v2.5 为功能增强版本,无数据库 schema 变更。 +-- 主要变更: +-- 1. 历史记录页优化(文字宽度/换行/合并按钮样式) +-- 2. 新增"状态"筛选项(正常/已撤销) +-- 3. 合并记录支持批量撤销/反撤销 +-- 4. 操作菜单底部遮挡修复 +-- 5. 删除科目报错修复 +-- =========================================== diff --git a/upgrade.php b/upgrade.php index d0d04fb..f5b0b20 100644 --- a/upgrade.php +++ b/upgrade.php @@ -26,6 +26,8 @@ $UPGRADE_VERSIONS = [ '2.1' => __DIR__ . '/sql/upgrades/v2.1.sql', '2.2' => __DIR__ . '/sql/upgrades/v2.2.sql', '2.3' => __DIR__ . '/sql/upgrades/v2.3.sql', + '2.4' => __DIR__ . '/sql/upgrades/v2.4.sql', + '2.5' => __DIR__ . '/sql/upgrades/v2.5.sql', ]; /**