新增: 三个PHP数据看板页面

- today_bonds.php: 今日债券变动看板(消费/收入/净变动)
- active_players.php: 本小时在线活跃看板(实时在线玩家)
- online_curve.php: 24小时在线曲线图(Chart.js柱状图+自动快照)
This commit is contained in:
ypshidifu
2026-07-13 19:12:21 +08:00
parent 4e11845636
commit 5e581adef3
3 changed files with 567 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
$isApi = (getParam('action') === 'data');
function getActivePlayers() {
$online = []; $count = 0;
try {
$db = getDB();
// 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$res = $db->query("SELECT player_name, login_time FROM online_players ORDER BY login_time ASC");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$online[] = [
'name' => $row['player_name'],
'login_time' => (int)$row['login_time'],
'login_time_fmt' => $row['login_time'] > 0 ? date('H:i:s', (int)$row['login_time']) : '-',
'duration' => $row['login_time'] > 0 ? time() - (int)$row['login_time'] : 0,
];
}
$count = count($online);
} catch (\Throwable $e) {
debugLog('[active_players] 查询异常: ' . $e->getMessage());
}
return ['online_list' => $online, 'online_count' => $count];
}
if ($isApi) {
success(getActivePlayers());
exit;
}
$initialState = json_encode(getActivePlayers(), JSON_UNESCAPED_UNICODE);
?>
<!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 - 在线活跃</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { 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:900px; margin:20px auto; padding:0 16px; }
/* 在线玩家卡片网格 */
.grid-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.grid-header h2 { font-size:15px; color:var(--accent); }
.grid-header .badge { background:var(--green); color:#000; padding:4px 12px; border-radius:20px; font-size:13px; font-weight:700; }
.player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
.player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; }
.player-card:hover { border-color:var(--accent); }
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; }
.player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; }
.player-card .meta .online-tag { color:var(--green); }
.player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; }
.player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; }
.empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); }
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:16px; }
#loading { text-align:center; padding:40px; color:var(--dim); }
@media(max-width:500px) {
.player-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>🟢 本小时在线活跃</h1>
<div class="info">每10秒刷新</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="grid-header">
<h2>👤 当前在线玩家</h2>
<span class="badge" id="countBadge">0 人</span>
</div>
<div class="player-grid" id="playerGrid"></div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
function fmtDuration(sec) {
if (sec < 60) return sec + '秒';
if (sec < 3600) return Math.floor(sec/60) + '分' + (sec%60) + '秒';
return Math.floor(sec/3600) + '时' + Math.floor((sec%3600)/60) + '分';
}
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('countBadge').textContent = data.online_count + ' 人';
const grid = document.getElementById('playerGrid');
if (data.online_list.length === 0) {
grid.innerHTML = '<div class="empty">当前没有玩家在线</div>';
} else {
grid.innerHTML = data.online_list.map(p => {
const mx = 6 * 3600; // 最长6小时占满
const pct = Math.min(100, Math.round(p.duration / mx * 100));
return `<div class="player-card"><div class="name">${esc(p.name)}</div><div class="meta"><span>登录:${p.login_time_fmt}</span><span class="online-tag">已在线 ${fmtDuration(p.duration)}</span></div><div class="time-bar"><div class="fill" style="width:${pct}%"></div></div></div>`;
}).join('');
}
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
function esc(s) { const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
render(state);
setInterval(() => {
fetch('active_players.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(d.data);
}).catch(()=>{});
}, 10000);
</script>
</body>
</html>
+265
View File
@@ -0,0 +1,265 @@
<?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');
// ===== 初始化快照表 =====
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)");
} catch (\Throwable $e) {
debugLog('[online_curve] 建表异常: ' . $e->getMessage());
}
}
// ===== 记录快照 =====
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;
}
// ===== 获取24小时聚合数据(按小时) =====
function getHourlyData() {
ensureSnapshotTable();
// 24小时前的时间戳
$since = time() - 86400;
// 按自然小时聚合:分桶到 h0~h23,取平均值
$buckets = [];
for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 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)) {
$h = (int)date('G', (int)$row['snapshot_time']); // 0-23 无前导零
$buckets[$h]['count'] += (int)$row['online_count'];
$buckets[$h]['samples']++;
}
} 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['samples'] > 0 ? round($b['count'] / max(1,$b['samples']), 1) : 0, // same as avg for now
'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秒刷新 · 自动记录快照</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">页面每60秒记录一次快照,积累数据后将自动生成曲线</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>
+180
View File
@@ -0,0 +1,180 @@
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
// 今日零时时间戳(北京时间)
$tz = new DateTimeZone('Asia/Shanghai');
$now = new DateTime('now', $tz);
$todayStart = (clone $now)->setTime(0, 0, 0)->getTimestamp();
$todayEnd = $todayStart + 86400;
$todayLabel = $now->format('Y-m-d');
// JSON API 模式:?action=data
$isApi = (getParam('action') === 'data');
function getTodayBondData() {
global $todayStart, $todayEnd;
$spendData = [];
$incomeData = [];
// orders.db 今日消费
try {
$odb = getOrdersDB();
$stmt = $odb->prepare("SELECT player_name, SUM(total_price) AS total_spend FROM cashier_orders WHERE status='completed' AND created_at >= :start AND created_at < :end GROUP BY player_name ORDER BY total_spend DESC");
$stmt->bindValue(':start', $todayStart, SQLITE3_INTEGER);
$stmt->bindValue(':end', $todayEnd, SQLITE3_INTEGER);
$res = $stmt->execute();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$spendData[$row['player_name']] = (int)$row['total_spend'];
}
} catch (\Throwable $e) {
debugLog('[today_bonds] 查询消费异常: ' . $e->getMessage());
}
// web.db 今日收入(amount>0)
try {
$db = getDB();
$stmt = $db->prepare("SELECT player_name, SUM(amount) AS total_income FROM web_transactions WHERE amount > 0 AND created_at >= :start AND created_at < :end GROUP BY player_name ORDER BY total_income DESC");
$stmt->bindValue(':start', $todayStart, SQLITE3_INTEGER);
$stmt->bindValue(':end', $todayEnd, SQLITE3_INTEGER);
$res = $stmt->execute();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$incomeData[$row['player_name']] = (int)$row['total_income'];
}
} catch (\Throwable $e) {
debugLog('[today_bonds] 查询收入异常: ' . $e->getMessage());
}
$allPlayers = array_unique(array_merge(array_keys($spendData), array_keys($incomeData)));
$rows = [];
$totalSpend = 0; $totalIncome = 0;
foreach ($allPlayers as $p) {
$spend = $spendData[$p] ?? 0;
$income = $incomeData[$p] ?? 0;
$net = $income - $spend;
$totalSpend += $spend; $totalIncome += $income;
$rows[] = ['name' => $p, 'spend' => $spend, 'income' => $income, 'net' => $net];
}
usort($rows, fn($a,$b) => $b['net'] - $a['net']);
return [
'rows' => $rows,
'total_spend' => $totalSpend,
'total_income' => $totalIncome,
'net_total' => $totalIncome - $totalSpend,
'player_count' => count($rows),
'date_label' => $GLOBALS['todayLabel'],
];
}
if ($isApi) {
success(getTodayBondData());
exit;
}
$initialState = json_encode(getTodayBondData(), JSON_UNESCAPED_UNICODE);
?>
<!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 - 今日债券变动</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { 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:1100px; margin:20px auto; padding:0 16px; }
.stat-row { display:flex; gap:14px; margin-bottom:20px; flex-wrap:wrap; }
.stat-card { flex:1; min-width:150px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:18px; text-align:center; transition:border-color .2s; }
.stat-card:hover { border-color:var(--accent); }
.stat-card .val { font-size:28px; font-weight:700; }
.stat-card .val.green { color:var(--green); }
.stat-card .val.red { color:var(--red); }
.stat-card .val.blue { color:var(--accent); }
.stat-card .val.yellow { color:var(--yellow); }
.stat-card .lbl { font-size:12px; color:var(--dim); margin-top:4px; }
.panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:18px; margin-bottom:16px; }
.panel h3 { font-size:15px; color:var(--accent); margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.table-wrap { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:10px 12px; color:var(--dim); border-bottom:1px solid var(--border); font-weight:600; white-space:nowrap; }
td { padding:10px 12px; border-bottom:1px solid var(--border); }
tr:hover td { background:rgba(88,166,255,0.04); }
.num { font-variant-numeric:tabular-nums; text-align:right; }
.positive { color:var(--green); }
.negative { color:var(--red); }
.zero { color:var(--dim); }
.empty { text-align:center; color:var(--dim); padding:40px; }
.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:640px) {
.stat-row { gap:8px; }
.stat-card { min-width:calc(50% - 4px); padding:12px; }
.stat-card .val { font-size:22px; }
th,td { padding:8px 6px; font-size:12px; }
}
</style>
</head>
<body>
<div class="header">
<h1>📊 今日债券变动</h1>
<div class="info"><span id="dateLabel"></span> · 每30秒刷新</div>
</div>
<div class="container" id="app">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="stat-row">
<div class="stat-card"><div class="val red" id="statSpend">0</div><div class="lbl">总消费</div></div>
<div class="stat-card"><div class="val green" id="statIncome">0</div><div class="lbl">总收入</div></div>
<div class="stat-card"><div class="val blue" id="statNet">0</div><div class="lbl">净变动</div></div>
<div class="stat-card"><div class="val yellow" id="statPlayers">0</div><div class="lbl">活跃玩家</div></div>
</div>
<div class="panel">
<h3>👤 玩家明细</h3>
<div class="table-wrap">
<table><thead><tr>
<th>玩家</th><th class="num">消费 ↓</th><th class="num">收入 ↓</th><th class="num">净变动</th>
</tr></thead><tbody id="tbody"></tbody></table>
</div>
</div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
function fmt(n) { return n.toLocaleString('zh-CN'); }
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('dateLabel').textContent = data.date_label;
document.getElementById('statSpend').textContent = fmt(data.total_spend);
document.getElementById('statIncome').textContent = fmt(data.total_income);
const netEl = document.getElementById('statNet');
netEl.textContent = (data.net_total >= 0 ? '+' : '') + fmt(data.net_total);
netEl.style.color = data.net_total > 0 ? 'var(--green)' : data.net_total < 0 ? 'var(--red)' : 'var(--dim)';
document.getElementById('statPlayers').textContent = data.player_count;
const tbody = document.getElementById('tbody');
if (data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">今日暂无数据</td></tr>';
} else {
tbody.innerHTML = data.rows.map(r => {
const netCls = r.net > 0 ? 'positive' : r.net < 0 ? 'negative' : 'zero';
const netSign = r.net >= 0 ? '+' : '';
return `<tr><td>${esc(r.name)}</td><td class="num negative">${fmt(r.spend)}</td><td class="num positive">${fmt(r.income)}</td><td class="num ${netCls}">${netSign}${fmt(r.net)}</td></tr>`;
}).join('');
}
document.getElementById('refreshHint').textContent = '上次更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
function esc(s) { const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
render(state);
// 每30秒刷新
setInterval(() => {
fetch('today_bonds.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(d);
}).catch(()=>{});
}, 30000);
</script>
</body>
</html>