WIP: php 本地未提交改动
包含: - admin.php / api/land_api.php / api/sync.php / login.php 修改 - 新增 active_players.php / online_curve.php / today_bonds.php 注:后续将视 origin/php 情况 reset --hard 对齐,本分支用于保留 WIP。
This commit is contained in:
@@ -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>
|
||||
@@ -81,6 +81,31 @@
|
||||
.glass-alert-card .alert-label{font-size:12px;color:#8b949e;margin-bottom:4px;text-align:left}
|
||||
@keyframes glassFadeIn{from{opacity:0}to{opacity:1}}
|
||||
@keyframes glassSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
|
||||
/* ===== 今日债券 / 本时活跃 / 在线曲线 看板样式 ===== */
|
||||
.dash-stats { display:flex; gap:12px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.dash-stat { flex:1; min-width:120px; background:var(--card); border:1px solid var(--border); border-radius:8px; padding:14px; text-align:center; }
|
||||
.dash-stat .v { font-size:22px; font-weight:700; }
|
||||
.dash-stat .v.green { color:var(--green); }
|
||||
.dash-stat .v.red { color:var(--red); }
|
||||
.dash-stat .v.blue { color:var(--accent); }
|
||||
.dash-stat .v.yellow { color:var(--yellow); }
|
||||
.dash-stat .l { font-size:11px; color:var(--dim); margin-top:4px; }
|
||||
.pgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:10px; }
|
||||
.pcard { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:12px 14px; transition:border-color .2s; }
|
||||
.pcard:hover { border-color:var(--accent); }
|
||||
.pcard .pn { font-size:14px; font-weight:700; margin-bottom:4px; }
|
||||
.pcard .pm { font-size:11px; color:var(--dim); display:flex; justify-content:space-between; }
|
||||
.pcard .pm .on { color:var(--green); }
|
||||
.pcard .tbar { margin-top:6px; height:3px; background:var(--bg); border-radius:3px; overflow:hidden; }
|
||||
.pcard .tbar .fill { height:100%; background:var(--green); border-radius:3px; }
|
||||
.curve-bars { display:flex; align-items:flex-end; gap:3px; height:220px; padding:10px 0 0; border-bottom:1px solid var(--border); }
|
||||
.cbar-item { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-end; height:100%; cursor:default; }
|
||||
.cbar { width:100%; background:linear-gradient(to top, rgba(88,166,255,0.3), var(--accent)); border-radius:3px 3px 0 0; min-height:2px; transition:height .4s ease; }
|
||||
.cbar.empty-bar { background:var(--border); }
|
||||
.cbar-item:hover .cbar:not(.empty-bar) { filter:brightness(1.2); }
|
||||
.cbar-lbl { font-size:9px; color:var(--dim); margin-top:5px; white-space:nowrap; }
|
||||
.curve-peak { font-size:12px; color:var(--dim); margin-top:10px; }
|
||||
.curve-peak b { color:var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -106,6 +131,9 @@
|
||||
<div class="si" data-p="users" onclick="go('users')">👥 用户管理</div>
|
||||
<div class="si" data-p="online" onclick="go('online')">🟢 在线玩家</div>
|
||||
<div class="si" data-p="active" onclick="go('active')">⏱️ 活跃用户</div>
|
||||
<div class="si" data-p="bonds_today" onclick="go('bonds_today')">📊 今日债券</div>
|
||||
<div class="si" data-p="active_hour" onclick="go('active_hour')">🟢 本时活跃</div>
|
||||
<div class="si" data-p="online_curve" onclick="go('online_curve')">📈 在线曲线</div>
|
||||
<div class="si" data-p="reset_requests" onclick="go('reset_requests')">🔑 密码重置审核</div>
|
||||
<div class="si" data-p="tickets" onclick="go('tickets')">📋 工单管理</div>
|
||||
<div class="si" data-p="lands" onclick="go('lands')">🏡 领地管理</div>
|
||||
@@ -172,6 +200,9 @@ function go(p) {
|
||||
else if (p==='users') loadUsers(c);
|
||||
else if (p==='online') loadOnlinePlayers(c);
|
||||
else if (p==='active') loadActivePlayers(c);
|
||||
else if (p==='bonds_today') loadBondsToday(c);
|
||||
else if (p==='active_hour') loadActiveHour(c);
|
||||
else if (p==='online_curve') loadOnlineCurve(c);
|
||||
else if (p==='reset_requests') loadResetRequests(c);
|
||||
else if (p==='tickets') loadTickets(c);
|
||||
else if (p==='lands') loadLands(c);
|
||||
@@ -836,10 +867,10 @@ function renderAllUsers(tab, users, onlineSet, onlineMap) {
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="form-row" style="margin-bottom:4px">
|
||||
<input id="userSearch" placeholder="智能搜索:玩家名 / IP / 日期(2026-06-18) / 地区(广东) / todayreg / yesterdayreg" oninput="handleUserSearch()" style="flex:1">
|
||||
<input id="userSearch" placeholder="智能搜索:玩家名 / IP / 日期(2026-06-18) / 地区(广东) / todayreg / yesterdayreg / ban / tempban" oninput="handleUserSearch()" style="flex:1">
|
||||
<button class="btn btn-blue" onclick="doLazyLoadSearch()">搜索</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--dim);margin-bottom:12px">支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索 | todayreg→今日注册 | yesterdayreg→昨日注册</div>
|
||||
<div style="font-size:11px;color:var(--dim);margin-bottom:12px">支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索 | todayreg→今日注册 | yesterdayreg→昨日注册 | ban→封禁玩家 | tempban→临时封禁</div>
|
||||
<div id="userLazyContainer">
|
||||
<div class="empty">加载中...</div>
|
||||
</div>
|
||||
@@ -1440,6 +1471,13 @@ function doLazyLoadSearch() {
|
||||
const search = searchInput ? searchInput.value.trim() : '';
|
||||
window.currentSearch = search;
|
||||
|
||||
// ★ ban/tempban 关键字:客户端筛选封禁玩家
|
||||
const sl = search.toLowerCase();
|
||||
if (sl === 'ban' || sl === 'tempban') {
|
||||
renderBanSearchResults(sl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置分页状态
|
||||
lazyLoadState.currentPage = 1;
|
||||
lazyLoadState.totalPages = 1;
|
||||
@@ -1449,6 +1487,90 @@ function doLazyLoadSearch() {
|
||||
lazyLoadUsersPage(1, 50, search, cachedOnlineSet);
|
||||
}
|
||||
|
||||
// ===== ban/tempban 搜索:客户端筛选封禁玩家 =====
|
||||
function renderBanSearchResults(type) {
|
||||
const div = document.getElementById('userLazyContainer');
|
||||
if (!div) return;
|
||||
div.innerHTML = '<div class="empty">加载中...</div>';
|
||||
|
||||
const banMap = window._banMap || {};
|
||||
document.getElementById('userLazyLoadMore').style.display = 'none';
|
||||
|
||||
jsonApi('admin.php?action=list_users').then(r => {
|
||||
if (!r.success || !r.data) {
|
||||
div.innerHTML = '<div class="empty">加载失败: ' + escAdmHtml(r.message || '未知错误') + '</div>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const isTempban = type === 'tempban';
|
||||
const users = r.data;
|
||||
const filtered = users.filter(u => {
|
||||
const name = (u.player_name || '').toLowerCase();
|
||||
const bans = banMap[name];
|
||||
if (!bans || bans.length === 0) return false;
|
||||
return bans.some(b => {
|
||||
const active = b.expire_time === 0 || b.expire_time > now;
|
||||
if (!active) return false;
|
||||
if (isTempban) return b.expire_time !== 0; // 临时封禁:expire_time > 0
|
||||
return true; // 全部封禁:永久+临时
|
||||
});
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
div.innerHTML = '<div class="empty">暂无' + (isTempban ? '临时封禁玩家' : '封禁玩家') + '</div>';
|
||||
return;
|
||||
}
|
||||
const label = isTempban ? '临时封禁' : '封禁玩家';
|
||||
let html = '<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">' + label + ' <span style="color:var(--accent)">(' + filtered.length + '人)</span></h3>';
|
||||
if (isTempban) {
|
||||
// 临时封禁:额外显示剩余时间
|
||||
html += '<table class="table"><tr><th>玩家名</th><th>注册时间</th><th>最后登录</th><th>IP地址</th><th>封禁原因</th><th>到期时间</th><th>剩余时间</th></tr>';
|
||||
filtered.forEach(u => {
|
||||
const name = (u.player_name || '').toLowerCase();
|
||||
const bans = banMap[name];
|
||||
const b = bans[0];
|
||||
const remainMs = b.expire_time - now;
|
||||
const remainHours = Math.floor(remainMs / 3600000);
|
||||
const remainMins = Math.floor((remainMs % 3600000) / 60000);
|
||||
const remainStr = remainHours > 24
|
||||
? Math.floor(remainHours / 24) + '天' + (remainHours % 24) + '小时'
|
||||
: remainHours + '小时' + remainMins + '分钟';
|
||||
const expireStr = new Date(b.expire_time).toLocaleString();
|
||||
html += '<tr>'
|
||||
+ '<td style="color:#ff4444;font-weight:bold">' + escAdmHtml(u.player_name) + '</td>'
|
||||
+ '<td>' + (u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-') + '</td>'
|
||||
+ '<td>' + (u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-') + '</td>'
|
||||
+ '<td style="font-size:12px;font-family:monospace">' + (u.ip_address || '-') + '</td>'
|
||||
+ '<td style="font-size:12px">' + (b.reason ? escAdmHtml(b.reason.substring(0, 50)) : '-') + '</td>'
|
||||
+ '<td style="font-size:12px;color:var(--yellow)">' + expireStr + '</td>'
|
||||
+ '<td style="font-size:12px;color:var(--accent)">' + remainStr + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
} else {
|
||||
html += '<table class="table"><tr><th>玩家名</th><th>注册时间</th><th>最后登录</th><th>IP地址</th><th>封禁类型</th><th>封禁原因</th><th>封禁到期</th></tr>';
|
||||
filtered.forEach(u => {
|
||||
const name = (u.player_name || '').toLowerCase();
|
||||
const bans = banMap[name];
|
||||
const b = bans[0];
|
||||
const isPerm = b.expire_time === 0;
|
||||
const typeLabel = b.ban_type === 'ip' ? 'IP封禁' : (isPerm ? '永久封禁' : '临时封禁');
|
||||
html += '<tr>'
|
||||
+ '<td style="color:#ff4444;font-weight:bold">' + escAdmHtml(u.player_name) + '</td>'
|
||||
+ '<td>' + (u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-') + '</td>'
|
||||
+ '<td>' + (u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-') + '</td>'
|
||||
+ '<td style="font-size:12px;font-family:monospace">' + (u.ip_address || '-') + '</td>'
|
||||
+ '<td><span style="color:#ff6666;font-size:11px;background:rgba(255,0,0,0.1);padding:1px 5px;border-radius:3px">🚫' + typeLabel + '</span></td>'
|
||||
+ '<td style="font-size:12px">' + (b.reason ? escAdmHtml(b.reason.substring(0, 50)) : '-') + '</td>'
|
||||
+ '<td style="font-size:12px;color:' + (isPerm ? '#ff6666' : 'var(--yellow)') + '">' + (isPerm ? '永久' : new Date(b.expire_time).toLocaleString()) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
}
|
||||
html += '</table>';
|
||||
div.innerHTML = html;
|
||||
}).catch(e => {
|
||||
div.innerHTML = '<div class="empty">网络错误: ' + escAdmHtml(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 活跃用户(独立标签页)=====
|
||||
async function loadActivePlayers(el) {
|
||||
el.innerHTML = '<div class="card">加载中...</div>';
|
||||
@@ -2224,7 +2346,7 @@ async function adminSubmitCreateTicket() {
|
||||
} catch (e) { errEl.textContent = '创建失败: ' + e.message; }
|
||||
}
|
||||
|
||||
function escAdmHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function escAdmHtml(s) { return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function adminProcessInlineMd(text) {
|
||||
let s = text;
|
||||
@@ -2641,7 +2763,7 @@ const LAND_API = 'api/land_api.php';
|
||||
|
||||
// 权限名称映射(中文名→Java key)
|
||||
const PERM_NAMES = {
|
||||
denyBlockBreak: '破坏方块', denyBlockPlace: '放置方块', denyPVP: 'PvP',
|
||||
denyBlockBreak: '破坏方块', denyBlockPlace: '放置方块', denySignEdit: '告示牌编辑', denyPVP: 'PvP',
|
||||
denyFireSpread: '火势蔓延', denyExplosion: '爆炸', denyMobGrief: '怪物破坏',
|
||||
denyMobAttack: '怪物攻击', denyLeavesDecay: '树叶消退', denyWeather: '天气影响',
|
||||
denyCropTrample: '踩踏作物', denyItemDrop: '物品丢弃', denyItemPickup: '物品拾取',
|
||||
@@ -3324,6 +3446,142 @@ async function cmDelete(id) {
|
||||
else toast(d.message || '删除失败', 'err');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ===== 今日债券变动看板 =====
|
||||
let _bondsTodayTimer = null;
|
||||
async function loadBondsToday(el) {
|
||||
if (_bondsTodayTimer) clearInterval(_bondsTodayTimer);
|
||||
el.innerHTML = '<div class="card">加载中...</div>';
|
||||
const renderBonds = (data) => {
|
||||
const fmt = n => Number(n).toLocaleString('zh-CN');
|
||||
const netCls = data.net_total > 0 ? 'green' : data.net_total < 0 ? 'red' : 'blue';
|
||||
const netSign = data.net_total >= 0 ? '+' : '';
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>📊 今日债券变动 <span style="font-size:12px;color:var(--dim)">${data.date_label}</span>
|
||||
<button class="btn btn-blue" style="float:right;padding:4px 12px" onclick="loadBondsToday(document.getElementById('C'))">刷新</button>
|
||||
</h2>
|
||||
<div class="dash-stats">
|
||||
<div class="dash-stat"><div class="v red">${fmt(data.total_spend)}</div><div class="l">总消费</div></div>
|
||||
<div class="dash-stat"><div class="v green">${fmt(data.total_income)}</div><div class="l">总收入</div></div>
|
||||
<div class="dash-stat"><div class="v ${netCls}">${netSign}${fmt(data.net_total)}</div><div class="l">净变动</div></div>
|
||||
<div class="dash-stat"><div class="v yellow">${data.player_count}</div><div class="l">活跃玩家</div></div>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr><th>玩家</th><th style="text-align:right">消费</th><th style="text-align:right">收入</th><th style="text-align:right">净变动</th></tr>
|
||||
${data.rows.length === 0 ? '<tr><td colspan="4" style="text-align:center;color:var(--dim)">今日暂无数据</td></tr>' :
|
||||
data.rows.map(r => {
|
||||
const nCls = r.net > 0 ? 'green' : r.net < 0 ? 'red' : '';
|
||||
const nSign = r.net >= 0 ? '+' : '';
|
||||
return `<tr><td>${escAdmHtml(r.name)}</td><td style="text-align:right;color:var(--red)">${fmt(r.spend)}</td><td style="text-align:right;color:var(--green)">${fmt(r.income)}</td><td style="text-align:right;color:var(--${nCls||'dim'})">${nSign}${fmt(r.net)}</td></tr>`;
|
||||
}).join('')}
|
||||
</table>
|
||||
</div>`;
|
||||
};
|
||||
try {
|
||||
const d = await fetch('today_bonds.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (!d.success) { el.innerHTML = '<div class="card">'+escAdmHtml(d.message||'加载失败')+'</div>'; return; }
|
||||
renderBonds(d.data);
|
||||
_bondsTodayTimer = setInterval(async () => {
|
||||
try {
|
||||
const d2 = await fetch('today_bonds.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (d2.success) renderBonds(d2.data);
|
||||
} catch(e){}
|
||||
}, 30000);
|
||||
} catch (e) { el.innerHTML = '<div class="card">网络错误: '+escAdmHtml(e.message)+'</div>'; }
|
||||
}
|
||||
|
||||
// ===== 本小时在线活跃看板 =====
|
||||
let _activeHourTimer = null;
|
||||
async function loadActiveHour(el) {
|
||||
if (_activeHourTimer) clearInterval(_activeHourTimer);
|
||||
el.innerHTML = '<div class="card">加载中...</div>';
|
||||
const fmtDur = sec => sec < 60 ? sec+'秒' : sec < 3600 ? Math.floor(sec/60)+'分'+(sec%60)+'秒' : Math.floor(sec/3600)+'时'+Math.floor((sec%3600)/60)+'分';
|
||||
const renderActive = (data) => {
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>🟢 本小时在线活跃 <span class="tag tag-unused" style="margin-left:8px">${data.online_count} 人在线</span>
|
||||
<button class="btn btn-blue" style="float:right;padding:4px 12px" onclick="loadActiveHour(document.getElementById('C'))">刷新</button>
|
||||
</h2>
|
||||
<div class="pgrid">
|
||||
${data.online_list.length === 0 ? '<div style="grid-column:1/-1;text-align:center;color:var(--dim);padding:30px">当前没有玩家在线</div>' :
|
||||
data.online_list.map(p => {
|
||||
const pct = Math.min(100, Math.round(p.duration / (6*3600) * 100));
|
||||
return `<div class="pcard"><div class="pn">${escAdmHtml(p.name)}</div><div class="pm"><span>登录 ${p.login_time_fmt}</span><span class="on">在线 ${fmtDur(p.duration)}</span></div><div class="tbar"><div class="fill" style="width:${pct}%"></div></div></div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
try {
|
||||
const d = await fetch('active_players.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (!d.success) { el.innerHTML = '<div class="card">'+escAdmHtml(d.message||'加载失败')+'</div>'; return; }
|
||||
renderActive(d.data);
|
||||
_activeHourTimer = setInterval(async () => {
|
||||
try {
|
||||
const d2 = await fetch('active_players.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (d2.success) renderActive(d2.data);
|
||||
} catch(e){}
|
||||
}, 10000);
|
||||
} catch (e) { el.innerHTML = '<div class="card">网络错误: '+escAdmHtml(e.message)+'</div>'; }
|
||||
}
|
||||
|
||||
// ===== 24小时在线曲线看板(纯CSS条形图) =====
|
||||
let _onlineCurveTimer = null;
|
||||
async function loadOnlineCurve(el) {
|
||||
if (_onlineCurveTimer) clearInterval(_onlineCurveTimer);
|
||||
el.innerHTML = '<div class="card">加载中...</div>';
|
||||
const renderCurve = (data) => {
|
||||
const buckets = data.buckets || [];
|
||||
const hasData = buckets.some(b => b.samples > 0);
|
||||
// 找峰值
|
||||
let peakIdx = -1, peakVal = 0;
|
||||
buckets.forEach((b,i) => { if (b.avg > peakVal) { peakVal = b.avg; peakIdx = i; } });
|
||||
const maxVal = Math.max(1, ...buckets.map(b => b.avg));
|
||||
el.innerHTML = `
|
||||
<div class="card">
|
||||
<h2>📈 24小时在线曲线
|
||||
<button class="btn btn-blue" style="float:right;padding:4px 12px" onclick="loadOnlineCurve(document.getElementById('C'))">刷新</button>
|
||||
</h2>
|
||||
<div class="dash-stats">
|
||||
<div class="dash-stat"><div class="v blue">${data.total_snapshots || 0}</div><div class="l">快照样本数</div></div>
|
||||
<div class="dash-stat"><div class="v yellow">${peakIdx>=0 ? buckets[peakIdx].hour : '-'}</div><div class="l">高峰时段</div></div>
|
||||
<div class="dash-stat"><div class="v green">${peakVal>0 ? peakVal.toFixed(1) : '0'}</div><div class="l">高峰均值</div></div>
|
||||
</div>
|
||||
${hasData ? `
|
||||
<div class="curve-bars">
|
||||
${buckets.map(b => {
|
||||
const h = Math.max(2, Math.round(b.avg / maxVal * 100));
|
||||
const empty = b.samples === 0;
|
||||
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 style="text-align:center;color:var(--dim);padding:50px">📊 数据收集中 — 页面每60秒记录一次快照,积累后将自动生成曲线</div>
|
||||
`}
|
||||
</div>`;
|
||||
};
|
||||
try {
|
||||
const d = await fetch('online_curve.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (!d.success) { el.innerHTML = '<div class="card">'+escAdmHtml(d.message||'加载失败')+'</div>'; return; }
|
||||
renderCurve(d.data);
|
||||
_onlineCurveTimer = setInterval(async () => {
|
||||
try {
|
||||
const d2 = await fetch('online_curve.php?action=data&_t=' + Date.now(), { credentials:'same-origin' }).then(r=>r.json());
|
||||
if (d2.success) renderCurve(d2.data);
|
||||
} catch(e){}
|
||||
}, 60000);
|
||||
} catch (e) { el.innerHTML = '<div class="card">网络错误: '+escAdmHtml(e.message)+'</div>'; }
|
||||
}
|
||||
|
||||
// 切换页面时清理定时器
|
||||
const _origGo = go;
|
||||
go = function(p) {
|
||||
if (_bondsTodayTimer) { clearInterval(_bondsTodayTimer); _bondsTodayTimer = null; }
|
||||
if (_activeHourTimer) { clearInterval(_activeHourTimer); _activeHourTimer = null; }
|
||||
if (_onlineCurveTimer) { clearInterval(_onlineCurveTimer); _onlineCurveTimer = null; }
|
||||
return _origGo(p);
|
||||
};
|
||||
</script>
|
||||
<script src="cashier.js?<?php echo filemtime(__FILE__); ?>"></script>
|
||||
</body>
|
||||
|
||||
+7
-3
@@ -73,6 +73,7 @@ $PERM_TYPES = [
|
||||
'denyMove' => '移动',
|
||||
'denyBlockPlace' => '放置方块',
|
||||
'denyBlockBreak' => '破坏方块',
|
||||
'denySignEdit' => '告示牌编辑',
|
||||
'denyContainer' => '容器管理',
|
||||
'denyPVP' => 'PVP战斗',
|
||||
'denyFallDamage' => '摔落伤害',
|
||||
@@ -111,6 +112,7 @@ $PERM_TYPES = [
|
||||
$PERM_DEFAULT_COLS = [
|
||||
'denyMove' => ['col' => 'deny_move'],
|
||||
'denyBlockPlace' => ['col' => 'deny_block_place'],
|
||||
'denySignEdit' => ['col' => 'deny_sign_edit'],
|
||||
'denyBlockBreak' => ['col' => 'deny_block_break'],
|
||||
'denyContainer' => ['col' => 'deny_container'],
|
||||
'denyPVP' => ['col' => 'deny_pvp'],
|
||||
@@ -614,6 +616,7 @@ function initLandTables($db) {
|
||||
'punish_commands' => "TEXT DEFAULT ''",
|
||||
'deny_block_place' => "INTEGER DEFAULT 0",
|
||||
'deny_block_break' => "INTEGER DEFAULT 0",
|
||||
'deny_sign_edit' => "INTEGER DEFAULT 0",
|
||||
'deny_pvp' => "INTEGER DEFAULT 0",
|
||||
'deny_fall_damage' => "INTEGER DEFAULT 0",
|
||||
'deny_hunger' => "INTEGER DEFAULT 0",
|
||||
@@ -816,7 +819,7 @@ function handleSyncLands($db, $post) {
|
||||
(id, name, owner, world, x1, z1, x2, z2, y_min, y_max,
|
||||
area_size, created_at, synced_at,
|
||||
confiscate_items, deny_use_items, give_effects, clear_effects, clear_all_bad,
|
||||
punish_commands, deny_block_place, deny_block_break, deny_pvp, deny_fall_damage,
|
||||
punish_commands, deny_block_place, deny_block_break, deny_sign_edit, deny_pvp, deny_fall_damage,
|
||||
deny_hunger, deny_all_damage, deny_drop, deny_mount, deny_ender_pearl,
|
||||
deny_bow, deny_potion, deny_explosion, deny_raid, deny_fire_spread,
|
||||
deny_all_effects, deny_item_frame, deny_entity_interact, deny_move, deny_pickup, deny_fire,
|
||||
@@ -831,7 +834,7 @@ function handleSyncLands($db, $post) {
|
||||
VALUES (:id, :name, :owner, :world, :x1, :z1, :x2, :z2, :ymin, :ymax,
|
||||
:size, :created, :synced,
|
||||
:confiscate_items, :deny_use_items, :give_effects, :clear_effects, :clear_all_bad,
|
||||
:punish_commands, :deny_block_place, :deny_block_break, :deny_pvp, :deny_fall_damage,
|
||||
:punish_commands, :deny_block_place, :deny_block_break, :deny_sign_edit, :deny_pvp, :deny_fall_damage,
|
||||
:deny_hunger, :deny_all_damage, :deny_drop, :deny_mount, :deny_ender_pearl,
|
||||
:deny_bow, :deny_potion, :deny_explosion, :deny_raid, :deny_fire_spread,
|
||||
:deny_all_effects, :deny_item_frame, :deny_entity_interact, :deny_move, :deny_pickup, :deny_fire,
|
||||
@@ -867,6 +870,7 @@ function handleSyncLands($db, $post) {
|
||||
$stmt->bindValue(':punish_commands', $land['punish_commands'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':deny_block_place', (int)($land['deny_block_place'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_block_break', (int)($land['deny_block_break'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_sign_edit', (int)($land['deny_sign_edit'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_pvp', (int)($land['deny_pvp'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_fall_damage', (int)($land['deny_fall_damage'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_hunger', (int)($land['deny_hunger'] ?? 0), SQLITE3_INTEGER);
|
||||
@@ -1653,7 +1657,7 @@ function handleUpdateLandField($db, $playerName, $post) {
|
||||
// 效果管理
|
||||
'clear_effects', 'give_effects', 'clear_all_bad', 'deny_all_effects',
|
||||
// 基础权限(与Java GUI访客权限列表一致)
|
||||
'deny_move', 'deny_block_place', 'deny_block_break', 'deny_entity_interact',
|
||||
'deny_move', 'deny_block_place', 'deny_block_break', 'deny_sign_edit', 'deny_entity_interact',
|
||||
'deny_container', 'deny_pvp', 'deny_mount', 'deny_ender_pearl',
|
||||
'deny_thrown_projectiles', 'deny_raid', 'deny_bow', 'deny_potion',
|
||||
'deny_fire', 'deny_fire_spread', 'deny_pickup', 'deny_drop',
|
||||
|
||||
+73
-9
@@ -182,6 +182,9 @@ switch ($action) {
|
||||
case 'pull_pending_transactions':
|
||||
pullPendingTransactions();
|
||||
break;
|
||||
case 'ack_transaction':
|
||||
ackTransaction();
|
||||
break;
|
||||
case 'confirm_transaction':
|
||||
confirmTransaction();
|
||||
break;
|
||||
@@ -492,7 +495,7 @@ function syncLands() {
|
||||
(id, name, owner, world, x1, z1, x2, z2, y_min, y_max, area_size, created_at, synced_at,
|
||||
peace_mode, peace_mode_duration, peace_whitelist, enforce_game_mode, mode_exempt,
|
||||
enter_msg, leave_msg, confiscate_msg, enable_announce, announce_template, txt_content,
|
||||
deny_block_break, deny_block_place, deny_fluid, deny_pvp, deny_fire_spread, deny_all_effects,
|
||||
deny_block_break, deny_block_place, deny_sign_edit, deny_fluid, deny_pvp, deny_fire_spread, deny_all_effects,
|
||||
deny_item_frame, deny_entity_interact, deny_move, deny_pickup, deny_drop, deny_explosion, deny_fall_damage, deny_hunger,
|
||||
deny_all_damage, clear_effects, give_effects, clear_all_bad,
|
||||
admin_changed, deny_thrown_projectiles, deny_glowing, deny_redstone_interaction, deny_door_interaction,
|
||||
@@ -504,7 +507,7 @@ function syncLands() {
|
||||
VALUES (:id, :name, :owner, :world, :x1, :z1, :x2, :z2, :ymin, :ymax, :size, :created, :synced,
|
||||
:peace_mode, :peace_dur, :peace_wl, :enforce_gm, :mode_exempt,
|
||||
:enter_msg, :leave_msg, :confiscate_msg, :announce, :announce_tpl, :txt_content,
|
||||
:deny_block_break, :deny_block_place, :deny_fluid, :deny_pvp, :deny_fire_spread, :deny_all_effects,
|
||||
:deny_block_break, :deny_block_place, :deny_sign_edit, :deny_fluid, :deny_pvp, :deny_fire_spread, :deny_all_effects,
|
||||
:deny_item_frame, :deny_entity_interact, :deny_move, :deny_pickup, :deny_drop, :deny_explosion, :deny_fall_damage, :deny_hunger,
|
||||
:deny_all_damage, :clear_effects, :give_effects, :clear_all_bad,
|
||||
:admin_changed, :deny_thrown_projectiles, :deny_glowing, :deny_redstone_interaction, :deny_door_interaction,
|
||||
@@ -541,6 +544,7 @@ function syncLands() {
|
||||
$stmt->bindValue(':txt_content', $land['txt_content'] ?? '', SQLITE3_TEXT);
|
||||
$stmt->bindValue(':deny_block_break', (int)($land['deny_block_break'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_block_place', (int)($land['deny_block_place'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_sign_edit', (int)($land['deny_sign_edit'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_fluid', (int)($land['deny_fluid'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_pvp', (int)($land['deny_pvp'] ?? 0), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':deny_fire_spread', (int)($land['deny_fire_spread'] ?? 0), SQLITE3_INTEGER);
|
||||
@@ -2814,13 +2818,24 @@ function pullPendingTransactions() {
|
||||
$ids = [];
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
|
||||
// 恢复超时的processing交易(超过1小时未确认的恢复为pending;原60秒太短,服务器重启时异步confirm可能未完成)
|
||||
if ($hasProcessedAt) {
|
||||
$db->exec("UPDATE web_transactions SET status = 'pending' WHERE status = 'processing' AND processed_at < " . (time() - 3600));
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("SELECT id, player_name, type, amount, reason, detail FROM web_transactions WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
|
||||
// 确保confirmed_transactions表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS confirmed_transactions (
|
||||
tx_id TEXT NOT NULL,
|
||||
tx_type TEXT NOT NULL,
|
||||
confirmed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (tx_id, tx_type)
|
||||
)");
|
||||
|
||||
// ★ 移除processing→pending超时恢复(防止已处理交易被重复拉取)
|
||||
// confirmed_transactions表+Java端ackTransaction双重保险
|
||||
|
||||
// ★ LEFT JOIN confirmed_transactions跳过已被Java确认的交易
|
||||
$stmt = $db->prepare("SELECT wt.id, wt.player_name, wt.type, wt.amount, wt.reason, wt.detail
|
||||
FROM web_transactions wt
|
||||
LEFT JOIN confirmed_transactions ct ON wt.id = ct.tx_id AND wt.type = ct.tx_type
|
||||
WHERE wt.status = 'pending' AND ct.tx_id IS NULL
|
||||
ORDER BY wt.created_at ASC");
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$transactions[] = $row;
|
||||
@@ -2877,6 +2892,55 @@ function confirmTransaction() {
|
||||
success(['tx_id' => $txId], '交易已确认');
|
||||
}
|
||||
|
||||
// ===== 插件拉取交易后立即贴标确认(防重复处理) =====
|
||||
function ackTransaction() {
|
||||
$secret = getParam('secret');
|
||||
if (!$secret || $secret !== SECRET_KEY) error('认证失败');
|
||||
|
||||
$txId = getParam('tx_id');
|
||||
if (!$txId) error('缺少tx_id');
|
||||
|
||||
$txType = getParam('tx_type');
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// tx_type缺失时从web_transactions反查
|
||||
if (!$txType) {
|
||||
$stmt = $db->prepare("SELECT type FROM web_transactions WHERE id = :id");
|
||||
$stmt->bindValue(':id', (int)$txId, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if ($row && !empty($row['type'])) {
|
||||
$txType = $row['type'];
|
||||
} else {
|
||||
$txType = 'shop_cart'; // 兜底默认
|
||||
}
|
||||
}
|
||||
|
||||
// 确保confirmed_transactions表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS confirmed_transactions (
|
||||
tx_id TEXT NOT NULL,
|
||||
tx_type TEXT NOT NULL,
|
||||
confirmed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (tx_id, tx_type)
|
||||
)");
|
||||
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
$stmt = $db->prepare("INSERT OR IGNORE INTO confirmed_transactions (tx_id, tx_type, confirmed_at) VALUES (:tx_id, :tx_type, :time)");
|
||||
$stmt->bindValue(':tx_id', $txId);
|
||||
$stmt->bindValue(':tx_type', $txType);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||||
debugLog("[ackTransaction] 失败: " . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['tx_id' => $txId, 'tx_type' => $txType], '已贴标确认');
|
||||
}
|
||||
|
||||
// ===== 插件拉取Web端修改的商品库存 =====
|
||||
function pullShopStock() {
|
||||
$secret = getParam('secret');
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
<h1 style="color:#f85149;margin-bottom:20px">🚫 访问被拒绝</h1>
|
||||
<p style="color:var(--dim);font-size:16px;line-height:1.8">
|
||||
Web登录必须通过游戏内Token验证。<br>
|
||||
请在游戏中执行 <b style="color:var(--accent)">/sdf1_login weblogin</b> 或 <b>/web</b><br>
|
||||
请在游戏中执行 <b style="color:var(--accent)"> <b>/web</b><br>
|
||||
然后点击上方链接登录Web端。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
// ===== 确保有最新快照(如果上次快照超过60秒则自动记录) =====
|
||||
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;
|
||||
}
|
||||
|
||||
// ===== 获取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];
|
||||
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>
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<?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 = [];
|
||||
try {
|
||||
$db = getDB();
|
||||
// ==========================================
|
||||
// 单一数据源:game_transactions(Java同步的完整交易记录)
|
||||
// 每笔交易都有 balance_before / balance_after,
|
||||
// 用余额变化方向判断收入/支出,100% 准确无重复。
|
||||
//
|
||||
// 不使用 web_transactions 的原因:
|
||||
// 同一笔 Web 商城购买会同时出现在 web_transactions(PHP端)
|
||||
// 和 game_transactions(Java同步端),导致消费重复计算。
|
||||
// ==========================================
|
||||
// 收入:余额增加
|
||||
$stmt = $db->prepare("SELECT player_name, SUM(balance_after - balance_before) AS total_income FROM game_transactions WHERE balance_after > balance_before AND tx_time >= :s1 AND tx_time < :e1 GROUP BY player_name");
|
||||
$stmt->bindValue(':s1', $todayStart, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':e1', $todayEnd, SQLITE3_INTEGER);
|
||||
$res = $stmt->execute();
|
||||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||
$incomeData[$row['player_name']] = ($incomeData[$row['player_name']] ?? 0) + (int)$row['total_income'];
|
||||
}
|
||||
// 消费:余额减少
|
||||
$stmt2 = $db->prepare("SELECT player_name, SUM(balance_before - balance_after) AS total_spend FROM game_transactions WHERE balance_after < balance_before AND tx_time >= :s2 AND tx_time < :e2 GROUP BY player_name");
|
||||
$stmt2->bindValue(':s2', $todayStart, SQLITE3_INTEGER);
|
||||
$stmt2->bindValue(':e2', $todayEnd, SQLITE3_INTEGER);
|
||||
$res2 = $stmt2->execute();
|
||||
while ($row = $res2->fetchArray(SQLITE3_ASSOC)) {
|
||||
$spendData[$row['player_name']] = ($spendData[$row['player_name']] ?? 0) + (int)$row['total_spend'];
|
||||
}
|
||||
} 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' => (string)$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>
|
||||
Reference in New Issue
Block a user