Files
Sdf1_login/online_curve.php
T
ypsdf1 d4748c4e62 3.11内测版:在线曲线采集上收core全站唯一入口+领地管理分页
- core.php 新增 onlineCurveEnsureSnapshot:心跳判据(>120秒记0防停服假曲线)、60秒节流、保留7天、source列迁移
- sync.php 三挂点(sync_online_players/online_heartbeat/对账兜底)自动留样本,不再依赖有人开页面
- online_curve.php 三本地函数改薄壳+max字段改真实最大值+文案改Java同步自动采集语义
- admin.php 领地列表/权限商店分页渲染:每页10/20/30预制+自定义不封顶+localStorage记忆;曲线文案同步
2026-10-22 16:43:00 +08:00

257 lines
10 KiB
PHP
Raw 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.
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
$action = getParam('action', 'page');
// ===== 快照采集已上收 core.php(3.11内测版)=====
// 全站唯一采集函数 onlineCurveEnsureSnapshot() 由 core.php 提供:
// sync.php 三个入口(sync_online_players / online_heartbeat / 对账兜底) + 本页面
// 多路触发统一写入,60秒节流,心跳判据(>120秒记0防停服假曲线),保留7天。
// 本地三个函数改薄壳保持旧调用点兼容。
function ensureSnapshotTable() {
onlineCurveEnsureSnapshot(false, 'page');
}
// ===== 记录快照(薄壳:是否真正落样本由 core 60秒节流裁决) =====
function recordSnapshot() {
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['count'];
}
// ===== 确保有最新快照(薄壳:节流由 core 统一处理) =====
function ensureRecentSnapshot() {
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['recorded'] ? $r['count'] : null;
}
// ===== 获取24小时聚合数据(按小时) =====
function getHourlyData() {
ensureSnapshotTable();
// 每次查询数据时自动确保有最新快照(如果超过60秒未记录)
ensureRecentSnapshot();
// 24小时前的时间戳
$since = time() - 86400;
// 按自然小时聚合:分桶到 h0~h23,取平均值
$buckets = [];
for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 0, 'max' => 0];
try {
$db = getDB();
$stmt = $db->prepare("SELECT snapshot_time, online_count FROM online_snapshots WHERE snapshot_time >= :since ORDER BY snapshot_time ASC");
$stmt->bindValue(':since', $since, SQLITE3_INTEGER);
$res = $stmt->execute();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$snapshotTime = (int)$row['snapshot_time'];
// 使用本地时区获取小时(0-23)
$h = (int)date('G', $snapshotTime);
$buckets[$h]['count'] += (int)$row['online_count'];
$buckets[$h]['samples']++;
if ((int)$row['online_count'] > $buckets[$h]['max']) $buckets[$h]['max'] = (int)$row['online_count'];
}
} catch (\Throwable $e) {
debugLog('[online_curve] 查询异常: ' . $e->getMessage());
}
$result = [];
foreach ($buckets as $b) {
$avg = $b['samples'] > 0 ? round($b['count'] / $b['samples'], 1) : 0;
$result[] = [
'hour' => sprintf('%02d:00', $b['hour']),
'avg' => $avg,
'max' => $b['max'],
'samples' => $b['samples'],
];
}
return ['buckets' => $result, 'total_snapshots' => array_sum(array_column($result, 'samples'))];
}
// ===== 路由 =====
switch ($action) {
case 'record':
$c = recordSnapshot();
success(['recorded' => true, 'online_count' => $c]);
exit;
case 'data':
success(getHourlyData());
exit;
case 'page':
default:
// 页面加载时也记录一次快照
$currentOnline = recordSnapshot();
$initialState = json_encode(array_merge(
['current_online' => $currentOnline],
getHourlyData()
), JSON_UNESCAPED_UNICODE);
break;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<title>SDF1 - 24小时在线曲线</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; --chart-grid:#30363d; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
.header h1 { font-size:18px; color:var(--accent); }
.header .info { font-size:13px; color:var(--dim); }
.container { max-width:960px; margin:24px auto; padding:0 16px; }
.stat-row { display:flex; gap:14px; margin-bottom:20px; flex-wrap:wrap; }
.stat-card { flex:1; min-width:140px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; text-align:center; }
.stat-card .val { font-size:26px; font-weight:700; color:var(--accent); }
.stat-card .lbl { font-size:12px; color:var(--dim); margin-top:4px; }
.chart-wrap { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; position:relative; }
.chart-wrap h3 { font-size:15px; color:var(--accent); margin-bottom:14px; }
.chart-wrap canvas { max-height:360px; }
.empty-chart { text-align:center; padding:60px 20px; color:var(--dim); }
.empty-chart .msg { font-size:16px; margin-bottom:8px; }
.empty-chart .sub { font-size:13px; }
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:12px; }
#loading { text-align:center; padding:40px; color:var(--dim); }
@media(max-width:600px) {
.stat-card { min-width:calc(50% - 7px); }
}
</style>
</head>
<body>
<div class="header">
<h1>📈 24小时在线曲线</h1>
<div class="info">每60秒刷新 · Java同步自动采集</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="stat-row">
<div class="stat-card"><div class="val" id="curOnline">0</div><div class="lbl">当前在线</div></div>
<div class="stat-card"><div class="val" id="totalSnap">0</div><div class="lbl">快照样本数</div></div>
<div class="stat-card"><div class="val" id="peakHour">-</div><div class="lbl">高峰时段</div></div>
<div class="stat-card"><div class="val" id="peakAvg">0</div><div class="lbl">高峰均值</div></div>
</div>
<div class="chart-wrap">
<h3>各时段平均在线人数</h3>
<div id="chartContainer">
<canvas id="curveChart"></canvas>
</div>
<div id="emptyHint" class="empty-chart" style="display:none">
<div class="msg">📊 数据收集中</div>
<div class="sub">Java 插件同步/心跳每60秒自动采集(停服时段记0防假数据),积累后自动生成曲线</div>
</div>
</div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
let chart = null;
function renderChart(buckets) {
const hasData = buckets.some(b => b.samples > 0);
const ctx = document.getElementById('curveChart').getContext('2d');
const labels = buckets.map(b => b.hour);
const values = buckets.map(b => b.avg);
document.getElementById('emptyHint').style.display = hasData ? 'none' : 'block';
if (chart) { chart.destroy(); chart = null; }
const isDark = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#0d1117';
const textColor = isDark ? '#e6edf3' : '#1f2328';
const gridColor = isDark ? '#30363d' : '#d0d7de';
if (!hasData) {
document.getElementById('curveChart').style.display = 'none';
return;
}
document.getElementById('curveChart').style.display = 'block';
chart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '平均在线',
data: values,
backgroundColor: 'rgba(88,166,255,0.55)',
borderColor: '#58a6ff',
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: textColor, font: { size: 12 } } },
tooltip: {
backgroundColor: isDark ? '#161b22' : '#fff',
titleColor: textColor,
bodyColor: textColor,
borderColor: isDark ? '#30363d' : '#d0d7de',
borderWidth: 1,
callbacks: {
afterBody: function(ctx) {
const i = ctx[0].dataIndex;
const s = buckets[i].samples;
return '采样数: ' + s;
}
}
}
},
scales: {
x: {
ticks: { color: textColor, font: { size: 10 }, maxRotation: 45 },
grid: { color: gridColor }
},
y: {
beginAtZero: true,
ticks: { color: textColor, font: { size: 11 }, stepSize: 1 },
grid: { color: gridColor }
}
}
}
});
}
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('curOnline').textContent = data.current_online;
document.getElementById('totalSnap').textContent = data.total_snapshots;
// 找高峰时段
let peakIdx = 0, peakVal = 0;
data.buckets.forEach((b, i) => {
if (b.avg > peakVal) { peakVal = b.avg; peakIdx = i; }
});
document.getElementById('peakHour').textContent = peakVal > 0 ? data.buckets[peakIdx].hour : '-';
document.getElementById('peakAvg').textContent = peakVal > 0 ? peakVal.toFixed(1) : '0';
renderChart(data.buckets);
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
render(state);
// 每60秒刷新 + 记录快照
setInterval(() => {
fetch('online_curve.php?action=record&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) {
document.getElementById('curOnline').textContent = d.data.online_count;
}
}).catch(()=>{});
fetch('online_curve.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(Object.assign(d.data, {current_online: parseInt(document.getElementById('curOnline').textContent) || 0}));
}).catch(()=>{});
}, 60000);
</script>
</body>
</html>