Files
squad-rain-ops-mini/wwwroot/ban-manager.html

795 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>封禁管理</title>
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
<style>
:root {
--primary-color: #0056b3;
--bg-color: #f0f2f5;
--card-bg: #ffffff;
--text-color: #2c3e50;
--border-color: #d1d5db;
--secondary-color: #6c757d;
--danger-color: #d93025;
--success-color: #107c10;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 1.5rem;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
color: #d93025;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background-color 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #a80000;
}
.btn-secondary {
background-color: #fff;
border: 1px solid #ccc;
color: #333;
}
.btn-secondary:hover {
background-color: #f0f0f0;
}
.container {
flex: 1;
background: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
padding: 15px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: #fcfcfc;
}
.search-box {
position: relative;
width: 300px;
}
.search-input {
width: 100%;
padding: 8px 12px;
padding-left: 30px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #888;
}
.table-container {
flex: 1;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
background: #f8f9fa;
padding: 12px 20px;
text-align: left;
font-weight: 600;
color: #555;
position: sticky;
top: 0;
border-bottom: 1px solid #eee;
}
td {
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
}
tr:hover {
background-color: #fff1f0;
}
.status-expired {
color: #999;
background: #eee;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
}
.status-active {
color: #d93025;
background: #ffebe9;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.steamid-text {
font-family: monospace;
color: #666;
}
.reason-text {
color: #333;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: white;
padding: 25px;
border-radius: 8px;
width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-title {
margin-top: 0;
margin-bottom: 20px;
font-size: 1.2rem;
}
.form-group {
margin-bottom: 15px;
}
.form-label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-input,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.form-row {
display: flex;
gap: 10px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 25px;
}
.empty-state {
padding: 40px;
text-align: center;
color: #999;
}
.status-msg {
margin-right: 15px;
font-size: 0.9rem;
}
.unsaved-badge {
display: inline-block;
background: #d93025;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.8rem;
margin-left: 10px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background-color: #eee;
}
</style>
</head>
<body>
<script>
(function() {
const required = 'bans.view';
const permsStr = localStorage.getItem('permissions');
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
if (isSuper) return;
if (!permsStr) {
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;color:red;font-size:1.5rem;">Access Denied: Not logged in.</div>';
throw new Error("Access Denied");
}
try {
const perms = JSON.parse(permsStr);
if (!perms.includes('all') && !perms.includes(required)) {
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;color:red;font-size:1.5rem;">Access Denied: Permission "' + required + '" required.</div>';
throw new Error("Access Denied");
}
} catch(e) {
if(e.message === "Access Denied") throw e;
console.error(e);
}
})();
</script>
<div class="header">
<div class="title">
<a href="/" class="btn btn-secondary btn-sm" style="margin-right: 10px;"><i class="fas fa-arrow-left"></i> 返回</a>
<i class="fas fa-ban"></i> 封禁管理
</div>
<div>
<span id="status-msg" class="status-msg"></span>
<button class="btn btn-secondary" onclick="loadBans()">
<i class="fas fa-sync"></i> 刷新
</button>
<button class="btn btn-primary" onclick="syncBans()" id="btnSync">
<i class="fas fa-file-export"></i> 同步到 Ban.cfg
</button>
</div>
</div>
<div class="container">
<div class="toolbar">
<div class="search-box">
<span class="search-icon"><i class="fas fa-search"></i></span>
<input type="text" class="search-input" id="search-input" placeholder="搜索 SteamID 或 原因..."
onkeyup="filterBans()">
</div>
<button class="btn btn-primary" onclick="showAddBanModal()">
<i class="fas fa-plus"></i> 新增封禁
</button>
</div>
<div class="table-container">
<table id="ban-table">
<thead>
<tr>
<th width="100" class="sortable" onclick="sortBans('status')"><i class="fas fa-sort"></i> 状态</th>
<th width="180" class="sortable" onclick="sortBans('steamId')"><i class="fas fa-sort"></i> SteamID</th>
<th width="160" class="sortable" onclick="sortBans('timestamp')"><i class="fas fa-sort"></i> 到期时间</th>
<th width="120" class="sortable" onclick="sortBans('remaining')"><i class="fas fa-sort"></i> 剩余时间</th>
<th class="sortable" onclick="sortBans('reason')"><i class="fas fa-sort"></i> 封禁原因</th>
<th width="100" class="sortable" onclick="sortBans('operator')"><i class="fas fa-sort"></i> 操作人</th>
<th width="160" class="sortable" onclick="sortBans('unbanTime')"><i class="fas fa-sort"></i> 解封时间</th>
<th width="100" class="sortable" onclick="sortBans('unbanOperator')"><i class="fas fa-sort"></i> 解封人</th>
<th width="100">操作</th>
</tr>
</thead>
<tbody id="ban-list">
<!-- Ban items here -->
</tbody>
</table>
</div>
</div>
<!-- Add/Edit Ban Modal -->
<div class="modal-overlay" id="ban-modal">
<div class="modal">
<h3 class="modal-title" id="modal-title">新增封禁</h3>
<input type="hidden" id="edit-index">
<div class="form-group">
<label class="form-label">SteamID</label>
<input type="text" class="form-input" id="ban-steamid" placeholder="例如: 00029f57b77c...">
</div>
<div class="form-group">
<label class="form-label">封禁原因</label>
<input type="text" class="form-input" id="ban-reason" placeholder="例如: Teamkill">
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label class="form-label">封禁时长</label>
<select class="form-select" id="ban-duration" onchange="updateCustomDate()">
<option value="3600">1 小时</option>
<option value="86400">1 天</option>
<option value="604800">1 周</option>
<option value="2592000">1 个月</option>
<option value="31536000">1 年</option>
<option value="0">永久</option>
<option value="custom">自定义时间</option>
</select>
</div>
</div>
<div class="form-group" id="custom-date-group" style="display: none;">
<label class="form-label">自定义到期时间</label>
<input type="datetime-local" class="form-input" id="ban-custom-date">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('ban-modal')"><i class="fas fa-times"></i> 取消</button>
<button class="btn btn-primary" onclick="confirmSaveBan()"><i class="fas fa-check"></i> 确定</button>
</div>
</div>
</div>
<script>
// Permission Check Helper
window.checkPermission = function(key) {
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
if (isSuper) return true;
try {
const perms = JSON.parse(localStorage.getItem('permissions') || '[]');
return perms.includes('all') || perms.includes(key);
} catch { return false; }
};
(function() {
const required = 'bans.view';
if (!checkPermission(required)) {
document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;color:red;font-size:1.5rem;">Access Denied: Permission "bans.view" required.</div>';
throw new Error("Access Denied");
}
})();
const CONFIG_FILENAME = 'Bans.cfg';
let bans = []; // { steamId, timestamp, reason }
let isEditing = false;
let hasUnsavedChanges = false;
let sortConfig = { key: 'timestamp', direction: 'desc' };
document.addEventListener('DOMContentLoaded', () => {
loadBans();
// Button Permissions
if (!checkPermission('bans.add')) {
const btn = document.querySelector('button[onclick="showAddBanModal()"]');
if (btn) btn.style.display = 'none';
}
if (!checkPermission('bans.edit')) {
const btn = document.getElementById('btnSync');
if (btn) btn.style.display = 'none';
}
});
let currentPage = 1;
let currentSearch = '';
let currentSort = { field: 'id', desc: true };
// Load sync status on start
updateSyncStatus();
// Update sync status every minute
setInterval(updateSyncStatus, 60000);
async function updateSyncStatus() {
try {
const response = await fetch('/api/bans/sync-status');
if (!response.ok) return;
const data = await response.json();
const btn = document.getElementById('btnSync');
if (data.nextSync && data.nextSync !== '0001-01-01T00:00:00') {
const nextDate = new Date(data.nextSync);
const timeStr = nextDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
btn.innerHTML = `<i class="fas fa-file-export"></i> 同步到 Ban.cfg (下次: ${timeStr})`;
btn.title = `上次同步: ${new Date(data.lastSync).toLocaleString()}\n下次同步: ${new Date(data.nextSync).toLocaleString()}`;
} else {
// Initial state or just started
btn.innerHTML = `<i class="fas fa-file-export"></i> 同步到 Ban.cfg (即将同步)`;
}
} catch(e) {
console.error("Failed to fetch sync status", e);
}
}
async function loadBans() {
const status = document.getElementById('status-msg');
status.textContent = '正在加载...';
try {
// Fetch from new API
const response = await fetch('/api/bans?page=1&pageSize=500'); // Fetch up to 500 for now
if (!response.ok) throw new Error('Load failed');
const data = await response.json();
bans = data.list || [];
renderBans();
status.textContent = '加载成功';
setTimeout(() => status.textContent = '', 2000);
} catch (err) {
console.error(err);
status.textContent = '加载失败: ' + err.message;
status.style.color = 'red';
}
}
// parseBans removed
// saveBans removed
function renderBans() {
const list = document.getElementById('ban-list');
list.innerHTML = '';
let displayedCount = 0;
const filter = document.getElementById('search-input').value.toLowerCase();
const now = Math.floor(Date.now() / 1000);
// Client-side sorting
bans.sort((a, b) => {
let valA, valB;
let key = sortConfig.key;
if (key === 'timestamp') key = 'expiryTimestamp';
if (key === 'status') {
// 0=active, 1=expired, 2=unbanned
valA = a.status !== undefined ? a.status : 0;
valB = b.status !== undefined ? b.status : 0;
} else {
valA = a[key];
valB = b[key];
}
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
bans.forEach((ban) => {
if (filter && !ban.steamId.toLowerCase().includes(filter) && !ban.reason.toLowerCase().includes(filter)) {
return;
}
displayedCount++;
const isPermanent = ban.expiryTimestamp === 0;
// status: 0=Active, 1=Expired, 2=Unbanned
let status = ban.status !== undefined ? ban.status : 0;
if (status === 0 && !isPermanent && ban.expiryTimestamp < now) {
status = 1; // Expired
}
let statusHtml = '';
let remainingStr = '-';
if (status === 2) {
statusHtml = '<span class="status-expired" style="background-color: #d93025; color: #fff;">已解封</span>';
remainingStr = '已解封';
} else if (status === 1) {
statusHtml = '<span class="status-expired">已过期</span>';
remainingStr = '0';
} else {
statusHtml = '<span class="status-active">封禁中</span>';
if (isPermanent) {
statusHtml = '<span class="status-active" style="background-color: #333; color: #fff;">永久封禁</span>';
remainingStr = '∞';
} else {
const diff = ban.expiryTimestamp - now;
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) remainingStr = `${days}${hours}小时`;
else remainingStr = `${hours}小时 ${Math.floor((diff % 3600) / 60)}`;
}
}
const dateStr = isPermanent ? '永久' : new Date(ban.expiryTimestamp * 1000).toLocaleString();
const unbanDateStr = ban.unbanTime ? new Date(ban.unbanTime).toLocaleString() : '-';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${statusHtml}</td>
<td class="steamid-text">${ban.steamId}</td>
<td>${dateStr}</td>
<td>${remainingStr}</td>
<td class="reason-text">${ban.reason || '-'}</td>
<td>${ban.operator || '-'}</td>
<td>${unbanDateStr}</td>
<td>${ban.unbanOperator || '-'}</td>
<td>
${status === 0 && checkPermission('bans.delete') ? `<button class="btn btn-secondary" style="padding: 4px 8px; font-size: 0.8rem; color: #d93025;" onclick="deleteBan('${ban.steamId}')"><i class="fas fa-trash"></i> 解封</button>` : ''}
</td>
`;
list.appendChild(tr);
});
if (displayedCount === 0) {
list.innerHTML = '<tr><td colspan="6" class="empty-state">暂无数据</td></tr>';
}
}
function sortBans(key) {
if (sortConfig.key === key) {
sortConfig.direction = sortConfig.direction === 'asc' ? 'desc' : 'asc';
} else {
sortConfig.key = key;
sortConfig.direction = 'desc';
}
renderBans();
}
function updateSaveButtonState() {
const btn = document.getElementById('save-btn');
if (hasUnsavedChanges) {
if (!document.getElementById('unsaved-badge')) {
const badge = document.createElement('span');
badge.id = 'unsaved-badge';
badge.className = 'unsaved-badge';
badge.textContent = '未保存';
btn.appendChild(badge);
}
} else {
const badge = document.getElementById('unsaved-badge');
if (badge) badge.remove();
}
}
function markUnsaved() {
hasUnsavedChanges = true;
updateSaveButtonState();
}
function filterBans() {
renderBans();
}
// Modal Functions
function showAddBanModal() {
isEditing = false;
document.getElementById('modal-title').textContent = '新增封禁';
document.getElementById('ban-steamid').value = '';
document.getElementById('ban-steamid').disabled = false;
document.getElementById('ban-reason').value = '';
document.getElementById('ban-duration').value = '604800'; // Default 1 week
updateCustomDate();
document.getElementById('ban-modal').classList.add('show');
}
function editBan(index) {
isEditing = true;
const ban = bans[index];
document.getElementById('modal-title').textContent = '编辑封禁';
document.getElementById('edit-index').value = index;
document.getElementById('ban-steamid').value = ban.steamId;
document.getElementById('ban-steamid').disabled = false; // Allow editing
document.getElementById('ban-reason').value = ban.reason;
// Try to match duration or set custom
const now = Math.floor(Date.now() / 1000);
if (ban.timestamp === 0) {
document.getElementById('ban-duration').value = '0';
updateCustomDate();
} else {
// Check if standard duration matches roughly (not perfect, but good for UI)
// Or just set to custom date
document.getElementById('ban-duration').value = 'custom';
updateCustomDate();
const date = new Date(ban.timestamp * 1000);
const iso = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
document.getElementById('ban-custom-date').value = iso;
}
document.getElementById('ban-modal').classList.add('show');
}
function updateCustomDate() {
const duration = document.getElementById('ban-duration').value;
const customGroup = document.getElementById('custom-date-group');
if (duration === 'custom') {
customGroup.style.display = 'block';
} else {
customGroup.style.display = 'none';
}
}
async function confirmSaveBan() {
const steamId = document.getElementById('ban-steamid').value.trim();
const reason = document.getElementById('ban-reason').value.trim();
const durationType = document.getElementById('ban-duration').value;
if (!steamId) return alert('请输入 SteamID');
let duration = 'perm';
if (durationType === 'custom') {
const dateStr = document.getElementById('ban-custom-date').value;
if (!dateStr) return alert('请选择到期时间');
const target = new Date(dateStr).getTime() / 1000;
const now = Date.now() / 1000;
const seconds = Math.floor(target - now);
if (seconds <= 0) return alert('到期时间必须在未来');
duration = seconds.toString();
} else {
let seconds = parseInt(durationType, 10);
if (seconds === 0) duration = 'perm';
else duration = seconds.toString();
}
try {
const response = await fetch('/api/players/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
steamId: steamId,
reason: reason,
duration: duration
})
});
if (!response.ok) throw new Error('Failed to ban');
loadBans();
closeModal('ban-modal');
alert('封禁成功');
} catch(e) {
alert('封禁失败: ' + e.message);
}
}
async function deleteBan(steamId) {
if (confirm('确定要解封该玩家吗?')) {
try {
const response = await fetch('/api/bans/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId: steamId })
});
if (!response.ok) throw new Error('Unban failed');
loadBans();
alert('解封成功');
} catch(e) {
alert('解封失败: ' + e.message);
}
}
}
async function syncBans() {
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 同步中...';
try {
const response = await fetch('/api/bans/sync', { method: 'POST' });
if (!response.ok) throw new Error('Sync failed');
const data = await response.json();
alert('同步成功');
updateSyncStatus(); // Refresh status immediately
} catch(e) {
alert('同步失败: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// saveBans function removed as it is now handled by API directly
async function executeRconCommand(cmd) {
try {
console.log('Sending RCON:', cmd);
await fetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd })
});
} catch (e) {
console.error('RCON Command failed:', e);
}
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
// Close modal on outside click
window.onclick = function (event) {
if (event.target.classList.contains('modal-overlay')) {
event.target.classList.remove('show');
}
}
</script>
</body>
</html>