mirror of
https://gitee.com/xiarenalofs/squad-rain-ops-mini.git
synced 2026-08-06 13:26:25 +08:00
628 lines
25 KiB
HTML
628 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>玩家库</title>
|
|
<link href="/lib/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
|
|
<script src="/lib/js/vue.global.prod.js"></script>
|
|
<style>
|
|
:root {
|
|
--primary-color: #007bff;
|
|
--header-bg: #f8f9fa;
|
|
}
|
|
|
|
body {
|
|
background-color: #f5f7fa;
|
|
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
color: #333;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
padding: 20px;
|
|
}
|
|
|
|
.header-card {
|
|
background: white;
|
|
padding: 15px 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.main-card {
|
|
flex: 1;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
}
|
|
|
|
.table {
|
|
margin-bottom: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.table thead th {
|
|
background: var(--header-bg);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
padding: 12px 15px;
|
|
border-bottom: 2px solid #dee2e6;
|
|
color: #495057;
|
|
}
|
|
|
|
.table td {
|
|
padding: 12px 15px;
|
|
vertical-align: middle;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.table tbody tr:hover {
|
|
background-color: #f8f9fa;
|
|
}
|
|
|
|
.pagination {
|
|
padding: 15px;
|
|
border-top: 1px solid #eee;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Modal Styles */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
z-index: 1000;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-content-large {
|
|
background: white;
|
|
width: 90%;
|
|
max-width: 1000px;
|
|
height: 80vh;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 15px 20px;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-body {
|
|
flex: 1;
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.close-btn {
|
|
cursor: pointer;
|
|
font-size: 1.5rem;
|
|
color: #999;
|
|
}
|
|
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
background: #f8f9fa;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.info-item label {
|
|
display: block;
|
|
color: #6c757d;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 4px;
|
|
}
|
|
.info-item span {
|
|
font-weight: 500;
|
|
color: #333;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="header-card">
|
|
<h4 class="m-0"><i class="fas fa-id-card"></i> 玩家库</h4>
|
|
<div style="display: flex; gap: 10px;">
|
|
<button class="btn btn-outline-primary" @click="openDuplicateIpModal">
|
|
<i class="fas fa-network-wired"></i> 同IP检测
|
|
</button>
|
|
<div class="input-group" style="width: 300px;">
|
|
<input type="text" class="form-control" placeholder="搜索玩家/SteamID/EOSID..." v-model="searchQuery" @keyup.enter="search">
|
|
<button class="btn btn-primary" @click="search">搜索</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-card">
|
|
<div class="table-container">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>昵称</th>
|
|
<th>SteamID</th>
|
|
<th>EOSID</th>
|
|
<th>Squad时长(小时)</th>
|
|
<th>游玩粘性(时/天)</th>
|
|
<th>首次记录</th>
|
|
<th>最近记录</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="8" class="text-center p-4">加载中...</td>
|
|
</tr>
|
|
<tr v-else-if="players.length === 0">
|
|
<td colspan="8" class="text-center p-4">暂无数据</td>
|
|
</tr>
|
|
<tr v-for="p in players" :key="p.steamID">
|
|
<td>{{ p.lastName || 'Unknown' }}</td>
|
|
<td>{{ p.steamID }}</td>
|
|
<td>{{ p.eosID }}</td>
|
|
<td>{{ p.squadPlaytimeHours }}</td>
|
|
<td>{{ p.playStickiness ? p.playStickiness.toFixed(2) : '0.00' }}</td>
|
|
<td>{{ formatDate(p.firstSeen) }}</td>
|
|
<td>{{ formatDate(p.lastSeen) }}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-info text-white me-1" @click="viewDetails(p)">
|
|
<i class="fas fa-list"></i> 详情
|
|
</button>
|
|
<button class="btn btn-sm btn-warning me-1" v-if="checkPermission('players.kick')" @click="openKickModal(p)">
|
|
<i class="fas fa-sign-out-alt"></i> 踢出
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" v-if="checkPermission('players.ban')" @click="openBanModal(p)">
|
|
<i class="fas fa-ban"></i> 封禁
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination">
|
|
<span>共 {{ total }} 条记录</span>
|
|
<div>
|
|
<button class="btn btn-sm btn-secondary" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
|
|
<span class="mx-2">第 {{ page }} 页</span>
|
|
<button class="btn btn-sm btn-secondary" :disabled="players.length < pageSize" @click="changePage(page + 1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Duplicate IP Modal -->
|
|
<div class="modal-overlay" v-if="showDuplicateIpModal" @click.self="showDuplicateIpModal = false">
|
|
<div class="modal-content-large" style="width: 80%; max-width: 1200px;">
|
|
<div class="modal-header">
|
|
<h5 class="m-0"><i class="fas fa-network-wired"></i> 同IP玩家检测</h5>
|
|
<span class="close-btn" @click="showDuplicateIpModal = false">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div v-if="loadingDuplicateIps" class="text-center p-5">
|
|
<i class="fas fa-spinner fa-spin fa-3x text-primary mb-3"></i>
|
|
<div class="mt-2">正在分析日志数据,请稍候...</div>
|
|
</div>
|
|
<div v-else-if="duplicateIps.length === 0" class="text-center p-5 text-muted">
|
|
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
|
|
<h5>未发现同IP多账号情况</h5>
|
|
</div>
|
|
<div v-else>
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle"></i> 共发现 {{ duplicateIps.length }} 个IP地址存在多个关联账号。
|
|
</div>
|
|
<div v-for="group in duplicateIps" :key="group.ip" class="card mb-3">
|
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
|
<span class="fw-bold text-primary"><i class="fas fa-globe"></i> {{ group.ip }}</span>
|
|
<span class="badge bg-secondary">{{ group.players.length }} 个关联账号</span>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<table class="table table-sm table-hover m-0">
|
|
<thead>
|
|
<tr>
|
|
<th>玩家昵称</th>
|
|
<th>SteamID</th>
|
|
<th>最近上线时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="p in group.players" :key="p.steamID">
|
|
<td>{{ p.name }}</td>
|
|
<td class="font-monospace">{{ p.steamID }}</td>
|
|
<td>{{ formatDate(p.lastSeen) }}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-danger py-0" @click="openBanModal({steamID: p.steamID, lastName: p.name})">
|
|
封禁
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Details Modal -->
|
|
<div class="modal-overlay" v-if="showModal" @click.self="showModal = false">
|
|
<div class="modal-content-large">
|
|
<div class="modal-header">
|
|
<h5 class="m-0">玩家详情: {{ selectedPlayer?.lastName }}</h5>
|
|
<span class="close-btn" @click="showModal = false">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<label>SteamID</label>
|
|
<span>{{ selectedPlayer?.steamID }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>EOSID</label>
|
|
<span>{{ selectedPlayer?.eosID }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>本服总时长</label>
|
|
<span>{{ (selectedPlayer?.totalPlaytimeSeconds / 3600).toFixed(1) }} 小时</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Squad总时长</label>
|
|
<span>{{ selectedPlayer?.squadPlaytimeHours }} 小时</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h6 class="mb-3">30日内游玩记录</h6>
|
|
<table class="table table-sm table-bordered">
|
|
<thead>
|
|
<tr>
|
|
<th>加入时间</th>
|
|
<th>退出时间</th>
|
|
<th>时长(分钟)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loadingSessions">
|
|
<td colspan="3" class="text-center">加载中...</td>
|
|
</tr>
|
|
<tr v-else-if="sessions.length === 0">
|
|
<td colspan="3" class="text-center">暂无记录</td>
|
|
</tr>
|
|
<tr v-for="s in sessions" :key="s.id">
|
|
<td>{{ formatDate(s.startTime) }}</td>
|
|
<td>{{ s.endTime ? formatDate(s.endTime) : '进行中' }}</td>
|
|
<td>{{ ((s.durationSeconds || 0) / 60).toFixed(0) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ban Modal -->
|
|
<div class="modal-overlay" v-if="showBanModal" @click.self="showBanModal = false">
|
|
<div class="modal-content-large" style="max-width: 500px; height: auto;">
|
|
<div class="modal-header">
|
|
<h5 class="m-0">封禁玩家</h5>
|
|
<span class="close-btn" @click="showBanModal = false">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">SteamID</label>
|
|
<input type="text" class="form-control" v-model="banForm.steamId" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">玩家名称</label>
|
|
<input type="text" class="form-control" v-model="banForm.name" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">封禁时长</label>
|
|
<select class="form-control" v-model="banForm.duration">
|
|
<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="mb-3">
|
|
<label class="form-label">封禁原因</label>
|
|
<input type="text" class="form-control" v-model="banForm.reason" placeholder="请输入原因">
|
|
</div>
|
|
<div class="text-end">
|
|
<button class="btn btn-secondary me-2" @click="showBanModal = false">取消</button>
|
|
<button class="btn btn-danger" @click="confirmBan">确认封禁</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kick Modal -->
|
|
<div class="modal-overlay" v-if="showKickModal" @click.self="showKickModal = false">
|
|
<div class="modal-content-large" style="max-width: 500px; height: auto;">
|
|
<div class="modal-header">
|
|
<h5 class="m-0">踢出玩家</h5>
|
|
<span class="close-btn" @click="showKickModal = false">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">SteamID</label>
|
|
<input type="text" class="form-control" v-model="kickForm.steamId" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">玩家名称</label>
|
|
<input type="text" class="form-control" v-model="kickForm.name" readonly>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">踢出原因</label>
|
|
<input type="text" class="form-control" v-model="kickForm.reason" placeholder="请输入原因 (可选)">
|
|
</div>
|
|
<div class="text-end">
|
|
<button class="btn btn-secondary me-2" @click="showKickModal = false">取消</button>
|
|
<button class="btn btn-warning" @click="confirmKick">确认踢出</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, onMounted } = Vue;
|
|
|
|
createApp({
|
|
setup() {
|
|
const players = ref([]);
|
|
const total = ref(0);
|
|
const page = ref(1);
|
|
const pageSize = ref(20);
|
|
const searchQuery = ref('');
|
|
const loading = ref(false);
|
|
|
|
const showModal = ref(false);
|
|
const selectedPlayer = ref(null);
|
|
const sessions = ref([]);
|
|
const loadingSessions = ref(false);
|
|
|
|
// Ban Logic
|
|
const showBanModal = ref(false);
|
|
const banForm = ref({
|
|
steamId: '',
|
|
name: '',
|
|
duration: 'perm',
|
|
reason: ''
|
|
});
|
|
|
|
// Kick Logic
|
|
const showKickModal = ref(false);
|
|
const kickForm = ref({
|
|
steamId: '',
|
|
name: '',
|
|
reason: ''
|
|
});
|
|
|
|
const 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;
|
|
}
|
|
};
|
|
|
|
const openBanModal = (player) => {
|
|
banForm.value.steamId = player.steamID;
|
|
banForm.value.name = player.lastName || 'Unknown';
|
|
banForm.value.duration = 'perm';
|
|
banForm.value.reason = '';
|
|
showBanModal.value = true;
|
|
};
|
|
|
|
const openKickModal = (player) => {
|
|
kickForm.value.steamId = player.steamID;
|
|
kickForm.value.name = player.lastName || 'Unknown';
|
|
kickForm.value.reason = '';
|
|
showKickModal.value = true;
|
|
};
|
|
|
|
const confirmBan = async () => {
|
|
if (!banForm.value.reason) {
|
|
alert("请输入封禁原因");
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch('/api/players/ban', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(banForm.value)
|
|
});
|
|
if (res.ok) {
|
|
alert('玩家已封禁');
|
|
showBanModal.value = false;
|
|
fetchPlayers();
|
|
} else {
|
|
const err = await res.json();
|
|
alert('封禁失败: ' + (err.error || 'Unknown error'));
|
|
}
|
|
} catch (e) {
|
|
alert('请求错误: ' + e.message);
|
|
}
|
|
};
|
|
|
|
const confirmKick = async () => {
|
|
try {
|
|
const res = await fetch('/api/players/kick', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(kickForm.value)
|
|
});
|
|
if (res.ok) {
|
|
alert('玩家已踢出');
|
|
showKickModal.value = false;
|
|
} else {
|
|
const err = await res.json();
|
|
alert('踢出失败: ' + (err.error || 'Unknown error'));
|
|
}
|
|
} catch (e) {
|
|
alert('请求错误: ' + e.message);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
return new Date(dateStr).toLocaleString();
|
|
};
|
|
|
|
const fetchPlayers = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const res = await fetch(`/api/players?page=${page.value}&pageSize=${pageSize.value}&search=${encodeURIComponent(searchQuery.value)}`);
|
|
const data = await res.json();
|
|
players.value = data.items;
|
|
total.value = data.total;
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('加载失败');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const changePage = (newPage) => {
|
|
page.value = newPage;
|
|
fetchPlayers();
|
|
};
|
|
|
|
const search = () => {
|
|
page.value = 1;
|
|
fetchPlayers();
|
|
};
|
|
|
|
const viewDetails = async (player) => {
|
|
selectedPlayer.value = player;
|
|
showModal.value = true;
|
|
loadingSessions.value = true;
|
|
sessions.value = [];
|
|
try {
|
|
const res = await fetch(`/api/players/${player.steamID}/sessions`);
|
|
const data = await res.json();
|
|
sessions.value = data;
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
loadingSessions.value = false;
|
|
}
|
|
};
|
|
|
|
// Duplicate IP Logic
|
|
const showDuplicateIpModal = ref(false);
|
|
const loadingDuplicateIps = ref(false);
|
|
const duplicateIps = ref([]);
|
|
|
|
const openDuplicateIpModal = () => {
|
|
showDuplicateIpModal.value = true;
|
|
fetchDuplicateIps();
|
|
};
|
|
|
|
const fetchDuplicateIps = async () => {
|
|
loadingDuplicateIps.value = true;
|
|
duplicateIps.value = [];
|
|
try {
|
|
const res = await fetch('/api/players/duplicate-ips');
|
|
if (res.ok) {
|
|
duplicateIps.value = await res.json();
|
|
} else {
|
|
alert('获取数据失败');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('网络错误');
|
|
} finally {
|
|
loadingDuplicateIps.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
fetchPlayers();
|
|
});
|
|
|
|
return {
|
|
players,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
searchQuery,
|
|
loading,
|
|
formatDate,
|
|
changePage,
|
|
search,
|
|
showModal,
|
|
selectedPlayer,
|
|
sessions,
|
|
showDuplicateIpModal,
|
|
loadingDuplicateIps,
|
|
duplicateIps,
|
|
openDuplicateIpModal,
|
|
loadingSessions,
|
|
viewDetails,
|
|
checkPermission,
|
|
showBanModal,
|
|
banForm,
|
|
openBanModal,
|
|
confirmBan,
|
|
showKickModal,
|
|
kickForm,
|
|
openKickModal,
|
|
confirmKick
|
|
};
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html> |