v2.5.1更新

This commit is contained in:
2026-05-29 20:16:25 +08:00
parent fe58ee1d23
commit b2c36cab2c
22 changed files with 347 additions and 166 deletions

View File

@@ -273,6 +273,10 @@ classmanager/
| v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分manual/homework/attendance、学生端作业详情优化 | | v1.8 | 2026.5.22 | 科目管理融入作业管理页、科目删除数据依赖检查、加减分记录类型区分manual/homework/attendance、学生端作业详情优化 |
| v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 | | v2.0.1 | 2026.5.23 | 操作列折叠优化、扣分类型大类区分、科目选择修复、改名作业扣分、记录人优化、家长端优化、学期管理优化 |
| v2.1 | 2026.5.26 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 | | v2.1 | 2026.5.26 | CSS变量化统一配色方案、简化按钮系统、操作列按钮风格统一、清理内联颜色、修复科目管理面板无法展开、数据库索引优化、清理init.sql冗余迁移代码、安全审计通过 |
| v2.2 | 2026.5.27 | 安全修复:管理员操作越权漏洞修复、新增宿舍集体加分功能、学生导入支持宿舍号、导入预览显示宿舍号列 |
| v2.3 | 2026.5.28 | 升级系统全面重构修复前后端通信错误处理、SQL DELIMITER解析修复、XSS防护、升级验证+自动重试+失败回滚机制、补全v1.0-v1.6增量升级脚本 |
| v2.5 | 2026.5.28 | 历史记录页优化(文字宽度/换行/合并按钮样式)、新增状态筛选项、合并记录批量撤销/反撤销、操作菜单底部遮挡修复、删除科目报错修复、学期自动关联+当前周数计算 |
| v2.5.1 | 2026.5.28 | 筛选学生时自动取消合并记录、合并记录选项样式修复竖排显示、历史记录筛选改为折叠式、科目管理调用修复、全局escapeHtml XSS转义修复 |
## 许可证 ## 许可证

View File

@@ -1 +1 @@
2.5 2.5.1

View File

@@ -224,7 +224,8 @@ class ConductModel:
related_type: str = None, related_type: str = None,
reason_prefix: str = None, reason_prefix: str = None,
page: int = 1, page: int = 1,
page_size: int = 20 page_size: int = 20,
is_revoked: int = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""获取分组后的操行分记录(同批次合并)""" """获取分组后的操行分记录(同批次合并)"""
if start_date == "": if start_date == "":
@@ -236,9 +237,15 @@ class ConductModel:
if reason_prefix == "": if reason_prefix == "":
reason_prefix = None reason_prefix = None
conditions = ["cr.is_revoked = 0"] conditions = ["1=1"]
params = [] params = []
if is_revoked is not None:
conditions.append("cr.is_revoked = %s")
params.append(1 if is_revoked else 0)
else:
conditions.append("cr.is_revoked = 0")
if student_id: if student_id:
conditions.append("cr.student_id = %s") conditions.append("cr.student_id = %s")
params.append(student_id) params.append(student_id)
@@ -258,7 +265,7 @@ class ConductModel:
where_clause = " AND ".join(conditions) where_clause = " AND ".join(conditions)
count_sql = f""" 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 SELECT COUNT(DISTINCT CONCAT(cr.points_change, '|', cr.reason, '|', cr.recorder_id, '|', DATE_FORMAT(cr.created_at, '%%Y-%%m-%%d %%H:%%i:%%s'))) as total
FROM conduct_records cr FROM conduct_records cr
WHERE {where_clause} WHERE {where_clause}
""" """
@@ -270,11 +277,12 @@ class ConductModel:
cr.recorder_name, cr.recorder_name,
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,
MAX(cr.is_revoked) as all_revoked
FROM conduct_records cr FROM conduct_records cr
JOIN students s ON cr.student_id = s.student_id JOIN students s ON cr.student_id = s.student_id
WHERE {where_clause} WHERE {where_clause}
GROUP BY cr.points_change, cr.reason, cr.recorder_id, DATE_FORMAT(cr.created_at, '%%Y-%%m-%%d %%H:%%i') GROUP BY cr.points_change, cr.reason, cr.recorder_id, DATE_FORMAT(cr.created_at, '%%Y-%%m-%%d %%H:%%i:%%s')
ORDER BY MIN(cr.created_at) DESC ORDER BY MIN(cr.created_at) DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """

View File

@@ -42,11 +42,28 @@ async def list_semesters(request: Request):
@router.get("/active") @router.get("/active")
async def get_active_semester(request: Request): async def get_active_semester(request: Request):
"""获取当前活跃学期""" """获取当前活跃学期(含当前周数)"""
user = await get_current_user(request) user = await get_current_user(request)
result = await SemesterService.get_active_semester() result = await SemesterService.get_active_semester()
if result["success"]: if result["success"]:
return success_response(data=result.get("semester")) semester = result.get("semester")
if semester and semester.get('start_date'):
from datetime import date, datetime
try:
start = semester['start_date']
if isinstance(start, str):
start_date = datetime.strptime(start, '%Y-%m-%d').date()
else:
start_date = start
today = date.today()
delta = (today - start_date).days
if delta >= 0:
semester['current_week'] = delta // 7 + 1
else:
semester['current_week'] = 0
except Exception:
semester['current_week'] = None
return success_response(data=semester)
else: else:
return error_response(message=result["message"]) return error_response(message=result["message"])

View File

@@ -52,4 +52,4 @@ async def delete_subject(request: Request, subject_id: int):
if not await PermissionChecker.check_can_manage_subjects(user["user_id"]): if not await PermissionChecker.check_can_manage_subjects(user["user_id"]):
return error_response(message="无权限", code=403) return error_response(message="无权限", code=403)
result = await SubjectService.delete_subject(subject_id) result = await SubjectService.delete_subject(subject_id)
return success_response(message="科目已禁用") if result["success"] else error_response(message=result["message"]) return success_response(message="科目已删除") if result["success"] else error_response(message=result["message"])

View File

@@ -37,6 +37,7 @@ ALL_VERSIONS = {
'2.3': 'v2.3.sql', '2.3': 'v2.3.sql',
'2.4': 'v2.4.sql', '2.4': 'v2.4.sql',
'2.5': 'v2.5.sql', '2.5': 'v2.5.sql',
'2.5.1': 'v2.5.1.sql',
} }
# 版本特征标记(按优先级从高到低) # 版本特征标记(按优先级从高到低)

View File

@@ -15,6 +15,7 @@ from datetime import datetime
from models.student import StudentModel from models.student import StudentModel
from models.conduct import ConductModel from models.conduct import ConductModel
from models.user import UserModel from models.user import UserModel
from models.semester import SemesterModel
from middleware.permission import PermissionChecker from middleware.permission import PermissionChecker
from config import settings from config import settings
from utils.logger import get_logger from utils.logger import get_logger
@@ -86,6 +87,10 @@ class ConductService:
fail_count = 0 fail_count = 0
details = [] details = []
# 自动获取当前活跃学期
active_semester = await SemesterModel.get_active()
semester_id = active_semester['semester_id'] if active_semester else None
for student_id in student_ids: for student_id in student_ids:
try: try:
# 检查学生是否存在 # 检查学生是否存在
@@ -104,6 +109,17 @@ class ConductService:
related_type=related_type related_type=related_type
) )
# 自动关联到当前学期
if semester_id and record_id:
try:
from utils.database import execute_update as _exec_update
await _exec_update(
"UPDATE conduct_records SET semester_id = %s WHERE record_id = %s AND semester_id IS NULL",
(semester_id, record_id)
)
except Exception:
pass # 关联失败不影响主流程
# 更新学生总分 # 更新学生总分
await StudentModel.update_total_points(student_id, points_change) await StudentModel.update_total_points(student_id, points_change)
@@ -242,7 +258,8 @@ class ConductService:
related_type=related_type, related_type=related_type,
reason_prefix=reason_prefix, reason_prefix=reason_prefix,
page=page, page=page,
page_size=page_size page_size=page_size,
is_revoked=is_revoked
) )
records = await ConductModel.get_all_records( records = await ConductModel.get_all_records(

View File

@@ -30,10 +30,23 @@ class SemesterService:
"""获取学期列表""" """获取学期列表"""
try: try:
semesters = await SemesterModel.get_all() semesters = await SemesterModel.get_all()
today = datetime.date.today()
for sem in semesters: for sem in semesters:
counts = await SemesterModel.count_records_by_semester(sem['semester_id']) counts = await SemesterModel.count_records_by_semester(sem['semester_id'])
sem['conduct_count'] = counts['conduct_count'] sem['conduct_count'] = counts['conduct_count']
sem['attendance_count'] = counts['attendance_count'] sem['attendance_count'] = counts['attendance_count']
# 计算当前周数(仅活跃学期且有开始日期时)
sem['current_week'] = None
if sem.get('is_active') and sem.get('start_date'):
try:
s_date = sem['start_date']
if isinstance(s_date, str):
s_date = datetime.datetime.strptime(s_date, '%Y-%m-%d').date()
delta = (today - s_date).days
if delta >= 0:
sem['current_week'] = delta // 7 + 1
except (ValueError, TypeError):
pass
return { return {
"success": True, "success": True,
"semesters": semesters "semesters": semesters

View File

@@ -26,54 +26,59 @@ include __DIR__ . '/../includes/header.php';
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<div class="filter-bar"> <div class="filter-bar" id="historyFilterBar">
<div class="filter-group">
<label>开始日期</label>
<input type="date" id="historyStartDate">
</div>
<div class="filter-group">
<label>结束日期</label>
<input type="date" id="historyEndDate">
</div>
<div class="filter-group"> <div class="filter-group">
<label>学生</label> <label>学生</label>
<select id="historyStudentId"> <select id="historyStudentId" onchange="onStudentFilterChange()">
<option value="">全部</option> <option value="">全部</option>
</select> </select>
</div> </div>
<div class="filter-group">
<label>扣分类型</label>
<select id="historyReasonFilter">
<option value="">全部类型</option>
<option value="卫生">卫生</option>
<option value="课堂">课堂</option>
<option value="纪律">纪律</option>
<option value="作业">作业</option>
<option value="考勤">考勤</option>
<option value="劳动">劳动</option>
<option value="志愿">志愿</option>
</select>
</div>
<?php if ($role === '班主任' || $role === '班长'): ?>
<div class="filter-group">
<label>状态</label>
<select id="historyStatusFilter">
<option value="">全部</option>
<option value="0">正常</option>
<option value="1">已撤销</option>
</select>
</div>
<?php endif; ?>
<button class="btn btn-primary" onclick="loadHistory(1)">查询</button> <button class="btn btn-primary" onclick="loadHistory(1)">查询</button>
<div class="filter-group" style="min-width:auto;"> <button class="btn btn-ghost" id="filterToggleBtn" onclick="toggleFilterPanel()">展开筛选 ▼</button>
<label>&nbsp;</label> <?php if ($role === '班主任'): ?>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;"> <button class="btn btn-secondary" onclick="exportHistoryRecords()">导出</button>
<?php endif; ?>
</div>
<!-- 高级筛选面板(默认折叠) -->
<div id="advancedFilters" style="display:none; padding: 0 16px 16px; border-top: 1px solid var(--color-border-light);">
<div style="display:flex; flex-wrap:wrap; gap:16px; padding-top:16px;">
<div class="filter-group">
<label>开始日期</label>
<input type="date" id="historyStartDate">
</div>
<div class="filter-group">
<label>结束日期</label>
<input type="date" id="historyEndDate">
</div>
<div class="filter-group">
<label>扣分类型</label>
<select id="historyReasonFilter">
<option value="">全部类型</option>
<option value="卫生">卫生</option>
<option value="课堂">课堂</option>
<option value="纪律">纪律</option>
<option value="作业">作业</option>
<option value="考勤">考勤</option>
<option value="劳动">劳动</option>
<option value="志愿">志愿</option>
</select>
</div>
<?php if ($role === '班主任' || $role === '班长'): ?>
<div class="filter-group">
<label>状态</label>
<select id="historyStatusFilter">
<option value="">全部</option>
<option value="0">正常</option>
<option value="1">已撤销</option>
</select>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 12px;">
<label class="history-grouped-label">
<input type="checkbox" id="historyGrouped" checked onchange="loadHistory(1)"> 批次合并 <input type="checkbox" id="historyGrouped" checked onchange="loadHistory(1)"> 批次合并
</label> </label>
</div> </div>
<?php if ($role === '班主任'): ?>
<button class="btn btn-secondary" onclick="exportHistoryRecords()">导出历史记录</button>
<?php endif; ?>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">

View File

@@ -41,6 +41,7 @@ include __DIR__ . '/../includes/header.php';
<th>学期名称</th> <th>学期名称</th>
<th>开始日期</th> <th>开始日期</th>
<th>结束日期</th> <th>结束日期</th>
<th>当前周数</th>
<th>状态</th> <th>状态</th>
<th>记录数</th> <th>记录数</th>
<th>创建时间</th> <th>创建时间</th>

View File

@@ -931,19 +931,20 @@ tr:hover {
/* ========== 历史记录页优化 ========== */ /* ========== 历史记录页优化 ========== */
/* 时间列:确保分两行显示(日期+时间) */ /* 时间列:确保分两行显示(日期+时间) */
.history-time { .history-time {
white-space: pre-line; white-space: nowrap;
min-width: 80px; min-width: 80px;
line-height: 1.5; line-height: 1.5;
word-break: break-all; vertical-align: top;
} }
/* 原因列每行最少7个字自动换行 */ /* 原因列每行最少7个字自动换行 */
.history-reason { .history-reason {
min-width: 7em; min-width: 7em;
max-width: 200px; max-width: 200px;
white-space: pre-wrap; white-space: normal;
word-break: break-word; word-break: break-word;
line-height: 1.5; line-height: 1.5;
vertical-align: top;
} }
/* 学生名列:允许换行 */ /* 学生名列:允许换行 */
@@ -953,6 +954,34 @@ tr:hover {
min-width: 60px; min-width: 60px;
max-width: 120px; max-width: 120px;
line-height: 1.5; line-height: 1.5;
vertical-align: top;
}
/* 合并记录复选框样式 */
.history-grouped-label {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-hover);
transition: all 0.2s;
white-space: nowrap;
user-select: none;
}
.history-grouped-label:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.history-grouped-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
} }
/* 合并记录按钮样式 */ /* 合并记录按钮样式 */

View File

@@ -337,25 +337,40 @@ function toggleActionDropdown(el) {
var isOpen = menu.classList.contains('show'); var isOpen = menu.classList.contains('show');
// 先关闭所有 // 先关闭所有
document.querySelectorAll('.action-dropdown-menu.show').forEach(function(m) { closeAllDropdowns();
m.classList.remove('show');
var toggle = m.closest('.action-dropdown').querySelector('.action-dropdown-toggle');
if (toggle) toggle.classList.remove('open');
});
if (!isOpen) { if (!isOpen) {
// 使用 fixed 定位,避免被 overflow 容器裁剪
var rect = el.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.bottom = 'auto';
menu.style.right = 'auto';
menu.style.left = rect.right - 120 + 'px'; // 120px = min-width
menu.style.top = (rect.top - 4) + 'px';
menu.style.transform = 'translateY(-100%)';
menu.classList.add('show'); menu.classList.add('show');
el.classList.add('open'); el.classList.add('open');
} }
} }
function closeAllDropdowns() {
document.querySelectorAll('.action-dropdown-menu.show').forEach(function(m) {
m.classList.remove('show');
m.style.position = '';
m.style.left = '';
m.style.top = '';
m.style.transform = '';
var toggle = m.closest('.action-dropdown');
if (toggle) {
var btn = toggle.querySelector('.action-dropdown-toggle');
if (btn) btn.classList.remove('open');
}
});
}
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (!e.target.closest('.action-dropdown')) { if (!e.target.closest('.action-dropdown')) {
document.querySelectorAll('.action-dropdown-menu.show').forEach(function(m) { closeAllDropdowns();
m.classList.remove('show');
var toggle = m.closest('.action-dropdown').querySelector('.action-dropdown-toggle');
if (toggle) toggle.classList.remove('open');
});
} }
}); });

View File

@@ -14,9 +14,15 @@ const role = window.PAGE_CONFIG.role;
let totalStudents = 0; let totalStudents = 0;
async function loadDashboard() { async function loadDashboard() {
const studentsRes = await apiGet('/api/admin/students'); // 并行加载学生数据和学期信息
const [studentsRes, semesterRes] = await Promise.all([
apiGet('/api/admin/students'),
apiGet('/api/semester/active')
]);
let statsHtml = '';
if (studentsRes && studentsRes.success) { if (studentsRes && studentsRes.success) {
document.getElementById('dashboardStats').innerHTML = ` statsHtml += `
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">学生总数</div> <div class="stat-label">学生总数</div>
<div class="stat-value">${studentsRes.data.total || 0}</div> <div class="stat-value">${studentsRes.data.total || 0}</div>
@@ -24,6 +30,24 @@ async function loadDashboard() {
`; `;
} }
// 显示学期信息和当前周数
if (semesterRes && semesterRes.success && semesterRes.data) {
const sem = semesterRes.data;
const weekNum = sem.current_week;
let semesterInfo = escapeHtml(sem.semester_name);
if (weekNum && weekNum > 0) {
semesterInfo += ` · 第${weekNum}`;
}
statsHtml += `
<div class="stat-card">
<div class="stat-label">当前学期</div>
<div class="stat-value" style="font-size:20px;">${semesterInfo}</div>
</div>
`;
}
document.getElementById('dashboardStats').innerHTML = statsHtml;
let quickActions = ''; let quickActions = '';
if (role === '班主任' || role === '班长' || role === '学习委员' || role === '劳动委员' || role === '志愿委员') { if (role === '班主任' || role === '班长' || role === '学习委员' || role === '劳动委员' || role === '志愿委员') {
quickActions += '<button class="btn btn-primary" onclick="location.href=\'/admin/conduct.php\'">操行分管理</button>'; quickActions += '<button class="btn btn-primary" onclick="location.href=\'/admin/conduct.php\'">操行分管理</button>';

View File

@@ -15,41 +15,74 @@ const currentUserId = window.PAGE_CONFIG.userId;
let currentHistoryPage = 1; let currentHistoryPage = 1;
let totalHistoryPages = 1; let totalHistoryPages = 1;
function escapeHtml(str) {
if (!str) return '';
var el = document.createElement('span');
el.appendChild(document.createTextNode(str));
return el.innerHTML;
}
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) {
let html = '<option value="">全部</option>'; let html = '<option value="">全部</option>';
res.data.students.forEach(s => { res.data.students.forEach(s => {
html += `<option value="${s.student_id}">${escapeHtml(s.student_no)} - ${escapeHtml(s.name)}</option>`; html += '<option value="' + s.student_id + '">' + escapeHtml(s.student_no) + ' - ' + escapeHtml(s.name) + '</option>';
}); });
document.getElementById('historyStudentId').innerHTML = html; document.getElementById('historyStudentId').innerHTML = html;
} }
} }
async function loadHistory(page = 1) { // 筛选学生时自动取消合并记录
currentHistoryPage = page; function onStudentFilterChange() {
const startDate = document.getElementById('historyStartDate').value; var studentId = document.getElementById('historyStudentId').value;
const endDate = document.getElementById('historyEndDate').value; if (studentId) {
const studentId = document.getElementById('historyStudentId').value; var grouped = document.getElementById('historyGrouped');
const reasonFilter = document.getElementById('historyReasonFilter').value; if (grouped) grouped.checked = false;
const isGrouped = document.getElementById('historyGrouped').checked; }
const statusFilter = document.getElementById('historyStatusFilter')?.value; }
const params = { // 折叠/展开筛选面板
page, page_size: 20, function toggleFilterPanel() {
var panel = document.getElementById('advancedFilters');
var btn = document.getElementById('filterToggleBtn');
if (panel.style.display === 'none') {
panel.style.display = 'block';
btn.textContent = '收起筛选 ▲';
} else {
panel.style.display = 'none';
btn.textContent = '展开筛选 ▼';
}
}
async function loadHistory(page) {
page = page || 1;
currentHistoryPage = page;
var startDate = document.getElementById('historyStartDate').value;
var endDate = document.getElementById('historyEndDate').value;
var studentId = document.getElementById('historyStudentId').value;
var reasonFilter = document.getElementById('historyReasonFilter').value;
var isGrouped = document.getElementById('historyGrouped').checked;
var statusFilter = document.getElementById('historyStatusFilter') ? document.getElementById('historyStatusFilter').value : '';
// 筛选学生时强制取消合并
if (studentId) isGrouped = false;
var params = {
page: page, page_size: 20,
start_date: startDate, start_date: startDate,
end_date: endDate end_date: endDate
}; };
if (studentId) params.student_id = studentId; if (studentId) params.student_id = studentId;
if (reasonFilter) params.reason_prefix = reasonFilter; if (reasonFilter) params.reason_prefix = reasonFilter;
if (isGrouped) params.grouped = true; if (isGrouped) params.grouped = true;
if (statusFilter !== undefined && statusFilter !== '') params.is_revoked = parseInt(statusFilter); if (statusFilter !== '') params.is_revoked = parseInt(statusFilter);
const res = await apiGet('/api/admin/conduct/history', params); var res = await apiGet('/api/admin/conduct/history', params);
if (res && res.success) { if (res && res.success) {
const nowrapStyle = ' style="white-space: nowrap; min-width: 80px;"'; var nowrapStyle = ' style="white-space:nowrap;min-width:80px;"';
let 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' + nowrapStyle + '>操作人</th><th>涉及学生</th>';
if (role === '班主任' || role === '班长') { if (role === '班主任' || role === '班长') {
@@ -63,71 +96,71 @@ async function loadHistory(page = 1) {
} }
document.getElementById('historyTableHead').innerHTML = headHtml; document.getElementById('historyTableHead').innerHTML = headHtml;
let html = ''; var html = '';
if (isGrouped) { if (isGrouped) {
res.data.records.forEach(record => { res.data.records.forEach(function(record) {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; var pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const names = record.student_names || ''; var names = record.student_names || '';
const allRevoked = record.all_revoked; var allRevoked = record.all_revoked;
const 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 class="history-time">' + formatDateTime(record.created_at) + '</td>' +
<td class="preserve-newlines history-reason">${escapeHtml(record.reason)}</td> '<td class="history-reason">' + escapeHtml(record.reason) + '</td>' +
<td class="${pointsClass}">${record.points_change > 0 ? '+' : ''}${record.points_change}×${record.student_count}</td> '<td class="' + pointsClass + '">' + (record.points_change > 0 ? '+' : '') + record.points_change + '&times;' + record.student_count + '</td>' +
<td>${escapeHtml(record.recorder_name || '')}</td> '<td>' + escapeHtml(record.recorder_name || '') + '</td>' +
<td class="history-students">${escapeHtml(names)}</td>`; '<td class="history-students">' + escapeHtml(names) + '</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>';
} else { } else {
html += `<td><button class="btn btn-sm btn-outline-danger" onclick="batchRevokeGrouped('${escapeHtml(record.reason)}', ${record.points_change}, '${escapeHtml(record.recorder_name || '')}', '${formatDateTime(record.created_at)}')">批量撤销</button></td>`; html += '<td><button class="btn btn-sm btn-outline" onclick="batchRevokeGrouped(\'' + escapeHtml(record.reason).replace(/'/g, "\\'") + '\',' + record.points_change + ',\'' + escapeHtml(record.recorder_name || '').replace(/'/g, "\\'") + '\',\'' + formatDateTime(record.created_at) + '\')">批量撤销</button></td>';
} }
} }
html += `</tr>`; html += '</tr>';
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
const colSpan = (role === '班主任' || role === '班长') ? 6 : 5; var colSpan = (role === '班主任' || role === '班长') ? 6 : 5;
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(record => { res.data.records.forEach(function(record) {
const pointsClass = record.points_change > 0 ? 'plus' : 'minus'; var pointsClass = record.points_change > 0 ? 'plus' : 'minus';
const 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 class="history-time">' + formatDateTime(record.created_at) + '</td>' +
<td class="history-students">${escapeHtml(record.student_name)}</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="preserve-newlines history-reason">${escapeHtml(record.reason)}</td> '<td class="history-reason">' + escapeHtml(record.reason) + '</td>' +
<td>${escapeHtml(record.recorder_name)}</td>`; '<td>' + escapeHtml(record.recorder_name) + '</td>';
if (role === '班主任') { if (role === '班主任') {
if (record.is_revoked == 1) { if (record.is_revoked == 1) {
const revokerInfo = record.revoker_name ? `${escapeHtml(record.revoker_name)} 撤销` : '已撤销'; var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销';
html += `<td><span class="text-muted" style="margin-right:4px;">${revokerInfo}</span><button class="btn btn-sm btn-outline" onclick="restoreRecord(${record.record_id})">反撤销</button></td>`; html += '<td><span class="text-muted" style="margin-right:4px;">' + revokerInfo + '</span><button class="btn btn-sm btn-outline" onclick="restoreRecord(' + record.record_id + ')">反撤销</button></td>';
} else { } else {
html += `<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(${record.record_id})">撤销</button></td>`; html += '<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(' + record.record_id + ')">撤销</button></td>';
} }
} else if (role === '班长') { } else if (role === '班长') {
if (record.is_revoked == 1) { if (record.is_revoked == 1) {
const revokerInfo = record.revoker_name ? `${escapeHtml(record.revoker_name)} 撤销` : '已撤销'; var revokerInfo = record.revoker_name ? '由 ' + escapeHtml(record.revoker_name) + ' 撤销' : '已撤销';
html += `<td><span class="text-muted">${revokerInfo}</span></td>`; html += '<td><span class="text-muted">' + revokerInfo + '</span></td>';
} else { } else {
html += `<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(${record.record_id})">撤销</button></td>`; html += '<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(' + record.record_id + ')">撤销</button></td>';
} }
} else if (role === '考勤委员') { } else if (role === '考勤委员') {
if (record.is_revoked == 1) { if (record.is_revoked == 1) {
html += `<td><span class="text-muted">已撤销</span></td>`; html += '<td><span class="text-muted">已撤销</span></td>';
} else if (record.recorder_id == currentUserId) { } else if (record.recorder_id == currentUserId) {
html += `<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(${record.record_id})">撤销</button></td>`; html += '<td><button class="btn btn-sm btn-outline" onclick="revokeRecord(' + record.record_id + ')">撤销</button></td>';
} else { } else {
html += `<td><span class="text-muted">-</span></td>`; html += '<td><span class="text-muted">-</span></td>';
} }
} }
html += `</tr>`; html += '</tr>';
}); });
if (res.data.records.length === 0) { if (res.data.records.length === 0) {
const colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 6 : 5; var colSpan = (role === '班主任' || role === '班长' || role === '考勤委员') ? 6 : 5;
html = `<tr><td colspan="${colSpan}" style="text-align:center;">暂无记录</td></tr>`; html = '<tr><td colspan="' + colSpan + '" style="text-align:center;">暂无记录</td></tr>';
} }
} }
@@ -145,47 +178,47 @@ function renderHistoryPagination() {
} }
async function exportHistoryRecords() { async function exportHistoryRecords() {
const startDate = document.getElementById('historyStartDate').value; var startDate = document.getElementById('historyStartDate').value;
const endDate = document.getElementById('historyEndDate').value; var endDate = document.getElementById('historyEndDate').value;
const studentId = document.getElementById('historyStudentId').value; var studentId = document.getElementById('historyStudentId').value;
showToast('正在导出历史记录...', 'info'); showToast('正在导出历史记录...', 'info');
try { try {
const reasonFilter = document.getElementById('historyReasonFilter').value; var reasonFilter = document.getElementById('historyReasonFilter').value;
const params = { page: 1, page_size: 1000 }; var params = { page: 1, page_size: 1000 };
if (startDate) params.start_date = startDate; if (startDate) params.start_date = startDate;
if (endDate) params.end_date = endDate; if (endDate) params.end_date = endDate;
if (studentId) params.student_id = studentId; if (studentId) params.student_id = studentId;
if (reasonFilter) params.reason_prefix = reasonFilter; if (reasonFilter) params.reason_prefix = reasonFilter;
const res = await apiGet('/api/admin/conduct/history', params); var res = await apiGet('/api/admin/conduct/history', params);
if (res && res.success && res.data.records) { if (res && res.success && res.data.records) {
const records = res.data.records; var records = res.data.records;
if (records.length === 0) { if (records.length === 0) {
showToast('没有找到记录', 'warning'); showToast('没有找到记录', 'warning');
return; return;
} }
let csv = '\uFEFF'; var csv = '\uFEFF';
csv += '时间,学号,姓名,分数变动,原因,操作人\n'; csv += '时间,学号,姓名,分数变动,原因,操作人\n';
records.forEach(r => { records.forEach(function(r) {
csv += `${r.created_at || ''},${r.student_no || ''},${r.student_name || ''},${r.points_change > 0 ? '+' : ''}${r.points_change},${(r.reason || '').replace(/,/g, ';')},${r.recorder_name || ''}\n`; csv += (r.created_at || '') + ',' + (r.student_no || '') + ',' + (r.student_name || '') + ',' + (r.points_change > 0 ? '+' : '') + r.points_change + ',' + (r.reason || '').replace(/,/g, ';') + ',' + (r.recorder_name || '') + '\n';
}); });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
const link = document.createElement('a'); var link = document.createElement('a');
link.href = url; link.href = url;
link.download = `历史记录_${new Date().toISOString().slice(0,10)}.csv`; link.download = '历史记录_' + new Date().toISOString().slice(0,10) + '.csv';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast(`导出成功,共${records.length}条记录`); showToast('导出成功,共' + records.length + '条记录');
} else { } else {
showToast('导出失败:' + (res?.message || '未知错误'), 'error'); showToast('导出失败:' + (res && res.message || '未知错误'), 'error');
} }
} catch (err) { } catch (err) {
showToast('导出失败:' + err.message, 'error'); showToast('导出失败:' + err.message, 'error');
@@ -194,13 +227,12 @@ async function exportHistoryRecords() {
// 批量撤销合并记录(按条件查找并撤销) // 批量撤销合并记录(按条件查找并撤销)
async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) { async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt) {
if (!confirm(`确定要撤销所有"${reason}"(${pointsChange > 0 ? '+' : ''}${pointsChange}分)的记录吗?`)) return; if (!confirm('确定要撤销所有"' + reason + '"(' + (pointsChange > 0 ? '+' : '') + pointsChange + '分)的记录吗?')) return;
showToast('正在批量撤销...', 'info'); showToast('正在批量撤销...', 'info');
try { try {
// 先查询匹配的记录 var params = {
const params = {
page: 1, page_size: 1000, page: 1, page_size: 1000,
start_date: document.getElementById('historyStartDate').value, start_date: document.getElementById('historyStartDate').value,
end_date: document.getElementById('historyEndDate').value, end_date: document.getElementById('historyEndDate').value,
@@ -208,15 +240,14 @@ async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt)
grouped: false grouped: false
}; };
const res = await apiGet('/api/admin/conduct/history', params); var res = await apiGet('/api/admin/conduct/history', params);
if (!res || !res.success || !res.data.records) { if (!res || !res.success || !res.data.records) {
showToast('查询记录失败', 'error'); showToast('查询记录失败', 'error');
return; return;
} }
// 精确匹配 var matchedIds = [];
const matchedIds = []; res.data.records.forEach(function(r) {
res.data.records.forEach(r => {
if (r.reason === reason && r.points_change === pointsChange && r.is_revoked == 0) { if (r.reason === reason && r.points_change === pointsChange && r.is_revoked == 0) {
matchedIds.push(r.record_id); matchedIds.push(r.record_id);
} }
@@ -227,32 +258,33 @@ async function batchRevokeGrouped(reason, pointsChange, recorderName, createdAt)
return; return;
} }
const revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds }); var revokeRes = await apiPost('/api/admin/conduct/batch-revoke', { record_ids: matchedIds });
if (revokeRes && revokeRes.success) { if (revokeRes && revokeRes.success) {
showToast(`批量撤销完成: ${revokeRes.data.success_count}条成功`); showToast('批量撤销完成: ' + (revokeRes.data ? revokeRes.data.success_count : 0) + '条成功');
loadHistory(currentHistoryPage); loadHistory(currentHistoryPage);
} else { } else {
showToast(revokeRes?.message || '批量撤销失败', 'error'); showToast(revokeRes && revokeRes.message || '批量撤销失败', 'error');
} }
} catch (err) { } catch (err) {
showToast('批量撤销失败: ' + err.message, 'error'); showToast('批量撤销失败: ' + err.message, 'error');
} }
} }
loadStudentsForSelect().then(() => { loadStudentsForSelect().then(function() {
const urlParams = new URLSearchParams(window.location.search); var urlParams = new URLSearchParams(window.location.search);
const preStudentId = urlParams.get('student_id'); var preStudentId = urlParams.get('student_id');
if (preStudentId) { if (preStudentId) {
document.getElementById('historyStudentId').value = preStudentId; document.getElementById('historyStudentId').value = preStudentId;
loadHistory(); onStudentFilterChange();
} else {
loadHistory();
} }
loadHistory();
}); });
window.loadHistory = loadHistory; window.loadHistory = loadHistory;
window.loadStudentsForSelect = loadStudentsForSelect; window.loadStudentsForSelect = loadStudentsForSelect;
window.exportHistoryRecords = exportHistoryRecords; window.exportHistoryRecords = exportHistoryRecords;
window.batchRevokeGrouped = batchRevokeGrouped; window.batchRevokeGrouped = batchRevokeGrouped;
window.onStudentFilterChange = onStudentFilterChange;
window.toggleFilterPanel = toggleFilterPanel;
})(); })();

View File

@@ -127,25 +127,29 @@ function toggleSubjectPanel() {
async function loadSubjectList() { async function loadSubjectList() {
const res = await apiGet('/api/subject/list'); const res = await apiGet('/api/subject/list');
if (res && res.success) { if (res && res.success && res.data) {
let html = ''; let html = '';
res.data.subjects.forEach(sub => { const subjects = res.data.subjects || [];
subjects.forEach(sub => {
const safeName = escapeHtml(sub.subject_name || '');
const safeCode = escapeHtml(sub.subject_code || '');
const sortOrder = sub.sort_order || 0;
html += ` html += `
<div class="subject-item"> <div class="subject-item">
<span class="subject-name">${escapeHtml(sub.subject_name)}</span> <span class="subject-name">${safeName}</span>
<span class="subject-code">${escapeHtml(sub.subject_code || '')}</span> <span class="subject-code">${safeCode}</span>
<span class="subject-status ${sub.is_active ? 'subject-status-active' : 'subject-status-inactive'}"> <span class="subject-status ${sub.is_active ? 'subject-status-active' : 'subject-status-inactive'}">
${sub.is_active ? '启用' : '禁用'} ${sub.is_active ? '启用' : '禁用'}
</span> </span>
<button class="btn btn-sm btn-outline" onclick="showEditSubjectModal(${sub.subject_id}, '${escapeHtml(sub.subject_name)}', '${escapeHtml(sub.subject_code || '')}', ${sub.sort_order || 0})">编辑</button> <button class="btn btn-sm btn-outline" onclick="showEditSubjectModal(${sub.subject_id}, '${safeName.replace(/'/g, "\\'")}', '${safeCode.replace(/'/g, "\\'")}', ${sortOrder})">编辑</button>
<button class="btn btn-sm btn-ghost" onclick="toggleSubjectStatus(${sub.subject_id}, ${!sub.is_active})"> <button class="btn btn-sm btn-ghost" onclick="toggleSubjectStatus(${sub.subject_id}, ${sub.is_active ? 'false' : 'true'})">
${sub.is_active ? '禁用' : '启用'} ${sub.is_active ? '禁用' : '启用'}
</button> </button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteSubject(${sub.subject_id})">删除</button> <button class="btn btn-sm btn-outline-danger" onclick="deleteSubject(${sub.subject_id})">删除</button>
</div> </div>
`; `;
}); });
if (res.data.subjects.length === 0) { if (subjects.length === 0) {
html = '<p style="text-align:center;padding:40px;">暂无科目,请点击"添加科目"</p>'; html = '<p style="text-align:center;padding:40px;">暂无科目,请点击"添加科目"</p>';
} }
document.getElementById('subjectList').innerHTML = html; document.getElementById('subjectList').innerHTML = html;

View File

@@ -15,12 +15,9 @@
// HTML转义 // HTML转义
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/[&<>]/g, function(m) { var el = document.createElement('span');
if (m === '&') return '&'; el.appendChild(document.createTextNode(str));
if (m === '<') return '<'; return el.innerHTML;
if (m === '>') return '>';
return m;
});
} }
// 全选功能 // 全选功能

View File

@@ -81,10 +81,12 @@ async function loadSemesters() {
recordText = `${conductCount}条操行分 / ${attendanceCount}条考勤`; recordText = `${conductCount}条操行分 / ${attendanceCount}条考勤`;
} }
const weekText = sem.current_week ? `${sem.current_week}` : '-';
html += `<tr> html += `<tr>
<td>${escapeHtml(sem.semester_name)}</td> <td>${escapeHtml(sem.semester_name)}</td>
<td>${formatDate(sem.start_date)}</td> <td>${formatDate(sem.start_date)}</td>
<td>${formatDate(sem.end_date)}</td> <td>${formatDate(sem.end_date)}</td>
<td>${weekText}</td>
<td><span class="${statusClass}">${statusText}</span></td> <td><span class="${statusClass}">${statusText}</span></td>
<td>${recordText}</td> <td>${recordText}</td>
<td>${formatDateTime(sem.created_at)}</td> <td>${formatDateTime(sem.created_at)}</td>
@@ -92,7 +94,7 @@ async function loadSemesters() {
</tr>`; </tr>`;
}); });
if (semesters.length === 0) { if (semesters.length === 0) {
html = '<tr><td colspan="7" style="text-align:center;">暂无学期,请点击上方按钮创建新学期</td></tr>'; html = '<tr><td colspan="8" style="text-align:center;">暂无学期,请点击上方按钮创建新学期</td></tr>';
} }
document.getElementById('semesterList').innerHTML = html; document.getElementById('semesterList').innerHTML = html;
} }

View File

@@ -82,7 +82,7 @@ async function loadHistory(page) {
const pointsText = record.points_change > 0 ? `+${record.points_change}` : record.points_change; const pointsText = record.points_change > 0 ? `+${record.points_change}` : record.points_change;
html += `<tr> html += `<tr>
<td>${formatDateTime(record.created_at)}</td> <td>${formatDateTime(record.created_at)}</td>
<td class="preserve-newlines">${escapeHtml(record.reason || '-')}</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>班主任</td> <td>班主任</td>
</tr>`; </tr>`;

View File

@@ -232,8 +232,8 @@ INSERT IGNORE INTO `subjects` (`subject_name`, `subject_code`, `sort_order`) VAL
-- 初始化系统版本号 -- 初始化系统版本号
INSERT INTO `system_settings` (`setting_key`, `setting_value`) INSERT INTO `system_settings` (`setting_key`, `setting_value`)
VALUES ('db_version', '2.5') VALUES ('db_version', '2.5.1')
ON DUPLICATE KEY UPDATE `setting_value` = '2.5'; ON DUPLICATE KEY UPDATE `setting_value` = '2.5.1';
-- 控制台输出初始化结果(含版本号) -- 控制台输出初始化结果(含版本号)
SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message; SELECT CONCAT('数据库初始化完成!版本: v', (SELECT setting_value FROM system_settings WHERE setting_key = 'db_version')) AS message;

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

@@ -0,0 +1,11 @@
-- ===========================================
-- 班级操行分管理系统 - v2.5 → v2.5.1 升级脚本
-- 字符集: utf8mb4
--
-- 说明: v2.5.1 为 UI 优化版本,无数据库 schema 变更。
-- 主要变更:
-- 1. 筛选学生时自动取消合并记录
-- 2. 合并记录选项样式优化(修复竖排显示)
-- 3. 历史记录筛选功能改为折叠式
-- 4. 科目管理调用修复
-- ===========================================

View File

@@ -4,7 +4,7 @@
-- --
-- 说明: v2.5 为功能增强版本,无数据库 schema 变更。 -- 说明: v2.5 为功能增强版本,无数据库 schema 变更。
-- 主要变更: -- 主要变更:
-- 1. 历史记录页优化(文字宽度/换行/合并按钮样式) -- 1. 历史记录页UI优化(文字宽度/换行/合并按钮样式)
-- 2. 新增"状态"筛选项(正常/已撤销) -- 2. 新增"状态"筛选项(正常/已撤销)
-- 3. 合并记录支持批量撤销/反撤销 -- 3. 合并记录支持批量撤销/反撤销
-- 4. 操作菜单底部遮挡修复 -- 4. 操作菜单底部遮挡修复

View File

@@ -28,6 +28,7 @@ $UPGRADE_VERSIONS = [
'2.3' => __DIR__ . '/sql/upgrades/v2.3.sql', '2.3' => __DIR__ . '/sql/upgrades/v2.3.sql',
'2.4' => __DIR__ . '/sql/upgrades/v2.4.sql', '2.4' => __DIR__ . '/sql/upgrades/v2.4.sql',
'2.5' => __DIR__ . '/sql/upgrades/v2.5.sql', '2.5' => __DIR__ . '/sql/upgrades/v2.5.sql',
'2.5.1' => __DIR__ . '/sql/upgrades/v2.5.1.sql',
]; ];
/** /**