Files
squad-rain-ops-mini/wwwroot/players.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">&times;</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">&times;</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">&times;</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">&times;</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>