diff --git a/admin.php b/admin.php
index fc6b2dc..add7a55 100644
--- a/admin.php
+++ b/admin.php
@@ -2682,67 +2682,182 @@ async function loadLands(el) {
`;
html += '';
- // 领地列表
- html += '
📍 领地列表
';
- if (lands.length === 0) {
- html += '暂无领地数据
Java端会自动同步领地数据到此
';
- } else {
- html += '';
- html += '| ID | 名称 | 所有者 | 世界 | 坐标 | 面积 | 操作 | ';
- html += '
';
- for (const l of lands) {
- const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1));
- html += `
- | ${l.id} |
- ${escAdmHtml(l.name)} |
- ${escAdmHtml(l.owner || '无')} |
- ${escAdmHtml(l.world)} |
- ${l.x1},${l.z1} → ${l.x2},${l.z2} |
- ${size} 格² |
-
-
-
- |
-
`;
- }
- html += '
';
- }
+ // 领地列表(3.11内测版:分页渲染,表格挪到 renderLandListSec)
+ html += '';
+ html += '
📍 领地列表
';
+ html += landPerCtlHtml();
+ html += '';
+ html += '';
- // 权限商店
- html += '🛒 权限商店
';
+ // 权限商店(3.11内测版:分页渲染,表格挪到 renderLandShopSec)
const allShop = shopRes.items || [];
- if (allShop.length === 0) {
- html += '暂无权限商品
';
- } else {
- html += '';
- html += '| ID | 领地 | 卖家 | 价格 | 时长 | 状态 | 买家 | 操作 | ';
- html += '
';
- 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 += `
- | ${s.id} |
- ${escAdmHtml(s.land_name)} |
- ${escAdmHtml(s.seller)} |
- ${s.price}💰 |
- ${dur} |
- ${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'} |
- ${s.buyer ? escAdmHtml(s.buyer) : '-'} |
-
- ${s.status === 'active' ? `` : ''}
- |
-
`;
- }
- html += '
';
- }
+ html += '🛒 权限商店
';
+ html += '';
+ // 缓存全量数据供分页渲染(3.11内测版)
+ window._landCache = { lands: lands, shopAll: allShop, cfg: cfg };
el.innerHTML = html;
+ renderLandListSec();
+ renderLandShopSec();
} catch(e) {
console.error('[LANDS] Error:', e);
el.innerHTML = `加载失败: ${escAdmHtml(e.message)}
`;
}
}
+// ===== 领地管理分页(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 `每页
+
+
+ 个/页
+
`;
+}
+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
+ ? ``
+ : ``;
+ }
+ return `
+ 显示 ${from}-${to} / ${total} 条 · 共 ${pages} 页
+
+
+ ${btns}
+
+
+
`;
+}
+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 = '暂无领地数据
Java端会自动同步领地数据到此
';
+ 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 = '';
+ html += '| ID | 名称 | 所有者 | 世界 | 坐标 | 面积 | 操作 | ';
+ html += '
';
+ for (const l of rows) {
+ const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1));
+ html += `
+ | ${l.id} |
+ ${escAdmHtml(l.name)} |
+ ${escAdmHtml(l.owner || '无')} |
+ ${escAdmHtml(l.world)} |
+ ${l.x1},${l.z1} → ${l.x2},${l.z2} |
+ ${size} 格² |
+
+
+
+ |
+
`;
+ }
+ html += '
';
+ 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 = '暂无权限商品
';
+ 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 = '';
+ html += '| ID | 领地 | 卖家 | 价格 | 时长 | 状态 | 买家 | 操作 | ';
+ html += '
';
+ 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 += `
+ | ${s.id} |
+ ${escAdmHtml(s.land_name)} |
+ ${escAdmHtml(s.seller)} |
+ ${s.price}💰 |
+ ${dur} |
+ ${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'} |
+ ${s.buyer ? escAdmHtml(s.buyer) : '-'} |
+
+ ${s.status === 'active' ? `` : ''}
+ |
+
`;
+ }
+ html += '
';
+ 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 ``;
}).join('')}
- 横轴为 00–23 时,每个柱为该时段平均在线人数;快照每60秒自动记录一次。
+ 横轴为 00–23 时,每个柱为该时段平均在线人数;快照由 Java 同步/心跳每60秒自动采集(停服时段记0)。
` : `
- 📊 数据收集中 — 页面每60秒记录一次快照,积累后将自动生成曲线
+ 📊 数据收集中 — Java 同步/心跳每60秒自动采集,积累后将自动生成曲线
`}
`;
};
diff --git a/api/sync.php b/api/sync.php
index 7aef641..35fcd7d 100644
--- a/api/sync.php
+++ b/api/sync.php
@@ -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,
diff --git a/core.php b/core.php
index 8569214..d20706c 100644
--- a/core.php
+++ b/core.php
@@ -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]);
}
diff --git a/online_curve.php b/online_curve.php
index 3ca871c..7b80000 100644
--- a/online_curve.php
+++ b/online_curve.php
@@ -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
加载中...
@@ -174,7 +141,7 @@ body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui
📊 数据收集中
-
页面每60秒记录一次快照,积累数据后将自动生成曲线
+
Java 插件同步/心跳每60秒自动采集(停服时段记0防假数据),积累后自动生成曲线