feat: 多班级版班级管理系统 v2.0
技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 主要功能: - 多班级完全隔离(class_id 贯穿全系统) - 后端 Go Gin(端口 56789),Nginx 反代 - 超级管理员独立登录(env 配置,默认账密 admin/Admin123) - bcrypt 密码加密(无 PASSWORD_SALT) - 科任老师/课代表新角色 - 课代表作业管理页面 - 排行榜分项排行(操行分/考勤/作业) - 角色加减分上下限由班主任配置 - 家长改密功能(可开关) - 班级角色按需开关 - 宿舍号格式:南0-000 - 周度/月度重置功能 - MySQL 5.7 兼容 - 43 轮代码审查 + 全部修复 开发者: Canglan 版权归属: Sea Network Technology Studio 许可证: Apache License 2.0
This commit is contained in:
425
frontend/assets/js/common.js
Normal file
425
frontend/assets/js/common.js
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 多班级版班级管理系统 - 公共JS
|
||||
*
|
||||
* 开发者: Canglan
|
||||
* 联系方式: admin@sea-studio.top
|
||||
* 版权归属: Sea Network Technology Studio
|
||||
* 许可证: Apache License 2.0
|
||||
*
|
||||
* 版权所有 © Sea Network Technology Studio
|
||||
*/
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(window.JWT_STORAGE_KEY || 'class_system_token');
|
||||
}
|
||||
|
||||
function getUserInfo() {
|
||||
const userStr = localStorage.getItem(window.USER_STORAGE_KEY || 'class_system_user');
|
||||
if (!userStr) return null;
|
||||
try {
|
||||
return JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setUserInfo(user) {
|
||||
localStorage.setItem(window.USER_STORAGE_KEY || 'class_system_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
localStorage.removeItem(window.JWT_STORAGE_KEY || 'class_system_token');
|
||||
localStorage.removeItem(window.USER_STORAGE_KEY || 'class_system_user');
|
||||
}
|
||||
|
||||
async function apiRequest(url, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const baseUrl = window.API_BASE_URL;
|
||||
const fullUrl = `${baseUrl}${url}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, { ...options, headers });
|
||||
const data = await response.json();
|
||||
|
||||
if (response.status === 401) {
|
||||
clearAuth();
|
||||
|
||||
// 同步清除 PHP Session,防止 index.php 302 重定向循环
|
||||
try {
|
||||
await fetch('/api/clear_session.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Auth] 清除PHP Session失败:', e);
|
||||
}
|
||||
|
||||
// 防循环机制:检查是否已在登录页
|
||||
if (window.location.pathname === '/index.php' || window.location.pathname === '/') {
|
||||
console.warn('[Auth] 已在登录页收到401,停止重定向');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 防循环机制:5秒内重复401则停止重定向
|
||||
const now = Date.now();
|
||||
const lastRedirect = parseInt(sessionStorage.getItem('_last_401_redirect') || '0');
|
||||
if (now - lastRedirect < 5000) {
|
||||
console.warn('[Auth] 5秒内重复401,停止重定向。请检查Token是否有效。');
|
||||
return null;
|
||||
}
|
||||
sessionStorage.setItem('_last_401_redirect', now.toString());
|
||||
|
||||
window.location.href = '/index.php';
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API请求错误:', error);
|
||||
showToast('网络错误,请稍后重试', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function apiGet(url, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return apiRequest(fullUrl, { method: 'GET' });
|
||||
}
|
||||
|
||||
function apiPost(url, data = {}) {
|
||||
return apiRequest(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
function apiPut(url, data = {}) {
|
||||
return apiRequest(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
function apiDelete(url) {
|
||||
return apiRequest(url, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getStatusBadge(status, type = 'attendance') {
|
||||
const statusMap = {
|
||||
attendance: {
|
||||
'present': '出勤',
|
||||
'absent': '缺勤',
|
||||
'late': '迟到',
|
||||
'leave': '请假'
|
||||
}
|
||||
};
|
||||
const texts = statusMap[type] || statusMap.attendance;
|
||||
const text = texts[status] || status;
|
||||
let className = 'status-badge ';
|
||||
switch (status) {
|
||||
case 'present':
|
||||
className += 'status-submitted';
|
||||
break;
|
||||
case 'absent':
|
||||
className += 'status-not_submitted';
|
||||
break;
|
||||
case 'late':
|
||||
className += 'status-late';
|
||||
break;
|
||||
case 'leave':
|
||||
className += 'status-leave';
|
||||
break;
|
||||
default:
|
||||
className += 'status-not_submitted';
|
||||
}
|
||||
return `<span class="${className}">${text}</span>`;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
// 清除 PHP Session
|
||||
try {
|
||||
await fetch('/api/clear_session.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('清除Session失败', e);
|
||||
}
|
||||
|
||||
// 清除后端 Token
|
||||
try {
|
||||
await apiPost('/api/auth/logout');
|
||||
} catch (e) {
|
||||
console.warn('后端登出失败', e);
|
||||
}
|
||||
|
||||
// 清除 localStorage
|
||||
clearAuth();
|
||||
|
||||
// 跳转回登录页
|
||||
window.location.href = '/index.php';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/`/g, '`')
|
||||
.replace(/\//g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分页渲染(最多显示7个页码 + 跳转输入框)
|
||||
* @param {string|HTMLElement} container - 分页容器ID或DOM元素
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数
|
||||
* @param {function} onPageChange - 页码变化回调函数,参数为新的页码
|
||||
*/
|
||||
function renderSmartPagination(container, currentPage, totalPages, onPageChange) {
|
||||
if (typeof container === 'string') {
|
||||
container = document.getElementById(container);
|
||||
}
|
||||
if (!container || totalPages <= 1) {
|
||||
if (container) container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 7;
|
||||
let html = '';
|
||||
|
||||
// 上一页按钮
|
||||
if (currentPage > 1) {
|
||||
html += `<a href="#" class="page-nav" data-page="${currentPage - 1}">« 上一页</a>`;
|
||||
}
|
||||
|
||||
if (totalPages <= MAX_VISIBLE) {
|
||||
// 总页数不超过最大显示数,全部显示
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === currentPage) {
|
||||
html += `<span class="active">${i}</span>`;
|
||||
} else {
|
||||
html += `<a href="#" data-page="${i}">${i}</a>`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 需要省略号
|
||||
// 始终显示第1页
|
||||
if (currentPage === 1) {
|
||||
html += `<span class="active">1</span>`;
|
||||
} else {
|
||||
html += `<a href="#" data-page="1">1</a>`;
|
||||
}
|
||||
|
||||
// 计算中间页码范围
|
||||
let start = Math.max(2, currentPage - 2);
|
||||
let end = Math.min(totalPages - 1, currentPage + 2);
|
||||
|
||||
// 调整确保中间至少有3个页码(加上首尾共5-7个)
|
||||
if (currentPage <= 3) {
|
||||
end = Math.min(5, totalPages - 1);
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
start = Math.max(2, totalPages - 4);
|
||||
}
|
||||
|
||||
// 前省略号
|
||||
if (start > 2) {
|
||||
html += `<span class="ellipsis">...</span>`;
|
||||
}
|
||||
|
||||
// 中间页码
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i === currentPage) {
|
||||
html += `<span class="active">${i}</span>`;
|
||||
} else {
|
||||
html += `<a href="#" data-page="${i}">${i}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 后省略号
|
||||
if (end < totalPages - 1) {
|
||||
html += `<span class="ellipsis">...</span>`;
|
||||
}
|
||||
|
||||
// 始终显示最后一页
|
||||
if (currentPage === totalPages) {
|
||||
html += `<span class="active">${totalPages}</span>`;
|
||||
} else {
|
||||
html += `<a href="#" data-page="${totalPages}">${totalPages}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页按钮
|
||||
if (currentPage < totalPages) {
|
||||
html += `<a href="#" class="page-nav" data-page="${currentPage + 1}">下一页 »</a>`;
|
||||
}
|
||||
|
||||
// 页码跳转
|
||||
html += `<span class="page-jump">跳至 <input type="number" min="1" max="${totalPages}" placeholder="页码"> / ${totalPages}页</span>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 绑定页码点击事件
|
||||
container.querySelectorAll('a[data-page]').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const page = parseInt(this.dataset.page);
|
||||
if (page && page !== currentPage && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 绑定跳转输入框事件
|
||||
const jumpInput = container.querySelector('.page-jump input');
|
||||
if (jumpInput) {
|
||||
jumpInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const page = parseInt(this.value);
|
||||
if (page && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
} else {
|
||||
showToast(`请输入1-${totalPages}之间的页码`, 'warning');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const user = getUserInfo();
|
||||
const userNameSpan = document.getElementById('userName');
|
||||
if (userNameSpan && user) {
|
||||
userNameSpan.textContent = user.real_name || user.username;
|
||||
}
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', logout);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleActionDropdown(el) {
|
||||
var dropdown = el.closest('.action-dropdown');
|
||||
if (!dropdown) return;
|
||||
var menu = dropdown.querySelector('.action-dropdown-menu');
|
||||
if (!menu) return;
|
||||
|
||||
var isOpen = menu.classList.contains('show');
|
||||
// 先关闭所有
|
||||
closeAllDropdowns();
|
||||
|
||||
if (!isOpen) {
|
||||
// 使用 fixed 定位,避免被 overflow 容器裁剪
|
||||
var rect = el.getBoundingClientRect();
|
||||
// 先显示以便测量高度
|
||||
menu.classList.add('show');
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.bottom = 'auto';
|
||||
menu.style.right = 'auto';
|
||||
menu.style.transform = 'none';
|
||||
var menuHeight = menu.offsetHeight;
|
||||
// 智能判断:按钮在上半部分则菜单显示在下方,否则显示在上方
|
||||
if (rect.top < window.innerHeight / 2) {
|
||||
menu.style.top = rect.bottom + 'px';
|
||||
} else {
|
||||
menu.style.top = (rect.top - menuHeight) + 'px';
|
||||
}
|
||||
menu.style.left = Math.min(rect.left, window.innerWidth - 130) + 'px';
|
||||
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.bottom = '';
|
||||
m.style.right = '';
|
||||
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) {
|
||||
if (!e.target.closest('.action-dropdown')) {
|
||||
closeAllDropdowns();
|
||||
}
|
||||
});
|
||||
|
||||
// 全局textarea键盘事件:Ctrl+Enter提交表单,Enter换行(默认行为)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName !== 'TEXTAREA') return;
|
||||
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
// Ctrl+Enter / Cmd+Enter 提交表单
|
||||
e.preventDefault();
|
||||
var form = e.target.closest('form');
|
||||
if (form) {
|
||||
var submitEvent = new Event('submit', { cancelable: true, bubbles: true });
|
||||
form.dispatchEvent(submitEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.selectDeductionType = function(points, reason) {
|
||||
var pointsEl = document.getElementById('pointsChange');
|
||||
var reasonEl = document.getElementById('pointsReason');
|
||||
if (points === 0 && reason === '') {
|
||||
// 自定义模式 - 清空分值和原因,聚焦原因输入框
|
||||
if (pointsEl) pointsEl.value = '';
|
||||
if (reasonEl) {
|
||||
reasonEl.value = '';
|
||||
reasonEl.focus();
|
||||
}
|
||||
} else if (points === null || points === undefined) {
|
||||
// 类别模式 - 仅填充原因,聚焦分值输入框
|
||||
if (reasonEl) reasonEl.value = reason;
|
||||
if (pointsEl) {
|
||||
pointsEl.value = '';
|
||||
pointsEl.focus();
|
||||
}
|
||||
} else {
|
||||
// 预设模式 - 同时填充分值和原因
|
||||
if (pointsEl) pointsEl.value = points;
|
||||
if (reasonEl) reasonEl.value = reason;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user