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记忆;曲线文案同步
This commit is contained in:
ypsdf1
2026-10-22 16:43:00 +08:00
parent f62a446d03
commit d4748c4e62
4 changed files with 285 additions and 103 deletions
+168 -53
View File
@@ -2682,67 +2682,182 @@ async function loadLands(el) {
</div>`;
html += '</div>';
// 领地列表
html += '<h3 style="color:var(--fg);margin-bottom:8px">📍 领地列表</h3>';
if (lands.length === 0) {
html += '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无领地数据<br><span style="font-size:11px">Java端会自动同步领地数据到此</span></div>';
} else {
html += '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>名称</th><th>所有者</th><th>世界</th><th>坐标</th><th>面积</th><th>操作</th>';
html += '</tr>';
for (const l of lands) {
const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1));
html += `<tr>
<td>${l.id}</td>
<td><strong>${escAdmHtml(l.name)}</strong></td>
<td>${escAdmHtml(l.owner || '无')}</td>
<td>${escAdmHtml(l.world)}</td>
<td style="font-size:11px">${l.x1},${l.z1} → ${l.x2},${l.z2}</td>
<td>${size} 格²</td>
<td>
<button onclick="adminTransferLand('${escAdmHtml(l.name)}','${escAdmHtml(l.owner)}')" style="padding:2px 8px;background:#ff9800;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">改主</button>
<button onclick="deleteLand('${escAdmHtml(l.name)}')" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">删除</button>
</td>
</tr>`;
}
html += '</table></div>';
}
// 领地列表(3.11内测版:分页渲染,表格挪到 renderLandListSec)
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-wrap:wrap;gap:8px">';
html += '<h3 style="color:var(--fg);margin:0">📍 领地列表</h3>';
html += landPerCtlHtml();
html += '</div>';
html += '<div id="landListSec"></div>';
// 权限商店
html += '<h3 style="color:var(--fg);margin:20px 0 8px">🛒 权限商店</h3>';
// 权限商店(3.11内测版:分页渲染,表格挪到 renderLandShopSec)
const allShop = shopRes.items || [];
if (allShop.length === 0) {
html += '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无权限商品</div>';
} else {
html += '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>领地</th><th>卖家</th><th>价格</th><th>时长</th><th>状态</th><th>买家</th><th>操作</th>';
html += '</tr>';
for (const s of allShop) {
const dur = s.duration >= 86400 ? Math.floor(s.duration/86400)+'天' : s.duration >= 3600 ? Math.floor(s.duration/3600)+'小时' : Math.floor(s.duration/60)+'分钟';
const statusColor = s.status === 'active' ? '#4caf50' : s.status === 'sold' ? '#ff9800' : '#f44336';
html += `<tr>
<td>${s.id}</td>
<td>${escAdmHtml(s.land_name)}</td>
<td>${escAdmHtml(s.seller)}</td>
<td style="color:#ff9800">${s.price}💰</td>
<td>${dur}</td>
<td style="color:${statusColor}">${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'}</td>
<td>${s.buyer ? escAdmHtml(s.buyer) : '-'}</td>
<td>
${s.status === 'active' ? `<button onclick="deleteShopItem(${s.id})" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">下架</button>` : ''}
</td>
</tr>`;
}
html += '</table></div>';
}
html += '<h3 style="color:var(--fg);margin:20px 0 8px">🛒 权限商店</h3>';
html += '<div id="landShopSec"></div>';
// 缓存全量数据供分页渲染(3.11内测版)
window._landCache = { lands: lands, shopAll: allShop, cfg: cfg };
el.innerHTML = html;
renderLandListSec();
renderLandShopSec();
} catch(e) {
console.error('[LANDS] Error:', e);
el.innerHTML = `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">加载失败: ${escAdmHtml(e.message)}</div>`;
}
}
// ===== 领地管理分页(3.11内测版)=====
// 全量数据缓存在 window._landCache,渲染函数只画当前页;
// 每页条数:预制10/20/30 + 自定义(不设封顶),localStorage 记忆。
window._landPg = { list: 1, shop: 1 };
function landPerPage() {
const v = parseInt(localStorage.getItem('sdf_land_perpage') || '10', 10);
return v > 0 ? v : 10; // 自定义不设封顶,仅下限1
}
function landSetPer(v) {
let n = parseInt(v, 10);
if (!(n > 0)) n = 10;
localStorage.setItem('sdf_land_perpage', String(n));
window._landPg.list = 1;
window._landPg.shop = 1;
renderLandListSec();
renderLandShopSec();
}
function landPerSelChange(v) {
if (v === 'custom') {
const inp = document.getElementById('landPerNum');
if (inp) { inp.focus(); inp.select(); }
} else {
landSetPer(v);
}
}
function landPerCtlHtml() {
const per = landPerPage();
const custom = !(per === 10 || per === 20 || per === 30);
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)">每页
<select onchange="landPerSelChange(this.value)" style="padding:3px 6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px">
<option value="10" ${per===10?'selected':''}>10个</option>
<option value="20" ${per===20?'selected':''}>20个</option>
<option value="30" ${per===30?'selected':''}>30个</option>
<option value="custom" ${custom?'selected':''}>自定义</option>
</select>
<input id="landPerNum" type="number" min="1" value="${per}" onchange="landSetPer(this.value)" style="width:62px;padding:3px 6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px" title="自定义每页条数,不设封顶">
个/页
</div>`;
}
function landPageBox(total) {
const per = landPerPage();
return Math.max(1, Math.ceil(total / per));
}
function landPagerHtml(kind, total) {
if (total === 0) return '';
const per = landPerPage();
const pages = landPageBox(total);
let pg = window._landPg[kind] || 1;
if (pg > pages) pg = pages;
if (pg < 1) pg = 1;
window._landPg[kind] = pg;
const from = (pg - 1) * per + 1;
const to = Math.min(total, pg * per);
let s = Math.max(1, pg - 4), e = Math.min(pages, s + 8);
s = Math.max(1, e - 8);
let btns = '';
const base = 'padding:3px 9px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;';
for (let i = s; i <= e; i++) {
btns += i === pg
? `<button style="${base}background:var(--accent);color:#fff;border-color:var(--accent)">${i}</button>`
: `<button onclick="landGoPage('${kind}',${i})" style="${base}background:var(--card);color:var(--dim)">${i}</button>`;
}
return `<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
<span style="font-size:12px;color:var(--dim)">显示 ${from}-${to} / ${total} 条 · 共 ${pages} 页</span>
<button onclick="landGoPage('${kind}',1)" style="${base}background:var(--card);color:var(--dim)">⏮ 首页</button>
<button onclick="landGoPage('${kind}',${pg-1})" style="${base}background:var(--card);color:var(--dim)">‹ 上一页</button>
${btns}
<button onclick="landGoPage('${kind}',${pg+1})" style="${base}background:var(--card);color:var(--dim)">下一页 ›</button>
<button onclick="landGoPage('${kind}',${pages})" style="${base}background:var(--card);color:var(--dim)">末页 ⏭</button>
</div>`;
}
function landGoPage(kind, p) {
const cache = window._landCache || {};
const total = kind === 'list' ? ((cache.lands || []).length) : ((cache.shopAll || []).length);
const pages = landPageBox(total);
let n = parseInt(p, 10);
if (!(n >= 1)) n = 1;
if (n > pages) n = pages;
window._landPg[kind] = n;
if (kind === 'list') renderLandListSec(); else renderLandShopSec();
}
function renderLandListSec() {
const box = document.getElementById('landListSec');
if (!box) return;
const lands = ((window._landCache || {}).lands) || [];
if (lands.length === 0) {
box.innerHTML = '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无领地数据<br><span style="font-size:11px">Java端会自动同步领地数据到此</span></div>';
return;
}
const per = landPerPage();
const pages = landPageBox(lands.length);
if (window._landPg.list > pages) window._landPg.list = pages;
if (window._landPg.list < 1) window._landPg.list = 1;
const rows = lands.slice((window._landPg.list - 1) * per, window._landPg.list * per);
let html = '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>名称</th><th>所有者</th><th>世界</th><th>坐标</th><th>面积</th><th>操作</th>';
html += '</tr>';
for (const l of rows) {
const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1));
html += `<tr>
<td>${l.id}</td>
<td><strong>${escAdmHtml(l.name)}</strong></td>
<td>${escAdmHtml(l.owner || '无')}</td>
<td>${escAdmHtml(l.world)}</td>
<td style="font-size:11px">${l.x1},${l.z1} → ${l.x2},${l.z2}</td>
<td>${size} 格²</td>
<td>
<button onclick="adminTransferLand('${escAdmHtml(l.name)}','${escAdmHtml(l.owner)}')" style="padding:2px 8px;background:#ff9800;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">改主</button>
<button onclick="deleteLand('${escAdmHtml(l.name)}')" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">删除</button>
</td>
</tr>`;
}
html += '</table></div>';
html += landPagerHtml('list', lands.length);
box.innerHTML = html;
}
function renderLandShopSec() {
const box = document.getElementById('landShopSec');
if (!box) return;
const allShop = ((window._landCache || {}).shopAll) || [];
if (allShop.length === 0) {
box.innerHTML = '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无权限商品</div>';
return;
}
const per = landPerPage();
const pages = landPageBox(allShop.length);
if (window._landPg.shop > pages) window._landPg.shop = pages;
if (window._landPg.shop < 1) window._landPg.shop = 1;
const rows = allShop.slice((window._landPg.shop - 1) * per, window._landPg.shop * per);
let html = '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>领地</th><th>卖家</th><th>价格</th><th>时长</th><th>状态</th><th>买家</th><th>操作</th>';
html += '</tr>';
for (const s of rows) {
const dur = s.duration >= 86400 ? Math.floor(s.duration/86400)+'天' : s.duration >= 3600 ? Math.floor(s.duration/3600)+'小时' : Math.floor(s.duration/60)+'分钟';
const statusColor = s.status === 'active' ? '#4caf50' : s.status === 'sold' ? '#ff9800' : '#f44336';
html += `<tr>
<td>${s.id}</td>
<td>${escAdmHtml(s.land_name)}</td>
<td>${escAdmHtml(s.seller)}</td>
<td style="color:#ff9800">${s.price}💰</td>
<td>${dur}</td>
<td style="color:${statusColor}">${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'}</td>
<td>${s.buyer ? escAdmHtml(s.buyer) : '-'}</td>
<td>
${s.status === 'active' ? `<button onclick="deleteShopItem(${s.id})" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">下架</button>` : ''}
</td>
</tr>`;
}
html += '</table></div>';
html += landPagerHtml('shop', allShop.length);
box.innerHTML = html;
}
function showLandConfig() {
const cfg = window._landCfgData || {};
const price = cfg.create_price_per_sqm || '10';
@@ -4177,9 +4292,9 @@ async function loadOnlineCurve(el) {
return `<div class="cbar-item" title="${b.hour} 平均 ${b.avg} 人 (采样 ${b.samples})"><div class="cbar ${empty?'empty-bar':''}" style="height:${empty?'2px':h+'%'}"></div><div class="cbar-lbl">${b.hour.slice(0,2)}</div></div>`;
}).join('')}
</div>
<div class="curve-peak">横轴为 00–23 时,每个柱为该时段<b>平均在线人数</b>;快照每60秒自动记录一次。</div>
<div class="curve-peak">横轴为 00–23 时,每个柱为该时段<b>平均在线人数</b>;快照由 Java 同步/心跳每60秒自动采集(停服时段记0)。</div>
` : `
<div style="text-align:center;color:var(--dim);padding:50px">📊 数据收集中 — 页面每60秒记录一次快照,积累后将自动生成曲线</div>
<div style="text-align:center;color:var(--dim);padding:50px">📊 数据收集中 — Java 同步/心跳每60秒自动采集,积累后将自动生成曲线</div>
`}
</div>`;
};
+9
View File
@@ -1558,6 +1558,8 @@ function checkAlignment() {
@error_log("[checkAlignment] online心跳兜底刷新失败: " . $e->getMessage());
// 失败不影响对账本身
}
// ★ 3.11内测版:对账链路通也视为 Java 活着,顺手留曲线样本
onlineCurveEnsureSnapshot(false, 'align');
}
// ---- Java 报上来的本机 key + 指纹 ----
@@ -2249,6 +2251,9 @@ function syncOnlinePlayers() {
$verifyRow = $verifyStmt->fetchArray(SQLITE3_ASSOC);
@error_log("[syncOnlinePlayers] Synced $synced/$playerCount, DB now has " . ($verifyRow['cnt'] ?? 0) . " records");
// ★ 3.11内测版:在线曲线快照统一采集(名单有变化的全量同步路径)
onlineCurveEnsureSnapshot(false, 'sync');
success(['synced' => $synced, 'server_time' => $now], "同步了" . $synced . "个在线玩家");
}
@@ -2308,6 +2313,10 @@ function onlineHeartbeat() {
error('心跳写入失败: ' . $e->getMessage());
}
// ★ 3.11内测版:在线曲线快照统一采集(名单无变化的轻量保活路径——
// 玩家稳定在线时 Java 只走这条出口,采集必须挂这里才不会整段丢样本)
onlineCurveEnsureSnapshot(false, 'heartbeat');
success([
'last_seen' => $fresh,
'server_time' => $now,
+91
View File
@@ -2664,6 +2664,97 @@ function jsonResponse($data, $code = 200) {
exit;
}
/**
* ===== 在线曲线快照统一采集(3.11内测版 · 2026-10-22)=====
*
* 背景:此前快照只在有人打开 online_curve.php 页面时才记录(页面加载 + 60秒
* JS定时器 + ensureRecentSnapshot),三环根因导致数据丢失:
* ① 没人开页面 = 全天零样本,曲线空白;
* ② 停服后若浏览器页面仍开着,定时器拿冻结的 online_players 旧值记账 →
* 停服时段出现假在线曲线(如15点假数据);
* ③ Java 短暂抽风(<120秒)时恰无人看页 → 该分钟样本永久丢失。
*
* 修法:采集上收到 core.php 全站唯一入口,多路触发统一挂载——
* sync.php 的 syncOnlinePlayers / onlineHeartbeat / checkAlignment(cat=online)
* 以及 online_curve.php 页面。只要 Java 与 PHP 的同步链路活着(名单变化推送、
* 轻量心跳、对账任一),就自动留样本,不再依赖有没有人开页面。
*
* 心跳判据(与 admin 后台 120 秒归零口径一致):
* online_player_hb.last_seen 距现在 <=120秒 → Java 活着 → 记 COUNT(online_players);
* Java 短抽风(<120秒)时表里是最后已知值,样本不丢;
* >120秒 → 记 0(服务端已停/抽风超阈值,杜绝停服后伪造曲线)。
*
* 其余:60秒节流(全站唯一写者,force 可穿透);保留7天(聚合只取近24小时,
* 多留供回溯排障);source 列标记样本来源(sync/heartbeat/align/page)便于排障。
*
* @return array ['count'=>int 当前在线(陈旧记0口径), 'recorded'=>bool 本次是否落样本, 'stale'=>bool 心跳是否陈旧]
*/
function onlineCurveEnsureSnapshot($force = false, $source = 'page') {
$now = time();
$res = ['count' => 0, 'recorded' => false, 'stale' => true];
try {
$db = getDB();
$db->exec('PRAGMA busy_timeout=5000');
$db->exec("CREATE TABLE IF NOT EXISTS online_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_time INTEGER NOT NULL,
online_count INTEGER NOT NULL DEFAULT 0,
source TEXT DEFAULT ''
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)");
// 老表无 source 列 → 幂等迁移补列
$hasSource = false;
$cols = @$db->query("PRAGMA table_info(online_snapshots)");
while ($cols && ($c = $cols->fetchArray(SQLITE3_ASSOC))) {
if (($c['name'] ?? '') === 'source') { $hasSource = true; break; }
}
if (!$hasSource) @$db->exec("ALTER TABLE online_snapshots ADD COLUMN source TEXT DEFAULT ''");
// 心跳判据:<=120秒新鲜 → Java 活着(与 admin 后台归零口径一致)
$stale = true;
try {
@$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
$hb = $db->query("SELECT last_seen FROM online_player_hb WHERE id = 1");
$hbRow = $hb ? $hb->fetchArray(SQLITE3_ASSOC) : null;
if ($hbRow && (int)$hbRow['last_seen'] > 0 && ($now - (int)$hbRow['last_seen']) <= 120) {
$stale = false;
}
} catch (\Throwable $eHeartbeat) {
// 心跳表异常按陈旧处理(记0),不影响采集函数本身
}
$count = 0;
if (!$stale) {
$r = $db->query("SELECT COUNT(*) AS cnt FROM online_players");
$row = $r ? $r->fetchArray(SQLITE3_ASSOC) : null;
$count = (int)($row['cnt'] ?? 0);
}
$res = ['count' => $count, 'recorded' => false, 'stale' => $stale];
// 60秒节流:全站唯一写者,防止多路触发在同分钟内重复落样本
if (!$force) {
$mx = $db->query("SELECT MAX(snapshot_time) AS t FROM online_snapshots");
$mxRow = $mx ? $mx->fetchArray(SQLITE3_ASSOC) : null;
$last = (int)($mxRow['t'] ?? 0);
if ($last > 0 && ($now - $last) < 60) return $res;
}
$stmt = $db->prepare("INSERT INTO online_snapshots (snapshot_time, online_count, source) VALUES (:t, :c, :s)");
$stmt->bindValue(':t', $now, SQLITE3_INTEGER);
$stmt->bindValue(':c', $count, SQLITE3_INTEGER);
$stmt->bindValue(':s', (string)$source, SQLITE3_TEXT);
$stmt->execute();
$res['recorded'] = true;
// 保留7天(聚合取近24小时,多留供回溯排障)
$db->exec("DELETE FROM online_snapshots WHERE snapshot_time < " . ($now - 604800));
$GLOBALS['ONLINE_CURVE_SOURCE'] = $source;
} catch (\Throwable $e) {
debugLog('[onlineCurve] 采集异常(' . $source . '): ' . $e->getMessage());
}
return $res;
}
function success($data = null, $message = 'ok') {
jsonResponse(['success' => true, 'message' => $message, 'data' => $data]);
}
+17 -50
View File
@@ -5,59 +5,25 @@ 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() {
try {
$db = getDB();
$db->exec("CREATE TABLE IF NOT EXISTS online_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_time INTEGER NOT NULL,
online_count INTEGER NOT NULL DEFAULT 0
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_snap_time ON online_snapshots(snapshot_time)");
// 清理24小时前的旧快照(保留24小时数据)
$db->exec("DELETE FROM online_snapshots WHERE snapshot_time < " . (time() - 86400));
} catch (\Throwable $e) {
debugLog('[online_curve] 建表异常: ' . $e->getMessage());
}
onlineCurveEnsureSnapshot(false, 'page');
}
// ===== 记录快照 =====
// ===== 记录快照(薄壳:是否真正落样本由 core 60秒节流裁决) =====
function recordSnapshot() {
ensureSnapshotTable();
$count = 0;
try {
$db = getDB();
$res = $db->query("SELECT COUNT(*) AS cnt FROM online_players");
$row = $res->fetchArray(SQLITE3_ASSOC);
$count = (int)($row['cnt'] ?? 0);
$now = time();
$stmt = $db->prepare("INSERT INTO online_snapshots (snapshot_time, online_count) VALUES (:t, :c)");
$stmt->bindValue(':t', $now, SQLITE3_INTEGER);
$stmt->bindValue(':c', $count, SQLITE3_INTEGER);
$stmt->execute();
} catch (\Throwable $e) {
debugLog('[online_curve] 记录快照异常: ' . $e->getMessage());
}
return $count;
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['count'];
}
// ===== 确保有最新快照(如果上次快照超过60秒则自动记录) =====
// ===== 确保有最新快照(薄壳:节流由 core 统一处理) =====
function ensureRecentSnapshot() {
try {
$db = getDB();
$res = $db->query("SELECT MAX(snapshot_time) AS last_time FROM online_snapshots");
$row = $res->fetchArray(SQLITE3_ASSOC);
$lastTime = (int)($row['last_time'] ?? 0);
$now = time();
// 没有快照,或上次快照超过 55 秒(留 5 秒余量防止频繁写)
if ($lastTime === 0 || ($now - $lastTime) > 55) {
return recordSnapshot();
}
} catch (\Throwable $e) {
debugLog('[online_curve] ensureRecentSnapshot 异常: ' . $e->getMessage());
}
return null;
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['recorded'] ? $r['count'] : null;
}
// ===== 获取24小时聚合数据(按小时) =====
@@ -69,7 +35,7 @@ function getHourlyData() {
$since = time() - 86400;
// 按自然小时聚合:分桶到 h0~h23,取平均值
$buckets = [];
for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 0];
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");
@@ -81,6 +47,7 @@ function getHourlyData() {
$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());
@@ -91,7 +58,7 @@ function getHourlyData() {
$result[] = [
'hour' => sprintf('%02d:00', $b['hour']),
'avg' => $avg,
'max' => $b['samples'] > 0 ? round($b['count'] / max(1,$b['samples']), 1) : 0, // same as avg for now
'max' => $b['max'],
'samples' => $b['samples'],
];
}
@@ -156,7 +123,7 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui
<body>
<div class="header">
<h1>📈 24小时在线曲线</h1>
<div class="info">每60秒刷新 · 自动记录快照</div>
<div class="info">每60秒刷新 · Java同步自动采集</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
@@ -174,7 +141,7 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui
</div>
<div id="emptyHint" class="empty-chart" style="display:none">
<div class="msg">📊 数据收集中</div>
<div class="sub">页面每60秒记录一次快照,积累数据后将自动生成曲线</div>
<div class="sub">Java 插件同步/心跳每60秒自动采集(停服时段记0防假数据),积累后自动生成曲线</div>
</div>
</div>
<div class="refresh-hint" id="refreshHint"></div>