diff --git a/active_players.php b/active_players.php new file mode 100644 index 0000000..84fdfde --- /dev/null +++ b/active_players.php @@ -0,0 +1,122 @@ +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); +?> + + + + + + +SDF1 - 在线活跃 + + + +
+

🟢 本小时在线活跃

+
每10秒刷新
+
+
+
加载中...
+ +
+ + + diff --git a/admin.php b/admin.php index 17be1c7..4905fcf 100644 --- a/admin.php +++ b/admin.php @@ -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); } @@ -106,6 +131,9 @@
👥 用户管理
🟢 在线玩家
⏱️ 活跃用户
+
📊 今日债券
+
🟢 本时活跃
+
📈 在线曲线
🔑 密码重置审核
📋 工单管理
🏡 领地管理
@@ -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 = `
- +
-
支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索 | todayreg→今日注册 | yesterdayreg→昨日注册
+
支持:纯文本→玩家名 | IP格式→IP搜索 | 日期格式→日期搜索 | 省/市名→地区搜索 | todayreg→今日注册 | yesterdayreg→昨日注册 | ban→封禁玩家 | tempban→临时封禁
加载中...
@@ -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 = '
加载中...
'; + + 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 = '
加载失败: ' + escAdmHtml(r.message || '未知错误') + '
'; + 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 = '
暂无' + (isTempban ? '临时封禁玩家' : '封禁玩家') + '
'; + return; + } + const label = isTempban ? '临时封禁' : '封禁玩家'; + let html = '

' + label + ' (' + filtered.length + '人)

'; + if (isTempban) { + // 临时封禁:额外显示剩余时间 + html += ''; + 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 += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + } else { + html += '
玩家名注册时间最后登录IP地址封禁原因到期时间剩余时间
' + escAdmHtml(u.player_name) + '' + (u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-') + '' + (u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-') + '' + (u.ip_address || '-') + '' + (b.reason ? escAdmHtml(b.reason.substring(0, 50)) : '-') + '' + expireStr + '' + remainStr + '
'; + 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 += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + } + html += '
玩家名注册时间最后登录IP地址封禁类型封禁原因封禁到期
' + escAdmHtml(u.player_name) + '' + (u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-') + '' + (u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-') + '' + (u.ip_address || '-') + '🚫' + typeLabel + '' + (b.reason ? escAdmHtml(b.reason.substring(0, 50)) : '-') + '' + (isPerm ? '永久' : new Date(b.expire_time).toLocaleString()) + '
'; + div.innerHTML = html; + }).catch(e => { + div.innerHTML = '
网络错误: ' + escAdmHtml(e.message) + '
'; + }); +} + // ===== 活跃用户(独立标签页)===== async function loadActivePlayers(el) { el.innerHTML = '
加载中...
'; @@ -2224,7 +2346,7 @@ async function adminSubmitCreateTicket() { } catch (e) { errEl.textContent = '创建失败: ' + e.message; } } -function escAdmHtml(s) { return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +function escAdmHtml(s) { return String(s??'').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 = '
加载中...
'; + 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 = ` +
+

📊 今日债券变动 ${data.date_label} + +

+
+
${fmt(data.total_spend)}
总消费
+
${fmt(data.total_income)}
总收入
+
${netSign}${fmt(data.net_total)}
净变动
+
${data.player_count}
活跃玩家
+
+ + + ${data.rows.length === 0 ? '' : + data.rows.map(r => { + const nCls = r.net > 0 ? 'green' : r.net < 0 ? 'red' : ''; + const nSign = r.net >= 0 ? '+' : ''; + return ``; + }).join('')} +
玩家消费收入净变动
今日暂无数据
${escAdmHtml(r.name)}${fmt(r.spend)}${fmt(r.income)}${nSign}${fmt(r.net)}
+
`; + }; + 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 = '
'+escAdmHtml(d.message||'加载失败')+'
'; 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 = '
网络错误: '+escAdmHtml(e.message)+'
'; } +} + +// ===== 本小时在线活跃看板 ===== +let _activeHourTimer = null; +async function loadActiveHour(el) { + if (_activeHourTimer) clearInterval(_activeHourTimer); + el.innerHTML = '
加载中...
'; + 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 = ` +
+

🟢 本小时在线活跃 ${data.online_count} 人在线 + +

+
+ ${data.online_list.length === 0 ? '
当前没有玩家在线
' : + data.online_list.map(p => { + const pct = Math.min(100, Math.round(p.duration / (6*3600) * 100)); + return `
${escAdmHtml(p.name)}
登录 ${p.login_time_fmt}在线 ${fmtDur(p.duration)}
`; + }).join('')} +
+
`; + }; + 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 = '
'+escAdmHtml(d.message||'加载失败')+'
'; 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 = '
网络错误: '+escAdmHtml(e.message)+'
'; } +} + +// ===== 24小时在线曲线看板(纯CSS条形图) ===== +let _onlineCurveTimer = null; +async function loadOnlineCurve(el) { + if (_onlineCurveTimer) clearInterval(_onlineCurveTimer); + el.innerHTML = '
加载中...
'; + 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 = ` +
+

📈 24小时在线曲线 + +

+
+
${data.total_snapshots || 0}
快照样本数
+
${peakIdx>=0 ? buckets[peakIdx].hour : '-'}
高峰时段
+
${peakVal>0 ? peakVal.toFixed(1) : '0'}
高峰均值
+
+ ${hasData ? ` +
+ ${buckets.map(b => { + const h = Math.max(2, Math.round(b.avg / maxVal * 100)); + const empty = b.samples === 0; + return `
${b.hour.slice(0,2)}
`; + }).join('')} +
+
横轴为 00–23 时,每个柱为该时段平均在线人数;快照每60秒自动记录一次。
+ ` : ` +
📊 数据收集中 — 页面每60秒记录一次快照,积累后将自动生成曲线
+ `} +
`; + }; + 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 = '
'+escAdmHtml(d.message||'加载失败')+'
'; 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 = '
网络错误: '+escAdmHtml(e.message)+'
'; } +} + +// 切换页面时清理定时器 +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); +}; diff --git a/api/land_api.php b/api/land_api.php index 590dc02..e74444f 100644 --- a/api/land_api.php +++ b/api/land_api.php @@ -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', diff --git a/api/sync.php b/api/sync.php index c3e24e5..a10c684 100644 --- a/api/sync.php +++ b/api/sync.php @@ -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'); diff --git a/login.php b/login.php index 97fe1e7..decbb48 100644 --- a/login.php +++ b/login.php @@ -120,7 +120,7 @@

🚫 访问被拒绝

Web登录必须通过游戏内Token验证。
- 请在游戏中执行 /sdf1_login weblogin 或 /web
+ 请在游戏中执行 /web
然后点击上方链接登录Web端。

diff --git a/online_curve.php b/online_curve.php new file mode 100644 index 0000000..4f34fe3 --- /dev/null +++ b/online_curve.php @@ -0,0 +1,285 @@ +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; +} +?> + + + + + + +SDF1 - 24小时在线曲线 + + + + +
+

📈 24小时在线曲线

+
每60秒刷新 · 自动记录快照
+
+
+
加载中...
+ +
+ + + diff --git a/today_bonds.php b/today_bonds.php new file mode 100644 index 0000000..ff76b8b --- /dev/null +++ b/today_bonds.php @@ -0,0 +1,184 @@ +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); +?> + + + + + + +SDF1 - 今日债券变动 + + + +
+

📊 今日债券变动

+
· 每30秒刷新
+
+
+
加载中...
+ +
+ + +