Files
squad-rain-ops-mini/wwwroot/analytics.html

719 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据分析</title>
<link rel="stylesheet" href="/lib/css/bootstrap.min.css">
<link rel="stylesheet" href="/lib/css/font-awesome.min.css">
<script src="/lib/js/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
<style>
:root {
/* Modern Palette */
--primary-color: #4361ee;
--secondary-color: #3f37c9;
--success-color: #4caf50;
--info-color: #00b4d8;
--warning-color: #f72585;
--danger-color: #f44336;
--bg-color: #f8f9fa;
--card-bg: #ffffff;
--text-primary: #2b2d42;
--text-secondary: #8d99ae;
--border-color: #edf2f4;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 24px 40px;
background-color: var(--bg-color);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
.container {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
flex-wrap: wrap;
gap: 15px;
}
.header-title {
display: flex;
align-items: center;
}
.server-status-pill {
display: inline-flex;
align-items: center;
background-color: #e8f0fe;
color: var(--primary-color);
padding: 6px 14px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
margin-left: 15px;
}
.server-status-pill i {
margin-right: 6px;
}
h2 {
margin: 0;
color: var(--text-primary);
font-weight: 700;
font-size: 1.5rem;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
}
.date-input {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: inherit;
color: var(--text-primary);
}
.btn-refresh {
padding: 8px 18px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(67, 97, 238, 0.3);
}
.btn-refresh:hover {
background-color: var(--secondary-color);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(67, 97, 238, 0.4);
}
.charts-container {
display: flex;
gap: 24px;
flex-direction: column;
}
.card {
background: #fff;
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
border: 1px solid var(--border-color);
margin-bottom: 24px;
}
.card-header {
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.chart-box {
position: relative;
height: 400px;
width: 100%;
}
.row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.col-half {
flex: 1;
min-width: 450px;
}
.loading {
text-align: center;
padding: 60px;
color: var(--text-secondary);
font-size: 1.2rem;
}
.error {
color: var(--danger-color);
text-align: center;
padding: 20px;
background: #ffebee;
border-radius: 8px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-title">
<h2><i class="fas fa-chart-line"></i> 数据分析</h2>
<div id="server-status" class="server-status-pill" style="display: none;">
<i class="fas fa-users"></i> <span id="player-count-display">--/--</span>
</div>
</div>
<div class="controls">
<label>开始: <input type="date" id="startDate" class="date-input"></label>
<label>结束: <input type="date" id="endDate" class="date-input"></label>
<button onclick="loadAnalytics()" class="btn-refresh"><i class="fas fa-search"></i> 查询</button>
</div>
</div>
<div id="loading" class="loading"><i class="fas fa-spinner fa-spin"></i> 正在加载数据...</div>
<div id="error-msg" class="error" style="display: none;"></div>
<div id="analytics-content" class="charts-container" style="display: none;">
<!-- 详细 Tick Rate 图表 -->
<div class="card">
<div class="card-header"><i class="fas fa-microchip"></i> 服务器性能详细分析 (Tick Rate / 玩家数 / 局时)</div>
<div class="chart-box">
<canvas id="tickRateChart"></canvas>
</div>
<div style="text-align: center; margin-top: 10px; font-size: 0.85rem; color: var(--text-secondary);">
<i class="fas fa-info-circle"></i> 提示:在图表区域使用滚轮缩放,按住鼠标左键拖动平移
</div>
</div>
<div class="row">
<!-- 玩家人数趋势 -->
<div class="col-half card">
<div class="card-header"><i class="fas fa-user-friends"></i> 历史玩家人数趋势</div>
<div class="chart-box">
<canvas id="playerChart"></canvas>
</div>
</div>
<!-- 地图热度 -->
<div class="col-half card">
<div class="card-header"><i class="fas fa-map"></i> 地图游玩热度排行</div>
<div class="chart-box">
<canvas id="mapChart"></canvas>
</div>
</div>
</div>
<!-- User Login Stats -->
<div class="card">
<div class="card-header"><i class="fas fa-users-cog"></i> 子账户登录情况统计</div>
<div class="chart-box" style="height: 300px; margin-bottom: 20px;">
<canvas id="userLoginChart"></canvas>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 12px; text-align: left;">用户名</th>
<th style="padding: 12px; text-align: center;">活跃度 (登录次数)</th>
<th style="padding: 12px; text-align: right;">最近登录时间</th>
</tr>
</thead>
<tbody id="userLoginTableBody">
<!-- Data -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// Permission Check
(function() {
const required = 'analytics';
const permsStr = localStorage.getItem('permissions');
const isSuper = localStorage.getItem('isSuperAdmin') === 'true';
if (isSuper) return;
if (!permsStr) {
document.body.innerHTML = '<div style="text-align:center; margin-top:50px;">请先登录</div>';
return;
}
try {
const perms = JSON.parse(permsStr);
if (!perms.includes('all') && !perms.includes(required)) {
document.body.innerHTML = '<div style="text-align:center; margin-top:50px; color:red;">您没有权限访问此页面</div>';
}
} catch (e) {
console.error(e);
}
})();
// ================= Analytics Logic =================
// Register DataLabels plugin
Chart.register(ChartDataLabels);
let chartInstancePlayer = null;
let chartInstanceMap = null;
let chartInstanceTick = null;
let chartInstanceUserLogin = null;
function renderUserStats(data) {
const tableData = data.tableData || [];
// Render Table (Sorted by Activity Desc)
const tbody = document.getElementById('userLoginTableBody');
tbody.innerHTML = '';
tableData.forEach(item => {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #edf2f4';
tr.innerHTML = `
<td style="padding: 12px; color: var(--primary-color); font-weight: 500;">${item.username || item.Username}</td>
<td style="padding: 12px; text-align: center;">${item.count || item.Count}</td>
<td style="padding: 12px; text-align: right; color: var(--text-secondary);">${new Date(item.lastLogin || item.LastLogin).toLocaleString()}</td>
`;
tbody.appendChild(tr);
});
// Render Chart
const ctx = document.getElementById('userLoginChart').getContext('2d');
if (chartInstanceUserLogin) chartInstanceUserLogin.destroy();
chartInstanceUserLogin = new Chart(ctx, {
type: 'bar',
data: {
labels: tableData.map(d => d.username || d.Username),
datasets: [{
label: '登录次数',
data: tableData.map(d => d.count || d.Count),
backgroundColor: 'rgba(67, 97, 238, 0.6)',
borderColor: 'rgba(67, 97, 238, 1)',
borderWidth: 1,
borderRadius: 4,
barThickness: 30
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
title: { display: true, text: '活跃度排名' },
datalabels: { display: false } // Disable labels for this chart
},
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0 }
}
}
}
});
}
// Init Dates
function initDates() {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 3); // Default 3 days
document.getElementById('endDate').valueAsDate = end;
document.getElementById('startDate').valueAsDate = start;
}
initDates();
// Fetch current server status (players)
async function updateServerHeader() {
try {
const res = await fetch('/api/server-info');
if (res.ok) {
const data = await res.json();
if (data.serverInfo) {
const current = data.serverInfo.playerCount_I || '0';
const max = data.serverInfo.maxPlayers || 100;
document.getElementById('player-count-display').innerText = `${current}/${max}`;
document.getElementById('server-status').style.display = 'inline-flex';
}
}
} catch (e) {
console.error("Failed to fetch server info", e);
}
}
async function loadAnalytics() {
updateServerHeader();
const loading = document.getElementById('loading');
const content = document.getElementById('analytics-content');
const errorMsg = document.getElementById('error-msg');
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
loading.style.display = 'block';
content.style.display = 'none';
errorMsg.style.display = 'none';
errorMsg.innerText = '';
const queryParams = `?start=${startDate}&end=${endDate}`;
try {
// 1. Fetch Tick Stats
const tickRes = await fetch('/api/analytics/tickrate' + queryParams);
let tickData = [];
if (tickRes.ok) {
tickData = await tickRes.json();
renderTickChart(tickData);
}
// 2. Fetch Player/Map Stats
try {
const playerRes = await fetch('/api/analytics/players' + queryParams);
if (playerRes.ok) {
const playerData = await playerRes.json();
const mapRes = await fetch('/api/analytics/maps' + queryParams);
if (mapRes.ok) {
const mapData = await mapRes.json();
renderCharts(playerData, mapData);
}
}
} catch (e) {
console.warn("Existing analytics endpoints failed or missing:", e);
}
// 3. Fetch User Login Stats
try {
const userStatsRes = await fetch('/api/analytics/user-stats');
if (userStatsRes.ok) {
const userStatsData = await userStatsRes.json();
renderUserStats(userStatsData);
}
} catch (e) {
console.warn("User stats fetch failed:", e);
}
loading.style.display = 'none';
content.style.display = 'flex';
} catch (err) {
console.error("Failed to load analytics:", err);
loading.style.display = 'none';
errorMsg.style.display = 'block';
errorMsg.innerText = '加载数据失败: ' + err.message;
}
}
const zoomOptions = {
pan: {
enabled: true,
mode: 'x'
},
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true
},
mode: 'x',
}
};
function renderTickChart(data) {
const ctx = document.getElementById('tickRateChart').getContext('2d');
if (chartInstanceTick) chartInstanceTick.destroy();
// Helper to get property regardless of case (camelCase or PascalCase)
const getVal = (obj, key) => {
if (!obj) return null;
if (obj[key] !== undefined) return obj[key];
const pascal = key.charAt(0).toUpperCase() + key.slice(1);
return obj[pascal];
};
chartInstanceTick = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => new Date(getVal(d, 'logTime')).toLocaleString()),
datasets: [
{
label: 'Server Tick Rate',
data: data.map(d => parseFloat(getVal(d, 'tickRate'))),
borderColor: '#4caf50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
yAxisID: 'y',
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5
},
{
label: '在线玩家数',
data: data.map(d => getVal(d, 'playerCount')),
borderColor: '#ff9800',
backgroundColor: 'rgba(255, 152, 0, 0.1)',
yAxisID: 'y1',
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5
},
{
label: '游戏开局时间 (分钟)',
data: data.map(d => {
const val = getVal(d, 'matchTime');
return val ? (parseFloat(val) / 60).toFixed(1) : 0;
}),
borderColor: '#2196f3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
yAxisID: 'y1',
tension: 0.1,
pointRadius: 0,
borderWidth: 1.5,
borderDash: [5, 5]
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { position: 'top' },
datalabels: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + context.parsed.y;
},
afterBody: function(context) {
if (context.length > 0) {
const index = context[0].dataIndex;
const item = data[index];
const mapName = getVal(item, 'mapName');
if (mapName) {
return '地图: ' + mapName;
}
}
return '';
}
}
},
zoom: zoomOptions
},
scales: {
x: {
display: true,
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 12, // 限制X轴标签数量避免拥挤
callback: function(val, index) {
// val is index
if (typeof val === 'number' && data[val]) {
const date = new Date(getVal(data[val], 'logTime'));
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
// Show date if it's the first point or if we are near a day change
// Since autoSkip is on, we can't rely on index-1 for visual boundary,
// but we can just show the date if it's the first label shown or looks important.
// Let's simply return Date + Time for clarity in this view
return (date.getMonth()+1) + '-' + date.getDate() + ' ' + timeStr;
}
return this.getLabelForValue(val);
}
},
grid: {
color: function(context) {
// Highlight day boundaries with a red line
if (context.tick && typeof context.tick.value === 'number') {
const index = context.tick.value;
if (index > 0 && data[index] && data[index-1]) {
const curr = new Date(getVal(data[index], 'logTime'));
const prev = new Date(getVal(data[index-1], 'logTime'));
if (curr.getDate() !== prev.getDate()) {
return 'rgba(255, 99, 132, 0.8)'; // Red line for day change
}
}
}
return '#edf2f4';
},
lineWidth: function(context) {
if (context.tick && typeof context.tick.value === 'number') {
const index = context.tick.value;
if (index > 0 && data[index] && data[index-1]) {
const curr = new Date(getVal(data[index], 'logTime'));
const prev = new Date(getVal(data[index-1], 'logTime'));
if (curr.getDate() !== prev.getDate()) {
return 2;
}
}
}
return 1;
}
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Tick Rate' },
min: 0,
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: true, text: '玩家数 / 时长(分)' },
grid: { drawOnChartArea: false },
min: 0,
}
}
}
});
}
function renderCharts(playerData, mapData) {
const ctxPlayer = document.getElementById('playerChart').getContext('2d');
const ctxMap = document.getElementById('mapChart').getContext('2d');
if (chartInstancePlayer) chartInstancePlayer.destroy();
if (chartInstanceMap) chartInstanceMap.destroy();
// 1. Player Count Chart
chartInstancePlayer = new Chart(ctxPlayer, {
type: 'line',
data: {
labels: playerData.map(d => new Date(d.timestamp).toLocaleString()),
datasets: [{
label: '在线玩家人数',
data: playerData.map(d => d.playerCount),
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
zoom: zoomOptions,
datalabels: {
align: 'top',
anchor: 'end',
display: function(context) {
return context.dataIndex % 5 === 0;
},
color: '#007bff',
font: { weight: 'bold' }
},
tooltip: {
callbacks: {
title: function(context) {
if (context.length > 0) {
const index = context[0].dataIndex;
const d = playerData[index];
return new Date(d.timestamp).toLocaleString();
}
return '';
},
afterBody: function(context) {
if (context.length > 0) {
const index = context[0].dataIndex;
const item = playerData[index];
if (item && item.mapName) {
return '地图: ' + item.mapName;
}
}
return '';
}
}
}
},
scales: {
x: {
display: true,
ticks: {
maxTicksLimit: 12,
callback: function(val, index) {
// val is index
const d = playerData[val];
if (d) {
const date = new Date(d.timestamp);
return (date.getMonth() + 1) + '-' + date.getDate() + ' ' +
date.getHours().toString().padStart(2, '0') + ':' +
date.getMinutes().toString().padStart(2, '0');
}
return this.getLabelForValue(val);
}
}
},
y: { beginAtZero: true, max: 150 }
}
}
});
// 2. Map Frequency Chart
chartInstanceMap = new Chart(ctxMap, {
type: 'bar',
data: {
labels: mapData.map(d => d.mapName),
datasets: [{
label: '时长(10min)',
data: mapData.map(d => d.count),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
datalabels: {
anchor: 'end',
align: 'end',
color: '#333',
font: { weight: 'bold' },
formatter: function(value) { return value; }
}
},
scales: {
x: {
grace: '10%' // Add some space for the label
}
}
}
});
}
// Call once on init
setTimeout(loadAnalytics, 500);
</script>
</body>
</html>