mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-07 22:05:45 +08:00
2768 lines
116 KiB
HTML
2768 lines
116 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>Dashboard</title>
|
|
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
|
|
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
|
|
<script src="lib/js/signalr.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--primary-blue: #1976d2;
|
|
--primary-red: #d32f2f;
|
|
--bg-color: #f4f6f9;
|
|
--card-bg: #ffffff;
|
|
--text-primary: #333333;
|
|
--text-secondary: #666666;
|
|
--border-color: #e0e0e0;
|
|
--team-1-bg: #e3f2fd;
|
|
--team-2-bg: #ffebee;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
padding: 20px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* Server Banner */
|
|
.server-banner {
|
|
background: #fff;
|
|
padding: 15px 20px;
|
|
border-radius: 6px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
position: relative;
|
|
}
|
|
|
|
.banner-top-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
|
|
.server-title-wrapper {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.server-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 700;
|
|
color: #2c3e50;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.server-title.expanded {
|
|
white-space: normal;
|
|
overflow: visible;
|
|
}
|
|
|
|
.server-version {
|
|
font-size: 0.75rem;
|
|
color: #7f8c8d;
|
|
background: #f0f2f5;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.banner-bottom-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding-top: 15px;
|
|
border-top: 1px solid #f0f2f5;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
}
|
|
|
|
.server-stats-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 0.9rem;
|
|
color: #555;
|
|
}
|
|
|
|
.stat-item i { color: var(--primary-blue); opacity: 0.8; }
|
|
.stat-item strong { font-weight: 600; color: #444; }
|
|
|
|
.banner-tools {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.btn-tool {
|
|
padding: 6px 14px;
|
|
border: 1px solid #e0e0e0;
|
|
background: #fff;
|
|
color: #555;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-tool:hover {
|
|
background: #f8f9fa;
|
|
border-color: #ccc;
|
|
color: #333;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.search-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.search-wrapper input {
|
|
padding: 6px 10px 6px 30px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 20px;
|
|
outline: none;
|
|
font-size: 0.9rem;
|
|
width: 200px;
|
|
transition: all 0.2s;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.search-wrapper input:focus {
|
|
width: 260px;
|
|
background: #fff;
|
|
border-color: var(--primary-blue);
|
|
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: #aaa;
|
|
pointer-events: none;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Main Layout */
|
|
.container {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.team-column {
|
|
flex: 1;
|
|
min-width: 600px;
|
|
background: var(--card-bg);
|
|
border-radius: 4px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.team-header {
|
|
padding: 12px 20px;
|
|
font-weight: bold;
|
|
font-size: 1.1rem;
|
|
border-bottom: 2px solid;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.team-1-header-bg {
|
|
background-color: #fff;
|
|
border-bottom-color: var(--primary-blue);
|
|
color: var(--primary-blue);
|
|
}
|
|
|
|
.team-2-header-bg {
|
|
background-color: #fff;
|
|
border-bottom-color: var(--primary-red);
|
|
color: var(--primary-red);
|
|
}
|
|
|
|
/* Squad Styling */
|
|
.squad-group {
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.squad-header {
|
|
background-color: #f8f9fa;
|
|
padding: 6px 15px;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.squad-badge {
|
|
color: white;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 3px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-size: 0.8rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* Player Table */
|
|
.player-table-header {
|
|
display: grid;
|
|
/* ID | Status | Role | Name | SteamID | Score | Time | Action */
|
|
grid-template-columns: 40px 40px 40px 1fr 150px 80px 60px 70px;
|
|
padding: 8px 15px;
|
|
background: #fff;
|
|
border-bottom: 2px solid #eee;
|
|
font-weight: 600;
|
|
color: #555;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.player-row {
|
|
display: grid;
|
|
grid-template-columns: 40px 40px 40px 1fr 150px 80px 60px 70px;
|
|
padding: 5px 15px;
|
|
align-items: center;
|
|
border-bottom: 1px solid #f9f9f9;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.player-row:nth-child(odd) {
|
|
background-color: #fafafa;
|
|
}
|
|
|
|
.player-row:hover {
|
|
background-color: #e3f2fd !important;
|
|
}
|
|
|
|
.col-center { text-align: center; }
|
|
.col-right { text-align: right; }
|
|
.col-steam { font-family: Consolas, monospace; color: #888; font-size: 0.85rem; }
|
|
|
|
.role-img { width: 24px; height: 24px; vertical-align: middle; }
|
|
|
|
/* Actions */
|
|
.btn-operate {
|
|
padding: 2px 8px;
|
|
border: 1px solid #ddd;
|
|
background: #fff;
|
|
border-radius: 3px;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
color: #555;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.btn-operate:hover {
|
|
border-color: #bbb;
|
|
color: #333;
|
|
}
|
|
|
|
/* Dropdown */
|
|
.action-dropdown { position: relative; }
|
|
.dropdown-menu {
|
|
display: none;
|
|
position: absolute;
|
|
right: 0;
|
|
top: 100%;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
border-radius: 4px;
|
|
z-index: 100;
|
|
min-width: 120px;
|
|
padding: 5px 0;
|
|
}
|
|
.dropdown-menu.show { display: block; }
|
|
.dropdown-item {
|
|
padding: 6px 15px;
|
|
cursor: pointer;
|
|
display: block;
|
|
color: #333;
|
|
text-decoration: none;
|
|
}
|
|
.dropdown-item:hover { background: #f5f5f5; color: var(--primary-blue); }
|
|
.dropdown-item.danger { color: var(--primary-red); }
|
|
.dropdown-item.danger:hover { background: #fff0f0; }
|
|
|
|
/* Utilities */
|
|
.loading { padding: 40px; text-align: center; color: #999; }
|
|
.error { color: red; text-align: center; padding: 20px; }
|
|
|
|
/* Modal (Keep existing structure but simplify) */
|
|
.modal-overlay { display: none; position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
|
|
.modal-overlay.show { display: flex; }
|
|
|
|
.modal-content-large {
|
|
background:#fff;
|
|
width:90%;
|
|
max-width:1000px;
|
|
height:80vh;
|
|
border-radius:6px;
|
|
display:flex;
|
|
flex-direction:column;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.modal-content-custom {
|
|
background: #fff;
|
|
border-radius: 6px;
|
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 90vh;
|
|
/* width is often set inline, but default can be auto or max-content */
|
|
width: 500px;
|
|
max-width: 95%;
|
|
}
|
|
|
|
/* Missing Button Styles */
|
|
.btn-cancel {
|
|
padding: 6px 16px;
|
|
border: 1px solid #ddd;
|
|
background: #fff;
|
|
color: #555;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-cancel:hover {
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
|
|
.btn-confirm-danger {
|
|
padding: 6px 16px;
|
|
border: 1px solid #d32f2f;
|
|
background: #d32f2f;
|
|
color: #fff;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-confirm-danger:hover {
|
|
background: #b71c1c;
|
|
border-color: #b71c1c;
|
|
}
|
|
|
|
.modal-header { padding:15px; border-bottom:1px solid #eee; display:flex; justify-content:space-between; align-items:center; font-weight:bold; font-size: 1.1rem; color: #333; }
|
|
.modal-body { flex:1; overflow:hidden; position:relative; }
|
|
.modal-iframe { width:100%; height:100%; border:none; }
|
|
.modal-close-btn { background:none; border:none; font-size:1.5rem; cursor:pointer; color:#999; }
|
|
|
|
@media (max-width: 1200px) {
|
|
.team-column { min-width: 100%; }
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.player-table-header, .player-row {
|
|
/* Mobile Grid: Hide SteamID */
|
|
/* Columns: ID(30) Status(30) Role(30) Name(1fr) Score(50) Time(40) Action(50) */
|
|
grid-template-columns: 30px 30px 30px 1fr 50px 40px 50px;
|
|
font-size: 0.8rem;
|
|
padding: 5px 2px;
|
|
gap: 2px;
|
|
}
|
|
.col-steam {
|
|
display: none;
|
|
}
|
|
.team-column {
|
|
min-width: 100%;
|
|
border-radius: 0;
|
|
border-left: none;
|
|
border-right: none;
|
|
}
|
|
.container {
|
|
padding: 0;
|
|
gap: 10px;
|
|
|
|
}
|
|
body {
|
|
padding: 10px 5px;
|
|
}
|
|
/* Adjust action button for mobile */
|
|
.btn-operate {
|
|
padding: 2px 4px;
|
|
font-size: 0.75rem;
|
|
}
|
|
.btn-operate i {
|
|
display: none; /* Hide caret on mobile to save space */
|
|
}
|
|
|
|
/* Banner Mobile Optimization */
|
|
.banner-bottom-row {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 10px;
|
|
padding-top: 10px;
|
|
}
|
|
|
|
.server-stats-bar {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr; /* 2 columns for stats */
|
|
gap: 8px;
|
|
width: 100%;
|
|
}
|
|
|
|
.stat-item {
|
|
justify-content: center;
|
|
background: #f8f9fa;
|
|
padding: 5px;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.banner-tools {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr); /* 4 buttons in one row */
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
#last-updated {
|
|
grid-column: 1 / -1;
|
|
text-align: center;
|
|
order: 10;
|
|
margin-right: 0 !important;
|
|
margin-top: 8px;
|
|
font-size: 0.75rem;
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.btn-tool {
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
padding: 8px 4px;
|
|
font-size: 0.75rem;
|
|
height: 100%;
|
|
gap: 4px;
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.btn-tool i {
|
|
font-size: 1.2rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.action-dropdown {
|
|
width: 100%;
|
|
display: flex; /* Ensure button fills height */
|
|
position: relative; /* Ensure dropdown aligns correctly */
|
|
}
|
|
|
|
.action-dropdown .btn-tool {
|
|
width: 100%;
|
|
}
|
|
|
|
/* Hide caret in manage button on mobile to save space/clean look */
|
|
.btn-manage .fa-caret-down {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<script>
|
|
(function() {
|
|
const required = 'dashboard';
|
|
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="server-banner" id="server-info-banner" style="display:none;">
|
|
<div class="banner-top-row">
|
|
<div class="server-title-wrapper" onclick="toggleServerTitle(this)" title="点击展开/折叠完整名称">
|
|
<div id="server-name" class="server-title">Loading...</div>
|
|
<span id="server-version" class="server-version">v0.0.0</span>
|
|
</div>
|
|
|
|
<div class="search-wrapper">
|
|
<i class="fas fa-search search-icon"></i>
|
|
<input type="text" id="player-search" placeholder="搜索玩家 / SteamID..." oninput="filterPlayers()">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="banner-bottom-row">
|
|
<div class="server-stats-bar">
|
|
<span class="stat-item" title="当前地图">
|
|
<i class="fas fa-map"></i> <strong id="map-name">--</strong>
|
|
</span>
|
|
<span class="stat-item" title="下一局地图">
|
|
<i class="fas fa-forward"></i> <span id="next-map">--</span>
|
|
</span>
|
|
<span class="stat-item" title="对局时间">
|
|
<i class="fas fa-clock"></i> <span id="match-time">--:--:--</span>
|
|
</span>
|
|
<span class="stat-item" title="在线玩家">
|
|
<i class="fas fa-users"></i> <span id="player-count-display">0/100</span>
|
|
</span>
|
|
<span class="stat-item" title="网络延迟">
|
|
<i class="fas fa-network-wired"></i> <span id="network-delay">-- ms</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="banner-tools">
|
|
<span id="last-updated" style="color: #999; font-size: 0.8em; margin-right: 10px;"></span>
|
|
|
|
<button class="btn-tool btn-chat" onclick="openChatModal()">
|
|
<i class="fas fa-comments"></i> 聊天
|
|
</button>
|
|
<button class="btn-tool btn-battle" onclick="openBattleModal()">
|
|
<i class="fas fa-crosshairs"></i> 战斗
|
|
</button>
|
|
<button class="btn-tool btn-online" onclick="openOnlineModal()">
|
|
<i class="fas fa-list"></i> 在线
|
|
</button>
|
|
|
|
<div class="action-dropdown">
|
|
<button class="btn-tool btn-manage" onclick="toggleDropdown(this, event)">
|
|
<i class="fas fa-cogs"></i> 管理 <i class="fas fa-caret-down"></i>
|
|
</button>
|
|
<div class="dropdown-menu" style="right: 0; min-width: 220px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); padding: 8px 0; margin-top: 8px;">
|
|
<label class="dropdown-item" style="display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 10px 20px;" onclick="event.stopPropagation()">
|
|
<input type="checkbox" id="auto-refresh-check" checked onchange="toggleAutoRefresh()">
|
|
<span>自动刷新 (<span id="countdown" style="font-family: monospace; width: 20px; display: inline-block; text-align: center;">30</span>)</span>
|
|
</label>
|
|
<div style="border-top: 1px solid #eee; margin: 5px 0;"></div>
|
|
<div class="dropdown-item" id="menu-refresh" onclick="manualRefresh()" style="padding: 10px 20px;"><i class="fas fa-sync-alt" style="width:20px;text-align:center;margin-right:5px;"></i> 刷新数据</div>
|
|
<div class="dropdown-item" id="menu-shuffle" onclick="openShuffleModal()" style="padding: 10px 20px;"><i class="fas fa-dice" style="width:20px;text-align:center;margin-right:5px;"></i> 随机打乱</div>
|
|
<div class="dropdown-item" id="menu-clearafk" onclick="openClearAfkModal()" style="padding: 10px 20px;"><i class="fas fa-broom" style="width:20px;text-align:center;margin-right:5px;"></i> 清理挂机</div>
|
|
<div class="dropdown-item" id="menu-maps" onclick="showMapModal()" style="padding: 10px 20px;"><i class="fas fa-map" style="width:20px;text-align:center;margin-right:5px;"></i> 地图管理</div>
|
|
<div class="dropdown-item" id="menu-duplicate-ip" onclick="openDuplicateIpModal()" style="padding: 10px 20px;"><i class="fas fa-network-wired" style="width:20px;text-align:center;margin-right:5px;"></i> 同IP检测</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="error-msg" class="error"></div>
|
|
<div id="loading" class="loading" style="display:none;">正在加载数据...</div>
|
|
|
|
<div class="container" id="main-content" style="display:none; max-width: 100% !important;">
|
|
<!-- Team 1 -->
|
|
<div class="team-column">
|
|
<div class="team-header team-1-header-bg">
|
|
<span id="team1-name">队伍 1</span>
|
|
<span id="count-team-1" style="font-size: 0.8em; opacity: 0.7;">0 人</span>
|
|
</div>
|
|
<div class="player-table-header">
|
|
<div class="col-center">ID</div>
|
|
<div class="col-center">状态</div>
|
|
<div class="col-center">兵种</div>
|
|
<div>昵称</div>
|
|
<div>SteamID</div>
|
|
<div class="col-center">战绩</div>
|
|
<div class="col-center">时长</div>
|
|
<div class="col-right">操作</div>
|
|
</div>
|
|
<div id="list-team-1"></div>
|
|
</div>
|
|
|
|
<!-- Team 2 -->
|
|
<div class="team-column">
|
|
<div class="team-header team-2-header-bg">
|
|
<span id="team2-name">队伍 2</span>
|
|
<span id="count-team-2" style="font-size: 0.8em; opacity: 0.7;">0 人</span>
|
|
</div>
|
|
<div class="player-table-header">
|
|
<div class="col-center">ID</div>
|
|
<div class="col-center">状态</div>
|
|
<div class="col-center">兵种</div>
|
|
<div>昵称</div>
|
|
<div>SteamID</div>
|
|
<div class="col-center">战绩</div>
|
|
<div class="col-center">时长</div>
|
|
<div class="col-right">操作</div>
|
|
</div>
|
|
<div id="list-team-2"></div>
|
|
</div>
|
|
|
|
<!-- Chat Panel Removed -->
|
|
</div>
|
|
|
|
<script>
|
|
// Permission Check
|
|
(function() {
|
|
const required = 'dashboard';
|
|
const permsStr = localStorage.getItem('permissions');
|
|
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
|
|
|
|
if (isSuper) return;
|
|
|
|
// Allow dashboard for everyone usually, but user asked for strict check.
|
|
// If permsStr is missing, they might not be logged in or sub-account with no dashboard access.
|
|
// If sub-account has no "dashboard" and no "all", deny.
|
|
|
|
if (!permsStr) {
|
|
// Try to fetch permissions from API or redirect to login?
|
|
// Since this is iframe, better show message.
|
|
// Actually index.html handles login check.
|
|
// But for direct access:
|
|
// window.top.location.href = '/login.html'; // Aggressive
|
|
return;
|
|
}
|
|
|
|
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;">拒绝访问: 需要 "dashboard" 权限.</div>';
|
|
// Stop further script execution (though HTML is already rendered partially, this overwrites it)
|
|
throw new Error("Access Denied");
|
|
}
|
|
} catch(e) {
|
|
if(e.message === "Access Denied") throw e;
|
|
console.error(e);
|
|
}
|
|
})();
|
|
|
|
let refreshTimer = null;
|
|
let countdownTimer = null;
|
|
let countdownValue = 30;
|
|
let REFRESH_INTERVAL = 30; // seconds
|
|
|
|
function toggleServerTitle(wrapper) {
|
|
const title = wrapper.querySelector('.server-title');
|
|
if (title) {
|
|
title.classList.toggle('expanded');
|
|
}
|
|
}
|
|
|
|
function filterPlayers() {
|
|
const input = document.getElementById('player-search');
|
|
if (!input) return;
|
|
|
|
const filter = input.value.toLowerCase().trim();
|
|
const rows = document.querySelectorAll('.player-row:not(.player-header-row)');
|
|
|
|
// Filter Players
|
|
rows.forEach(row => {
|
|
const nameDiv = row.querySelector('.col-name');
|
|
const steamDiv = row.querySelector('.col-steam');
|
|
|
|
if (!nameDiv || !steamDiv) return;
|
|
|
|
const name = nameDiv.textContent.toLowerCase();
|
|
const steam = steamDiv.textContent.toLowerCase();
|
|
|
|
// Match Name or SteamID
|
|
if (name.includes(filter) || steam.includes(filter)) {
|
|
row.style.display = 'grid';
|
|
// Highlight match if filter is not empty (optional, maybe later)
|
|
} else {
|
|
row.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Handle Squad Groups Visibility
|
|
const squadGroups = document.querySelectorAll('.squad-group');
|
|
squadGroups.forEach(group => {
|
|
const players = group.querySelectorAll('.player-row:not(.player-header-row)');
|
|
// If there are no players in this squad (empty squad), show it only if filter is empty?
|
|
// Or just check visible players.
|
|
|
|
if (players.length === 0) {
|
|
// Empty squad (maybe just created)
|
|
group.style.display = filter ? 'none' : 'block';
|
|
} else {
|
|
let hasVisible = false;
|
|
players.forEach(p => {
|
|
if (p.style.display !== 'none') hasVisible = true;
|
|
});
|
|
|
|
if (!hasVisible && filter !== "") {
|
|
group.style.display = 'none';
|
|
} else {
|
|
group.style.display = 'block';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadData(isAuto = false) {
|
|
if (!isAuto) {
|
|
document.getElementById('loading').style.display = 'block';
|
|
}
|
|
|
|
document.getElementById('error-msg').innerText = '';
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const response = await fetch('/api/server-info');
|
|
|
|
// Calculate delay
|
|
const endTime = Date.now();
|
|
const delay = endTime - startTime;
|
|
const delayEl = document.getElementById('network-delay');
|
|
if (delayEl) {
|
|
delayEl.innerText = delay + ' ms';
|
|
delayEl.style.color = delay < 150 ? '#28a745' : (delay < 300 ? '#ff9800' : '#f44336');
|
|
}
|
|
|
|
if (!response.ok) throw new Error('网络请求失败');
|
|
const data = await response.json();
|
|
|
|
// Update Interval from server
|
|
if (data.interval && data.interval >= 10 && data.interval <= 60) {
|
|
REFRESH_INTERVAL = data.interval;
|
|
}
|
|
|
|
// Store globally for shuffle feature
|
|
window.currentPlayers = data.players || [];
|
|
window.gameTimes = data.gameTimes || {};
|
|
|
|
// Render Server Info
|
|
if (data.serverInfo) {
|
|
renderServerInfo(data.serverInfo);
|
|
}
|
|
|
|
const t1Tickets = data.tickets ? data.tickets.team1 : null;
|
|
const t2Tickets = data.tickets ? data.tickets.team2 : null;
|
|
|
|
renderTeam(1, data.squads, data.players, data.serverInfo ? data.serverInfo.teamOne_s : "队伍 1", t1Tickets);
|
|
renderTeam(2, data.squads, data.players, data.serverInfo ? data.serverInfo.teamTwo_s : "队伍 2", t2Tickets);
|
|
|
|
// Re-apply filter if exists
|
|
filterPlayers();
|
|
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('main-content').style.display = 'flex';
|
|
document.getElementById('server-info-banner').style.display = 'flex';
|
|
|
|
if (data.lastUpdated) {
|
|
document.getElementById('last-updated').innerText = '最后更新: ' + data.lastUpdated;
|
|
} else {
|
|
const now = new Date();
|
|
document.getElementById('last-updated').innerText = '最后更新: ' + now.toLocaleTimeString();
|
|
}
|
|
|
|
resetCountdown();
|
|
|
|
} catch (err) {
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('error-msg').innerText = '加载失败: ' + err.message;
|
|
}
|
|
}
|
|
|
|
function renderServerInfo(info) {
|
|
document.getElementById('server-name').innerText = info.serverName_s || "未知服务器";
|
|
document.getElementById('server-version').innerText = info.gameVersion_s || "";
|
|
document.getElementById('map-name').innerText = info.mapName_s || "--";
|
|
document.getElementById('next-map').innerText = info.nextLayer_s || "--";
|
|
|
|
// Format Match Time
|
|
const seconds = parseInt(info.playTime_I) || 0;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
let timeStr = "";
|
|
if (h > 0) timeStr += `${h}h `;
|
|
if (m > 0 || h > 0) timeStr += `${m}m `;
|
|
timeStr += `${s}s`;
|
|
|
|
document.getElementById('match-time').innerText = `⏱️ ${timeStr}`;
|
|
|
|
const current = parseInt(info.playerCount_I) || 0;
|
|
const max = info.maxPlayers || 100;
|
|
document.getElementById('player-count-display').innerText = `${current}/${max}`;
|
|
}
|
|
|
|
function manualRefresh() {
|
|
loadData(false);
|
|
resetCountdown();
|
|
}
|
|
|
|
function resetCountdown() {
|
|
countdownValue = REFRESH_INTERVAL;
|
|
updateCountdownDisplay();
|
|
}
|
|
|
|
function updateCountdownDisplay() {
|
|
const el = document.getElementById('countdown');
|
|
if (el) el.innerText = countdownValue;
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
const isChecked = document.getElementById('auto-refresh-check').checked;
|
|
if (isChecked) {
|
|
startTimers();
|
|
} else {
|
|
stopTimers();
|
|
}
|
|
}
|
|
|
|
function startTimers() {
|
|
stopTimers();
|
|
countdownTimer = setInterval(() => {
|
|
countdownValue--;
|
|
if (countdownValue <= 0) {
|
|
loadData(true);
|
|
countdownValue = REFRESH_INTERVAL;
|
|
}
|
|
updateCountdownDisplay();
|
|
}, 1000);
|
|
}
|
|
|
|
function stopTimers() {
|
|
if (countdownTimer) clearInterval(countdownTimer);
|
|
countdownTimer = null;
|
|
document.getElementById('countdown').innerText = '--';
|
|
}
|
|
|
|
// Permission Helper
|
|
function checkPermission(code) {
|
|
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
|
|
if (isSuper) return true;
|
|
|
|
const permsStr = localStorage.getItem('permissions');
|
|
if (!permsStr) return false;
|
|
|
|
try {
|
|
const perms = JSON.parse(permsStr);
|
|
if (perms.includes('all')) return true;
|
|
return perms.includes(code);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function applyStaticPermissions() {
|
|
if (!checkPermission('dashboard.chat')) document.querySelector('.btn-chat').style.display = 'none';
|
|
if (!checkPermission('dashboard.battle')) document.querySelector('.btn-battle').style.display = 'none';
|
|
if (!checkPermission('dashboard.online')) document.querySelector('.btn-online').style.display = 'none';
|
|
|
|
if (!checkPermission('dashboard.refresh')) document.getElementById('menu-refresh').style.display = 'none';
|
|
if (!checkPermission('dashboard.shuffle')) document.getElementById('menu-shuffle').style.display = 'none';
|
|
if (!checkPermission('dashboard.clearafk')) document.getElementById('menu-clearafk').style.display = 'none';
|
|
if (!checkPermission('dashboard.maps')) document.getElementById('menu-maps').style.display = 'none';
|
|
if (!checkPermission('players')) document.getElementById('menu-duplicate-ip').style.display = 'none';
|
|
}
|
|
|
|
// Chat Logic
|
|
// Old Chat logic removed (replaced by SignalR implementation at bottom)
|
|
async function setTickets(teamId, current) {
|
|
const newTickets = prompt(`请输入队伍 ${teamId} 的新票数 (当前: ${current}):`, current);
|
|
if (newTickets === null) return;
|
|
|
|
const tickets = parseInt(newTickets);
|
|
if (isNaN(tickets)) {
|
|
alert("请输入有效的数字");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/rcon/tickets', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ teamId: teamId, tickets: tickets })
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert("票数修改成功!");
|
|
loadData(false); // Refresh
|
|
} else {
|
|
const text = await response.text();
|
|
alert("修改失败: " + text);
|
|
}
|
|
} catch (e) {
|
|
alert("请求错误: " + e.message);
|
|
}
|
|
}
|
|
|
|
function renderTeam(teamId, allSquads, allPlayers, teamName, tickets) {
|
|
const container = document.getElementById(`list-team-${teamId}`);
|
|
container.innerHTML = '';
|
|
|
|
// Update header
|
|
let displayName = teamName;
|
|
if (tickets !== undefined && tickets !== null) {
|
|
if (checkPermission('dashboard.settickets')) {
|
|
displayName += ` <span style="font-size:0.9em; background:rgba(0,0,0,0.1); padding:2px 8px; border-radius:4px; margin-left:8px; cursor:pointer;" title="点击修改票数" onclick="setTickets(${teamId}, ${tickets})">🎫 ${tickets} <i class="fas fa-edit" style="font-size:0.8em; opacity:0.6;"></i></span>`;
|
|
} else {
|
|
displayName += ` <span style="font-size:0.9em; background:rgba(0,0,0,0.1); padding:2px 8px; border-radius:4px; margin-left:8px; cursor:default;" title="票数">🎫 ${tickets}</span>`;
|
|
}
|
|
}
|
|
document.getElementById(`team${teamId}-name`).innerHTML = displayName;
|
|
|
|
// Filter data
|
|
const squads = allSquads.filter(s => s.teamId === teamId);
|
|
const players = allPlayers.filter(p => p.teamId === teamId);
|
|
|
|
document.getElementById(`count-team-${teamId}`).innerText = `${players.length} Players`;
|
|
|
|
// Group players by squad
|
|
const squadMap = {};
|
|
squads.forEach(s => squadMap[s.id] = { info: s, players: [] });
|
|
|
|
// Unassigned players
|
|
const unassigned = [];
|
|
|
|
players.forEach(p => {
|
|
if (p.squadId && squadMap[p.squadId]) {
|
|
squadMap[p.squadId].players.push(p);
|
|
} else {
|
|
unassigned.push(p);
|
|
}
|
|
});
|
|
|
|
// Render Squads
|
|
squads.forEach(s => {
|
|
const sData = squadMap[s.id];
|
|
const squadDiv = document.createElement('div');
|
|
squadDiv.className = 'squad-group';
|
|
|
|
// Squad Header
|
|
const createdTime = s.lastCreatedTime ? `<span style="font-size:0.8em; margin-left:10px; color:#999;">${s.lastCreatedTime}</span>` : '';
|
|
|
|
let disbandBtn = '';
|
|
if (checkPermission('players.disbandsquad')) {
|
|
disbandBtn = `<button class="btn-operate" style="color:#d32f2f; border-color:#ffcdd2; background:#ffebee;" onclick="disbandSquad(${s.teamId}, ${s.id})">解散</button>`;
|
|
}
|
|
|
|
const badgeColor = teamId === 1 ? 'var(--primary-blue)' : 'var(--primary-red)';
|
|
|
|
squadDiv.innerHTML = `
|
|
<div class="squad-header">
|
|
<span class="squad-badge" style="background-color: ${badgeColor}">${s.id}</span>
|
|
<span style="font-weight:bold; color:#333;">${s.name}</span>
|
|
${s.locked ? '<i class="fas fa-lock" style="color:#f57c00; font-size:0.8em; margin-left:5px;" title="已锁定"></i>' : ''}
|
|
${createdTime}
|
|
<span style="margin-left:auto; display:flex; align-items:center; gap:10px;">
|
|
<span style="font-size:0.85em; color:#666;">${s.size} 人</span>
|
|
${disbandBtn}
|
|
</span>
|
|
</div>
|
|
`;
|
|
|
|
// Players in Squad
|
|
sData.players.forEach(p => {
|
|
const pDiv = createPlayerRow(p);
|
|
squadDiv.appendChild(pDiv);
|
|
});
|
|
|
|
container.appendChild(squadDiv);
|
|
});
|
|
|
|
// Render Unassigned
|
|
if (unassigned.length > 0) {
|
|
const unDiv = document.createElement('div');
|
|
unDiv.className = 'squad-group';
|
|
unDiv.innerHTML = '<div class="squad-header" style="color:#666; font-style:italic;">无小队 (Unassigned)</div>';
|
|
|
|
unassigned.forEach(p => {
|
|
const pDiv = createPlayerRow(p);
|
|
unDiv.appendChild(pDiv);
|
|
});
|
|
container.appendChild(unDiv);
|
|
}
|
|
}
|
|
|
|
// Helper for C# style Contains
|
|
function Contains(str, pattern) {
|
|
if (!str) return false;
|
|
return str.indexOf(pattern) > -1;
|
|
}
|
|
|
|
//兵种图标
|
|
function gameico(rolename) {
|
|
let imgdiv = rolename;
|
|
//步兵队长
|
|
//步枪兵
|
|
if (Contains(rolename, '_AR')) {
|
|
imgdiv = "机枪.png";
|
|
}
|
|
else if (Contains(rolename, '_SL_')) {
|
|
imgdiv = "步兵队长.png";
|
|
}
|
|
else if (Contains(rolename, 'Rifleman')) {
|
|
imgdiv = "步枪.png";
|
|
}
|
|
else if (Contains(rolename, 'SLCrewman')) {
|
|
imgdiv = "载具队长.png";
|
|
} else if (Contains(rolename, 'Crewman')) {
|
|
imgdiv = "载具.png";
|
|
}
|
|
else if (Contains(rolename, 'SLPilot') || Contains(rolename, 'Pilot')) {
|
|
imgdiv = "飞行.png";
|
|
} else if (Contains(rolename, 'Engineer') || Contains(rolename, 'Sapper')) {
|
|
imgdiv = "工兵.png";
|
|
} else if (Contains(rolename, 'Marksman') || Contains(rolename, 'Sniper')) {
|
|
imgdiv = "特射.png";
|
|
} else if (Contains(rolename, 'LAT')) {
|
|
imgdiv = "轻筒.png";
|
|
} else if (Contains(rolename, 'HAT')) {
|
|
imgdiv = "重筒.png";
|
|
} else if (Contains(rolename, 'Grenadier')) {
|
|
if (rolename == "TLF_Grenadier_02") {
|
|
imgdiv = "转管榴弹.png";
|
|
} else {
|
|
imgdiv = "榴弹.png";
|
|
}
|
|
} else if (Contains(rolename, 'MachineGunner')) {
|
|
imgdiv = "通机.png";
|
|
} else if (Contains(rolename, 'Scout')) {
|
|
imgdiv = "侦察兵.png";
|
|
} else if (Contains(rolename, 'Recruit')) {
|
|
imgdiv = "白板.png";
|
|
} else if (Contains(rolename, 'Medic')) {
|
|
imgdiv = "医疗.png";
|
|
} else if (Contains(rolename, 'Raider') || Contains(rolename, 'Jandarma')) {
|
|
imgdiv = "突袭.png";
|
|
} else if (Contains(rolename, 'Unarmed')) {
|
|
imgdiv = "白旗.png";
|
|
} else if (Contains(rolename, 'Saboteur')) {
|
|
imgdiv = "渗透者.png";
|
|
}
|
|
|
|
return '<img src="/images/roleico/' + imgdiv + '" alt="' + rolename + '" class="role-img">';
|
|
}
|
|
|
|
function formatGameTime(minutes) {
|
|
if (!minutes || minutes <= 0) return '未公开';
|
|
// Reference style: just minutes if < 60? or just show minutes number like "1876"?
|
|
// Let's keep formatted but maybe more detailed or just raw number if user wants match reference 100%.
|
|
// Reference "1876" is definitely minutes.
|
|
return minutes.toString();
|
|
}
|
|
|
|
function createPlayerRow(p) {
|
|
const div = document.createElement('div');
|
|
div.className = 'player-row';
|
|
|
|
// Add click listener for mobile details
|
|
div.addEventListener('click', function(e) {
|
|
// Check if mobile view (max-width: 768px)
|
|
if (window.innerWidth <= 768) {
|
|
// Prevent triggering if clicking on buttons/dropdowns
|
|
if (e.target.closest('.btn-operate') || e.target.closest('.dropdown-menu')) {
|
|
return;
|
|
}
|
|
showPlayerDetails(p);
|
|
}
|
|
});
|
|
|
|
const isLeader = p.isLeader ? '<span style="color:#f57c00;">★</span>' : '';
|
|
const roleIcon = gameico(p.role || '');
|
|
|
|
const gameTime = window.gameTimes && window.gameTimes[p.steamId] ? window.gameTimes[p.steamId] : 0;
|
|
const timeStr = formatGameTime(gameTime);
|
|
|
|
// Score: K/D
|
|
const score = `<span style="color:#2e7d32;">${p.kills||0}</span>/<span style="color:#c62828;">${p.deaths||0}</span>`;
|
|
|
|
div.innerHTML = `
|
|
<div class="col-center" style="color:#888;">${p.id}</div>
|
|
<div class="col-center">${isLeader}</div>
|
|
<div class="col-center">${roleIcon}</div>
|
|
<div class="col-name" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${p.name}">${p.name}</div>
|
|
<div class="col-steam">${p.steamId}</div>
|
|
<div class="col-center" style="font-weight:500;">${score}</div>
|
|
<div class="col-center" style="color:#666;">${timeStr}</div>
|
|
<div class="col-right">
|
|
<div class="action-dropdown">
|
|
<button class="btn-operate" onclick="toggleDropdown(this, event)">
|
|
操作 <i class="fas fa-caret-down" style="font-size: 0.8em;"></i>
|
|
</button>
|
|
<div class="dropdown-menu">
|
|
${checkPermission('players.changeteam') ? `<div class="dropdown-item" onclick="changeTeam('${p.steamId}')">更换阵营</div>` : ''}
|
|
${checkPermission('players.removesquad') ? `<div class="dropdown-item" onclick="removeFromSquad(${p.id})">移出小队</div>` : ''}
|
|
${checkPermission('players.warn') ? `<div class="dropdown-item" onclick="warnPlayer('${p.steamId}')">发送警告</div>` : ''}
|
|
${checkPermission('players.gametime') ? `<div class="dropdown-item" onclick="refreshGameTime('${p.steamId}')">查询游戏时长</div>` : ''}
|
|
${checkPermission('players.gametime') ? `<div class="dropdown-item" onclick="setGameTime('${p.steamId}')">设置游戏时长</div>` : ''}
|
|
${checkPermission('players.kick') ? `<div class="dropdown-item danger" onclick="kickPlayer('${p.steamId}')">踢出服务器</div>` : ''}
|
|
${checkPermission('players.ban') ? `<div class="dropdown-item danger" onclick="banPlayer('${p.steamId}', '${p.name.replace(/'/g, "\\'")}')">封禁并踢出</div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
|
|
function showPlayerDetails(p) {
|
|
const roleIcon = gameico(p.role || '');
|
|
const isLeader = p.isLeader ? '<span style="color:#f57c00;">★ 小队队长</span>' : '队员';
|
|
const score = `<span style="color:#2e7d32;">${p.kills||0} 击杀</span> / <span style="color:#c62828;">${p.deaths||0} 死亡</span>`;
|
|
const timeStr = formatGameTime(window.gameTimes && window.gameTimes[p.steamId] ? window.gameTimes[p.steamId] : 0) + ' 分钟';
|
|
|
|
const content = `
|
|
<div style="display:flex; align-items:center; gap:15px; margin-bottom:20px;">
|
|
<div style="font-size:2rem;">${roleIcon}</div>
|
|
<div>
|
|
<div style="font-size:1.2rem; font-weight:bold;">${p.name}</div>
|
|
<div style="color:#666;">${isLeader}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:grid; grid-template-columns: 80px 1fr; gap:10px; font-size:0.95rem;">
|
|
<div style="color:#888;">ID:</div> <div>${p.id}</div>
|
|
<div style="color:#888;">SteamID:</div> <div style="font-family:monospace; word-break: break-all; user-select:all;">${p.steamId}</div>
|
|
<div style="color:#888;">兵种:</div> <div>${p.role || 'Unknown'}</div>
|
|
<div style="color:#888;">战绩:</div> <div>${score}</div>
|
|
<div style="color:#888;">游戏时长:</div> <div>${timeStr}</div>
|
|
<div style="color:#888;">小队:</div> <div>${p.squadId ? p.squadId : '无'}</div>
|
|
</div>
|
|
|
|
<div style="margin-top:20px; border-top:1px solid #eee; padding-top:15px;">
|
|
<div style="margin-bottom:10px; font-weight:bold;">快捷操作:</div>
|
|
<div style="display:flex; flex-wrap:wrap; gap:10px;">
|
|
${checkPermission('players.gametime') ? `<button class="btn-tool" onclick="refreshGameTime('${p.steamId}')">刷新时长</button>` : ''}
|
|
${checkPermission('players.warn') ? `<button class="btn-tool" onclick="warnPlayer('${p.steamId}')">警告</button>` : ''}
|
|
<button class="btn-tool" onclick="showBattleStats('${p.steamId}', '${p.name.replace(/'/g, "\\'")}')">查看战斗记录</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('player-detail-content').innerHTML = content;
|
|
document.getElementById('player-detail-modal').classList.add('show');
|
|
}
|
|
|
|
function closePlayerDetailModal() {
|
|
document.getElementById('player-detail-modal').classList.remove('show');
|
|
}
|
|
|
|
async function sendCommand(cmd) {
|
|
try {
|
|
const response = await fetch('/api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: cmd })
|
|
});
|
|
const text = await response.text();
|
|
alert('服务器响应:\n' + text);
|
|
} catch (err) {
|
|
alert('命令执行失败: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function kickPlayer(steamId) {
|
|
const reason = prompt("请输入踢出原因 (Kick Reason):", "违反服务器规定");
|
|
if (reason !== null) {
|
|
sendCommand(`AdminKick ${steamId} ${reason}`);
|
|
}
|
|
}
|
|
|
|
function disbandSquad(teamId, squadId) {
|
|
if (confirm(`确定要解散小队 (Team: ${teamId}, Squad: ${squadId}) 吗?`)) {
|
|
sendCommand(`AdminDisbandSquad ${teamId} ${squadId}`);
|
|
}
|
|
}
|
|
|
|
let banTargetId = null;
|
|
|
|
function banPlayer(steamId, name) {
|
|
banTargetId = steamId;
|
|
document.getElementById('ban-steamid').value = steamId;
|
|
document.getElementById('ban-name').value = name || 'Unknown';
|
|
document.getElementById('ban-duration').value = 'perm';
|
|
document.getElementById('ban-reason').value = '';
|
|
document.getElementById('ban-modal').classList.add('show');
|
|
}
|
|
|
|
function hideBanModal() {
|
|
document.getElementById('ban-modal').classList.remove('show');
|
|
banTargetId = null;
|
|
}
|
|
|
|
async function confirmBan() {
|
|
if (!banTargetId) return;
|
|
|
|
const steamId = banTargetId;
|
|
const duration = document.getElementById('ban-duration').value;
|
|
const reason = document.getElementById('ban-reason').value;
|
|
|
|
if (!reason) {
|
|
alert("请输入封禁原因");
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`确定要封禁玩家 ${steamId} 吗?`)) return;
|
|
|
|
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) {
|
|
alert('玩家已封禁并踢出 (Added to Bans.cfg & Kicked)');
|
|
hideBanModal();
|
|
loadData();
|
|
} else {
|
|
const text = await response.text();
|
|
alert('操作失败: ' + text);
|
|
}
|
|
} catch (err) {
|
|
alert('网络错误: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function changeTeam(steamId) {
|
|
if (confirm("确定要强制更换该玩家的阵营吗?")) {
|
|
sendCommand(`AdminForceTeamChange ${steamId}`);
|
|
}
|
|
}
|
|
|
|
function removeFromSquad(playerId) {
|
|
if (confirm(`确定要将玩家 (ID: ${playerId}) 移出小队吗?`)) {
|
|
sendCommand(`AdminRemovePlayerFromSquadById ${playerId}`);
|
|
}
|
|
}
|
|
|
|
function warnPlayer(steamId) {
|
|
const msg = prompt("请输入警告消息:", "注意你的行为");
|
|
if (msg !== null) {
|
|
sendCommand(`AdminWarn ${steamId} ${msg}`);
|
|
}
|
|
}
|
|
|
|
async function refreshGameTime(steamId) {
|
|
if (!confirm("确定要刷新该玩家的游戏时长吗?")) return;
|
|
try {
|
|
const res = await fetch(`/api/players/${steamId}/gametime/refresh`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
alert(`刷新成功! 当前时长: ${data.hours}小时 (${data.minutes}分钟)`);
|
|
if (!window.gameTimes) window.gameTimes = {};
|
|
window.gameTimes[steamId] = data.minutes;
|
|
loadData();
|
|
} else {
|
|
const err = await res.json();
|
|
alert('刷新失败: ' + (err.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('请求错误: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function setGameTime(steamId) {
|
|
const hoursStr = prompt("请输入游戏时长 (小时):", "0");
|
|
if (hoursStr === null) return;
|
|
const hours = parseInt(hoursStr);
|
|
if (isNaN(hours) || hours < 0) {
|
|
alert("请输入有效的整数小时数");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/players/${steamId}/gametime`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hours: hours })
|
|
});
|
|
|
|
if (res.ok) {
|
|
alert('设置成功!');
|
|
if (!window.gameTimes) window.gameTimes = {};
|
|
window.gameTimes[steamId] = hours * 60;
|
|
loadData();
|
|
} else {
|
|
const err = await res.json();
|
|
alert('设置失败: ' + (err.error || '未知错误'));
|
|
}
|
|
} catch (e) {
|
|
alert('请求错误: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// Load on start
|
|
loadData();
|
|
// startChatTimer(); // Removed: Replaced by SignalR logic in chat modal
|
|
if (document.getElementById('auto-refresh-check').checked) {
|
|
startTimers();
|
|
}
|
|
|
|
// Dropdown Functions
|
|
function toggleDropdown(btn, event) {
|
|
event.stopPropagation();
|
|
|
|
// Close all other dropdowns
|
|
closeAllDropdowns();
|
|
|
|
const menu = btn.nextElementSibling;
|
|
if (menu.classList.contains('show')) {
|
|
menu.classList.remove('show');
|
|
} else {
|
|
menu.classList.add('show');
|
|
}
|
|
}
|
|
|
|
function closeAllDropdowns() {
|
|
const dropdowns = document.querySelectorAll('.dropdown-menu.show');
|
|
dropdowns.forEach(d => d.classList.remove('show'));
|
|
}
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener('click', function (event) {
|
|
if (!event.target.closest('.action-dropdown')) {
|
|
closeAllDropdowns();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<!-- Map Modal -->
|
|
<div class="modal-overlay" id="map-modal" onclick="if(event.target === this) hideMapModal()">
|
|
<div class="modal-content-large">
|
|
<div class="modal-header">
|
|
<div class="modal-title">地图与层级管理</div>
|
|
<button class="modal-close-btn" onclick="hideMapModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<iframe src="map-manager.html" class="modal-iframe"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function showMapModal() {
|
|
document.getElementById('map-modal').classList.add('show');
|
|
}
|
|
|
|
function hideMapModal() {
|
|
document.getElementById('map-modal').classList.remove('show');
|
|
}
|
|
|
|
// ================= Online Status Logic =================
|
|
const connection = new signalR.HubConnectionBuilder()
|
|
.withUrl("/presenceHub")
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
connection.start().catch(err => console.error("SignalR Connection Error: ", err));
|
|
|
|
async function openOnlineModal() {
|
|
document.getElementById('online-modal').classList.add('show');
|
|
loadOnlineData();
|
|
}
|
|
|
|
function closeOnlineModal() {
|
|
document.getElementById('online-modal').classList.remove('show');
|
|
}
|
|
|
|
async function loadOnlineData() {
|
|
const panelContainer = document.getElementById('panel-users-list');
|
|
const camContainer = document.getElementById('cameramen-list');
|
|
|
|
panelContainer.innerHTML = 'Loading...';
|
|
camContainer.innerHTML = 'Loading...';
|
|
|
|
try {
|
|
const res = await fetch('/api/online-status');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
renderOnlineUsers(data);
|
|
} else {
|
|
panelContainer.innerHTML = '<span style="color:red">加载失败</span>';
|
|
camContainer.innerHTML = '<span style="color:red">加载失败</span>';
|
|
}
|
|
} catch(e) {
|
|
console.error(e);
|
|
panelContainer.innerHTML = '<span style="color:red">请求错误</span>';
|
|
camContainer.innerHTML = '<span style="color:red">请求错误</span>';
|
|
}
|
|
}
|
|
|
|
function renderOnlineUsers(data) {
|
|
// Panel Users
|
|
const panelContainer = document.getElementById('panel-users-list');
|
|
if (!data.panelUsers || data.panelUsers.length === 0) {
|
|
panelContainer.innerHTML = '<div style="color:#888; font-style:italic;">暂无其他用户在线</div>';
|
|
} else {
|
|
panelContainer.innerHTML = data.panelUsers.map(u =>
|
|
`<span style="display:inline-block; background:#e3f2fd; color:#0d47a1; margin-right:8px; margin-bottom:5px; padding:6px 12px; border-radius:20px; font-size:0.9rem; border:1px solid #bbdefb;">
|
|
<i class="fas fa-user"></i> ${u}
|
|
</span>`
|
|
).join('');
|
|
}
|
|
|
|
// Cameramen
|
|
const camContainer = document.getElementById('cameramen-list');
|
|
if (!data.cameramen || data.cameramen.length === 0) {
|
|
camContainer.innerHTML = '<div style="color:#888; font-style:italic;">暂无录像员在线</div>';
|
|
} else {
|
|
camContainer.innerHTML = data.cameramen.map(c =>
|
|
`<div style="display:flex; justify-content:space-between; align-items:center; padding:10px; background:#fff; margin-bottom:8px; border-radius:6px; border:1px solid #eee; box-shadow:0 1px 2px rgba(0,0,0,0.02);">
|
|
<div style="display:flex; flex-direction:column;">
|
|
<span style="font-weight:bold; color:#333;">${c.name || 'Unknown'} <span style="font-weight:normal; font-size:0.85em; color:#666; margin-left:5px;">(${c.groupName})</span></span>
|
|
<span style="font-size:0.8em; color:#999; font-family:monospace;">${c.steamId}</span>
|
|
</div>
|
|
<span style="background:#d4edda; color:#155724; padding:2px 8px; border-radius:4px; font-size:0.8em; border:1px solid #c3e6cb;">
|
|
<i class="fas fa-check-circle"></i> 在线
|
|
</span>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- Online Users Modal -->
|
|
<div class="modal-overlay" id="online-modal" onclick="if(event.target === this) closeOnlineModal()">
|
|
<div class="modal-content-custom" style="width: 500px; max-width:95%;">
|
|
<div class="modal-header">
|
|
<div class="modal-title"><i class="fas fa-users"></i> 在线人员监控</div>
|
|
<button class="modal-close-btn" onclick="closeOnlineModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px; overflow-y: auto;">
|
|
<div style="margin-bottom: 25px;">
|
|
<h5 style="margin-top:0; border-bottom:1px solid #eee; padding-bottom:10px; color:#0056b3;">
|
|
<i class="fas fa-desktop"></i> 面板在线用户
|
|
</h5>
|
|
<div id="panel-users-list">
|
|
<!-- Content -->
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h5 style="margin-top:0; border-bottom:1px solid #eee; padding-bottom:10px; color:#28a745;">
|
|
<i class="fas fa-video"></i> 游戏在线录像员 (Cameraman)
|
|
</h5>
|
|
<div id="cameramen-list">
|
|
<!-- Content -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Battle Stats Modal -->
|
|
<div class="modal-overlay" id="battle-stats-modal" onclick="if(event.target === this) closeBattleStatsModal()">
|
|
<div class="modal-content-custom" style="width: 800px; max-width:95%;">
|
|
<div class="modal-header">
|
|
<div class="modal-title"><i class="fas fa-crosshairs"></i> 玩家战斗详情 - <span id="battle-stats-player-name"></span></div>
|
|
<button class="modal-close-btn" onclick="closeBattleStatsModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px; overflow-y: auto; max-height: 70vh;">
|
|
<div style="display: flex; gap: 20px;">
|
|
<!-- Kills Column -->
|
|
<div style="flex: 1;">
|
|
<h5 style="color: #28a745; border-bottom: 2px solid #28a745; padding-bottom: 5px; margin-top: 0;">
|
|
击杀列表 (<span id="battle-stats-kills-count">0</span>)
|
|
</h5>
|
|
<div id="battle-stats-kills-list" style="font-size: 0.9em;">加载中...</div>
|
|
</div>
|
|
<!-- Deaths Column -->
|
|
<div style="flex: 1;">
|
|
<h5 style="color: #d93025; border-bottom: 2px solid #d93025; padding-bottom: 5px; margin-top: 0;">
|
|
死亡列表 (<span id="battle-stats-deaths-count">0</span>)
|
|
</h5>
|
|
<div id="battle-stats-deaths-list" style="font-size: 0.9em;">加载中...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function closeBattleStatsModal() {
|
|
document.getElementById('battle-stats-modal').classList.remove('show');
|
|
}
|
|
|
|
async function showBattleStats(steamId, playerName) {
|
|
document.getElementById('battle-stats-player-name').textContent = playerName;
|
|
document.getElementById('battle-stats-modal').classList.add('show');
|
|
|
|
const killsList = document.getElementById('battle-stats-kills-list');
|
|
const deathsList = document.getElementById('battle-stats-deaths-list');
|
|
killsList.innerHTML = '<div style="text-align:center; padding:20px;"><i class="fas fa-spinner fa-spin"></i> 加载数据中...</div>';
|
|
deathsList.innerHTML = '<div style="text-align:center; padding:20px;"><i class="fas fa-spinner fa-spin"></i> 加载数据中...</div>';
|
|
document.getElementById('battle-stats-kills-count').textContent = '-';
|
|
document.getElementById('battle-stats-deaths-count').textContent = '-';
|
|
|
|
try {
|
|
const res = await fetch(`/api/player-battle-stats?steamId=${steamId}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
renderBattleStats(data);
|
|
} else {
|
|
killsList.innerHTML = '<div style="color:red">加载失败</div>';
|
|
deathsList.innerHTML = '<div style="color:red">加载失败</div>';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
killsList.innerHTML = '<div style="color:red">请求错误</div>';
|
|
deathsList.innerHTML = '<div style="color:red">请求错误</div>';
|
|
}
|
|
}
|
|
|
|
function renderBattleStats(data) {
|
|
const kills = data.kills || [];
|
|
const deaths = data.deaths || [];
|
|
|
|
document.getElementById('battle-stats-kills-count').textContent = kills.length;
|
|
document.getElementById('battle-stats-deaths-count').textContent = deaths.length;
|
|
|
|
const killsList = document.getElementById('battle-stats-kills-list');
|
|
if (kills.length === 0) {
|
|
killsList.innerHTML = '<div style="color:#999; text-align:center; padding:10px;">暂无击杀记录</div>';
|
|
} else {
|
|
killsList.innerHTML = kills.map(k => {
|
|
const time = new Date(k.time).toLocaleTimeString();
|
|
const isTk = k.isTK ? '<span style="color:red; font-weight:bold;">[TK]</span>' : '';
|
|
return `
|
|
<div style="padding: 8px; border-bottom: 1px solid #eee; display:flex; justify-content:space-between; align-items:center;">
|
|
<div>
|
|
<div style="font-weight:bold;">${isTk} 击杀 ${k.victimName || 'Unknown'}</div>
|
|
<div style="font-size:0.85em; color:#666;">${k.weapon || 'Unknown Weapon'}</div>
|
|
</div>
|
|
<div style="font-size:0.8em; color:#999;">${time}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
const deathsList = document.getElementById('battle-stats-deaths-list');
|
|
if (deaths.length === 0) {
|
|
deathsList.innerHTML = '<div style="color:#999; text-align:center; padding:10px;">暂无死亡记录</div>';
|
|
} else {
|
|
deathsList.innerHTML = deaths.map(d => {
|
|
const time = new Date(d.time).toLocaleTimeString();
|
|
const isTk = d.isTK ? '<span style="color:red; font-weight:bold;">[TK]</span>' : '';
|
|
return `
|
|
<div style="padding: 8px; border-bottom: 1px solid #eee; display:flex; justify-content:space-between; align-items:center;">
|
|
<div>
|
|
<div style="font-weight:bold;">${isTk} 被 ${d.attackerName || 'Unknown'} 击杀</div>
|
|
<div style="font-size:0.85em; color:#666;">${d.weapon || 'Unknown Weapon'}</div>
|
|
</div>
|
|
<div style="font-size:0.8em; color:#999;">${time}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="modal-overlay" id="ban-modal" onclick="if(event.target === this) hideBanModal()">
|
|
<div class="modal-content-custom">
|
|
<div class="modal-header">
|
|
<div class="modal-title">封禁并踢出玩家</div>
|
|
<button class="modal-close-btn" onclick="hideBanModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px;">
|
|
<div class="form-group">
|
|
<label>SteamID</label>
|
|
<input type="text" id="ban-steamid" class="form-control" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>玩家名称 (参考)</label>
|
|
<input type="text" id="ban-name" class="form-control" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>封禁时长</label>
|
|
<select id="ban-duration" class="form-control">
|
|
<option value="perm">永久</option>
|
|
<option value="1d">1 天</option>
|
|
<option value="3d">3 天</option>
|
|
<option value="1w">1 周</option>
|
|
<option value="1m">1 月</option>
|
|
<option value="3m">3 月</option>
|
|
<option value="6m">6 月</option>
|
|
<option value="1y">1 年</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>封禁原因</label>
|
|
<input type="text" id="ban-reason" class="form-control" placeholder="请输入原因,例如:违反服务器规则">
|
|
</div>
|
|
<div class="modal-footer" style="margin-top: 20px; text-align: right;">
|
|
<button class="btn-cancel" onclick="hideBanModal()">取消</button>
|
|
<button class="btn-confirm-danger" onclick="confirmBan()">确认封禁</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Shuffle Modal -->
|
|
<div class="modal-overlay" id="shuffle-modal">
|
|
<div class="modal-content-custom" style="width: 1000px; max-width: 95%; max-height: 80vh;">
|
|
<div class="modal-header">
|
|
<div class="modal-title">随机平衡/打乱玩家 (Shuffle Players)</div>
|
|
<button class="modal-close-btn" onclick="closeShuffleModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 15px; height: 100%; overflow: hidden;">
|
|
<div style="background: #e6f7ff; border: 1px solid #91d5ff; padding: 10px; border-radius: 4px; font-size: 0.9em; color: #0050b3; flex-shrink: 0;">
|
|
<span style="font-weight: bold;">说明:</span>
|
|
系统已自动选中部分玩家以平衡阵营。您可以在下方列表中勾选/取消勾选来手动调整。<br>
|
|
左侧显示 Team 1 玩家 (选中后移动到 Team 2),右侧显示 Team 2 玩家 (选中后移动到 Team 1)。
|
|
</div>
|
|
|
|
<div id="shuffle-summary" style="font-size: 1rem; font-weight: bold; color: #333; display: flex; gap: 40px; justify-content: center; flex-shrink: 0;">
|
|
<!-- Summary counts inserted here -->
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 20px; flex: 1; min-height: 0;">
|
|
<!-- Team 1 Panel -->
|
|
<div style="flex: 1; display: flex; flex-direction: column; border: 1px solid #ccd9ff; border-radius: 4px; background: #f0f4ff;">
|
|
<div style="padding: 10px; background: #dae6ff; font-weight: bold; color: #003366; border-bottom: 1px solid #ccd9ff;">
|
|
Team 1 (Move → Team 2)
|
|
</div>
|
|
<div style="padding: 10px; border-bottom: 1px solid #eee;">
|
|
<input type="text" class="form-control" placeholder="搜索玩家..." oninput="updateShuffleSearch(1, this.value)">
|
|
</div>
|
|
<div id="shuffle-list-1" style="flex: 1; overflow-y: auto; padding: 10px;">
|
|
<!-- Team 1 List -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Team 2 Panel -->
|
|
<div style="flex: 1; display: flex; flex-direction: column; border: 1px solid #ffccd0; border-radius: 4px; background: #fff0f0;">
|
|
<div style="padding: 10px; background: #ffe6e6; font-weight: bold; color: #660000; border-bottom: 1px solid #ffccd0;">
|
|
Team 2 (Move → Team 1)
|
|
</div>
|
|
<div style="padding: 10px; border-bottom: 1px solid #eee;">
|
|
<input type="text" class="form-control" placeholder="搜索玩家..." oninput="updateShuffleSearch(2, this.value)">
|
|
</div>
|
|
<div id="shuffle-list-2" style="flex: 1; overflow-y: auto; padding: 10px;">
|
|
<!-- Team 2 List -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group" style="flex-shrink: 0;">
|
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">确认操作</label>
|
|
<input type="text" id="shuffle-confirm-input" class="form-control" placeholder="请输入 '是' 确认执行" style="border: 1px solid #ff9800;">
|
|
<div style="font-size: 0.8em; color: #d93025; margin-top: 5px;">
|
|
⚠️ 执行后将立即强制更换这些玩家的阵营。
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer" style="text-align: right; margin-top: 10px; flex-shrink: 0;">
|
|
<button class="btn-cancel" onclick="closeShuffleModal()">取消</button>
|
|
<button class="btn-confirm-danger" style="background-color: #ff9800; color: white;" onclick="confirmShuffle()">我确认要打乱</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let playersToShuffle = []; // Array of steamIds
|
|
let shuffleSearch = { 1: '', 2: '' };
|
|
|
|
function calculateKD(p) {
|
|
const k = p.kills || 0;
|
|
const d = p.deaths || 0;
|
|
if (d === 0) return k;
|
|
return k / d;
|
|
}
|
|
|
|
function openShuffleModal() {
|
|
if (!window.currentPlayers || window.currentPlayers.length === 0) {
|
|
alert("没有玩家数据,无法进行打乱操作。");
|
|
return;
|
|
}
|
|
|
|
const team1 = window.currentPlayers.filter(p => p.teamId === 1);
|
|
const team2 = window.currentPlayers.filter(p => p.teamId === 2);
|
|
|
|
const count1 = team1.length;
|
|
const count2 = team2.length;
|
|
const diff = Math.abs(count1 - count2);
|
|
|
|
// Logic Updated:
|
|
// 1. Minimum 10 players shuffled total.
|
|
// 2. Prioritize balancing.
|
|
// 3. Sort by K/D Descending.
|
|
|
|
const MIN_SHUFFLE_TOTAL = 10;
|
|
const totalPlayers = count1 + count2;
|
|
const targetTotal = Math.min(MIN_SHUFFLE_TOTAL, totalPlayers);
|
|
|
|
// Determine moves for balancing
|
|
let movesFrom1 = 0;
|
|
let movesFrom2 = 0;
|
|
|
|
const movesForBalance = Math.floor(diff / 2);
|
|
|
|
if (count1 > count2) {
|
|
movesFrom1 = movesForBalance;
|
|
} else {
|
|
movesFrom2 = movesForBalance;
|
|
}
|
|
|
|
let currentTotalMoves = movesFrom1 + movesFrom2;
|
|
|
|
// If we haven't reached target, add swap pairs
|
|
if (currentTotalMoves < targetTotal) {
|
|
const deficit = targetTotal - currentTotalMoves;
|
|
const pairsNeeded = Math.ceil(deficit / 2);
|
|
|
|
const maxPairs1 = count1 - movesFrom1;
|
|
const maxPairs2 = count2 - movesFrom2;
|
|
const maxPairs = Math.min(maxPairs1, maxPairs2);
|
|
|
|
const actualPairs = Math.min(pairsNeeded, maxPairs);
|
|
|
|
movesFrom1 += actualPairs;
|
|
movesFrom2 += actualPairs;
|
|
}
|
|
|
|
// Sort players by K/D descending
|
|
const sorted1 = [...team1].sort((a, b) => calculateKD(b) - calculateKD(a));
|
|
const sorted2 = [...team2].sort((a, b) => calculateKD(b) - calculateKD(a));
|
|
|
|
// Select players
|
|
const selectedFrom1 = sorted1.slice(0, movesFrom1);
|
|
const selectedFrom2 = sorted2.slice(0, movesFrom2);
|
|
|
|
// Store selected SteamIDs
|
|
playersToShuffle = [];
|
|
selectedFrom1.forEach(p => playersToShuffle.push(p.steamId));
|
|
selectedFrom2.forEach(p => playersToShuffle.push(p.steamId));
|
|
|
|
// Reset search
|
|
shuffleSearch = { 1: '', 2: '' };
|
|
const inputs = document.querySelectorAll('#shuffle-modal input[type="text"]');
|
|
inputs.forEach(i => { if(i.id !== 'shuffle-confirm-input') i.value = ''; });
|
|
|
|
renderShuffleUI();
|
|
|
|
document.getElementById('shuffle-confirm-input').value = '';
|
|
document.getElementById('shuffle-modal').style.display = 'flex';
|
|
setTimeout(() => document.getElementById('shuffle-modal').classList.add('show'), 10);
|
|
}
|
|
|
|
function updateShuffleSearch(teamId, value) {
|
|
shuffleSearch[teamId] = value.toLowerCase();
|
|
renderShuffleList(teamId);
|
|
}
|
|
|
|
function renderShuffleUI() {
|
|
renderShuffleList(1);
|
|
renderShuffleList(2);
|
|
updateShuffleSummary();
|
|
}
|
|
|
|
function renderShuffleList(teamId) {
|
|
const container = document.getElementById(`shuffle-list-${teamId}`);
|
|
container.innerHTML = '';
|
|
|
|
const players = window.currentPlayers.filter(p => p.teamId === teamId);
|
|
const searchText = shuffleSearch[teamId];
|
|
|
|
// Group by Squad
|
|
const squads = {};
|
|
const unassigned = [];
|
|
|
|
players.forEach(p => {
|
|
if (searchText && !p.name.toLowerCase().includes(searchText)) return;
|
|
|
|
if (p.squadId) {
|
|
if (!squads[p.squadId]) squads[p.squadId] = [];
|
|
squads[p.squadId].push(p);
|
|
} else {
|
|
unassigned.push(p);
|
|
}
|
|
});
|
|
|
|
// Helper to render squad block
|
|
const renderBlock = (title, pList, squadId) => {
|
|
if (pList.length === 0) return;
|
|
|
|
const block = document.createElement('div');
|
|
block.style.marginBottom = '15px';
|
|
block.style.background = '#fff';
|
|
block.style.borderRadius = '4px';
|
|
block.style.border = '1px solid #eee';
|
|
block.style.overflow = 'hidden';
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.style.padding = '8px 10px';
|
|
header.style.background = '#f9f9f9';
|
|
header.style.borderBottom = '1px solid #eee';
|
|
header.style.display = 'flex';
|
|
header.style.justifyContent = 'space-between';
|
|
header.style.alignItems = 'center';
|
|
header.style.fontSize = '0.9em';
|
|
header.style.fontWeight = 'bold';
|
|
|
|
const checkId = `squad-check-${teamId}-${squadId || 'u'}`;
|
|
|
|
// Check if all displayed are selected
|
|
const allSelected = pList.every(p => playersToShuffle.includes(p.steamId));
|
|
|
|
header.innerHTML = `
|
|
<span>${title} (${pList.length})</span>
|
|
<label style="font-weight: normal; font-size: 0.9em; cursor: pointer; user-select: none;">
|
|
<input type="checkbox" id="${checkId}" ${allSelected ? 'checked' : ''} onchange="toggleSquadShuffle(${teamId}, '${squadId || 'u'}', this.checked)"> 全选
|
|
</label>
|
|
`;
|
|
block.appendChild(header);
|
|
|
|
// List
|
|
pList.forEach(p => {
|
|
const isSelected = playersToShuffle.includes(p.steamId);
|
|
const row = document.createElement('div');
|
|
row.style.padding = '6px 10px';
|
|
row.style.borderBottom = '1px solid #f5f5f5';
|
|
row.style.display = 'flex';
|
|
row.style.alignItems = 'center';
|
|
row.style.gap = '10px';
|
|
row.style.cursor = 'pointer';
|
|
row.onclick = (e) => {
|
|
if (e.target.type !== 'checkbox') {
|
|
const cb = row.querySelector('input[type="checkbox"]');
|
|
cb.checked = !cb.checked;
|
|
toggleShufflePlayer(p.steamId, cb.checked);
|
|
}
|
|
};
|
|
if (isSelected) row.style.backgroundColor = '#fff7e6'; // Highlight selected
|
|
|
|
const kd = (p.kills || 0) + '/' + (p.deaths || 0);
|
|
|
|
row.innerHTML = `
|
|
<input type="checkbox" ${isSelected ? 'checked' : ''} onchange="toggleShufflePlayer('${p.steamId}', this.checked)">
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 500; font-size: 0.9em;">${p.name}</div>
|
|
<div style="font-size: 0.8em; color: #888;">ID: ${p.id} | KD: ${kd}</div>
|
|
</div>
|
|
`;
|
|
block.appendChild(row);
|
|
});
|
|
|
|
container.appendChild(block);
|
|
};
|
|
|
|
// Render Squads
|
|
Object.keys(squads).sort((a, b) => parseInt(a) - parseInt(b)).forEach(sid => {
|
|
renderBlock(`Squad ${sid}`, squads[sid], sid);
|
|
});
|
|
|
|
// Render Unassigned
|
|
renderBlock('Unassigned', unassigned, null);
|
|
}
|
|
|
|
function toggleShufflePlayer(steamId, checked) {
|
|
if (checked) {
|
|
if (!playersToShuffle.includes(steamId)) playersToShuffle.push(steamId);
|
|
} else {
|
|
playersToShuffle = playersToShuffle.filter(id => id !== steamId);
|
|
}
|
|
// Re-render to update highlights and "Select All" states
|
|
// We can optimize by just updating the summary and class, but re-render is safer
|
|
renderShuffleUI();
|
|
}
|
|
|
|
function toggleSquadShuffle(teamId, squadIdKey, checked) {
|
|
const players = window.currentPlayers.filter(p => p.teamId === teamId);
|
|
const searchText = shuffleSearch[teamId];
|
|
|
|
players.forEach(p => {
|
|
// Filter logic must match render logic
|
|
if (searchText && !p.name.toLowerCase().includes(searchText)) return;
|
|
|
|
const pSquadKey = p.squadId ? p.squadId.toString() : 'u';
|
|
if (pSquadKey === squadIdKey) {
|
|
if (checked) {
|
|
if (!playersToShuffle.includes(p.steamId)) playersToShuffle.push(p.steamId);
|
|
} else {
|
|
playersToShuffle = playersToShuffle.filter(id => id !== p.steamId);
|
|
}
|
|
}
|
|
});
|
|
renderShuffleUI();
|
|
}
|
|
|
|
function updateShuffleSummary() {
|
|
// Count moves
|
|
let t1to2 = 0;
|
|
let t2to1 = 0;
|
|
|
|
playersToShuffle.forEach(sid => {
|
|
const p = window.currentPlayers.find(cp => cp.steamId === sid);
|
|
if (p) {
|
|
if (p.teamId === 1) t1to2++;
|
|
else if (p.teamId === 2) t2to1++;
|
|
}
|
|
});
|
|
|
|
document.getElementById('shuffle-summary').innerHTML = `
|
|
<span style="color: #003366;">Team 1 → 2: ${t1to2} 人</span>
|
|
<span style="color: #660000;">Team 2 → 1: ${t2to1} 人</span>
|
|
`;
|
|
}
|
|
|
|
function closeShuffleModal() {
|
|
document.getElementById('shuffle-modal').classList.remove('show');
|
|
setTimeout(() => document.getElementById('shuffle-modal').style.display = 'none', 300);
|
|
}
|
|
|
|
async function confirmShuffle() {
|
|
const input = document.getElementById('shuffle-confirm-input').value;
|
|
if (input !== '是') {
|
|
alert("请输入 '是' 以确认执行操作!");
|
|
return;
|
|
}
|
|
|
|
if (!playersToShuffle || playersToShuffle.length === 0) return;
|
|
|
|
if (!confirm("如果执行则会立即打乱玩家是否继续执行?")) return;
|
|
|
|
// Execute
|
|
const btn = document.querySelector('#shuffle-modal .btn-confirm-danger');
|
|
const originalText = btn.innerText;
|
|
btn.innerText = "正在执行...";
|
|
btn.disabled = true;
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
// We need player IDs for the command
|
|
const targets = [];
|
|
playersToShuffle.forEach(sid => {
|
|
const p = window.currentPlayers.find(cp => cp.steamId === sid);
|
|
if (p) targets.push(p);
|
|
});
|
|
|
|
for (const p of targets) {
|
|
try {
|
|
const response = await fetch('/api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: `AdminForceTeamChangeById ${p.id}` })
|
|
});
|
|
|
|
if (response.ok) successCount++;
|
|
else failCount++;
|
|
|
|
} catch (e) {
|
|
failCount++;
|
|
console.error(e);
|
|
}
|
|
|
|
// Small delay to prevent RCON flood
|
|
await new Promise(r => setTimeout(r, 200));
|
|
}
|
|
|
|
alert(`操作完成!\n成功移动: ${successCount} 人\n失败: ${failCount} 人`);
|
|
|
|
btn.innerText = originalText;
|
|
btn.disabled = false;
|
|
closeShuffleModal();
|
|
loadData(); // Refresh list
|
|
}
|
|
|
|
// Duplicate IP Modal
|
|
async function openDuplicateIpModal() {
|
|
const modal = document.getElementById('duplicate-ip-modal');
|
|
modal.style.display = 'flex';
|
|
setTimeout(() => modal.classList.add('show'), 10);
|
|
|
|
const content = document.getElementById('duplicate-ip-content');
|
|
content.innerHTML = '<div style="text-align:center;padding:40px;"><i class="fas fa-spinner fa-spin fa-3x"></i><p>正在分析日志数据...</p></div>';
|
|
|
|
try {
|
|
const res = await fetch('/api/players/duplicate-ips');
|
|
if (!res.ok) throw new Error("API Error");
|
|
const data = await res.json();
|
|
|
|
if (data.length === 0) {
|
|
content.innerHTML = '<div style="text-align:center;padding:40px;color:#28a745;"><i class="fas fa-check-circle fa-3x"></i><p>未发现同IP多账号情况</p></div>';
|
|
return;
|
|
}
|
|
|
|
let html = `<div class="alert alert-info" style="padding:10px; background:#e3f2fd; color:#0d47a1; border-radius:4px; margin-bottom:15px;">
|
|
<i class="fas fa-info-circle"></i> 共发现 ${data.length} 个IP地址存在多个关联账号。
|
|
</div>`;
|
|
|
|
data.forEach(group => {
|
|
html += `<div class="card" style="margin-bottom:15px; border:1px solid #ddd; border-radius:4px;">
|
|
<div class="card-header" style="background:#f8f9fa; padding:10px 15px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #ddd;">
|
|
<span style="font-weight:bold; color:#007bff;"><i class="fas fa-globe"></i> ${group.ip}</span>
|
|
<span class="badge" style="background:#6c757d; color:white; padding:2px 8px; border-radius:10px; font-size:0.8em;">${group.players.length} 个关联账号</span>
|
|
</div>
|
|
<div class="card-body" style="padding:0;">
|
|
<table style="width:100%; border-collapse:collapse; font-size:0.9em;">
|
|
<thead>
|
|
<tr style="background:#fff; border-bottom:2px solid #eee; text-align:left;">
|
|
<th style="padding:8px 15px;">玩家昵称</th>
|
|
<th style="padding:8px 15px;">SteamID</th>
|
|
<th style="padding:8px 15px;">最近上线时间</th>
|
|
<th style="padding:8px 15px;">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>`;
|
|
|
|
group.players.forEach(p => {
|
|
const lastSeen = p.lastSeen ? new Date(p.lastSeen).toLocaleString() : '-';
|
|
html += `<tr style="border-bottom:1px solid #f0f0f0;">
|
|
<td style="padding:8px 15px;">${p.name}</td>
|
|
<td style="padding:8px 15px; font-family:monospace;">${p.steamID}</td>
|
|
<td style="padding:8px 15px;">${lastSeen}</td>
|
|
<td style="padding:8px 15px;">
|
|
<button onclick="confirmBan('${p.steamID}', '${p.name.replace(/'/g, "\\'")}')" style="padding:2px 8px; font-size:0.8em; border:1px solid #dc3545; color:#dc3545; background:white; border-radius:4px; cursor:pointer;">封禁</button>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += `</tbody></table></div></div>`;
|
|
});
|
|
|
|
content.innerHTML = html;
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
content.innerHTML = '<div style="text-align:center;padding:40px;color:#dc3545;"><i class="fas fa-exclamation-circle fa-3x"></i><p>获取数据失败,请重试</p></div>';
|
|
}
|
|
}
|
|
|
|
function closeDuplicateIpModal() {
|
|
const modal = document.getElementById('duplicate-ip-modal');
|
|
modal.classList.remove('show');
|
|
setTimeout(() => modal.style.display = 'none', 300);
|
|
}
|
|
|
|
async function confirmBan(steamId, name) {
|
|
if(!confirm(`确定要封禁玩家 ${name} (${steamId}) 吗?`)) return;
|
|
const reason = prompt("请输入封禁理由:", "同IP多账号违规");
|
|
if(!reason) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/players/ban', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ steamId, reason, duration: 'Perma', note: 'Dashboard Duplicate IP Check' })
|
|
});
|
|
if(res.ok) alert('封禁成功');
|
|
else alert('封禁失败: ' + await res.text());
|
|
} catch(e) {
|
|
alert('网络错误');
|
|
}
|
|
}
|
|
</script>
|
|
<!-- Chat Modal -->
|
|
<div class="modal-overlay" id="chat-modal" onclick="if(event.target === this) hideChatModal()">
|
|
<div class="modal-content-large" style="width: 800px; max-width: 95%;">
|
|
<div class="modal-header">
|
|
<div class="modal-title">
|
|
💬 聊天记录与广播
|
|
<span id="chat-status" style="font-size: 0.8rem; margin-left: 10px; padding: 2px 6px; border-radius: 4px; background: #eee; color: #666;">连接中...</span>
|
|
</div>
|
|
<button class="modal-close-btn" onclick="hideChatModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="display: flex; flex-direction: column; background-color: #f5f7fa;">
|
|
<!-- Chat List -->
|
|
<div id="chat-list" class="chat-messages" style="flex: 1; padding: 20px; overflow-y: auto; background-color: #fff; margin: 15px; border-radius: 4px; border: 1px solid #eee;">
|
|
<div style="text-align: center; color: #999; padding: 20px;">
|
|
<p>正在加载...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input Area -->
|
|
<div style="padding: 15px 20px; background-color: #fff; border-top: 1px solid #eee; display: flex; gap: 10px; align-items: center;">
|
|
<input type="text" id="chat-input" class="form-control" placeholder="输入消息 (Enter 发送)..." onkeydown="if(event.key === 'Enter') sendAdminBroadcast()">
|
|
<button class="btn-refresh" onclick="sendAdminBroadcast()" style="background-color: #007bff;width:90px; padding: 8px 20px;">发送</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Battle Event Modal -->
|
|
<div id="battle-modal" class="modal-overlay" onclick="if(event.target === this && !this.classList.contains('pinned')) hideBattleModal()">
|
|
<div class="modal-content-large" id="battle-modal-content" style="width: 1000px; max-width: 95%; height: 80vh; display: flex; flex-direction: column;">
|
|
<div class="modal-header" onmousedown="startDrag(event, 'battle-modal-content')" style="background-color: #ef4444; color: white;">
|
|
<h3 style="margin: 0; display: flex; align-items: center; gap: 10px;">
|
|
<i class="fas fa-crosshairs"></i> 实时战斗事件
|
|
<button onclick="togglePin('battle-modal', this)" style="background:none; border:none; color:white; cursor:pointer; font-size: 0.9em; opacity: 0.8; transition: transform 0.2s;" title="钉住窗口 (悬浮显示)">
|
|
<i class="fas fa-thumbtack"></i>
|
|
</button>
|
|
</h3>
|
|
<span class="close-btn" onclick="hideBattleModal()" style="color: white; cursor: pointer; font-size: 1.5rem; opacity: 0.8;">×</span>
|
|
</div>
|
|
<div class="modal-body" style="flex: 1; padding: 0; overflow-y: auto; background: #f8f9fa;" id="battle-list">
|
|
<div style="text-align: center; color: #999; padding: 20px;">等待战斗事件...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.battle-event-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 15px;
|
|
border-bottom: 1px solid #eee;
|
|
background: #fff;
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
.battle-event-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.battle-time {
|
|
color: #999;
|
|
font-size: 0.85em;
|
|
width: 80px;
|
|
flex-shrink: 0;
|
|
}
|
|
.battle-icon {
|
|
width: 40px;
|
|
text-align: center;
|
|
font-size: 1.2em;
|
|
margin-right: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
.battle-content {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 5px;
|
|
}
|
|
.player-badge {
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
font-weight: 500;
|
|
font-size: 0.9em;
|
|
}
|
|
.team-1-badge { background-color: #007bff; }
|
|
.team-2-badge { background-color: #dc3545; }
|
|
.team-0-badge { background-color: #6c757d; }
|
|
|
|
.event-wound .battle-icon { color: #ed6c02; }
|
|
.event-die .battle-icon { color: #d32f2f; }
|
|
.event-revived .battle-icon { color: #2e7d32; }
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(-10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// Battle Events Logic
|
|
const MAX_BATTLE_EVENTS = 100;
|
|
|
|
// Drag Logic
|
|
let dragObj = null;
|
|
|
|
function startDrag(e, elementId) {
|
|
const el = document.getElementById(elementId);
|
|
const overlay = el.closest('.modal-overlay');
|
|
if (!overlay || !overlay.classList.contains('pinned')) return;
|
|
|
|
// Prevent text selection
|
|
e.preventDefault();
|
|
|
|
dragObj = {
|
|
el: el,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
startLeft: el.offsetLeft,
|
|
startTop: el.offsetTop
|
|
};
|
|
|
|
document.addEventListener('mousemove', doDrag);
|
|
document.addEventListener('mouseup', stopDrag);
|
|
}
|
|
|
|
function doDrag(e) {
|
|
if (!dragObj) return;
|
|
const dx = e.clientX - dragObj.startX;
|
|
const dy = e.clientY - dragObj.startY;
|
|
dragObj.el.style.left = (dragObj.startLeft + dx) + 'px';
|
|
dragObj.el.style.top = (dragObj.startTop + dy) + 'px';
|
|
}
|
|
|
|
function stopDrag() {
|
|
dragObj = null;
|
|
document.removeEventListener('mousemove', doDrag);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
}
|
|
|
|
function togglePin(modalId, btn) {
|
|
const modal = document.getElementById(modalId);
|
|
const content = modal.querySelector('.modal-content-large');
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (modal.classList.contains('pinned')) {
|
|
// Unpinning
|
|
modal.classList.remove('pinned');
|
|
btn.style.transform = 'rotate(0deg)';
|
|
btn.style.opacity = '0.8';
|
|
|
|
// Reset styles to default centered modal
|
|
content.style.position = '';
|
|
content.style.left = '';
|
|
content.style.top = '';
|
|
content.style.margin = '';
|
|
} else {
|
|
// Pinning
|
|
const rect = content.getBoundingClientRect();
|
|
|
|
modal.classList.add('pinned');
|
|
btn.style.transform = 'rotate(45deg)';
|
|
btn.style.opacity = '1';
|
|
|
|
// Fix position to current coordinates
|
|
content.style.position = 'fixed';
|
|
content.style.left = rect.left + 'px';
|
|
content.style.top = rect.top + 'px';
|
|
content.style.margin = '0';
|
|
}
|
|
}
|
|
|
|
function openBattleModal() {
|
|
document.getElementById('battle-modal').classList.add('show');
|
|
const list = document.getElementById('battle-list');
|
|
if(list.innerText.trim() === '等待战斗事件...') {
|
|
// Keep placeholder
|
|
}
|
|
// Scroll to bottom
|
|
list.scrollTop = list.scrollHeight;
|
|
}
|
|
|
|
function hideBattleModal() {
|
|
document.getElementById('battle-modal').classList.remove('show');
|
|
}
|
|
|
|
function appendBattleEvent(evt) {
|
|
const container = document.getElementById('battle-list');
|
|
if (!container) return;
|
|
|
|
// Remove placeholder
|
|
if (container.innerText.trim() === '等待战斗事件...') {
|
|
container.innerHTML = '';
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.className = `battle-event-item event-${evt.type.toLowerCase()}`;
|
|
|
|
// Icon & Type
|
|
let icon = 'question';
|
|
let actionText = '未知';
|
|
|
|
if (evt.type === 'Wound') {
|
|
icon = 'procedures'; // Stretcher/Bed
|
|
actionText = '击倒了';
|
|
} else if (evt.type === 'Die') {
|
|
icon = 'skull';
|
|
actionText = '击杀了';
|
|
} else if (evt.type === 'Revived') {
|
|
icon = 'heartbeat';
|
|
actionText = '救起了';
|
|
}
|
|
|
|
const timeStr = new Date(evt.timestamp).toLocaleTimeString();
|
|
|
|
// Attacker Badge
|
|
const attackerClass = evt.attackerTeamId === 1 ? 'team-1-badge' : (evt.attackerTeamId === 2 ? 'team-2-badge' : 'team-0-badge');
|
|
const attackerHtml = `<span class="player-badge ${attackerClass}">${evt.attackerName}</span>`;
|
|
|
|
// Victim Badge
|
|
const victimClass = evt.victimTeamId === 1 ? 'team-1-badge' : (evt.victimTeamId === 2 ? 'team-2-badge' : 'team-0-badge');
|
|
const victimHtml = `<span class="player-badge ${victimClass}">${evt.victimName}</span>`;
|
|
|
|
// Weapon/Damage Info
|
|
let details = '';
|
|
if (evt.type !== 'Revived') {
|
|
details = `<span style="color:#666; font-size:0.9em; margin-left: 5px;">(${evt.weapon || 'Unknown'} - ${evt.damage.toFixed(0)} dmg)</span>`;
|
|
}
|
|
|
|
// TK Warning
|
|
let tkBadge = '';
|
|
if (evt.isTK) {
|
|
tkBadge = `<span style="background:#ffc107; color:#000; padding:1px 5px; border-radius:3px; margin-right:5px; font-weight:bold; font-size:0.8em;">TK</span>`;
|
|
}
|
|
|
|
div.innerHTML = `
|
|
<div class="battle-time">${timeStr}</div>
|
|
<div class="battle-icon"><i class="fas fa-${icon}"></i></div>
|
|
<div class="battle-content">
|
|
${tkBadge}
|
|
${attackerHtml}
|
|
<span style="margin: 0 5px; color:#555;">${actionText}</span>
|
|
${victimHtml}
|
|
${details}
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(div);
|
|
|
|
// Limit items
|
|
if (container.children.length > MAX_BATTLE_EVENTS) {
|
|
container.removeChild(container.firstElementChild);
|
|
}
|
|
|
|
// Auto scroll if near bottom
|
|
if (container.scrollHeight - container.scrollTop - container.clientHeight < 100) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- Clear AFK Modal -->
|
|
<div class="modal-overlay" id="clear-afk-modal">
|
|
<div class="modal-content-custom" style="width: 800px; max-width: 95%;">
|
|
<div class="modal-header">
|
|
<div class="modal-title">清理未入队玩家 (Kick Unassigned)</div>
|
|
<button class="modal-close-btn" onclick="closeClearAfkModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px; display: flex; flex-direction: column; gap: 15px;">
|
|
<div style="background: #fffbe6; border: 1px solid #ffe58f; padding: 10px; border-radius: 4px; font-size: 0.9em; color: #8a6d3b;">
|
|
<span style="font-weight: bold;">说明:</span>
|
|
以下是当前未加入任何小队的玩家。勾选后点击确认将执行踢出操作。<br>
|
|
命令: <code>AdminKick <SteamID> 长时间未加入队伍</code>
|
|
</div>
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0 5px;">
|
|
<label style="cursor: pointer; user-select: none; font-weight: bold;">
|
|
<input type="checkbox" id="afk-check-all" onchange="toggleAllAfk(this.checked)"> 全选/取消全选
|
|
</label>
|
|
<span id="afk-count-display">共 0 人</span>
|
|
</div>
|
|
|
|
<div id="afk-list" style="max-height: 400px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; background: #fff;">
|
|
<!-- List items -->
|
|
</div>
|
|
|
|
<div class="modal-footer" style="text-align: right; margin-top: 10px;">
|
|
<button class="btn-cancel" onclick="closeClearAfkModal()">取消</button>
|
|
<button class="btn-confirm-danger" onclick="confirmClearAfk()">确认清理</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
let chatIntervalId = null;
|
|
const CHAT_POLL_INTERVAL = 2000;
|
|
|
|
let chatConnection = null;
|
|
|
|
try {
|
|
if (typeof signalR !== 'undefined') {
|
|
chatConnection = new signalR.HubConnectionBuilder()
|
|
.withUrl("/chatHub")
|
|
.withAutomaticReconnect()
|
|
.build();
|
|
|
|
chatConnection.on("ReceiveChat", function (msg) {
|
|
console.log("Received chat:", msg);
|
|
appendChatMessage(msg);
|
|
});
|
|
|
|
chatConnection.on("ReceiveBattleEvent", function (evt) {
|
|
// console.log("Received battle event:", evt);
|
|
appendBattleEvent(evt);
|
|
});
|
|
|
|
chatConnection.start().then(() => {
|
|
console.log("Chat SignalR Connected");
|
|
updateChatStatus(true);
|
|
}).catch(function (err) {
|
|
console.error("Chat SignalR Connection Error: " + err.toString());
|
|
updateChatStatus(false);
|
|
// Fallback to polling on connection failure
|
|
startPolling();
|
|
});
|
|
|
|
chatConnection.onclose(() => {
|
|
updateChatStatus(false);
|
|
startPolling();
|
|
});
|
|
} else {
|
|
throw new Error("SignalR library not loaded");
|
|
}
|
|
} catch (err) {
|
|
console.error("Chat SignalR Setup Failed: " + err);
|
|
updateChatStatus(false);
|
|
startPolling();
|
|
}
|
|
function openChatModal() {
|
|
document.getElementById('chat-modal').classList.add('show');
|
|
loadChatData();
|
|
|
|
// If SignalR is not connected/available, start polling
|
|
if (!chatConnection || (chatConnection.state && chatConnection.state !== "Connected")) {
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
function updateChatStatus(connected) {
|
|
const el = document.getElementById('chat-status');
|
|
if (el) {
|
|
el.innerText = connected ? '实时连接正常' : '轮询模式';
|
|
el.style.backgroundColor = connected ? '#d4edda' : '#fff3cd';
|
|
el.style.color = connected ? '#155724' : '#856404';
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
if (!chatIntervalId) {
|
|
chatIntervalId = setInterval(loadChatData, CHAT_POLL_INTERVAL);
|
|
}
|
|
}
|
|
|
|
function getChatHtml(msg) {
|
|
const typeClass = msg.type ? msg.type.replace(/[^a-zA-Z0-9]/g, '') : 'ChatGame';
|
|
const timeStr = new Date(msg.timestamp).toLocaleTimeString();
|
|
const safeName = (msg.name || 'Unknown').replace(/</g, "<").replace(/>/g, ">");
|
|
const safeMsg = (msg.message || '').replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
let metaHtml = '';
|
|
|
|
// Add Chat Type Badge
|
|
let typeLabel = '系统';
|
|
let typeBgColor = '#6c757d';
|
|
let typeTextColor = '#fff';
|
|
|
|
if (msg.type === 'ChatAll') {
|
|
typeLabel = '全体';
|
|
typeBgColor = '#007bff';
|
|
} else if (msg.type === 'ChatTeam') {
|
|
typeLabel = '阵营';
|
|
typeBgColor = '#28a745';
|
|
} else if (msg.type === 'ChatSquad') {
|
|
typeLabel = '小队';
|
|
typeBgColor = '#ffc107';
|
|
typeTextColor = '#212529'; // Dark text for yellow background
|
|
} else if (msg.type === 'ChatAdmin') {
|
|
typeLabel = '管理';
|
|
typeBgColor = '#dc3545';
|
|
}
|
|
|
|
metaHtml += `<span class="badge" style="font-size: 0.75rem; margin-right: 5px; padding: 2px 6px; background-color: ${typeBgColor}; color: ${typeTextColor}; border-radius: 4px;">${typeLabel}</span>`;
|
|
|
|
if (msg.teamId) {
|
|
metaHtml += `<span class="badge badge-secondary" style="font-size: 0.75rem; margin-right: 5px; padding: 2px 6px; background-color: ${msg.teamId === 1 ? '#6c757d' : '#adb5bd'}; color: #fff;">T${msg.teamId}</span>`;
|
|
}
|
|
if (msg.squadId) {
|
|
metaHtml += `<span class="badge badge-info" style="font-size: 0.75rem; margin-right: 5px; padding: 2px 6px; background-color: #17a2b8; color: #fff;">SQ${msg.squadId}</span>`;
|
|
}
|
|
|
|
const steamIdHtml = msg.steamId ? `<span class="chat-steam">(${msg.steamId})</span>` : '';
|
|
|
|
return `
|
|
<div class="chat-msg-item ${typeClass}">
|
|
<span class="chat-time">${timeStr}</span>
|
|
<div class="chat-badges">${metaHtml}</div>
|
|
<span class="chat-name" title="${safeName} EOS: ${msg.eosId || 'N/A'}">${safeName}</span>
|
|
${steamIdHtml}
|
|
<span style="margin-right: 6px; color: #666;">:</span>
|
|
<span class="chat-content" title="${safeMsg}">${safeMsg}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function appendChatMessage(msg) {
|
|
const container = document.getElementById('chat-list');
|
|
if (!container) return;
|
|
|
|
// Remove placeholder if exists
|
|
const placeholder = container.querySelector('p');
|
|
if (placeholder && placeholder.innerText === '正在加载...') {
|
|
container.innerHTML = '';
|
|
}
|
|
if (container.innerText.trim() === '暂无消息') {
|
|
container.innerHTML = '';
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
// We use a temporary wrapper to parse string to DOM
|
|
div.innerHTML = getChatHtml(msg);
|
|
|
|
// Append to bottom (Newest Last)
|
|
container.appendChild(div.firstElementChild);
|
|
|
|
// Scroll to bottom
|
|
container.scrollTop = container.scrollHeight;
|
|
|
|
// Optional: Limit number of messages in DOM
|
|
if (container.children.length > 300) {
|
|
container.removeChild(container.firstElementChild);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function hideChatModal() {
|
|
document.getElementById('chat-modal').classList.remove('show');
|
|
|
|
if (chatIntervalId) {
|
|
clearInterval(chatIntervalId);
|
|
chatIntervalId = null;
|
|
}
|
|
}
|
|
|
|
async function loadChatData() {
|
|
try {
|
|
const response = await fetch('/api/rcon/chat');
|
|
if (!response.ok) return;
|
|
const messages = await response.json();
|
|
renderChat(messages);
|
|
} catch (err) {
|
|
console.error("Chat load failed", err);
|
|
}
|
|
}
|
|
|
|
function renderChat(messages) {
|
|
const container = document.getElementById('chat-list');
|
|
if (!container) return;
|
|
|
|
if (!messages || messages.length === 0) {
|
|
container.innerHTML = '<div style="text-align: center; color: #999; padding: 20px;">暂无消息</div>';
|
|
return;
|
|
}
|
|
|
|
// Sort by timestamp asc (Oldest first) so new messages are at bottom
|
|
const sorted = [...messages].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
|
|
let html = '';
|
|
sorted.forEach(msg => {
|
|
html += getChatHtml(msg);
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Scroll to bottom
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
async function sendAdminBroadcast() {
|
|
const input = document.getElementById('chat-input');
|
|
const msg = input.value.trim();
|
|
if (!msg) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: `AdminBroadcast ${msg}` })
|
|
});
|
|
|
|
if (response.ok) {
|
|
input.value = '';
|
|
setTimeout(loadChatData, 500);
|
|
} else {
|
|
const text = await response.text();
|
|
alert('发送失败: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('发送出错: ' + err.message);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script>
|
|
let afkPlayersToKick = []; // Array of SteamIDs
|
|
|
|
function openClearAfkModal() {
|
|
if (!window.currentPlayers) {
|
|
alert("正在加载玩家数据,请稍后...");
|
|
return;
|
|
}
|
|
|
|
// Find unassigned players (no squadId)
|
|
const afkPlayers = window.currentPlayers.filter(p => !p.squadId);
|
|
|
|
afkPlayersToKick = afkPlayers.map(p => p.steamId);
|
|
|
|
renderAfkList(afkPlayers);
|
|
|
|
document.getElementById('clear-afk-modal').classList.add('show');
|
|
document.getElementById('clear-afk-modal').style.display = 'flex';
|
|
}
|
|
|
|
function closeClearAfkModal() {
|
|
document.getElementById('clear-afk-modal').classList.remove('show');
|
|
setTimeout(() => document.getElementById('clear-afk-modal').style.display = 'none', 300);
|
|
}
|
|
|
|
function renderAfkList(players) {
|
|
const container = document.getElementById('afk-list');
|
|
container.innerHTML = '';
|
|
|
|
document.getElementById('afk-count-display').innerText = `共 ${players.length} 人`;
|
|
|
|
// Check All checkbox state
|
|
const allSelected = players.length > 0 && players.every(p => afkPlayersToKick.includes(p.steamId));
|
|
document.getElementById('afk-check-all').checked = allSelected;
|
|
|
|
if (players.length === 0) {
|
|
container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">当前没有未入队玩家。</div>';
|
|
return;
|
|
}
|
|
|
|
players.forEach(p => {
|
|
const isSelected = afkPlayersToKick.includes(p.steamId);
|
|
const row = document.createElement('div');
|
|
row.style.padding = '8px 15px';
|
|
row.style.borderBottom = '1px solid #f5f5f5';
|
|
row.style.display = 'flex';
|
|
row.style.alignItems = 'center';
|
|
row.style.gap = '15px';
|
|
row.style.cursor = 'pointer';
|
|
row.style.background = isSelected ? '#fff7e6' : '#fff';
|
|
|
|
row.onclick = (e) => {
|
|
if (e.target.type !== 'checkbox') {
|
|
const cb = row.querySelector('input[type="checkbox"]');
|
|
cb.checked = !cb.checked;
|
|
toggleAfkPlayer(p.steamId, cb.checked);
|
|
}
|
|
};
|
|
|
|
const kd = (p.kills || 0) + '/' + (p.deaths || 0);
|
|
|
|
row.innerHTML = `
|
|
<input type="checkbox" ${isSelected ? 'checked' : ''} onchange="toggleAfkPlayer('${p.steamId}', this.checked)">
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 500;">${p.name}</div>
|
|
<div style="font-size: 0.8em; color: #888;">ID: ${p.id} | SteamID: ${p.steamId} | Team: ${p.teamId}</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function toggleAfkPlayer(steamId, checked) {
|
|
if (checked) {
|
|
if (!afkPlayersToKick.includes(steamId)) afkPlayersToKick.push(steamId);
|
|
} else {
|
|
afkPlayersToKick = afkPlayersToKick.filter(id => id !== steamId);
|
|
}
|
|
// Re-render to update highlighting
|
|
const afkPlayers = window.currentPlayers.filter(p => !p.squadId);
|
|
renderAfkList(afkPlayers);
|
|
}
|
|
|
|
function toggleAllAfk(checked) {
|
|
const afkPlayers = window.currentPlayers.filter(p => !p.squadId);
|
|
if (checked) {
|
|
afkPlayersToKick = afkPlayers.map(p => p.steamId);
|
|
} else {
|
|
afkPlayersToKick = [];
|
|
}
|
|
renderAfkList(afkPlayers);
|
|
}
|
|
|
|
async function confirmClearAfk() {
|
|
if (afkPlayersToKick.length === 0) return;
|
|
|
|
if (!confirm(`确定要踢出选中的 ${afkPlayersToKick.length} 名玩家吗?`)) return;
|
|
|
|
const btn = document.querySelector('#clear-afk-modal .btn-confirm-danger');
|
|
const originalText = btn.innerText;
|
|
btn.innerText = "执行中...";
|
|
btn.disabled = true;
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const steamId of afkPlayersToKick) {
|
|
try {
|
|
const response = await fetch('/api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: `AdminKick ${steamId} 长时间未加入队伍` })
|
|
});
|
|
|
|
if (response.ok) successCount++;
|
|
else failCount++;
|
|
|
|
} catch (e) {
|
|
failCount++;
|
|
console.error(e);
|
|
}
|
|
|
|
// Small delay
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
alert(`清理完成!\n成功: ${successCount}\n失败: ${failCount}`);
|
|
|
|
btn.innerText = originalText;
|
|
btn.disabled = false;
|
|
closeClearAfkModal();
|
|
// Refresh data
|
|
loadData();
|
|
}
|
|
</script>
|
|
<!-- Duplicate IP Modal -->
|
|
<div id="duplicate-ip-modal" class="modal-overlay" onclick="if(event.target === this) closeDuplicateIpModal()">
|
|
<div class="modal-content-large" style="width: 800px; max-width: 95%; display: flex; flex-direction: column; max-height: 85vh;">
|
|
<div class="modal-header" style="background:#fff; border-bottom:1px solid #eee; padding:15px 20px; display:flex; justify-content:space-between; align-items:center;">
|
|
<h3 style="margin:0; color:#333;"><i class="fas fa-network-wired" style="color:#007bff; margin-right:10px;"></i> 同IP关联账号检测</h3>
|
|
<span onclick="closeDuplicateIpModal()" style="font-size:1.5rem; cursor:pointer; color:#999;">×</span>
|
|
</div>
|
|
<div class="modal-body" id="duplicate-ip-content" style="flex:1; overflow-y:auto; padding:20px; background:#f8f9fa;">
|
|
<!-- Content injected by JS -->
|
|
</div>
|
|
<div class="modal-footer" style="padding:15px 20px; border-top:1px solid #eee; background:#fff; text-align:right;">
|
|
<button onclick="closeDuplicateIpModal()" style="padding:8px 20px; border:1px solid #ddd; background:#fff; border-radius:4px; cursor:pointer;">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Player Detail Modal (Mobile) -->
|
|
<div class="modal-overlay" id="player-detail-modal" onclick="if(event.target === this) closePlayerDetailModal()">
|
|
<div class="modal-content-custom" style="width: 400px; max-width: 90%;">
|
|
<div class="modal-header">
|
|
<div class="modal-title">玩家详情</div>
|
|
<button class="modal-close-btn" onclick="closePlayerDetailModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding: 20px;" id="player-detail-content">
|
|
<!-- Content injected via JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html> |