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',
];
/**