Files
Sdf1_login/admin.php
T

5050 lines
286 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ★ 紧急加固(2026-09-29):三层加固的第三层 + 第二层
// 第三层动态令牌:必须 admin.php?token=<SEC_ACCESS_TOKEN> 才放行,
// 不带/带错令牌一律返回 nginx 原生 404 页(与文件不存在时完全一致)。
// 注意必须放在 secGatePage() 之前 —— 令牌层先判(两层现已统一返回 nginx 404,
// 但顺序仍要保持:引导模式下令牌层放行、页面层负责跳配置页)。
// ★ boot 恢复模式(2026-10-05):令牌层也认 boot 令牌 —— 否则被锁在外面的运维
// 敲 admin.php?boot=<引导令牌> 会在令牌层就吃 404,连"送回引导配置页"的
// 恢复路由(secGatePage 里)都到不了。放行令牌层后,下面的 secGatePage()
// 会把非白名单的 boot 持有者 302 到 admin_2fa_setup.php 去把自己加白,
// 页面本身对非白名单 IP 依旧不渲染(白名单校验没有被绕过)。
// 第二层 IP 白名单 —— 页面本身也只对白名单 IP 开放。
require_once __DIR__ . '/security.php';
secTokenGate(true);
secGatePage();
// 令牌在 URL 上,禁止页面内任何外链通过 Referer 把它带走
header('Referrer-Policy: no-referrer');
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
// ★ AGPL-3.0 合规:源码地址(使用者可在 开源地址.txt 中改成自己的仓库地址),与 player.php 同款
$OSS_REPO_DEFAULT = 'https://git.ypshidifu.cn/youpaishidifu/Sdf1_login/src/branch/php/';
$OSS_REPO = $OSS_REPO_DEFAULT;
$ossTxt = __DIR__ . '/开源地址.txt';
if (is_file($ossTxt)) {
foreach (file($ossTxt, FILE_IGNORE_NEW_LINES) as $ossLine) {
$ossLine = trim($ossLine);
if ($ossLine === '' || $ossLine[0] === '#') continue;
if (preg_match('#^https?://#i', $ossLine)) { $OSS_REPO = $ossLine; 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-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>SDF1 - 管理后台 v<?php echo date('ymd_His', filemtime(__FILE__)); ?></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; }
.header h1 { font-size:18px; color:var(--accent); }
.main { display:flex; min-height:calc(100vh - 52px - 44px); }
.sidebar { width:200px; background:var(--card); border-right:1px solid var(--border); padding:12px 0; flex-shrink:0; overflow-y:auto; max-height:calc(100vh - 52px - 44px); }
.si { padding:10px 20px; cursor:pointer; color:var(--dim); transition:all 0.2s; font-size:13px; display:flex; align-items:center; gap:8px; }
.si:hover { background:rgba(88,166,255,0.1); color:var(--text); }
.si.active { color:var(--accent); border-right:3px solid var(--accent); background:rgba(88,166,255,0.05); }
.content { flex:1; padding:20px; overflow-y:auto; max-height:calc(100vh - 52px - 44px); }
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; }
.card h2 { font-size:15px; color:var(--accent); margin-bottom:10px; }
.stats { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; margin-bottom:16px; }
.stat { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:14px; text-align:center; }
.stat .v { font-size:24px; font-weight:700; color:var(--accent); }
.stat .l { font-size:11px; color:var(--dim); margin-top:4px; }
.table { width:100%; border-collapse:collapse; font-size:12px; }
.table th { text-align:left; padding:8px; color:var(--dim); border-bottom:1px solid var(--border); }
.table td { padding:8px; border-bottom:1px solid var(--border); }
.table tr:hover td { background:rgba(88,166,255,0.05); }
.form-row { display:flex; gap:8px; margin-bottom:8px; align-items:center; }
.form-row label { min-width:60px; font-size:12px; color:var(--dim); }
.form-row input, .form-row select { padding:7px 10px; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); font-size:13px; outline:none; flex:1; }
.form-row input:focus { border-color:var(--accent); }
.btn { padding:7px 14px; border:none; border-radius:4px; cursor:pointer; font-size:12px; font-weight:600; transition:all 0.2s; }
.btn-blue { background:var(--accent); color:#fff; }
.btn-green { background:var(--green); color:#fff; }
.btn-red { background:var(--red); color:#fff; }
.btn-yellow { background:var(--yellow); color:#000; }
.btn:hover { opacity:0.85; }
.toast { position:fixed; top:16px; right:16px; padding:10px 16px; border-radius:6px; font-size:13px; z-index:200; animation:sIn 0.3s; }
.toast.ok { background:var(--green); color:#fff; }
.toast.err { background:var(--red); color:#fff; }
@keyframes sIn { from{transform:translateX(100%);opacity:0} to{transform:translateX(0);opacity:1} }
.tabs { display:flex; gap:4px; margin-bottom:12px; flex-wrap:wrap; }
.tab { padding:6px 14px; background:var(--bg); border:1px solid var(--border); border-radius:4px; cursor:pointer; font-size:12px; color:var(--dim); }
.tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.tag { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
.tag-used { background:rgba(248,81,73,0.2); color:var(--red); }
.tag-unused { background:rgba(63,185,80,0.2); color:var(--green); }
.player-online { color:var(--green) !important; font-weight:bold; }
.hamburger { display:none; background:none; border:none; color:var(--text); font-size:24px; cursor:pointer; }
.sidebar-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:999; }
@media(max-width:768px) {
.hamburger { display:block; }
.sidebar { position:fixed; left:-200px; top:52px; bottom:0; z-index:1000; transition:left 0.3s; }
.sidebar.open { left:0; }
.sidebar-overlay.show { display:block; }
.stats { grid-template-columns:repeat(2,1fr); }
.table { font-size:11px; }
}
.theme-picker { display:flex; gap:8px; align-items:center; margin-top:8px; }
.color-btn { width:30px; height:30px; border-radius:4px; cursor:pointer; border:2px solid transparent; transition:all 0.2s; }
.color-btn:hover { border-color:var(--accent); transform:scale(1.1); }
.color-input { padding:7px 10px; background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text); font-size:13px; flex:1; }
.glass-alert-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:5000;justify-content:center;align-items:center;animation:glassFadeIn 0.25s ease}
.glass-alert-overlay.show{display:flex}
.glass-alert-card{background:rgba(22,27,34,0.92);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid rgba(88,166,255,0.25);border-radius:16px;padding:28px 28px 20px;width:360px;max-width:88%;box-shadow:0 12px 48px rgba(0,0,0,0.5),inset 0 1px 0 rgba(255,255,255,0.05);animation:glassSlideUp 0.3s ease;text-align:center}
.glass-alert-card .alert-icon{font-size:36px;margin-bottom:12px}
.glass-alert-card .alert-msg{font-size:14px;color:#e6edf3;line-height:1.6;margin-bottom:20px;word-break:break-word}
.glass-alert-card .alert-btns{display:flex;gap:10px;justify-content:center}
.glass-alert-card .alert-btns button{padding:8px 24px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all 0.2s}
.glass-alert-card .alert-btns .ag-ok{background:#58a6ff;color:#fff}
.glass-alert-card .alert-btns .ag-cancel{background:rgba(255,255,255,0.08);color:#8b949e}
.glass-alert-card .alert-btns button:hover{opacity:0.85;transform:scale(1.03)}
.glass-alert-card .alert-input{width:100%;padding:10px 12px;background:rgba(13,17,23,0.8);border:1px solid rgba(88,166,255,0.3);border-radius:8px;color:#e6edf3;font-size:13px;outline:none;box-sizing:border-box;transition:border 0.2s;text-align:center;margin-bottom:16px}
.glass-alert-card .alert-input:focus{border-color:#58a6ff;box-shadow:0 0 0 2px rgba(88,166,255,0.15)}
.glass-alert-card .alert-hint{font-size:11px;color:#8b949e;margin:-12px 0 16px;text-align:left}
.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); }
/* 充值对账标签页 */
.ro-stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:10px; margin-bottom:14px; }
.ro-sc { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px; text-align:center; }
.ro-sc .ro-v { font-size:24px; font-weight:700; margin-bottom:2px; }
.ro-sc .ro-l { font-size:11px; color:var(--dim); }
.ro-sc.ro-b .ro-v { color:var(--accent); }
.ro-sc.ro-g .ro-v { color:var(--green); }
.ro-sc.ro-y .ro-v { color:var(--yellow); }
.ro-sc.ro-r .ro-v { color:var(--red); }
.ro-srch { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.ro-srch input,.ro-srch select { padding:7px 10px; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:12px; outline:none; flex:1; min-width:140px; }
.ro-srch input:focus { border-color:var(--accent); }
.ro-row { font-size:12px; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:8px; margin-bottom:8px; cursor:pointer; transition:border-color .2s; }
.ro-row:hover { border-color:var(--accent); }
.ro-row .ro-t { display:flex; justify-content:space-between; align-items:center; margin-bottom:4px; }
.ro-row .ro-no { color:var(--accent); font-weight:700; font-size:13px; }
.ro-row .ro-i { color:var(--dim); font-size:11px; margin-top:3px; line-height:1.5; }
.ro-tag { display:inline-block; padding:1px 7px; border-radius:999px; font-size:10px; font-weight:600; }
.ro-tag-p { background:rgba(63,185,80,.15); color:var(--green); }
.ro-tag-c { background:rgba(210,153,34,.15); color:var(--yellow); }
.ro-empty { text-align:center; color:var(--dim); padding:30px; font-size:13px; }
/* 充值订单详情毛玻璃弹窗 */
.ro-dtl-ol { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,.55); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px); z-index:6000; justify-content:center; align-items:flex-start; padding:24px 12px; overflow-y:auto; }
.ro-dtl-ol.show { display:flex; }
.ro-dtl-cd { background:rgba(22,27,34,0.95); backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px); border:1px solid rgba(88,166,255,0.25); border-radius:16px; padding:24px; width:500px; max-width:100%; box-shadow:0 16px 48px rgba(0,0,0,.5); animation:glassSlideUp .3s ease; }
.ro-dtl-cd h2 { color:var(--accent); font-size:16px; margin-bottom:12px; }
.ro-dtl-cd .ro-dl { display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; }
.ro-dtl-cd .ro-dl.ro-tt { border-bottom:none; font-size:15px; font-weight:700; margin-top:8px; padding-top:10px; border-top:2px solid var(--accent); }
</style>
</head>
<body>
<div class="header">
<div style="display:flex;align-items:center;gap:12px">
<button class="hamburger" onclick="toggleSidebar()">☰</button>
<h1>⚙️ SDF1 管理后台</h1>
</div>
<div style="display:flex;align-items:center;gap:12px">
<button class="btn btn-yellow" onclick="showThemePicker()">🎨 主题</button>
<button class="btn btn-red" onclick="doLogout()">登出</button>
</div>
</div>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<div class="main">
<div class="sidebar" id="sidebar">
<div class="si active" data-p="dashboard" onclick="go('dashboard')">📊 总览</div>
<div class="si" data-p="bonds" onclick="go('bonds')">💰 债券管理</div>
<div class="si" data-p="shop" onclick="go('shop')">🛒 商品管理</div>
<div class="si" data-p="cdk" onclick="go('cdk')">🎁 CDK管理</div>
<div class="si" data-p="transactions" onclick="go('transactions')">📋 流水记录</div>
<div class="si" data-p="token" onclick="go('token')">🔑 Token生成</div>
<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="sn" onclick="go('sn')">🔢 SN防伪管理</div>
<div class="si" data-p="lands" onclick="go('lands')">🏡 领地管理</div>
<div class="si" data-p="usergroups" onclick="go('usergroups')">👥 用户组</div>
<div class="si" data-p="cashier" onclick="openCashierPage()">🧾 收银台(独立页)</div>
<div class="si" data-p="cashier_manage" onclick="go('cashier_manage')">🧾 收银员管理</div>
<div class="si" data-p="recharge_orders" onclick="go('recharge_orders')">💳 充值对账</div>
<div class="si" data-p="shop_config" onclick="go('shop_config')">🛒 充值商店配置</div>
<div class="si" onclick="location.href='admin_2fa_setup.php'">🛡️ 安全配置(2FA / IP白名单 / 入口令牌)</div>
</div>
<div class="content" id="C"></div>
</div>
<script data-cfasync="false">
// ★ 通信密钥与 API 地址:定义放到 script 最前,避免下方代码在 const 初始化前引用(TDZ)。
// 真实密钥值只存在 config.php 里(不入库),运行时由服务端注入,源码里没有字面量。
const SECRET = <?php echo json_encode((string)SECRET_KEY, JSON_UNESCAPED_UNICODE); ?>;
const LAND_API = 'api/land_api.php';
// ★ 全局错误处理器:捕获所有未处理异常
window.onerror = function(msg, src, line, col, err) {
console.error('[GlobalError]', msg, 'at', src, 'line', line + ':' + col, err);
return false;
};
window.addEventListener('unhandledrejection', function(e) {
console.error('[UnhandledPromise]', e.reason);
});
// ★ 强制清除Service Worker缓存(防止旧JS缓存)
if ('caches' in window) {
caches.keys().then(names => names.forEach(n => caches.delete(n)));
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
}
const A = 'api/admin.php';
const _BUILD_TS = 1782652854; // 版本号,用于缓存失效 - 用户组管理页面
console.log('[INIT] Admin panel loaded, build:', _BUILD_TS);
// ★ 自检:验证新代码是否加载
setTimeout(() => {
const ok = typeof lazyLoadUsersPage === 'function' && typeof batchQueryIpLocations === 'function';
console.log('[INIT] Code check:', ok ? '✓ All functions present' : '✗ Missing functions - possible old cache');
if (!ok) {
console.error('[INIT] WARNING: Old JavaScript may be cached. Please press Ctrl+Shift+R to force refresh.');
}
}, 200);
let page = 'dashboard';
let onlineInterval = null;
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('show');
}
function go(p) {
page = p;
document.querySelectorAll('.si').forEach(e => e.classList.toggle('active', e.dataset.p === p));
// 移动端关闭侧边栏
if (window.innerWidth <= 768) toggleSidebar();
// 停止之前的定时器
if (onlineInterval) clearInterval(onlineInterval);
const c = document.getElementById('C');
if (p==='dashboard') loadDashboard(c);
else if (p==='bonds') loadBonds(c);
else if (p==='shop') loadShop(c);
else if (p==='cdk') loadCDK(c);
else if (p==='transactions') loadTx(c);
else if (p==='token') loadToken(c);
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==='sn') loadSn(c);
else if (p==='lands') loadLands(c);
else if (p==='usergroups') loadUserGroups(c);
else if (p==='cashier') loadCashier(c);
else if (p==='cashier_manage') loadCashierManage(c);
else if (p==='recharge_orders') loadRechargeOrders(c);
else if (p==='shop_config') loadShopConfig(c);
}
// 检查登录状态
(async function(){
try {
const s = await fetch('api/admin.php?action=status', {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const d = await s.text().then(text => {
text = text.replace(/^\uFEFF/, '').trim();
if (!text) throw new Error('Empty response');
try { return JSON.parse(text); } catch(e) {
console.error('Status API returned invalid JSON:', text.substring(0, 200));
throw new Error('服务器返回无效JSON: ' + text.substring(0, 50));
}
});
if (!d.data || !d.data.logged_in) {
location.href='admin_login.php';
return;
}
if (d.data.need_2fa) {
location.href='admin_2fa.php';
return;
}
go('dashboard');
} catch (e) {
console.error('Login check failed:', e);
const c = document.getElementById('C');
if (c) c.innerHTML = '<div class="card" style="text-align:center;padding:40px"><h2 style="color:var(--red)">服务器连接失败</h2><p style="color:var(--dim);margin-top:8px">请确保 api/admin.php 文件存在且可访问</p><p style="color:var(--red);margin-top:8px">错误: ' + e.message + '</p></div>';
}
})();
// ===== 总览 =====
async function loadDashboard(el) {
el.innerHTML = '<div class="card" style="text-align:center;padding:40px">加载中...</div>';
try {
// 先检查登录状态
const s = await fetch('api/admin.php?action=status', {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.text().then(t => { try { return JSON.parse(t.replace(/^\uFEFF/,'').trim()); } catch(e) { console.error('Status JSON error:', t.substring(0,200)); throw e; } }));
if (s.success && s.data && s.data.need_2fa) {
location.href='admin_2fa.php';
return;
}
if (!s.success || !s.data || !s.data.logged_in) {
el.innerHTML = '<div class="card" style="text-align:center;padding:40px"><h2>请先登录</h2><p style="color:var(--dim);margin-top:8px">尚未登录管理后台</p><p style="color:var(--dim);margin-top:4px">如已登录,请清除浏览器缓存后重试</p><p style="color:var(--red);margin-top:4px">调试: ' + JSON.stringify(s) + '</p></div>';
return;
}
const [statsR, onlineR] = await Promise.all([
fetch('api/admin.php?action=get_stats_ex', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.text().then(t => {
console.log('[Stats] Response:', t.substring(0, 200));
try {
return JSON.parse(t.replace(/^\uFEFF/,'').trim());
} catch(e) {
console.error('Stats JSON error:', t.substring(0,500));
return {success:false,message:'JSON parse error: ' + t.substring(0, 100)};
}
})),
fetch('api/admin.php?action=list_online_players', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.text().then(t => {
try {
return JSON.parse(t.replace(/^\uFEFF/,'').trim());
} catch(e) {
console.error('Online JSON error:', t.substring(0,200));
return {success:false,message:'JSON parse error'};
}
}))
]);
let statsHtml = '';
if (statsR.success && statsR.data) {
const d = statsR.data;
statsHtml = `
<div class="stats">
<div class="stat"><div class="v">${d.total_users ?? 0}</div><div class="l">注册用户</div></div>
<div class="stat"><div class="v" style="color:var(--green)">${d.online_count ?? 0}</div><div class="l">在线玩家</div></div>
<div class="stat"><div class="v" style="color:var(--yellow)">${d.active_count_24h ?? 0}</div><div class="l">24h活跃</div></div>
<div class="stat"><div class="v" style="color:var(--purple)">${d.total_bonds ?? '-'}</div><div class="l">债券总和</div></div>
<div class="stat"><div class="v" style="color:#3fb950">${d.today_registered ?? '-'}</div><div class="l">今日注册</div></div>
</div>`;
} else {
statsHtml = `<div class="card" style="text-align:center;padding:20px"><p style="color:var(--red)">统计数据加载失败: ${statsR.message || 'unknown'}</p></div>`;
}
// 在线玩家列表
let onlineHtml = '';
if (onlineR.success && onlineR.data && onlineR.data.length > 0) {
const players = onlineR.data;
// 同IP段折叠:按/24子网分组
const subnetGroups = {};
const subnetOrder = [];
players.forEach(p => {
const ip = p.ip_address || '-';
let subnet = '_unknown_' + p.player_name; // 默认每个玩家独立一组
// 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.');
if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
}
}
if (!subnetGroups[subnet]) {
subnetGroups[subnet] = [];
subnetOrder.push(subnet);
}
subnetGroups[subnet].push(p);
});
// 构建显示列表:每组只显示第一个,后面标注折叠数量
const displayRows = [];
subnetOrder.forEach(subnet => {
const group = subnetGroups[subnet];
const first = group[0];
const hiddenCount = group.length - 1;
displayRows.push({player: first, hiddenCount});
});
onlineHtml = `
<div class="card">
<h2>实时在线玩家 <span style="color:var(--dim);font-size:12px">(${players.length}人)</span></h2>
<table class="table">
<tr><th>玩家名</th><th>IP地址</th><th>登录时间</th><th>在线时长</th></tr>
${displayRows.map(({player: p, hiddenCount}) => {
const loginTime = p.login_time ? new Date(p.login_time*1000).toLocaleString() : '-';
const mins = Math.floor((Date.now()/1000 - p.login_time)/60);
const ip = p.ip_address || '-';
const suffix = hiddenCount > 0 ? ` <span style="color:var(--yellow);font-size:12px">(折叠同ip段玩家${hiddenCount}名)</span>` : '';
return `<tr><td class="player-online">🟢 ${p.player_name}${suffix}</td><td style="font-size:12px;font-family:monospace">${ip}</td><td>${loginTime}</td><td>${mins}分钟</td></tr>`;
}).join('')}
</table>
</div>`;
} else {
onlineHtml = '<div class="card"><h2>实时在线玩家 <span style="color:var(--dim);font-size:12px">(0人)</span></h2><div class="empty">暂无在线玩家,请确认 Java 插件已推送在线数据</div></div>';
}
el.innerHTML = statsHtml + onlineHtml;
} catch (e) {
el.innerHTML = '<div class="card" style="color:var(--red);text-align:center">加载失败: '+e.message+'</div>';
}
}
// ===== 债券管理 =====
async function loadBonds(el) {
el.innerHTML = `
<div class="card">
<h2>给玩家增加债券</h2>
<div class="form-row"><label>玩家</label><input id="bPlayer" placeholder="玩家名"></div>
<div class="form-row"><label>金额</label><input id="bAmount" type="number" value="100" min="1"></div>
<div class="form-row"><label>理由</label><input id="bReason" value="管理员充值"></div>
<button class="btn btn-green" onclick="doAddBonds()">增加债券</button>
</div>
<div class="card">
<h2>扣除玩家债券</h2>
<div class="form-row"><label>玩家</label><input id="dPlayer" placeholder="玩家名"></div>
<div class="form-row"><label>金额</label><input id="dAmount" type="number" value="100" min="1"></div>
<div class="form-row"><label>理由</label><input id="dReason" value="管理员扣除"></div>
<button class="btn btn-red" onclick="doDeduct()">扣除债券</button>
</div>
<div class="card">
<h2>查询玩家债券</h2>
<div class="form-row"><label>玩家</label><input id="qPlayer" placeholder="玩家名"><button class="btn btn-blue" onclick="doQueryBond()">查询</button></div>
<div id="qResult"></div>
</div>`;
}
async function doAddBonds() {
const player = document.getElementById('bPlayer').value.trim();
const amount = parseInt(document.getElementById('bAmount').value);
const reason = document.getElementById('bReason').value;
if (!player||!amount) { toast('请填写完整','err'); return; }
const r = await postApi('add_bonds', {player, amount, reason});
toast(r.message, r.success?'ok':'err');
}
async function doDeduct() {
const player = document.getElementById('dPlayer').value.trim();
const amount = parseInt(document.getElementById('dAmount').value);
const reason = document.getElementById('dReason').value;
if (!player||!amount) { toast('请填写完整','err'); return; }
const r = await postApi('deduct', {player, amount, reason});
toast(r.message, r.success?'ok':'err');
}
async function doQueryBond() {
const player = document.getElementById('qPlayer').value.trim();
if (!player) return;
const r = await jsonApi('balance.php?action=query&player='+encodeURIComponent(player));
const div = document.getElementById('qResult');
if (r.success) div.innerHTML=`<div class="card" style="margin-top:8px"><p>债券: <b style="color:var(--green)">${r.data.bonds}</b> | 积分: <b style="color:var(--purple)">${r.data.points}</b></p></div>`;
else div.innerHTML=`<p style="color:var(--red);margin-top:8px">${r.message}</p>`;
}
// ===== 商品管理 =====
let _shopItems = [];
let _shopCategories = [];
let _currentShopCat = '';
async function loadShop(el) {
const r = await jsonApi('shop.php?action=list');
_shopItems = r.data || [];
_shopCategories = [...new Set(_shopItems.map(i => i.category || '默认'))];
_currentShopCat = _shopCategories[0] || '';
let catTabs = '';
if (_shopCategories.length > 0) {
catTabs = '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px">';
_shopCategories.forEach((cat, idx) => {
const active = idx === 0 ? 'color:#fff;background:var(--accent);border-color:var(--accent)' : '';
const catCount = _shopItems.filter(i => (i.category || '默认') === cat).length;
catTabs += `<div onclick="switchShopCat('${cat.replace(/'/g,"\\'")}')" style="padding:6px 14px;border:1px solid var(--border);border-radius:16px;cursor:pointer;font-size:13px;transition:all 0.2s;${active}" class="shop-cat-tab" data-cat="${cat}">${cat} <span style="opacity:0.7;font-size:11px">(${catCount})</span></div>`;
});
catTabs += '</div>';
}
el.innerHTML = `
<div class="card">
<h2>商品管理 <button class="btn btn-blue" style="float:right" onclick="showAddShop()">+ 添加商品</button></h2>
${catTabs}
<div id="shopAdminContent"></div>
</div>
<div class="card" id="addShopForm" style="display:none">
<h2>添加商品</h2>
<div class="form-row"><label>ID</label><input id="asId" placeholder="如 STONE"></div>
<div class="form-row"><label>名称</label><input id="asName" placeholder="显示名"></div>
<div class="form-row"><label>分类</label><input id="asCat" value="默认"></div>
<div class="form-row"><label>材质</label><input id="asMat" value="STONE"></div>
<div class="form-row"><label>价格</label><input id="asPrice" type="number" value="100"></div>
<div class="form-row"><label>库存</label><input id="asStock" type="number" value="-1" placeholder="-1无限"></div>
<button class="btn btn-green" onclick="doAddShop()">确认添加</button>
</div>
<div class="card" style="margin-top:16px">
<h2>🛒 购物车结算配置</h2>
<p style="color:var(--dim);font-size:13px;margin-bottom:14px">配置购物车结算时「塞背包」折扣与「潜影盒打包」加价系数,系数作用于商品原价合计。</p>
<div class="form-row"><label>塞背包折扣系数</label><input id="cfgBackpack" type="number" step="0.01" min="0.01" max="1" oninput="updateCartCfgHint()"></div>
<div class="form-row"><label>潜影盒加价系数</label><input id="cfgShulker" type="number" step="0.01" min="1" max="3" oninput="updateCartCfgHint()"></div>
<div class="form-row"><label>彩色潜影盒打包费</label><input id="cfgPackMoney" type="number" min="0" max="999" value="5"><span style="font-size:12px;color:var(--dim)">彩色潜影盒加收债券数(原色免费)</span></div>
<div class="form-row"><label>环保单折扣(折数)</label><input id="cfgGreen" type="number" min="0" max="10" step="0.1" value="10"><span style="font-size:12px;color:var(--dim)">折扣率(折数):10 = 不打折,9.9 = 9.9折(支付99%),9.8 = 9.8折(支付98%);玩家选「不打包」时按此折扣(命令 /sdf1_login shop setgreen 10 亦为不打折)</span></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn btn-green" onclick="saveCartConfig()">保存配置</button>
<span id="cartCfgHint" style="font-size:12px;color:var(--accent)"></span>
</div>
</div>`;
renderShopAdminContent();
loadCartConfig();
}
// 加载购物车结算配置(读公开 cart_config 接口)
async function loadCartConfig() {
try {
const r = await jsonApi('shop.php?action=cart_config');
if (r && r.success && r.data) {
const bp = document.getElementById('cfgBackpack');
const sh = document.getElementById('cfgShulker');
const pm = document.getElementById('cfgPackMoney');
const gd = document.getElementById('cfgGreen');
if (bp && r.data.backpack_rate != null) bp.value = r.data.backpack_rate;
if (sh && r.data.shulker_rate != null) sh.value = r.data.shulker_rate;
if (pm && r.data.packmoney != null) pm.value = r.data.packmoney;
if (gd && r.data.green_discount != null) gd.value = r.data.green_discount;
updateCartCfgHint();
}
} catch (e) {}
}
// 实时计算并显示折扣/加价说明
function updateCartCfgHint() {
const bpEl = document.getElementById('cfgBackpack');
const shEl = document.getElementById('cfgShulker');
const hint = document.getElementById('cartCfgHint');
if (!bpEl || !shEl || !hint) return;
const bp = parseFloat(bpEl.value);
const sh = parseFloat(shEl.value);
if (isNaN(bp) || isNaN(sh)) { hint.textContent = ''; return; }
const bpDisc = (100 - bp * 100).toFixed(0);
const shAdd = ((sh - 1) * 100).toFixed(0);
hint.textContent = `塞背包:打 ${(bp * 10).toFixed(1)} 折(-${bpDisc}%) | 潜影盒:加价 ${shAdd}%`;
}
// 保存购物车结算配置
async function saveCartConfig() {
const bpEl = document.getElementById('cfgBackpack');
const shEl = document.getElementById('cfgShulker');
const pmEl = document.getElementById('cfgPackMoney');
const gdEl = document.getElementById('cfgGreen');
const bp = parseFloat(bpEl.value);
const sh = parseFloat(shEl.value);
const pm = parseInt(pmEl.value || '5', 10);
let gd = parseFloat(gdEl.value || '0');
if (gd >= 10) gd = 0; // 10 视为不减免
if (isNaN(bp) || isNaN(sh)) { toast('请输入有效数字', 'err'); return; }
if (bp <= 0 || bp > 1) { toast('塞背包折扣系数应在 0.01 ~ 1.00 之间', 'err'); return; }
if (sh < 1 || sh > 3) { toast('潜影盒加价系数应在 1.00 ~ 3.00 之间', 'err'); return; }
if (isNaN(pm) || pm < 0 || pm > 999) { toast('打包费应在 0 ~ 999 之间', 'err'); return; }
if (gd < 0 || gd > 10) { toast('环保单折扣(折数)应在 0 ~ 10 之间', 'err'); return; }
const r = await postApi('save_shop_config', { cart_backpack_rate: bp, cart_shulker_rate: sh, packmoney: pm, green_discount: gd });
toast(r.message || (r.success ? '已保存' : '保存失败'), r.success ? 'ok' : 'err');
if (r.success) updateCartCfgHint();
}
function switchShopCat(cat) {
_currentShopCat = cat;
document.querySelectorAll('.shop-cat-tab').forEach(tab => {
const isActive = tab.dataset.cat === cat;
tab.style.background = isActive ? 'var(--accent)' : '';
tab.style.color = isActive ? '#fff' : '';
tab.style.borderColor = isActive ? 'var(--accent)' : 'var(--border)';
});
renderShopAdminContent();
}
function renderShopAdminContent() {
const div = document.getElementById('shopAdminContent');
if (!div) return;
const items = _shopItems.filter(i => (i.category || '默认') === _currentShopCat);
if (items.length === 0) {
div.innerHTML = '<div class="empty" style="padding:20px">该分类暂无商品</div>';
return;
}
div.innerHTML = `<table class="table">
<tr><th>ID</th><th>名称</th><th>分类</th><th>购入价</th><th>库存</th><th>销量</th><th>操作</th></tr>
${items.map(i=>`<tr>
<td>${i.id}</td><td>${i.display_name}</td><td>${i.category || '默认'}</td>
<td>${i.buy_price}</td><td>${i.stock==-1?'∞':i.stock}</td><td>${i.total_sales}</td>
<td><button class="btn btn-yellow" onclick="editStock('${i.id}',${i.stock})">改库存</button>
<button class="btn btn-red" onclick="removeShop('${i.id}')">删除</button></td>
</tr>`).join('')}
</table>`;
}
function showAddShop() { document.getElementById('addShopForm').style.display='block'; }
async function doAddShop() {
const r = await postApi('shop_add', {
id: document.getElementById('asId').value,
display_name: document.getElementById('asName').value,
category: document.getElementById('asCat').value,
material: document.getElementById('asMat').value,
buy_price: parseInt(document.getElementById('asPrice').value),
stock: parseInt(document.getElementById('asStock').value)
});
toast(r.message, r.success?'ok':'err');
if (r.success) loadShop(document.getElementById('C'));
}
async function editStock(id, current) {
const newStock = await showModal('修改库存', '当前库存: ' + current + ' (-1=无限, 0=售罄)', current);
if (newStock === null) return;
const r = await postApi('shop_update', {id, stock: parseInt(newStock)});
toast(r.message, r.success?'ok':'err');
if (r.success) loadShop(document.getElementById('C'));
}
async function removeShop(id) {
if (!await glassConfirm('确定删除商品 '+id+'?')) return;
const r = await postApi('shop_remove', {id});
toast(r.message, r.success?'ok':'err');
if (r.success) loadShop(document.getElementById('C'));
}
// ===== CDK管理 =====
async function loadCDK(el) {
const r = await jsonApi('cdk.php?action=list');
const list = r.data || [];
const now = Date.now();
const threeMinAgo = now - 3 * 60 * 1000;
// 计算可撤销的CDK(3分钟内创建且未使用)
const recentCDKs = list.filter(c => c.created_at && (c.created_at * 1000) > threeMinAgo && !c.used);
const canUndo = recentCDKs.length > 0;
el.innerHTML = `
<div class="card">
<h2>CDK管理</h2>
<div style="display:flex;gap:12px;margin-bottom:12px">
<div class="card" style="flex:1;margin:0">
<h2>生成CDK</h2>
<div class="form-row"><label>金额</label><input id="cAmount" type="text" value="100" placeholder="固定金额或区间(如100-200)"></div>
<div class="form-row"><label>数量</label><input id="cCount" type="number" value="10" min="1" max="100"></div>
<div style="display:flex;gap:8px">
<button class="btn btn-green" onclick="doBatchCDK()">批量生成</button>
${canUndo ? `<button class="btn btn-red" onclick="undoRecentCDK()">一键撤销 (${recentCDKs.length}个)</button>` : ''}
</div>
</div>
</div>
<table class="table">
<tr><th>兑换码</th><th>金额</th><th>状态</th><th>使用者</th><th>创建时间</th><th>操作</th></tr>
${list.map(c=>{
const deleteBtn = c.used ?
`<span style="color:var(--dim);font-size:12px">-</span>` :
`<button class="btn btn-red" style="padding:2px 8px;font-size:12px" onclick="deleteCDK('${c.code}')">删除</button>`;
return `<tr>
<td style="font-family:monospace">${c.code}</td>
<td>${c.amount}</td>
<td>${c.used?'<span class="tag tag-used">已使用</span>':'<span class="tag tag-unused">未使用</span>'}</td>
<td>${c.used_by||'-'}</td>
<td>${c.created_at?new Date(c.created_at*1000).toLocaleString():'-'}</td>
<td>${deleteBtn}</td>
</tr>`;
}).join('')}
</table>
</div>`;
}
async function doBatchCDK() {
const amountStr = document.getElementById('cAmount').value.trim();
const count = parseInt(document.getElementById('cCount').value);
if (!amountStr||!count) { toast('请填写完整','err'); return; }
// 解析金额:支持固定金额或区间
let amountData = {};
const separators = [',', ',', '-', '-', '/', '/', '~', '~'];
let foundSep = null;
for (const sep of separators) {
if (amountStr.includes(sep)) {
foundSep = sep;
break;
}
}
if (foundSep) {
const parts = amountStr.split(foundSep).map(s => parseInt(s.trim())).filter(n => !isNaN(n));
if (parts.length === 2 && parts[0] <= parts[1]) {
amountData = {min: parts[0], max: parts[1]};
} else {
toast('区间格式错误,如100-200','err'); return;
}
} else {
const amount = parseInt(amountStr);
if (isNaN(amount) || amount <= 0) { toast('金额必须为正整数','err'); return; }
amountData = {amount: amount};
}
const r = await postApi('cdk_batch', {...amountData, count});
if (r.success) {
toast('生成了'+count+'个CDK','ok');
loadCDK(document.getElementById('C'));
} else { toast(r.message,'err'); }
}
// ===== CDK撤销和删除 =====
async function undoRecentCDK() {
if (!await glassConfirm('确定要撤销最近3分钟内生成的CDK吗?')) return;
const r = await jsonApi('cdk.php?action=list');
const list = r.data || [];
const now = Date.now();
const threeMinAgo = now - 3 * 60 * 1000;
// 筛选3分钟内创建且未使用的CDK
const recentCDKs = list.filter(c => c.created_at && (c.created_at * 1000) > threeMinAgo && !c.used);
if (recentCDKs.length === 0) {
toast('没有可撤销的CDK','err');
return;
}
let successCount = 0;
let failCount = 0;
for (const cdk of recentCDKs) {
try {
const result = await postApi('cdk_delete', {code: cdk.code});
if (result.success) {
successCount++;
} else {
failCount++;
}
} catch (e) {
failCount++;
}
}
if (successCount > 0) {
toast(`成功撤销${successCount}个CDK${failCount > 0 ? `,${failCount}个失败` : ''}`, 'ok');
loadCDK(document.getElementById('C'));
} else {
toast('撤销失败', 'err');
}
}
async function deleteCDK(code) {
if (!await glassConfirm(`确定要删除CDK ${code}吗?`)) return;
const r = await postApi('cdk_delete', {code});
if (r.success) {
toast('CDK已删除', 'ok');
loadCDK(document.getElementById('C'));
} else {
toast(r.message, 'err');
}
}
// ===== 流水 =====
async function loadTx(el) {
el.innerHTML = `
<div class="card">
<h2>流水记录</h2>
<div class="tabs">
<div class="tab active" onclick="loadAllTx(this)">全服流水</div>
<div class="tab" onclick="loadPlayerTxTab(this)">指定玩家</div>
</div>
<div id="txContent"><div class="empty">点击上方标签加载</div></div>
</div>`;
loadAllTx(document.querySelector('.tab.active'));
}
async function loadAllTx(tab) {
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
if(tab)tab.classList.add('active');
const r = await jsonApi('admin.php?action=all_tx&limit=100');
const div = document.getElementById('txContent');
if (!r.success) { div.innerHTML=r.message; return; }
const txs = r.data||[];
div.innerHTML = txs.length ? `<table class="table"><tr><th>时间</th><th>玩家</th><th>类型</th><th>金额</th><th>操作人</th><th>备注</th></tr>
${txs.map(t=>`<tr><td>${new Date(t.created_at*1000).toLocaleString()}</td><td>${t.player_name}</td><td>${t.type}</td><td>${t.amount}</td><td>${t.operator||'-'}</td><td>${t.reason||'-'}</td></tr>`).join('')}</table>` : '<div class="empty">暂无记录</div>';
}
function loadPlayerTxTab(tab) {
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
if(tab)tab.classList.add('active');
document.getElementById('txContent').innerHTML = `
<div class="form-row"><label>玩家</label><input id="txPlayer" placeholder="玩家名"><button class="btn btn-blue" onclick="loadPlayerTx()">查询</button></div>
<div id="ptxResult"></div>`;
}
async function loadPlayerTx() {
const player = document.getElementById('txPlayer').value.trim();
if (!player) return;
const r = await jsonApi('admin.php?action=player_tx&player='+encodeURIComponent(player)+'&limit=100');
const div = document.getElementById('ptxResult');
if (!r.success) { div.innerHTML='<p style="color:var(--red)">'+r.message+'</p>'; return; }
const txs = r.data||[];
div.innerHTML = txs.length ? `<table class="table"><tr><th>时间</th><th>类型</th><th>金额</th><th>操作人</th><th>备注</th></tr>
${txs.map(t=>`<tr><td>${new Date(t.created_at*1000).toLocaleString()}</td><td>${t.type}</td><td>${t.amount}</td><td>${t.operator||'-'}</td><td>${t.reason||'-'}</td></tr>`).join('')}</table>` : '<div class="empty">暂无记录</div>';
}
// ===== Token生成 =====
function loadToken(el) {
el.innerHTML = `
<div class="card">
<h2>生成插件Token</h2>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">生成后复制给插件,插件可使用此token访问Web API</p>
<div class="form-row"><label>玩家</label><input id="tPlayer" value="admin"></div>
<div class="form-row"><label>用途</label>
<select id="tPurpose" style="flex:1;padding:7px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text)">
<option value="all">全部权限</option>
<option value="admin">管理员</option>
<option value="sync">数据同步</option>
<option value="shop">商城</option>
</select>
</div>
<div class="form-row"><label>有效期</label><input id="tExpire" type="number" value="600" placeholder="秒"> 秒</div>
<button class="btn btn-green" onclick="doGenToken()">生成Token</button>
<div id="tResult" style="margin-top:12px"></div>
</div>`;
}
async function doGenToken() {
const player = document.getElementById('tPlayer').value;
const purpose = document.getElementById('tPurpose').value;
const expire = parseInt(document.getElementById('tExpire').value);
const r = await fetch(A, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({action: 'gen_token', player, purpose, expire})
});
const res = await r.json();
const div = document.getElementById('tResult');
if (res.success) {
div.innerHTML = `<div class="card" style="border-color:var(--green)">
<p style="font-size:13px">Token:</p>
<code style="word-break:break-all;font-size:12px;color:var(--green);display:block;margin:8px 0">${res.data.token}</code>
<button class="btn btn-blue" onclick="navigator.clipboard.writeText('${res.data.token}');toast('已复制','ok')">复制</button>
</div>`;
} else {
div.innerHTML = `<p style="color:var(--red)">${res.message||'生成失败'}</p>`;
}
}
// ===== 用户管理 =====
async function loadUsers(el) {
el.innerHTML = '<div class="card">加载中...</div>';
try {
const [onlineR, bansR] = await Promise.all([
jsonApi('admin.php?action=list_online_names'),
jsonApi('admin.php?action=list_bans')
]);
const onlineMap = {};
const onlineSet = new Set();
if (onlineR.success && onlineR.data) {
onlineR.data.forEach(name => {
onlineMap[name.toLowerCase()] = true;
onlineSet.add(name.toLowerCase());
});
}
// 构建封禁映射:playerName → {type, reason, source, expire}
const banMap = {};
if (bansR.success && bansR.data) {
bansR.data.forEach(b => {
const key = b.target.toLowerCase();
if (!banMap[key]) banMap[key] = [];
banMap[key].push(b);
});
}
window._banMap = banMap;
renderUserTabs(el, [], onlineSet, onlineMap);
} catch(e) {
window._banMap = {};
renderUserTabs(el, [], new Set(), {});
}
}
// 用户管理缓存
let cachedUsersData = null;
let cachedOnlineSet = null;
let cachedOnlineMap = null;
function reRenderAllUsers() {
// 从其他标签切回来时,重新加载最新数据
if (cachedUsersData !== null) {
const el = document.getElementById('C');
if (!el) return;
loadUsers(el); // 重新加载数据
}
}
function renderUserTabs(el, users, onlineSet, onlineMap) {
const onlineCount = onlineSet.size;
el.innerHTML = `
<div class="card">
<h2>用户管理</h2>
<div class="tabs" id="userTabs">
<div class="tab active" data-tab="all" onclick="renderAllUsers(this)">全部用户</div>
<div class="tab" data-tab="online" onclick="renderOnlineUsers(this)">在线玩家</div>
<div class="tab" data-tab="sameip" onclick="loadSameIpTab(this)">同IP玩家</div>
<div class="tab" data-tab="active1h" onclick="loadActiveTab(this, 3600)">1小时活跃</div>
<div class="tab" data-tab="active1d" onclick="loadActiveTab(this, 86400)">1天活跃</div>
<div class="tab" data-tab="active1w" onclick="loadActiveTab(this, 604800)">1周活跃</div>
</div>
<div id="userTabContent"><div class="empty">点击标签页加载</div></div>
</div>`;
// 默认显示全部用户
const allTab = el.querySelector('#userTabs .tab[data-tab="all"]');
renderAllUsers(allTab, users, onlineSet, onlineMap);
}
// 注意:上面的缓存变量和 reRenderAllUsers() 已在第454-465行定义,这里不再重复
function renderAllUsers(tab, users, onlineSet, onlineMap) {
if (!tab) return;
document.querySelectorAll('#userTabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// 缓存数据
cachedUsersData = users;
cachedOnlineSet = onlineSet;
cachedOnlineMap = onlineMap;
const div = document.getElementById('userTabContent');
// 懒加载模式:初始只加载前50条,其余通过滚动/搜索加载
const loadPage = 1;
const limit = 50;
const search = (window.currentSearch || '').toLowerCase();
div.innerHTML = `
<div class="form-row" style="margin-bottom:4px">
<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→昨日注册 | ban→封禁玩家 | tempban→临时封禁</div>
<div id="userLazyContainer">
<div class="empty">加载中...</div>
</div>
<div id="userLazyLoadMore" style="text-align:center;padding:16px">
<button class="btn btn-blue" onclick="doLazyLoadMore()">加载更多</button>
</div>`;
lazyLoadUsersPage(loadPage, limit, search, onlineSet);
}
// 用户懒加载状态
let lazyLoadState = {
currentPage: 1,
totalPages: 1,
isLoading: false,
hasMore: true,
currentSearch: ''
};
let isFirstLoad = true; // ★ 标记是否为首次加载
function lazyLoadUsersPage(page, limit, search, onlineSet) {
// ★ 第44批根因修复:此前 isLoading=true 时直接 return —— 搜索请求(IP查询最长15-30秒)进行中
// 清空搜索词,新请求被静默丢弃 → 旧搜索结果滞留(10次里6次卡住的元凶)。
// 改为抢占式:中止在飞旧请求 + 代际令牌 _lazyUsersGen,旧回调全部作废。
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.isLoading = true;
lazyLoadState.currentPage = page;
lazyLoadState.currentSearch = search;
lazyLoadState.currentPage = page;
// 搜索模式:不限制条数,一次加载全部
// 浏览模式:每批15-20个
const batchSize = search ? 200 : 20;
const queryParams = {
action: 'list_users_paginated',
page: page,
limit: batchSize,
_t: Date.now(), // ★ 缓存失效:每次请求带时间戳
...(search ? { search: search } : {}) // ★ 第44批:URLSearchParams 自会编码,手动 encodeURIComponent 会二次转义(%2520)
};
const queryString = new URLSearchParams(queryParams).toString();
console.log('[LazyLoad] Fetching users page ' + page + ': api/admin.php?' + queryString);
// 设置超时时间(IP查询可能需要较长时间)
const timeoutMs = 15000;
// 使用fetch的timeout(AbortController)
const controller = new AbortController();
window._lazyUsersCtl = controller; // ★ 第44批:挂全局,供下一个请求抢占中止
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
fetch('api/admin.php?' + queryString, {
credentials: 'same-origin',
signal: controller.signal,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.finally(() => clearTimeout(timeoutId))
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求已被新请求抢占(第1段),丢弃'); return; }
clearTimeout(timeoutId);
console.log('[LazyLoad] Response status: ' + r.status);
// 先获取text再解析JSON,防止r.json()直接崩溃
return r.text().then(text => {
console.log('[LazyLoad] Raw response (' + text.length + ' bytes): ' + text.substring(0, 200));
// 清理可能的BOM或不可见字符
text = text.replace(/^\uFEFF/, '').trim();
if (!text) throw new Error('Empty response');
try {
const parsed = JSON.parse(text);
console.log('[LazyLoad] JSON parsed OK, keys:', Object.keys(parsed));
return parsed;
} catch(e) {
console.error('[LazyLoad] JSON parse error:', e.message, 'text preview:', text.substring(0, 200));
throw new Error('服务器返回无效JSON: ' + text.substring(0, 50));
}
});
})
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求已被新请求抢占(第2段),丢弃'); return; }
console.log('[LazyLoad] ★★★ 进入第二个then块, r:', typeof r, r ? Object.keys(r).join(',') : 'null');
lazyLoadState.isLoading = false;
try {
if (!r || !r.success) {
console.log('[LazyLoad] 请求失败或无success字段:', r);
document.getElementById('userLazyContainer').innerHTML = '<div class="empty">加载失败: ' + (r?.message || '未知错误') + '</div>';
return;
}
const users = r.data || [];
const pag = r.pagination || {};
lazyLoadState.totalPages = pag.total_pages || 1;
lazyLoadState.hasMore = pag.has_more || false;
// ★ 收集所有需要查询的IP(未缓存 + 查询失败 + 查询中)
console.log('[LazyLoad] Backend uncached_ips:', JSON.stringify(r.uncached_ips || []));
console.log('[LazyLoad] Users count:', users.length);
users.forEach(u => {
console.log('[LazyLoad] User:', u.player_name, 'IP:', u.ip_address, 'Location:', u.ip_location);
});
const allQueryIps = new Set();
// 1. 后端返回的未缓存IP
const uncachedIps = r.uncached_ips || [];
uncachedIps.forEach(ip => {
const valid = /^\d+\.\d+\.\d+\.\d+$/.test(ip);
console.log('[LazyLoad] Uncached IP:', ip, 'valid:', valid);
if (valid) allQueryIps.add(ip);
});
// 2. 前端发现的需要查询的IP(查询失败/查询中.../短杠位置)
users.forEach(u => {
const loc = u.ip_location;
const ip = u.ip_address;
const needQuery = loc === '查询失败' || loc === '-' || loc === '查询中...' || !loc;
const ipValid = ip && ip !== '-' && /^\d+\.\d+\.\d+\.\d+$/.test(ip);
console.log('[LazyLoad] User:', u.player_name, 'IP:', ip, 'Location:', JSON.stringify(loc), 'needQuery:', needQuery, 'ipValid:', ipValid);
if (needQuery && ipValid) {
console.log('[LazyLoad] ★ Adding IP to query:', ip);
allQueryIps.add(ip);
}
});
// 3. 批量查询
const queryIps = Array.from(allQueryIps);
console.log('[LazyLoad] Total IPs to query:', queryIps.length, queryIps);
// ★ 渲染表格后再统一扫描DOM中的"查询中..."IP(解决uncached_ips为空的问题)
if (page === 1) {
// 第一页:清空并显示新数据
const div = document.getElementById('userLazyContainer');
div.innerHTML = `
<table class="table" id="lazyUserTable">
<tr><th>玩家名</th><th>注册时间</th><th>最后登录</th><th>积分</th><th>在线时长</th><th>邮箱</th><th>IP地址</th><th>IP属地</th><th>操作</th></tr>
</table>`;
// ★ 首次加载完成后,标记为非首次
if (isFirstLoad) {
isFirstLoad = false;
console.log('[LazyLoad] First load completed');
}
}
const table = document.getElementById('lazyUserTable');
if (users.length === 0 && page === 1) {
table.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px;color:var(--dim)">暂无用户数据</td></tr>';
document.getElementById('userLazyLoadMore').style.display = 'none';
return;
}
if (users.length === 0) {
document.getElementById('userLazyLoadMore').style.display = 'none';
return;
}
// ★ 判断是否为日期搜索:日期搜索时折叠同IP段玩家
const s = (search || '').toLowerCase();
const isDateSearch = /今天|昨日|today|yesterday|todayreg|yesterdayreg|今天|当日|前一日/.test(s) || /^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/.test(s);
// 追加用户行(日期搜索时按/24子网折叠)
let displayUsers = users;
if (isDateSearch && users.length > 0) {
// 按/24子网分组(只对有效IPv4地址进行分组)
const subnetMap = {};
const subnetOrder = [];
users.forEach(u => {
const ip = u.ip_address || '-';
let subnet = '_unknown_' + u.player_name; // 默认每个玩家独立一组
// 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.');
if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
}
}
if (!subnetMap[subnet]) { subnetMap[subnet] = []; subnetOrder.push(subnet); }
subnetMap[subnet].push(u);
});
displayUsers = [];
subnetOrder.forEach(sub => {
const group = subnetMap[sub];
displayUsers.push({...group[0], _foldCount: group.length - 1});
});
}
displayUsers.forEach(u => {
const isOnline = onlineSet && onlineSet.has((u.player_name || '').toLowerCase());
const playerNameClass = isOnline ? 'player-online' : '';
const regTime = u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-';
const loginTime = u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-';
const hours = Math.floor((u.total_online_time || 0) / 3600);
const ip = u.ip_address || '-';
const ipLoc = u.ip_location || '-';
const foldSuffix = u._foldCount > 0 ? ` <span style="color:var(--yellow);font-size:12px">(同ip段${u._foldCount}名)</span>` : '';
// ★ 封禁状态检查
const banMap = window._banMap || {};
const bans = banMap[(u.player_name || '').toLowerCase()];
let banBadge = '';
let nameStyle = '';
if (bans && bans.length > 0) {
const b = bans[0];
const now = Date.now();
// 检查是否仍在封禁期(永久封禁expire=0,限时封禁需检查expire)
const isActive = b.expire_time === 0 || b.expire_time > now;
if (isActive) {
nameStyle = 'color:#ff4444;font-weight:bold';
const reasonText = b.reason ? b.reason.substring(0, 30) : '无理由';
const isPerm = b.expire_time === 0;
const typeLabel = b.ban_type === 'ip' ? 'IP封禁' : '封禁';
banBadge = ` <span style="color:#ff6666;font-size:11px;background:rgba(255,0,0,0.1);padding:1px 5px;border-radius:3px" title="${typeLabel}: ${b.reason || '无理由'} | 来源: ${b.source || '未知'} | ${isPerm ? '永久封禁' : '至 ' + new Date(b.expire_time).toLocaleString()}">🚫${typeLabel}</span>`;
}
}
const tr = document.createElement('tr');
tr.setAttribute('data-name', (u.player_name || '').toLowerCase());
tr.innerHTML = `
<td class="${playerNameClass}" style="${nameStyle}">${isOnline ? '🟢 ' : ''}${u.player_name}${banBadge}${foldSuffix}</td>
<td>${regTime}</td>
<td>${loginTime}</td>
<td>${u.points || 0}</td>
<td>${hours}h</td>
<td>${u.email || '-'}</td>
<td style="font-size:12px;font-family:monospace">${ip}</td>
<td style="font-size:12px">${ipLoc}</td>
<td>
<button class="btn btn-blue" onclick="showUserInfoAndReset('${u.player_name}','${u.email || ''}')">查看 & 重置密码</button>
</td>`;
table.querySelector('tbody')?.appendChild(tr) || table.appendChild(tr);
});
// ★ 表格渲染完成后,扫描DOM中的"查询中..."IP并批量查询
// (后端uncached_ips可能为空,但前端仍需查询未缓存的IP)
setTimeout(() => {
const domQueryIps = new Set();
// 先加入后端和前端收集的IP
queryIps.forEach(ip => domQueryIps.add(ip));
// 再扫描DOM表格中的"查询中..."和"查询失败"
document.querySelectorAll('#lazyUserTable tr[data-name]').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
const locText = cells[7].textContent.trim(); // IP属地列 (第8列)
const ipText = cells[6].textContent.trim(); // IP地址列 (第7列)
if ((locText === '查询中...' || locText === '查询失败' || locText === '⚠️ 重试中...')
&& /^\d+\.\d+\.\d+\.\d+$/.test(ipText)) {
domQueryIps.add(ipText);
}
}
});
const finalIps = Array.from(domQueryIps);
console.log('[LazyLoad] ★ DOM scan found', finalIps.length, 'IPs to query:', finalIps);
if (finalIps.length > 0) {
batchQueryIpLocations(finalIps);
}
}, 500); // 延迟500ms确保DOM渲染完成
// ★ 3秒后自动重试:检查是否仍有"查询中..."的IP
setTimeout(() => {
const retryIps = [];
document.querySelectorAll('#lazyUserTable tr[data-name]').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
const locText = cells[7].textContent.trim();
const ipText = cells[6].textContent.trim();
if ((locText === '查询中...' || locText === '查询失败' || locText === '⚠️ 重试中...')
&& /^\d+\.\d+\.\d+\.\d+$/.test(ipText)) {
retryIps.push(ipText);
}
}
});
if (retryIps.length > 0) {
console.log('[LazyLoad] ★ Retry: still', retryIps.length, 'IPs need query:', retryIps);
batchQueryIpLocations(retryIps);
} else {
console.log('[LazyLoad] ✓ No more "查询中..." IPs');
}
}, 3000);
// 更新加载更多按钮
const loadMoreDiv = document.getElementById('userLazyLoadMore');
if (lazyLoadState.hasMore) {
loadMoreDiv.style.display = 'block';
loadMoreDiv.innerHTML = `<button class="btn btn-blue" onclick="doLazyLoadMore()">加载更多 (还有${lazyLoadState.totalPages - lazyLoadState.currentPage}页)</button>`;
} else {
loadMoreDiv.style.display = 'block';
loadMoreDiv.innerHTML = '<p style="color:var(--dim);font-size:12px">已加载全部内容 (共' + (pag.total || 0) + '人)</p>';
}
// ★ 更新未缓存IP的显示状态
if (uncachedIps.length > 0) {
updateUncachedIpDisplay(uncachedIps);
}
} catch(innerErr) {
console.error('[LazyLoad] ★★★ 内部处理异常:', innerErr.message, innerErr.stack);
lazyLoadState.isLoading = false;
document.getElementById('userLazyContainer').innerHTML = '<div class="empty">数据处理异常: ' + innerErr.message + '</div>';
}
})
.catch(e => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad] 旧请求被抢占/中止,忽略:', e.name, e.message); return; }
clearTimeout(timeoutId);
lazyLoadState.isLoading = false;
document.getElementById('userLazyContainer').innerHTML = '<div class="empty">加载失败: ' + (e.name === 'AbortError' ? '请求超时,请重试' : e.message) + '</div>';
});
}
// ★ 批量查询未缓存的IP归属地(串行,避免并发问题)
function batchQueryIpLocations(ips) {
if (!ips || ips.length === 0) {
console.log('[BatchIP] Empty IP list, skipping');
return;
}
// 去重
const uniqueIps = [...new Set(ips)];
console.log('[BatchIP] Starting batch query for ' + uniqueIps.length + ' IPs:', uniqueIps);
const batchSize = 3;
let idx = 0;
let batchNum = 0;
function queryNext() {
if (idx >= uniqueIps.length) {
console.log('[BatchIP] ✓ All ' + batchNum + ' batches completed');
return;
}
const batch = uniqueIps.slice(idx, idx + batchSize);
idx += batchSize;
batchNum++;
console.log('[BatchIP] Batch #' + batchNum + ' requesting:', batch);
const startTime = Date.now();
// ★ 前端超时15秒:防止PHP挂起后fetch永远不返回
const controller = new AbortController();
const abortTimer = setTimeout(() => controller.abort(), 15000);
fetch('api/admin.php', {
method: 'POST',
credentials: 'same-origin',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
action: 'batch_query_ips',
ips: batch
})
})
.then(r => {
clearTimeout(abortTimer);
console.log('[BatchIP] Batch #' + batchNum + ' HTTP status:', r.status, 'time:', (Date.now() - startTime) + 'ms');
return r.text();
})
.then(text => {
console.log('[BatchIP] Batch #' + batchNum + ' raw response:', text.substring(0, 500));
try {
const r = JSON.parse(text);
if (r.success && r.data) {
console.log('[BatchIP] Batch #' + batchNum + ' parsed results:', JSON.stringify(r.data));
updateIpLocationDisplay(r.data);
} else {
console.error('[BatchIP] Batch #' + batchNum + ' failed:', r.message || 'unknown');
}
} catch(e) {
console.error('[BatchIP] Batch #' + batchNum + ' JSON parse error:', e, 'text:', text.substring(0, 200));
}
// 串行:等当前批次完成后再查下一批
setTimeout(queryNext, 300);
})
.catch(e => {
clearTimeout(abortTimer);
console.error('[BatchIP] Batch #' + batchNum + ' fetch error:', e.name === 'AbortError' ? 'TIMEOUT (15s)' : e);
setTimeout(queryNext, 300);
});
}
queryNext();
}
// ★ 更新未缓存IP的显示状态(显示"查询中...")
function updateUncachedIpDisplay(ips) {
const table = document.getElementById('lazyUserTable');
if (!table) return;
const rows = table.querySelectorAll('tr[data-name]');
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(7)'); // IP地址列
const locCell = row.querySelector('td:nth-child(8)'); // IP属地列
if (ipCell && locCell) {
const ip = ipCell.textContent.trim();
if (ips.includes(ip)) {
locCell.textContent = '查询中...';
locCell.style.color = 'var(--yellow)';
}
}
});
}
// ★ 更新IP归属地显示
function updateIpLocationDisplay(ipLocationMap) {
const table = document.getElementById('lazyUserTable');
if (!table) return;
const rows = table.querySelectorAll('tr[data-name]');
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(7)'); // IP地址列
const locCell = row.querySelector('td:nth-child(8)'); // IP属地列
if (ipCell && locCell) {
const ip = ipCell.textContent.trim();
const currentLoc = locCell.textContent.trim();
// 只更新有效的IP归属地,不更新"查询失败"等无效值
if (ipLocationMap[ip] && ipLocationMap[ip] !== '查询失败' && ipLocationMap[ip] !== '-') {
locCell.textContent = ipLocationMap[ip];
locCell.style.color = ''; // 恢复默认颜色
} else if (ipLocationMap[ip] === '查询失败') {
// 如果还是查询失败,显示为黄色提示
locCell.textContent = '⚠️ 重试中...';
locCell.style.color = 'var(--yellow)';
}
}
});
}
function doLazyLoadMore() {
if (!lazyLoadState.hasMore || lazyLoadState.isLoading) return;
// ★ 第44批:进入新请求前换代,旧代回调(含被抢占的)一律作废
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.currentPage++;
const queryParams = {
action: 'list_users_paginated',
page: lazyLoadState.currentPage,
limit: 20,
_t: Date.now(),
...(lazyLoadState.currentSearch ? { search: lazyLoadState.currentSearch } : {}) // ★ 第44批:URLSearchParams 自会编码
};
const queryString = new URLSearchParams(queryParams).toString();
console.log('[LazyLoad More] Loading page ' + lazyLoadState.currentPage + ': api/admin.php?' + queryString);
const loadMoreDiv = document.getElementById('userLazyLoadMore');
loadMoreDiv.innerHTML = '<span style="color:var(--dim)">加载中...</span>';
// 设置30秒超时(IP查询需要时间)
const controller = new AbortController();
window._lazyUsersCtl = controller; // ★ 第44批
const timeoutId = setTimeout(() => controller.abort(), 30000);
fetch('api/admin.php?' + queryString, {
credentials: 'same-origin',
signal: controller.signal,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.finally(() => clearTimeout(timeoutId))
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求已被抢占(第1段),丢弃'); return; }
clearTimeout(timeoutId);
console.log('[LazyLoad More] Response status: ' + r.status);
return r.text().then(text => {
console.log('[LazyLoad More] Raw response (' + text.length + ' bytes)');
text = text.replace(/^\uFEFF/, '').trim();
if (!text) throw new Error('Empty response');
try {
return JSON.parse(text);
} catch(e) {
console.error('[LazyLoad More] JSON parse error:', e.message, text.substring(0, 200));
throw new Error('服务器返回无效JSON');
}
});
})
.then(r => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求已被抢占(第2段),丢弃'); return; }
console.log('[LazyLoad More] ★ Parsed OK, success:', r.success, 'data count:', r.data?.length);
lazyLoadState.isLoading = false;
if (!r.success || !r.data || r.data.length === 0) {
console.log('[LazyLoad More] No more data');
lazyLoadState.hasMore = false;
document.getElementById('userLazyLoadMore').innerHTML = '<p style="color:var(--dim);font-size:12px">已加载全部内容</p>';
return;
}
const users = r.data;
const pag = r.pagination || {};
lazyLoadState.totalPages = pag.total_pages || 1;
lazyLoadState.hasMore = pag.has_more || false;
// ★ 收集需要查询的IP(与lazyLoadUsersPage一致)
const moreQueryIps = new Set();
// 1. 后端返回的未缓存IP
const moreUncachedIps = r.uncached_ips || [];
console.log('[LazyLoad More] Backend uncached_ips:', JSON.stringify(moreUncachedIps));
moreUncachedIps.forEach(ip => {
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) moreQueryIps.add(ip);
});
// 2. 前端发现的需要查询的IP
users.forEach(u => {
const needQuery = u.ip_location === '查询失败' || u.ip_location === '-' || u.ip_location === '查询中...' || !u.ip_location;
const ipValid = u.ip_address && u.ip_address !== '-' && /^\d+\.\d+\.\d+\.\d+$/.test(u.ip_address);
if (needQuery && ipValid) {
console.log('[LazyLoad More] User IP needs query:', u.ip_address, 'location:', u.ip_location);
moreQueryIps.add(u.ip_address);
}
});
// 3. 批量查询
const moreQueryList = Array.from(moreQueryIps);
console.log('[LazyLoad More] Total IPs to query:', moreQueryList.length, moreQueryList);
if (moreQueryList.length > 0) {
batchQueryIpLocations(moreQueryList);
}
const table = document.getElementById('lazyUserTable');
const onlineSet = cachedOnlineSet;
users.forEach(u => {
const isOnline = onlineSet && onlineSet.has((u.player_name || '').toLowerCase());
const playerNameClass = isOnline ? 'player-online' : '';
const regTime = u.register_time ? new Date(u.register_time * 1000).toLocaleString() : '-';
const loginTime = u.last_login_time ? new Date(u.last_login_time * 1000).toLocaleString() : '-';
const hours = Math.floor((u.total_online_time || 0) / 3600);
const ip = u.ip_address || '-';
const ipLoc = u.ip_location || '-';
const tr = document.createElement('tr');
tr.setAttribute('data-name', (u.player_name || '').toLowerCase());
tr.innerHTML = `
<td class="${playerNameClass}">${isOnline ? '🟢 ' : ''}${u.player_name}</td>
<td>${regTime}</td>
<td>${loginTime}</td>
<td>${u.points || 0}</td>
<td>${hours}h</td>
<td>${u.email || '-'}</td>
<td style="font-size:12px;font-family:monospace">${ip}</td>
<td style="font-size:12px">${ipLoc}</td>
<td>
<button class="btn btn-blue" onclick="showUserInfoAndReset('${u.player_name}','${u.email || ''}')">查看 & 重置密码</button>
</td>`;
table.querySelector('tbody')?.appendChild(tr) || table.appendChild(tr);
});
// ★ 表格渲染完成后,扫描DOM中的"查询中..."IP并批量查询
setTimeout(() => {
const domQueryIps = new Set();
moreQueryList.forEach(ip => domQueryIps.add(ip));
document.querySelectorAll('#lazyUserTable tr[data-name]').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
const locText = cells[7].textContent.trim();
const ipText = cells[6].textContent.trim();
if ((locText === '查询中...' || locText === '查询失败' || locText === '⚠️ 重试中...')
&& /^\d+\.\d+\.\d+\.\d+$/.test(ipText)) {
domQueryIps.add(ipText);
}
}
});
const finalIps = Array.from(domQueryIps);
console.log('[LazyLoad More] ★ DOM scan found', finalIps.length, 'IPs to query:', finalIps);
if (finalIps.length > 0) {
batchQueryIpLocations(finalIps);
}
}, 500);
// ★ 3秒后自动重试
setTimeout(() => {
const retryIps = [];
document.querySelectorAll('#lazyUserTable tr[data-name]').forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
const locText = cells[7].textContent.trim();
const ipText = cells[6].textContent.trim();
if ((locText === '查询中...' || locText === '查询失败' || locText === '⚠️ 重试中...')
&& /^\d+\.\d+\.\d+\.\d+$/.test(ipText)) {
retryIps.push(ipText);
}
}
});
if (retryIps.length > 0) {
console.log('[LazyLoad More] ★ Retry:', retryIps.length, 'IPs still need query:', retryIps);
batchQueryIpLocations(retryIps);
}
}, 3000);
const loadMoreDiv = document.getElementById('userLazyLoadMore');
if (lazyLoadState.hasMore) {
loadMoreDiv.innerHTML = `<button class="btn btn-blue" onclick="doLazyLoadMore()">加载更多 (还有${lazyLoadState.totalPages - lazyLoadState.currentPage}页)</button>`;
} else {
loadMoreDiv.innerHTML = '<p style="color:var(--dim);font-size:12px">已加载全部内容 (共' + (pag.total || 0) + '人)</p>';
}
})
.catch(e => {
if (myGen !== window._lazyUsersGen) { console.log('[LazyLoad More] 旧请求被抢占/中止,忽略:', e.name, e.message); return; }
console.error('[LazyLoad More] ★ Error:', e.name, e.message);
clearTimeout(timeoutId);
lazyLoadState.isLoading = false;
document.getElementById('userLazyLoadMore').innerHTML = '<button class="btn btn-red" onclick="doLazyLoadMore()">加载失败,点击重试</button>';
});
}
function handleUserSearch() {
// 防抖:200ms后触发搜索
if (window.userSearchTimer) clearTimeout(window.userSearchTimer);
window.userSearchTimer = setTimeout(() => {
doLazyLoadSearch();
}, 200);
}
function doLazyLoadSearch() {
const searchInput = document.getElementById('userSearch');
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;
lazyLoadState.hasMore = true;
lazyLoadState.currentSearch = search;
lazyLoadUsersPage(1, 50, search, cachedOnlineSet);
}
// ===== ban/tempban 搜索:客户端筛选封禁玩家 =====
function renderBanSearchResults(type) {
// ★ 第44批:抢占在飞的懒加载请求并换代(否则旧搜索结果可能稍后覆盖封禁列表,反之亦然)
if (window._lazyUsersCtl) { try { window._lazyUsersCtl.abort(); } catch (e) {} }
window._lazyUsersGen = (window._lazyUsersGen || 0) + 1;
const myGen = window._lazyUsersGen;
lazyLoadState.isLoading = false;
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 (myGen !== window._lazyUsersGen) { console.log('[BanSearch] 已被新请求抢占,丢弃封禁列表结果'); return; }
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>';
const r = await jsonApi('admin.php?action=list_active_players');
if (!r.success) { el.innerHTML='<div class="card">'+r.message+'</div>'; return; }
const players = r.data || [];
const totalBeforeDedup = r.total_before_dedup || players.length;
const dedupCount = totalBeforeDedup - players.length;
el.innerHTML = `
<div class="card">
<h2>24小时活跃用户 <span style="color:var(--accent);font-size:14px">(${players.length}人)</span>
${dedupCount > 0 ? `<span style="color:var(--yellow);font-size:12px;margin-left:8px">(已隐藏${dedupCount}个同子网用户)</span>` : ''}
</h2>
<table class="table">
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th><th>IP</th><th>IP属地</th></tr>
${players.map(p => {
const loginTime = p.last_login_time ? new Date(p.last_login_time*1000).toLocaleString() : '-';
const ip = p.ip_address || '-';
const ipLoc = p.ip_location || '-';
return `<tr>
<td class="player-online">${p.player_name}</td>
<td>${loginTime}</td>
<td>${p.hours_online}小时</td>
<td style="font-size:12px;font-family:monospace">${ip}</td>
<td style="font-size:12px">${ipLoc}</td>
</tr>`;
}).join('')}
</table>
${players.length === 0 ? '<div class="empty">暂无活跃用户</div>' : ''}
</div>`;
}
// ===== 活跃用户(标签页内)=====
function loadActiveTab(tab, seconds) {
if (!tab) return;
document.querySelectorAll('#userTabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const div = document.getElementById('userTabContent');
div.innerHTML = '<div class="empty">加载中...</div>';
const label = seconds === 3600 ? '1小时' : seconds === 86400 ? '1天' : '1周';
const r = jsonApi('admin.php?action=list_active_players&period=' + seconds);
r.then(res => {
if (!res.success) { div.innerHTML='<div class="card" style="border-color:var(--red)">'+res.message+'</div>'; return; }
const players = res.data || [];
const totalBeforeDedup = res.total_before_dedup || players.length;
const dedupCount = totalBeforeDedup - players.length;
// 统计同子网玩家(只对有效IPv4地址进行分组)
const subnetCountMap = {};
players.forEach(p => {
const ip = p.ip_address || '-';
// 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.');
if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
const subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
subnetCountMap[subnet] = (subnetCountMap[subnet] || 0) + 1;
}
}
});
div.innerHTML = `
<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">
${label}内活跃用户 <span style="color:var(--accent)">(${players.length}人)</span>
${dedupCount > 0 ? `<span style="color:var(--yellow);font-size:12px;margin-left:8px">(已隐藏${dedupCount}个同子网用户)</span>` : ''}
</h3>
<table class="table">
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th><th>IP地址</th><th>IP属地</th></tr>
${players.map(p => {
const loginTime = p.last_login_time ? new Date(p.last_login_time*1000).toLocaleString() : '-';
const ip = p.ip_address || '-';
const ipLoc = p.ip_location || '-';
let subnet = '';
// 只有有效IPv4地址才进行子网分组
if (ip !== '-' && ip.indexOf('.') !== -1) {
const parts = ip.split('.');
if (parts.length === 4 && parts.every(part => part !== '' && !isNaN(part))) {
subnet = parts[0] + '.' + parts[1] + '.' + parts[2];
}
}
const isShared = subnet && (subnetCountMap[subnet] || 0) > 1;
const ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : '';
const ipBadge = isShared ? ` <span style="font-size:10px;background:var(--yellow);color:#000;padding:1px 4px;border-radius:3px">同网段${subnetCountMap[subnet]}人</span>` : '';
return `<tr>
<td>${p.player_name}</td>
<td>${loginTime}</td>
<td>${p.hours_online}h</td>
<td style="font-size:12px;font-family:monospace;${ipStyle}">${ip}${ipBadge}</td>
<td style="font-size:12px">${ipLoc}</td>
</tr>`;
}).join('')}
</table>
${players.length === 0 ? '<div class="empty">暂无活跃用户</div>' : ''}`;
}).catch(e => {
div.innerHTML = '<div class="card" style="border-color:var(--red)">加载失败: ' + e.message + '</div>';
});
}
// ===== 同IP玩家标签页 =====
async function loadSameIpTab(tab) {
if (!tab) return;
document.querySelectorAll('#userTabs .tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const div = document.getElementById('userTabContent');
div.innerHTML = '<div class="empty">加载中...</div>';
const r = await jsonApi('admin.php?action=list_same_ip');
if (!r.success || !r.data || r.data.length === 0) {
div.innerHTML = '<div class="card"><h3>同IP玩家</h3><p style="color:var(--dim)">暂无同IP多玩家记录</p></div>';
return;
}
const groups = r.data;
let html = `<h3 style="color:var(--dim);font-size:14px;margin-bottom:12px">同网段玩家 <span style="color:var(--dim)">(${groups.length}个网段组)</span></h3>`;
groups.forEach(g => {
html += `<div class="card" style="margin-bottom:12px">`;
html += `<h4 style="color:var(--yellow);margin-bottom:8px">🌐 ${g.subnet || g.ip} <span style="font-size:12px;color:var(--dim)">(${g.player_count}人 | ${g.ip_location || '-'})</span></h4>`;
html += `<table class="table">`;
html += `<tr><th>玩家名</th><th>IP地址</th><th>IP属地</th><th>最后登录时间</th></tr>`;
g.players.forEach(p => {
const loginTime = p.login_time ? new Date(p.login_time*1000).toLocaleString() : '-';
html += `<tr><td>${p.player_name}</td><td style="font-size:12px;font-family:monospace">${p.ip_address || '-'}</td><td style="font-size:12px">${p.ip_location || '-'}</td><td>${loginTime}</td></tr>`;
});
html += `</table></div>`;
});
div.innerHTML = html;
}
// ===== 在线玩家(独立标签页)=====
async function loadOnlinePlayers(el) {
el.innerHTML = '<div class="card">加载中...</div>';
loadOnlinePlayersData(el);
// 每10秒刷新一次
onlineInterval = setInterval(() => loadOnlinePlayersData(el), 10000);
}
async function loadOnlinePlayersData(el) {
const r = await jsonApi('admin.php?action=list_online_players');
if (!r.success) { el.innerHTML = '<div class="card">加载失败: ' + r.message + '</div>'; return; }
const players = r.data || [];
el.innerHTML = `
<div class="card">
<h2>在线玩家 <span style="color:var(--dim);font-size:12px">(${players.length}人在线)</span></h2>
<table class="table">
<tr><th>玩家名</th><th>登录时间</th><th>在线时长</th><th>IP地址</th><th>IP属地</th></tr>
${players.map(p => {
const loginTime = p.login_time ? new Date(p.login_time*1000).toLocaleString() : '-';
const minsOnline = Math.floor((Date.now()/1000 - p.login_time)/60);
const ip = p.ip_address || '-';
const ipLoc = p.ip_location || '-';
const isShared = p.ip_is_shared;
const ipStyle = isShared ? 'color:var(--yellow);font-weight:700' : '';
const ipBadge = isShared ? ` <span style="font-size:10px;background:var(--yellow);color:#000;padding:1px 4px;border-radius:3px">同IP多人</span>` : '';
return `<tr>
<td class="player-online">🟢 ${p.player_name}</td>
<td>${loginTime}</td>
<td>${minsOnline}分钟</td>
<td style="font-size:12px;font-family:monospace;${ipStyle}">${ip}${ipBadge}</td>
<td style="font-size:12px">${ipLoc}</td>
</tr>`;
}).join('')}
</table>
${players.length === 0 ? '<div class="empty">暂无在线玩家</div>' : ''}
</div>`;
}
// ===== 活跃用户(24小时) =====
async function loadActivePlayers(el) {
el.innerHTML = '<div class="card">加载中...</div>';
const r = await jsonApi('admin.php?action=list_active_players');
if (!r.success) { el.innerHTML='<div class="card">'+r.message+'</div>'; return; }
const players = r.data || [];
el.innerHTML = `
<div class="card">
<h2>24小时活跃用户 <span style="color:var(--dim);font-size:12px">(${players.length}人)</span></h2>
<table class="table">
<tr><th>玩家名</th><th>最后活跃</th><th>总在线时长</th></tr>
${players.map(p => {
const loginTime = p.last_login_time ? new Date(p.last_login_time*1000).toLocaleString() : '-';
return `<tr>
<td class="player-online">${p.player_name}</td>
<td>${loginTime}</td>
<td>${p.hours_online}小时</td>
</tr>`;
}).join('')}
</table>
${players.length === 0 ? '<div class="empty">暂无活跃用户</div>' : ''}
</div>`;
}
// ===== 发送重置密码链接(管理员后台) =====
async function showUserInfoAndReset(player, email) {
const res = await jsonApi('register.php?action=query&player='+encodeURIComponent(player));
let infoHtml = '';
if (res.success) {
const d = res.data;
const regTime = d.register_time ? new Date(d.register_time*1000).toLocaleString() : '-';
const loginTime = d.last_login_time ? new Date(d.last_login_time*1000).toLocaleString() : '-';
const hours = Math.floor((d.total_online_time||0)/3600);
infoHtml = `<div style="background:rgba(88,166,255,0.1);border:1px solid var(--accent);border-radius:8px;padding:12px;margin-bottom:12px;font-size:13px">
<p><b>玩家:</b> ${d.player_name}</p>
<p><b>注册:</b> ${regTime} | <b>最后登录:</b> ${loginTime}</p>
<p><b>积分:</b> ${d.points||0} | <b>礼包阶段:</b> ${d.gift_stage||0} | <b>在线时长:</b> ${hours}h</p>
<p><b>邮箱:</b> ${d.email||'-'}</p>
</div>`;
}
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:2000';
overlay.innerHTML = `
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:500px;max-width:90%">
<h3 style="margin-bottom:12px">查看 & 重置密码 - ${player}</h3>
<div id="userInfoPanel">${infoHtml}</div>
<div style="margin-top:12px">
<label style="color:var(--dim);font-size:12px">绑定邮箱</label>
<input type="text" id="resetPlayerEmail" value="${email}" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;box-sizing:border-box">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" id="userInfoCancel">关闭</button>
<button class="btn btn-blue" id="userInfoReset">发送重置链接</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.getElementById('userInfoCancel').onclick = () => overlay.remove();
document.getElementById('userInfoReset').onclick = async () => {
const emailInput = document.getElementById('resetPlayerEmail').value.trim();
overlay.remove();
const r = await fetch('api/sync.php?action=send_reset_password_link', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({player})
});
const data = await r.json();
if (data.success) {
toast('重置链接已发送到邮箱: ' + data.data.email, 'ok');
} else {
toast(data.message, 'err');
}
loadUsers(document.getElementById('C'));
};
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
}
async function adminSendReset(player, email) {
const emailInput = await showModal('发送重置密码链接', '为玩家 ' + player + ' 发送重置密码邮件', email);
if (!emailInput) return;
const r = await fetch('api/sync.php?action=send_reset_password_link', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({player})
});
const data = await r.json();
if (data.success) {
toast('重置链接已发送到邮箱: ' + data.data.email, 'ok');
} else {
toast(data.message, 'err');
}
}
// ===== 密码重置审核 =====
async function loadResetRequests(el) {
el.innerHTML = '<div class="card">加载中...</div>';
const r = await jsonApi('admin.php?action=list_reset_requests');
const requests = r.data || [];
if (requests.length === 0) {
el.innerHTML = '<div class="card"><h2>密码重置审核 <span style="color:var(--dim);font-size:12px">(无待审核请求)</span></h2></div>';
return;
}
el.innerHTML = `
<div class="card">
<h2>密码重置审核 <span style="color:var(--dim);font-size:12px">(${requests.length}个待审核)</span></h2>
<table class="table" id="resetTable">
<tr><th>ID</th><th>玩家名</th><th>请求邮箱</th><th>请求时间</th><th>操作</th></tr>
${requests.map(req => {
const reqTime = new Date(req.created_at * 1000).toLocaleString();
return `<tr data-id="${req.id}">
<td>#${req.id}</td>
<td><b style="color:var(--yellow)">${req.player_name}</b></td>
<td>${req.requested_email || '-'}</td>
<td>${reqTime}</td>
<td>
<button class="btn btn-green" onclick="adminApproveReset(${req.id})">批准</button>
<button class="btn btn-red" onclick="adminRejectReset(${req.id})">驳回</button>
</td>
</tr>`;
}).join('')}
</table>
</div>`;
}
async function adminApproveReset(reqId) {
const emailInput = await showModal('批准密码重置', '请输入要绑定的邮箱地址(将用于玩家后续验证)', '');
if (!emailInput) return;
if (!emailInput.includes('@')) { toast('请输入有效邮箱', 'err'); return; }
const r = await fetch('api/admin.php?action=admin_approve_reset', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({id: reqId, admin_email: emailInput})
});
const data = await r.json();
if (data.success) {
toast('已批准!临时密码: ' + data.data.temp_password, 'ok');
loadResetRequests(document.getElementById('C'));
} else {
toast(data.message, 'err');
}
}
async function adminRejectReset(reqId) {
if (!await confirmAction('确定驳回该密码重置请求?')) return;
const r = await fetch('api/admin.php?action=admin_reject_reset', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({id: reqId})
});
const data = await r.json();
if (data.success) {
toast('已驳回', 'ok');
loadResetRequests(document.getElementById('C'));
} else {
toast(data.message, 'err');
}
}
function confirmAction(msg) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:1000';
overlay.innerHTML = `
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:400px;text-align:center">
<p style="color:var(--text);font-size:14px;margin-bottom:20px">${msg}</p>
<div style="display:flex;gap:8px;justify-content:center">
<button class="btn" id="confirmCancel">取消</button>
<button class="btn btn-red" id="confirmOk">确定</button>
</div>
</div>`;
document.body.appendChild(overlay);
document.getElementById('confirmCancel').onclick = () => { overlay.remove(); resolve(false); };
document.getElementById('confirmOk').onclick = () => { overlay.remove(); resolve(true); };
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } };
});
}
// ===== 通用 API 调用(直接返回 JSON)=====
function jsonApi(path) {
console.log('[API] Fetching: ' + path);
return fetch(path.startsWith('api/') ? path : ('api/' + path), {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text().then(t => {
try { return JSON.parse(t.replace(/^\uFEFF/,'').trim()); } catch(e) {
console.error('Invalid JSON from ' + path + ':', t.substring(0, 200));
return { success: false, message: '服务器返回无效JSON: ' + t.substring(0, 50) };
}
});
})
.catch(e => {
console.error('Fetch error:', e);
return { success: false, message: '网络错误: ' + e.message };
});
}
async function postApi(action, data) {
const r = await fetch(A, {
method:'POST',
credentials: 'same-origin',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({action,...data})
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const text = await r.text();
try {
return JSON.parse(text);
} catch(e) {
console.error('Invalid JSON from POST api:', text.substring(0, 200));
return { success: false, message: '服务器返回无效JSON: ' + text.substring(0, 50) };
}
}
function toast(msg,type='ok') {
const t=document.createElement('div'); t.className='toast '+type; t.textContent=msg;
document.body.appendChild(t); setTimeout(()=>t.remove(),3000);
}
async function doLogout() {
await postApi('logout',{});
location.href='admin_login.php';
}
// ===== 模态框输入(替代prompt)=====
function showModal(title, message, defaultValue, customHtml) {
return new Promise((resolve) => {
// ★ 移除所有旧的modal overlay,防止重复id导致输入框读取失败
document.querySelectorAll('[data-modal-overlay]').forEach(el => el.remove());
const overlay = document.createElement('div');
overlay.dataset.modalOverlay = '1';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:1000';
if (customHtml) {
overlay.innerHTML = `
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:400px;max-width:90%">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">${title}</h3>
<span class="modal-close" style="cursor:pointer;font-size:18px;color:var(--dim)" onclick="this.closest('div[style*=fixed]').remove()">✕</span>
</div>
${customHtml}
</div>`;
} else {
overlay.innerHTML = `
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:400px;max-width:90%">
<h3 style="margin-bottom:12px">${title}</h3>
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">${message}</p>
<input type="text" id="modalInput" value="${defaultValue}" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;box-sizing:border-box">
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" id="modalCancel">取消</button>
<button class="btn btn-blue" id="modalConfirm">确认</button>
</div>
</div>`;
}
document.body.appendChild(overlay);
if (!customHtml) {
const input = document.getElementById('modalInput');
input.focus();
input.select();
input.addEventListener('keydown', e => { if (e.key === 'Enter') { resolve(input.value); overlay.remove(); } });
document.getElementById('modalCancel').onclick = () => { overlay.remove(); resolve(null); };
document.getElementById('modalConfirm').onclick = () => { resolve(input.value); overlay.remove(); };
}
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
});
}
// ===== 自定义背景系统(第46批):纯色 / 图片URL / API壁纸(必应) 三模式 =====
let _wpPendingUrl = null; // 待应用的图片URL(预览/获取后暂存,点应用才生效)
function wpEsc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 读取已保存的图片背景配置 {url, dim},非法数据返回 null
function getAdminBgImg() {
try {
const s = localStorage.getItem('sdf1_admin_bgimg');
if (!s) return null;
const o = JSON.parse(s);
if (o && typeof o.url === 'string' && /^https?:\/\//i.test(o.url)) {
return { url: o.url, dim: (typeof o.dim === 'number' ? o.dim : 0.35) };
}
} catch (e) {}
return null;
}
// 应用图片背景:linear-gradient 遮罩 + url 图片,cover 铺满,fixed 不随滚动
function applyAdminBgImage(url, dim, silent) {
dim = Math.max(0, Math.min(0.8, parseFloat(dim)));
if (isNaN(dim)) dim = 0.35;
const safe = url.replace(/"/g, '%22');
const b = document.body;
b.style.backgroundImage = 'linear-gradient(rgba(13,17,23,' + dim + '),rgba(13,17,23,' + dim + ')), url("' + safe + '")';
b.style.backgroundSize = 'cover';
b.style.backgroundPosition = 'center center';
b.style.backgroundRepeat = 'no-repeat';
b.style.backgroundAttachment = 'fixed';
document.documentElement.style.setProperty('--bg', 'rgba(13,17,23,' + Math.min(0.9, dim + 0.25) + ')');
localStorage.setItem('sdf1_admin_bgimg', JSON.stringify({ type: 'img', url: url, dim: dim }));
if (!silent) toast('背景图片已应用', 'ok');
}
function wpShowPreview(url) {
const pv = document.getElementById('wpPreviewBox');
if (!pv) return;
pv.style.backgroundImage = 'url("' + url.replace(/"/g, '%22') + '")';
pv.style.display = 'block';
}
// 选项卡切换:color / url / api
function wpTab(m) {
const paneColor = document.getElementById('wpPaneColor');
const paneUrl = document.getElementById('wpPaneUrl');
const paneApi = document.getElementById('wpPaneApi');
const dimRow = document.getElementById('wpDimRow');
if (!paneColor) return;
paneColor.style.display = (m === 'color') ? '' : 'none';
paneUrl.style.display = (m === 'url') ? '' : 'none';
paneApi.style.display = (m === 'api') ? '' : 'none';
dimRow.style.display = (m === 'color') ? 'none' : '';
document.getElementById('wpTabColor').className = 'btn' + (m === 'color' ? ' btn-blue' : '');
document.getElementById('wpTabUrl').className = 'btn' + (m === 'url' ? ' btn-blue' : '');
document.getElementById('wpTabApi').className = 'btn' + (m === 'api' ? ' btn-blue' : '');
}
// 图片URL预览:new Image 预检,失败不入暂存
function wpPreviewUrl() {
const input = document.getElementById('bgImgUrl');
const url = (input ? input.value : '').trim();
if (!/^https?:\/\//i.test(url)) { toast('请输入以 http(s):// 开头的图片地址', 'err'); return; }
const probe = new Image();
probe.onload = function () {
_wpPendingUrl = url;
wpShowPreview(url);
toast('预览就绪,点击应用生效', 'ok');
};
probe.onerror = function () { toast('图片加载失败,请检查地址或防盗链设置', 'err'); };
probe.src = url;
}
// API壁纸:取必应每日壁纸,成功后预览
function wpFetchBing() {
const info = document.getElementById('wpApiInfo');
if (info) info.textContent = '获取中...';
fetch('api/wallpaper.php?action=bing', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.success || !j.data || !j.data.url) {
if (info) info.textContent = '获取失败,可稍后重试或改用图片URL';
toast(j.message || '壁纸获取失败', 'err');
return;
}
_wpPendingUrl = j.data.url;
wpShowPreview(j.data.url);
const meta = [j.data.title, j.data.copyright].filter(Boolean).join(' — ');
if (info) info.textContent = meta || ('日期 ' + (j.data.date || ''));
toast('壁纸已预览,点击应用生效', 'ok');
})
.catch(function () {
if (info) info.textContent = '网络错误,可稍后重试或改用图片URL';
toast('壁纸获取失败', 'err');
});
}
// 应用图片背景(读遮罩滑条 + 暂存URL)
function wpApplyBg() {
if (!_wpPendingUrl) { toast('请先预览或获取一张图片', 'err'); return; }
const dimEl = document.getElementById('bgDim');
const dim = dimEl ? (parseInt(dimEl.value, 10) / 100) : 0.35;
applyAdminBgImage(_wpPendingUrl, dim, false);
const ov = document.getElementById('themePickerOverlay');
if (ov) ov.remove();
}
// 恢复默认背景(清纯色+图片配置)
function resetBg() {
localStorage.removeItem('sdf1_theme');
localStorage.removeItem('sdf1_admin_bgimg');
const b = document.body;
b.style.backgroundImage = 'none';
b.style.backgroundSize = '';
b.style.backgroundPosition = '';
b.style.backgroundRepeat = '';
b.style.backgroundAttachment = '';
document.documentElement.style.removeProperty('--bg');
b.style.background = '';
const ov = document.getElementById('themePickerOverlay');
if (ov) ov.remove();
toast('已恢复默认背景', 'ok');
}
// ===== 主题选择器(第46批:三模式背景) =====
function showThemePicker() {
const savedImg = getAdminBgImg();
_wpPendingUrl = savedImg ? savedImg.url : null;
const dimPct = savedImg ? Math.round(savedImg.dim * 100) : 35;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:2000';
overlay.id = 'themePickerOverlay';
overlay.innerHTML = `
<div id="themePickerModal" style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:520px;max-width:94%;position:relative;max-height:90vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">🎨 自定义背景</h3>
<button onclick="document.getElementById('themePickerOverlay').remove()" style="background:none;border:none;color:var(--dim);font-size:20px;cursor:pointer">✕</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:14px">
<button class="btn" id="wpTabColor" onclick="wpTab('color')">纯色</button>
<button class="btn" id="wpTabUrl" onclick="wpTab('url')">图片URL</button>
<button class="btn" id="wpTabApi" onclick="wpTab('api')">API壁纸</button>
</div>
<div id="wpPaneColor">
<div class="theme-picker" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<div class="color-btn" style="background:#0d1117" onclick="setTheme('#0d1117')"></div>
<div class="color-btn" style="background:#1a1e2e" onclick="setTheme('#1a1e2e')"></div>
<div class="color-btn" style="background:#0f4c75" onclick="setTheme('#0f4c75')"></div>
<div class="color-btn" style="background:#1b2631" onclick="setTheme('#1b2631')"></div>
<div class="color-btn" style="background:#2c3e50" onclick="setTheme('#2c3e50')"></div>
<div class="color-btn" style="background:#23272a" onclick="setTheme('#23272a')"></div>
</div>
<div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span style="color:var(--dim);font-size:12px;white-space:nowrap">R</span>
<input type="number" id="rgbR" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
<span style="color:var(--dim);font-size:12px;white-space:nowrap">G</span>
<input type="number" id="rgbG" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
<span style="color:var(--dim);font-size:12px;white-space:nowrap">B</span>
<input type="number" id="rgbB" min="0" max="255" placeholder="0-255" style="width:70px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px;text-align:center">
<span style="color:var(--dim);font-size:11px">或</span>
<input type="text" id="customColor" placeholder="#1a237e" class="color-input" style="flex:1;min-width:120px">
</div>
<div style="margin-top:10px"><button class="btn btn-blue" onclick="applyCustomColor()">应用纯色</button></div>
</div>
</div>
<div id="wpPaneUrl" style="display:none">
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="bgImgUrl" placeholder="https://example.com/wallpaper.jpg" value="${wpEsc(savedImg ? savedImg.url : '')}" style="flex:1;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
<button class="btn" onclick="wpPreviewUrl()">预览</button>
</div>
<div style="color:var(--dim);font-size:11px;margin-top:6px">支持任意直链 jpg/png/webp;对方站点防盗链可能导致加载失败</div>
</div>
<div id="wpPaneApi" style="display:none">
<button class="btn btn-blue" onclick="wpFetchBing()">获取必应每日壁纸</button>
<div id="wpApiInfo" style="margin-top:10px;color:var(--dim);font-size:12px">点击获取后自动预览,再点应用生效</div>
</div>
<div id="wpDimRow" style="display:none;margin-top:14px">
<div style="display:flex;align-items:center;gap:10px">
<span style="color:var(--dim);font-size:12px;white-space:nowrap">遮罩强度</span>
<input type="range" id="bgDim" min="0" max="80" value="${dimPct}" oninput="document.getElementById('bgDimVal').textContent=this.value+'%';" style="flex:1">
<span id="bgDimVal" style="color:var(--dim);font-size:12px;width:44px;text-align:right">${dimPct}%</span>
</div>
</div>
<div id="wpPreviewBox" style="display:none;margin-top:14px;border-radius:8px;height:120px;background-size:cover;background-position:center;border:1px solid var(--border)"></div>
<div style="display:flex;gap:8px;justify-content:space-between;margin-top:16px;flex-wrap:wrap">
<button class="btn" onclick="resetBg()">恢复默认背景</button>
<div style="display:flex;gap:8px">
<button class="btn" onclick="document.getElementById('themePickerOverlay').remove()">取消</button>
<button class="btn btn-blue" onclick="wpApplyBg()">应用图片</button>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
wpTab(savedImg ? 'url' : 'color');
if (savedImg) wpShowPreview(savedImg.url);
}
function setTheme(color) {
const b = document.body;
b.style.backgroundImage = 'none';
b.style.backgroundSize = '';
b.style.backgroundPosition = '';
b.style.backgroundRepeat = '';
b.style.backgroundAttachment = '';
document.documentElement.style.setProperty('--bg', color);
b.style.background = color;
localStorage.removeItem('sdf1_admin_bgimg');
localStorage.setItem('sdf1_theme', color);
_wpPendingUrl = null;
toast('主题已应用', 'ok');
}
function applyCustomColor() {
let rVal = document.getElementById('rgbR').value.trim();
let gVal = document.getElementById('rgbG').value.trim();
let bVal = document.getElementById('rgbB').value.trim();
const hexVal = document.getElementById('customColor').value.trim();
let color = null;
// 优先检查十六进制
if (hexVal) {
if (/^[0-9A-Fa-f]{6}$/.test(hexVal)) {
color = '#' + hexVal;
} else if (/^#[0-9A-Fa-f]{6}$/.test(hexVal)) {
color = hexVal;
} else {
toast('请输入有效的十六进制颜色,如 1a237e 或 #1a237e', 'err');
return;
}
}
// 否则检查 RGB 三个输入框 - 如果某个为空则自动补 0
else if (rVal !== '' || gVal !== '' || bVal !== '') {
if (rVal === '') rVal = 0;
if (gVal === '') gVal = 0;
if (bVal === '') bVal = 0;
const r = parseInt(rVal);
const g = parseInt(gVal);
const b = parseInt(bVal);
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
color = 'rgb(' + r + ',' + g + ',' + b + ')';
} else {
toast('RGB 值必须在 0-255 之间', 'err');
return;
}
} else {
toast('请输入十六进制颜色(如 1a237e)或填写任意一个 RGB 值', 'err');
return;
}
if (color) {
setTheme(color);
document.querySelector('[id="themeCancel"]').click();
}
}
// 加载保存的主题(第46批:图片背景优先,其次纯色)
(function() {
const savedImg = getAdminBgImg();
if (savedImg) { applyAdminBgImage(savedImg.url, savedImg.dim, true); return; }
const savedTheme = localStorage.getItem('sdf1_theme');
if (savedTheme) setTheme(savedTheme);
})();
// ===== 工单管理 =====
let ticketAdminState = { filter: 'all', view: 'list' };
async function loadTickets(el) {
ticketAdminState.view = 'list';
await loadTicketList(el);
}
async function loadTicketList(el) {
el.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'list_all');
if (ticketAdminState.filter !== 'all') url.searchParams.set('status', ticketAdminState.filter);
const res = await fetch(url, {credentials: 'same-origin'});
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p></div>`; return; }
const list = data.data.list;
const stats = data.data.stats;
const typeMap = {bug:'🐛 Bug',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const f = ticketAdminState.filter;
let html = `<div class="card"><h2>📋 工单管理</h2>
<div style="margin-bottom:10px"><button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminCreateTicketUI()">➕ 新建工单</button></div>
<div class="tabs">
<div class="tab ${f==='all'?'active':''}" onclick="ticketAdminState.filter='all';loadTicketList(document.getElementById('C'))">全部 (${stats.all})</div>
<div class="tab ${f==='submitted'?'active':''}" onclick="ticketAdminState.filter='submitted';loadTicketList(document.getElementById('C'))">⏳ 已提交 (${stats.submitted||0})</div>
<div class="tab ${f==='replied'?'active':''}" onclick="ticketAdminState.filter='replied';loadTicketList(document.getElementById('C'))">💬 已回复 (${stats.replied||0})</div>
<div class="tab ${f==='completed'?'active':''}" onclick="ticketAdminState.filter='completed';loadTicketList(document.getElementById('C'))">✅ 已完结 (${stats.completed||0})</div>
<div class="tab ${f==='withdrawn'?'active':''}" onclick="ticketAdminState.filter='withdrawn';loadTicketList(document.getElementById('C'))">🔙 已撤销 (${stats.withdrawn||0})</div>
<div class="tab ${f==='rejected'?'active':''}" onclick="ticketAdminState.filter='rejected';loadTicketList(document.getElementById('C'))">🚫 已驳回 (${stats.rejected||0})</div>
</div>`;
if (list.length === 0) {
html += `<p style="color:var(--dim);text-align:center;padding:30px">暂无工单</p>`;
} else {
html += `<table class="table"><thead><tr><th>ID</th><th>类型</th><th>标题</th><th>提交者</th><th>处理人</th><th>状态</th><th>时间</th><th>操作</th></tr></thead><tbody>`;
for (const t of list) {
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<tr>
<td>#${t.id}</td>
<td>${typeMap[t.type]||t.type}</td>
<td>${escAdmHtml(t.title)}</td>
<td>${escAdmHtml(t.requester)}</td>
<td>${t.assigned_to ? escAdmHtml(t.assigned_to) : '<span style="color:var(--dim)">-</span>'}</td>
<td><span style="color:${s.color}">${s.text}</span></td>
<td style="font-size:11px">${date}</td>
<td>
<button class="btn" style="padding:3px 8px;font-size:11px" onclick="viewAdminTicket(${t.id})">查看</button>
${t.status !== 'withdrawn' && t.status !== 'rejected' ? `<button class="btn" style="padding:3px 8px;font-size:11px;color:var(--yellow)" onclick="viewAdminTicket(${t.id})">回复</button>` : ''}
</td>
</tr>`;
}
html += `</tbody></table>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function viewAdminTicket(id) {
const c = document.getElementById('C');
c.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'detail');
url.searchParams.set('id', id);
url.searchParams.set('token', 'admin');
const res = await fetch(url, {credentials: 'same-origin'});
const data = await res.json();
if (!data.success) { c.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p></div>`; return; }
const t = data.data;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
const canOperate = !['withdrawn','rejected','completed'].includes(t.status);
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">#${t.id} ${escAdmHtml(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">
类型: ${typeMap[t.type]||t.type} | 提交者: ${escAdmHtml(t.requester)} | 处理人: ${t.assigned_to ? escAdmHtml(t.assigned_to) : '未分配'} | 时间: ${date}
</div>
<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">${adminRenderMd(applyTicketImgs(t.description || '无描述', t.images))}</div>`;
// 驳回原因
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--red);font-size:12px;margin-bottom:4px">🚫 驳回原因</div>
<div style="font-size:13px">${adminRenderMd(t.reject_reason)}</div></div>`;
}
// 回复列表
html += `<div style="margin-bottom:12px"><div style="font-size:12px;color:var(--dim);margin-bottom:6px">💬 回复记录 (${t.replies ? t.replies.length : 0})</div>`;
if (t.replies && t.replies.length > 0) {
for (const r of t.replies) {
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
html += `<div style="background:${bgColor};border-radius:6px;padding:10px 14px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
<span><b style="color:${roleColors[r.role]||'var(--text)'}">${escAdmHtml(r.sender)}</b> <span style="color:var(--dim)">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim)">${rDate}</span>
</div>
<div style="font-size:13px;line-height:1.6">${adminRenderMd(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:12px">暂无回复</p>`;
}
html += `</div>`;
// 操作区
if (canOperate) {
html += `<div style="border-top:1px solid var(--border);padding-top:12px">
<div style="margin-bottom:8px"><textarea id="admTicketReply" rows="2" placeholder="输入回复内容(支持Markdown)" style="width:100%;padding:8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;resize:vertical;font-family:monospace"></textarea></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminReplyTicket(${t.id})">💬 回复</button>
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--red)" onclick="adminRejectTicket(${t.id})">🚫 驳回</button>
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--green)" onclick="adminCompleteTicket(${t.id})">✅ 完结</button>
<input id="admAssignProvider" placeholder="服务商名" style="padding:5px 8px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:12px;width:120px">
<button class="btn" style="padding:5px 14px;font-size:12px;color:var(--green)" onclick="adminAssignTicket(${t.id})">📤 分配</button>
</div>
<p id="admTicketErr" style="color:var(--red);font-size:12px;margin-top:6px"></p>
</div>`;
}
html += `<div style="margin-top:12px"><button class="btn" style="padding:5px 14px;font-size:12px" onclick="loadTicketList(document.getElementById('C'))">← 返回列表</button></div></div>`;
c.innerHTML = html;
} catch (e) {
c.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function adminReplyTicket(id) {
const msg = document.getElementById('admTicketReply').value.trim();
const errEl = document.getElementById('admTicketErr');
if (!msg) { errEl.textContent = '请输入回复内容'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_reply');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({message: msg})});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { errEl.textContent = data.message; }
} catch (e) { errEl.textContent = '发送失败: ' + e.message; }
}
async function adminRejectTicket(id) {
const reason = prompt('请输入驳回原因:');
if (!reason) return;
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'reject');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({reason: reason})});
const data = await res.json();
if (data.success) { loadTicketList(document.getElementById('C')); } else { glassAlert(data.message); }
} catch (e) { glassAlert('操作失败: ' + e.message); }
}
async function adminCompleteTicket(id) {
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_complete');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin'});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { glassAlert(data.message); }
} catch (e) { glassAlert('操作失败: ' + e.message); }
}
async function adminAssignTicket(id) {
const provider = document.getElementById('admAssignProvider').value.trim();
if (!provider) { document.getElementById('admTicketErr').textContent = '请输入服务商名称'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'assign');
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({provider: provider})});
const data = await res.json();
if (data.success) { viewAdminTicket(id); } else { document.getElementById('admTicketErr').textContent = data.message; }
} catch (e) { document.getElementById('admTicketErr').textContent = '分配失败: ' + e.message; }
}
function adminCreateTicketUI() {
const c = document.getElementById('C');
let html = `<div class="card"><h2>➕ 新建工单</h2>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">类型</label>
<select id="admNewType" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px">
<option value="bug">🐛 Bug</option>
<option value="help">❓ 求助</option>
<option value="report">📢 举报</option>
<option value="apply">📝 申请</option>
<option value="other">📎 其他</option>
</select>
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">标题</label>
<input id="admNewTitle" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px" placeholder="工单标题">
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">描述(支持Markdown)</label>
<textarea id="admNewDesc" rows="4" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px;resize:vertical;font-family:monospace" placeholder="工单描述"></textarea>
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">附件图片(最多 3 张,png/jpg,≤2048K)</label>
<input type="file" id="admNewImg" accept=".png,.jpg,image/png,image/jpeg" multiple onchange="adminUploadTicketImage(this)" style="font-size:12px;margin-top:4px">
<div id="admImgList" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"></div>
<p id="admImgMsg" style="color:var(--red);font-size:11px;margin-top:4px"></p>
</div>
<div style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim)">派发服务商(可选)</label>
<input id="admNewProvider" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px" placeholder="服务商玩家名(留空则进入抢单大厅)">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" style="padding:5px 14px;font-size:12px" onclick="adminSubmitCreateTicket()">创建</button>
<button class="btn" style="padding:5px 14px;font-size:12px" onclick="loadTicketList(document.getElementById('C'))">取消</button>
</div>
<p id="admCreateErr" style="color:var(--red);font-size:12px;margin-top:6px"></p>
</div>`;
c.innerHTML = html;
}
// ===== 管理端创建工单附件图(session 认证,无需 token)=====
let admImages = [];
async function adminUploadTicketImage(input) {
const msg = document.getElementById('admImgMsg');
if (!input.files || !input.files.length) return;
const files = Array.from(input.files);
input.value = '';
for (const file of files) {
if (admImages.length >= 3) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '最多只能上传 3 张图片'; } break; }
const ext = (file.name.split('.').pop() || '').toLowerCase();
if (ext !== 'png' && ext !== 'jpg') { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '仅支持 png / jpg:' + file.name; } continue; }
if (file.size > 2048 * 1024) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '超过 2048K:' + file.name; } continue; }
if (msg) { msg.style.color = 'var(--dim)'; msg.textContent = '上传中… ' + file.name; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'upload_image');
const fd = new FormData();
fd.append('image', file, file.name);
const res = await fetch(url, { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
if (!data.success) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = data.message; } continue; }
admImages.push(data.data.url);
renderAdmImgs();
const fi = document.getElementById('admNewImg');
if (fi) fi.disabled = admImages.length >= 3;
if (msg) { msg.style.color = 'var(--green)'; msg.textContent = '已上传 ' + admImages.length + '/3 张'; }
} catch (e) {
if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '上传失败: ' + e.message; }
}
}
}
function renderAdmImgs() {
const box = document.getElementById('admImgList');
if (!box) return;
box.innerHTML = admImages.map((u, i) =>
`<div style="position:relative;width:64px;height:64px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
<img src="${escAdmHtml(u)}" style="width:100%;height:100%;object-fit:cover">
<span style="position:absolute;left:0;bottom:0;background:rgba(0,0,0,.6);color:#fff;font-size:10px;padding:1px 4px">图片${i + 1}</span>
</div>`).join('');
}
// 详情渲染:[图片N] 占位符 -> markdown 图片;未被引用的图片补在末尾
function applyTicketImgs(desc, images) {
let out = String(desc || '');
if (!images || !images.length) return out;
const used = new Array(images.length).fill(false);
out = out.replace(/\[图片(\d+)\]/g, function(m, n) {
const i = parseInt(n, 10) - 1;
if (i >= 0 && i < images.length && !used[i]) { used[i] = true; return '![](' + images[i] + ')'; }
return m;
});
const left = images.filter(function(u, k) { return !used[k]; });
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return '![](' + u + ')'; }).join('\n\n');
return out;
}
async function adminSubmitCreateTicket() {
const type = document.getElementById('admNewType').value;
const title = document.getElementById('admNewTitle').value.trim();
const desc = document.getElementById('admNewDesc').value.trim();
const provider = document.getElementById('admNewProvider').value.trim();
const errEl = document.getElementById('admCreateErr');
if (!title) { errEl.textContent = '请输入标题'; return; }
try {
const url = new URL('api/ticket.php', location.href);
url.searchParams.set('action', 'admin_create');
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({type, title, description: desc, provider, images: admImages})});
const data = await res.json();
if (data.success) {
admImages = []; // 已创建,附件清空
const fi = document.getElementById('admNewImg'); if (fi) fi.disabled = false;
loadTicketList(document.getElementById('C'));
} else { errEl.textContent = data.message; }
} catch (e) { errEl.textContent = '创建失败: ' + e.message; }
}
function escAdmHtml(s) { return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function adminProcessInlineMd(text) {
let s = text;
s = s.replace(/^######\s+(.+)$/, '<h6 style="margin:2px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/, '<h5 style="margin:3px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/, '<h4 style="margin:3px 0 2px;font-size:12px">$1</h4>');
s = s.replace(/^###\s+(.+)$/, '<h4 style="margin:4px 0 3px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/, '<h3 style="margin:5px 0 3px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/, '<h2 style="margin:6px 0 4px;font-size:15px">$1</h2>');
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:4px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
return s;
}
function adminRenderMd(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escAdmHtml(text);
// 1. 代码块提取保护
let cbs = [];
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
let idx = cbs.length;
cbs.push('<pre style="background:rgba(88,166,255,0.1);padding:8px;border-radius:4px;overflow-x:auto;font-size:12px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
return '\x00CB' + idx + '\x00';
});
// 2. 行内代码保护
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(88,166,255,0.15);padding:1px 4px;border-radius:2px;font-size:12px">$1</code>');
// 3. 引用块处理:先提取连续引用行,构建嵌套结构
let lines = s.split('\n');
let result = [];
let bqBuf = [];
function flushBq() {
if (bqBuf.length === 0) return;
let html = '';
let openDivs = 0;
for (let bi = 0; bi < bqBuf.length; bi++) {
let {level, content} = bqBuf[bi];
let prevLevel = (bi > 0) ? bqBuf[bi - 1].level : 0;
if (level > prevLevel) {
for (let j = prevLevel; j < level; j++) {
let pad = Math.min((j + 1) * 8, 32);
html += '<div style="border-left:3px solid var(--accent);padding:4px 10px;margin:2px 0 2px ' + pad + 'px;background:rgba(255,255,255,0.03)">';
openDivs++;
}
} else if (level < prevLevel) {
for (let j = prevLevel; j > level; j--) { html += '</div>'; openDivs--; }
} else if (bi > 0) {
html += '<br>';
}
let text = adminProcessInlineMd(content);
if (!text.trim()) text = '&nbsp;';
html += text;
}
for (let j = 0; j < openDivs; j++) html += '</div>';
result.push(html);
bqBuf = [];
}
for (let i = 0; i < lines.length; i++) {
let m = lines[i].match(/^((?:&gt;[ \t]*)+)(.*)$/);
if (m) {
bqBuf.push({ level: (m[1].match(/&gt;/g) || []).length, content: m[2] });
} else if (bqBuf.length > 0 && !lines[i].trim()) {
// ★ 空行在引用块内:向前看下一行是否仍是引用
let isContinuation = false;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].trim()) {
isContinuation = /^((?:&gt;[ \t]*)+)(.*)$/.test(lines[j]);
break;
}
}
if (isContinuation) {
bqBuf.push({ level: bqBuf[bqBuf.length - 1].level, content: '' });
} else {
flushBq();
}
} else {
flushBq();
result.push(lines[i]);
}
}
flushBq();
s = result.join('\n');
// 4. 非引用行的MD处理(标题、粗体、斜体、图片、链接等)
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:3px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:4px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h4 style="margin:4px 0 2px;font-size:12px">$1</h4>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:6px 0 4px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:10px 0 6px;font-size:15px">$1</h2>');
// 5. 表格
let inTable = false;
let tableHtml = '';
let tLines = s.split('\n');
let tResult = [];
for (let i = 0; i < tLines.length; i++) {
let line = tLines[i].trim();
if (line.match(/^\|(.+)\|$/)) {
if (line.match(/^\|[\s\-:|]+\|$/)) { inTable = true; continue; }
if (!inTable && i + 1 < tLines.length && tLines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:6px 0;font-size:12px"><tr style="background:rgba(88,166,255,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:4px 8px;text-align:left">' + c + '</th>';
tableHtml += '</tr>';
continue;
}
if (inTable) {
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml += '<tr>';
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:4px 8px">' + c + '</td>';
tableHtml += '</tr>';
continue;
}
}
if (inTable && tableHtml) {
tableHtml += '</table>';
tResult.push(tableHtml);
tableHtml = '';
inTable = false;
}
tResult.push(tLines[i]);
}
if (tableHtml) { tableHtml += '</table>'; tResult.push(tableHtml); }
s = tResult.join('\n');
// 6. 列表、分割线、换行
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:16px;font-size:12px">$1</li>');
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:10px 0">');
s = s.replace(/\n/g, '<br>');
// 7. 还原代码块
for (let i = 0; i < cbs.length; i++) {
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
}
return s;
} catch(e) { console.error('[MD_RENDER] Error:', e); return escAdmHtml(text).replace(/\n/g, '<br>'); }
}
// ==================== 领地管理 ====================
async function loadLands(el) {
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--dim)">加载中...</div>';
try {
const [landsRes, shopRes, configRes] = await Promise.all([
fetch('api/land_api.php?action=list_lands&secret=' + SECRET + '').then(r => r.json()),
fetch('api/land_api.php?action=list_shop&secret=' + SECRET + '').then(r => r.json()),
fetch('api/land_api.php?action=get_config&secret=' + SECRET + '').then(r => r.json())
]);
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px">';
html += '<h2 style="margin:0;color:var(--fg)">🏡 领地管理</h2>';
html += '<div style="display:flex;gap:8px">';
html += `<button onclick="showLandConfig()" style="padding:6px 12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px">⚙️ 配置</button>`;
html += `<button onclick="loadLands(document.getElementById('C'))" style="padding:6px 12px;background:var(--card);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px">🔄 刷新</button>`;
html += '</div></div>';
// 配置信息
const cfg = configRes.config || {};
window._landCfgData = cfg; // 缓存供showLandConfig使用
html += `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px">`;
const lands = landsRes.lands || [];
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:var(--accent)">${lands.length}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">总领地数</div>
</div>`;
const shopItems = (shopRes.items || []).filter(i => i.status === 'active' && !i.buyer);
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:#4caf50">${shopItems.length}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">在售权限</div>
</div>`;
const pricePerSqM = cfg.create_price_per_sqm || '10';
const maxLands = cfg.max_lands_per_player || '5';
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:#ff9800">${pricePerSqM}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">每平米价格(债券)</div>
</div>`;
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:#e91e63">${maxLands}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">最大领地数/人</div>
</div>`;
html += '</div>';
// 领地列表(3.11内测版:分页渲染,表格挪到 renderLandListSec)
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;flex-wrap:wrap;gap:8px">';
html += '<h3 style="color:var(--fg);margin:0">📍 领地列表</h3>';
html += landPerCtlHtml();
html += '</div>';
html += '<div id="landListSec"></div>';
// 权限商店(3.11内测版:分页渲染,表格挪到 renderLandShopSec)
const allShop = shopRes.items || [];
html += '<h3 style="color:var(--fg);margin:20px 0 8px">🛒 权限商店</h3>';
html += '<div id="landShopSec"></div>';
// 缓存全量数据供分页渲染(3.11内测版)
window._landCache = { lands: lands, shopAll: allShop, cfg: cfg };
el.innerHTML = html;
renderLandListSec();
renderLandShopSec();
} catch(e) {
console.error('[LANDS] Error:', e);
el.innerHTML = `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">加载失败: ${escAdmHtml(e.message)}</div>`;
}
}
// ===== 领地管理分页(3.11内测版)=====
// 全量数据缓存在 window._landCache,渲染函数只画当前页;
// 每页条数:预制10/20/30 + 自定义(不设封顶),localStorage 记忆。
window._landPg = { list: 1, shop: 1 };
function landPerPage() {
const v = parseInt(localStorage.getItem('sdf_land_perpage') || '10', 10);
return v > 0 ? v : 10; // 自定义不设封顶,仅下限1
}
function landSetPer(v) {
let n = parseInt(v, 10);
if (!(n > 0)) n = 10;
localStorage.setItem('sdf_land_perpage', String(n));
window._landPg.list = 1;
window._landPg.shop = 1;
renderLandListSec();
renderLandShopSec();
}
function landPerSelChange(v) {
if (v === 'custom') {
const inp = document.getElementById('landPerNum');
if (inp) { inp.focus(); inp.select(); }
} else {
landSetPer(v);
}
}
function landPerCtlHtml() {
const per = landPerPage();
const custom = !(per === 10 || per === 20 || per === 30);
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)">每页
<select onchange="landPerSelChange(this.value)" style="padding:3px 6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px">
<option value="10" ${per===10?'selected':''}>10个</option>
<option value="20" ${per===20?'selected':''}>20个</option>
<option value="30" ${per===30?'selected':''}>30个</option>
<option value="custom" ${custom?'selected':''}>自定义</option>
</select>
<input id="landPerNum" type="number" min="1" value="${per}" onchange="landSetPer(this.value)" style="width:62px;padding:3px 6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px" title="自定义每页条数,不设封顶">
个/页
</div>`;
}
function landPageBox(total) {
const per = landPerPage();
return Math.max(1, Math.ceil(total / per));
}
function landPagerHtml(kind, total) {
if (total === 0) return '';
const per = landPerPage();
const pages = landPageBox(total);
let pg = window._landPg[kind] || 1;
if (pg > pages) pg = pages;
if (pg < 1) pg = 1;
window._landPg[kind] = pg;
const from = (pg - 1) * per + 1;
const to = Math.min(total, pg * per);
let s = Math.max(1, pg - 4), e = Math.min(pages, s + 8);
s = Math.max(1, e - 8);
let btns = '';
const base = 'padding:3px 9px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;';
for (let i = s; i <= e; i++) {
btns += i === pg
? `<button style="${base}background:var(--accent);color:#fff;border-color:var(--accent)">${i}</button>`
: `<button onclick="landGoPage('${kind}',${i})" style="${base}background:var(--card);color:var(--dim)">${i}</button>`;
}
return `<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
<span style="font-size:12px;color:var(--dim)">显示 ${from}-${to} / ${total} 条 · 共 ${pages} 页</span>
<button onclick="landGoPage('${kind}',1)" style="${base}background:var(--card);color:var(--dim)">⏮ 首页</button>
<button onclick="landGoPage('${kind}',${pg-1})" style="${base}background:var(--card);color:var(--dim)">‹ 上一页</button>
${btns}
<button onclick="landGoPage('${kind}',${pg+1})" style="${base}background:var(--card);color:var(--dim)">下一页 ›</button>
<button onclick="landGoPage('${kind}',${pages})" style="${base}background:var(--card);color:var(--dim)">末页 ⏭</button>
</div>`;
}
function landGoPage(kind, p) {
const cache = window._landCache || {};
const total = kind === 'list' ? ((cache.lands || []).length) : ((cache.shopAll || []).length);
const pages = landPageBox(total);
let n = parseInt(p, 10);
if (!(n >= 1)) n = 1;
if (n > pages) n = pages;
window._landPg[kind] = n;
if (kind === 'list') renderLandListSec(); else renderLandShopSec();
}
function renderLandListSec() {
const box = document.getElementById('landListSec');
if (!box) return;
const lands = ((window._landCache || {}).lands) || [];
if (lands.length === 0) {
box.innerHTML = '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无领地数据<br><span style="font-size:11px">Java端会自动同步领地数据到此</span></div>';
return;
}
const per = landPerPage();
const pages = landPageBox(lands.length);
if (window._landPg.list > pages) window._landPg.list = pages;
if (window._landPg.list < 1) window._landPg.list = 1;
const rows = lands.slice((window._landPg.list - 1) * per, window._landPg.list * per);
let html = '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>名称</th><th>所有者</th><th>世界</th><th>坐标</th><th>面积</th><th>操作</th>';
html += '</tr>';
for (const l of rows) {
const size = l.area_size || Math.abs((l.x2-l.x1+1)*(l.z2-l.z1+1));
html += `<tr>
<td>${l.id}</td>
<td><strong>${escAdmHtml(l.name)}</strong></td>
<td>${escAdmHtml(l.owner || '无')}</td>
<td>${escAdmHtml(l.world)}</td>
<td style="font-size:11px">${l.x1},${l.z1} → ${l.x2},${l.z2}</td>
<td>${size} 格²</td>
<td>
<button onclick="adminTransferLand('${escAdmHtml(l.name)}','${escAdmHtml(l.owner)}')" style="padding:2px 8px;background:#ff9800;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">改主</button>
<button onclick="deleteLand('${escAdmHtml(l.name)}')" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">删除</button>
</td>
</tr>`;
}
html += '</table></div>';
html += landPagerHtml('list', lands.length);
box.innerHTML = html;
}
function renderLandShopSec() {
const box = document.getElementById('landShopSec');
if (!box) return;
const allShop = ((window._landCache || {}).shopAll) || [];
if (allShop.length === 0) {
box.innerHTML = '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无权限商品</div>';
return;
}
const per = landPerPage();
const pages = landPageBox(allShop.length);
if (window._landPg.shop > pages) window._landPg.shop = pages;
if (window._landPg.shop < 1) window._landPg.shop = 1;
const rows = allShop.slice((window._landPg.shop - 1) * per, window._landPg.shop * per);
let html = '<div style="overflow-x:auto"><table class="table"><tr>';
html += '<th>ID</th><th>领地</th><th>卖家</th><th>价格</th><th>时长</th><th>状态</th><th>买家</th><th>操作</th>';
html += '</tr>';
for (const s of rows) {
const dur = s.duration >= 86400 ? Math.floor(s.duration/86400)+'天' : s.duration >= 3600 ? Math.floor(s.duration/3600)+'小时' : Math.floor(s.duration/60)+'分钟';
const statusColor = s.status === 'active' ? '#4caf50' : s.status === 'sold' ? '#ff9800' : '#f44336';
html += `<tr>
<td>${s.id}</td>
<td>${escAdmHtml(s.land_name)}</td>
<td>${escAdmHtml(s.seller)}</td>
<td style="color:#ff9800">${s.price}💰</td>
<td>${dur}</td>
<td style="color:${statusColor}">${s.status === 'active' ? '在售' : s.status === 'sold' ? '已售' : '已下架'}</td>
<td>${s.buyer ? escAdmHtml(s.buyer) : '-'}</td>
<td>
${s.status === 'active' ? `<button onclick="deleteShopItem(${s.id})" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">下架</button>` : ''}
</td>
</tr>`;
}
html += '</table></div>';
html += landPagerHtml('shop', allShop.length);
box.innerHTML = html;
}
function showLandConfig() {
const cfg = window._landCfgData || {};
const price = cfg.create_price_per_sqm || '10';
const maxLands = cfg.max_lands_per_player || '5';
const refundRatio = cfg.refund_ratio_per_cent || '100';
const maxFreeArea = cfg.max_free_area_sqm || '3500';
const maxSide = cfg.max_land_side_blocks || '7000';
const html = `<div style="padding:16px">
<h3 style="margin:0 0 12px;color:var(--fg)">⚙️ 领地配置</h3>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">每平米价格(债券)</label>
<input id="cfgPrice" type="number" value="${price}" min="1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">每人最大领地数</label>
<input id="cfgMaxLands" type="number" value="${maxLands}" min="1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">删除领地退费比例(%) <span style="opacity:.7">0=不退款 100=全额</span></label>
<input id="cfgRefundRatio" type="number" value="${refundRatio}" min="0" max="100" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">单次扩建免审面积上限(㎡) <span style="opacity:.7">超出需管理员现场审批</span></label>
<input id="cfgMaxFreeArea" type="number" value="${maxFreeArea}" min="1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">领地免审边长上限(格) <span style="opacity:.7">单边超过即需审批,最大 7000×7000</span></label>
<input id="cfgMaxSide" type="number" value="${maxSide}" min="1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<button onclick="saveLandConfig()" style="width:100%;padding:8px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer">保存</button>
</div>`;
showModal('领地配置', '', null, html);
}
async function saveLandConfig() {
const price = document.getElementById('cfgPrice')?.value || '10';
const maxLands = document.getElementById('cfgMaxLands')?.value || '5';
const refundRatio = document.getElementById('cfgRefundRatio')?.value || '100';
const maxFreeArea = document.getElementById('cfgMaxFreeArea')?.value || '3500';
const maxSide = document.getElementById('cfgMaxSide')?.value || '7000';
try {
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=create_price_per_sqm&value=' + encodeURIComponent(price) + '&secret=' + SECRET + ''
});
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=max_lands_per_player&value=' + encodeURIComponent(maxLands) + '&secret=' + SECRET + ''
});
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=refund_ratio_per_cent&value=' + encodeURIComponent(refundRatio) + '&secret=' + SECRET + ''
});
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=max_free_area_sqm&value=' + encodeURIComponent(maxFreeArea) + '&secret=' + SECRET + ''
});
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=max_land_side_blocks&value=' + encodeURIComponent(maxSide) + '&secret=' + SECRET + ''
});
document.querySelector('.modal-close')?.click();
loadLands(document.getElementById('C'));
} catch(e) {
glassAlert('保存失败: ' + e.message);
}
}
async function deleteLand(name) {
if (!await glassConfirm('确定删除领地 [' + name + '] ?')) return;
fetch('api/land_api.php?action=delete_land', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(name) + '&secret=' + SECRET + ''
}).then(r => r.json()).then(d => {
if (d.success) loadLands(document.getElementById('C'));
else glassAlert('失败: ' + (d.error||''));
});
}
async function adminTransferLand(name, currentOwner) {
const newOwner = await glassPrompt('将领地 [' + name + '] 改主给谁?', '', '当前所有者: ' + (currentOwner||'无'));
if (!newOwner || !newOwner.trim()) return;
const trimmed = newOwner.trim();
if (!/^[a-zA-Z0-9_]{3,16}$/.test(trimmed)) {
glassAlert('玩家名格式不正确,仅支持英文字母、数字和下划线(3-16位)');
return;
}
if (trimmed === currentOwner) {
glassAlert('新旧所有者相同,无需更改');
return;
}
if (!await glassConfirm('确定将领地 [' + name + '] 改主为 [' + trimmed + '] ?\n(将写入待验证队列,等Java端确认后生效)')) return;
try {
const btn = event.target;
btn.disabled = true;
btn.textContent = '验证中...';
const res = await fetch('api/land_api.php?action=update_land_owner', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=' + encodeURIComponent(name) + '&owner=' + encodeURIComponent(trimmed) + '&secret=' + SECRET + ''
});
const d = await res.json();
if (d.success && d.pending) {
glassAlert(d.message || '已提交改主请求,等待Java端验证');
// 启动轮询,每3秒查询状态,最多等待15秒
let elapsed = 0;
const maxWait = 15000; // 15秒
const interval = 3000; // 3秒轮询一次
const poll = async () => {
try {
const pollRes = await fetch('api/land_api.php?action=get_owner_change_status&name=' + encodeURIComponent(name) + '&secret=' + SECRET + '');
const pollData = await pollRes.json();
if (pollData.success) {
if (pollData.status === 'completed') {
glassAlert('改主成功!领地 [' + name + '] 所有者已变更为 [' + trimmed + ']');
btn.textContent = '改主';
btn.disabled = false;
loadLands(document.getElementById('C'));
return;
} else if (pollData.status === 'failed') {
glassAlert('改主失败: ' + (pollData.reason || 'Java端验证失败'));
btn.textContent = '改主';
btn.disabled = false;
loadLands(document.getElementById('C'));
return;
}
}
elapsed += interval;
if (elapsed < maxWait) {
setTimeout(poll, interval);
} else {
// 超时,检查最终状态
const finalRes = await fetch('api/land_api.php?action=get_owner_change_status&name=' + encodeURIComponent(name) + '&secret=' + SECRET + '');
const finalData = await finalRes.json();
if (finalData.success && finalData.status === 'completed') {
glassAlert('改主成功!领地 [' + name + '] 所有者已变更为 [' + trimmed + ']');
} else if (finalData.success && finalData.status === 'failed') {
glassAlert('改主失败: ' + (finalData.reason || 'Java端验证失败'));
} else {
glassAlert('改主超时,可能是Java端处理延迟,请稍后刷新查看');
}
btn.textContent = '改主';
btn.disabled = false;
loadLands(document.getElementById('C'));
}
} catch (pollErr) {
console.error('轮询失败:', pollErr);
elapsed += interval;
if (elapsed < maxWait) {
setTimeout(poll, interval);
} else {
glassAlert('轮询超时,请稍后刷新查看');
btn.textContent = '改主';
btn.disabled = false;
loadLands(document.getElementById('C'));
}
}
};
setTimeout(poll, interval);
} else if (d.success) {
glassAlert(d.message || '改主成功');
btn.textContent = '改主';
btn.disabled = false;
loadLands(document.getElementById('C'));
} else {
glassAlert('失败: ' + (d.error || ''));
btn.textContent = '改主';
btn.disabled = false;
}
} catch(e) {
glassAlert('请求失败: ' + e.message);
const btn = event.target;
if (btn) { btn.textContent = '改主'; btn.disabled = false; }
}
}
async function deleteShopItem(id) {
if (!await glassConfirm('确定下架商品 #' + id + ' ?')) return;
fetch('api/land_api.php?action=delete_shop_item', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'id=' + id + '&secret=' + SECRET + ''
}).then(r => r.json()).then(d => {
if (d.success) loadLands(document.getElementById('C'));
else glassAlert('失败: ' + (d.error||''));
});
}
// ==================== 用户组管理 ====================
// 权限名称映射(中文名→Java key)
const PERM_NAMES = {
denyBlockBreak: '破坏方块', denyBlockPlace: '放置方块', denySignEdit: '告示牌编辑', denyPVP: 'PvP',
denyFireSpread: '火势蔓延', denyExplosion: '爆炸', denyMobGrief: '怪物破坏',
denyMobAttack: '怪物攻击', denyLeavesDecay: '树叶消退', denyWeather: '天气影响',
denyCropTrample: '踩踏作物', denyItemDrop: '物品丢弃', denyItemPickup: '物品拾取',
denyXpPickup: '经验拾取', denyEnderPearl: '末影珍珠', denyEnderChest: '末影箱',
denyAnvil: '铁砧使用', denyCraftingTable: '工作台', denyFurnace: '熔炉',
denyBrewing: '炼药锅', denyBeacon: '信标', denyJukebox: '唱片机',
denyNoteblock: '音符盒', denyBed: '床使用', denySpawn: '怪物刷新',
denyProjectileLaunch: '投掷物发射', denyThrownProjectiles: '投掷物',
denyGlowing: '发光效果', denyRedstoneInteraction: '红石交互',
denyDoorInteraction: '门交互', denyNoteblockJukebox: '音符盒/唱片机',
denyLead: '拴绳', denyCropHarvest: '作物收获', denyWoolShear: '剪羊毛',
denyAnimalFeeding: '动物喂养', denyContainer: '容器访问',
denyEffects: '药水效果', denyAllEffects: '禁止所有效果',
isPublicBuilding: '公共建筑设施'
};
async function apiCall(action, params = {}, method = 'GET') {
const url = new URL(LAND_API, window.location.href);
url.searchParams.set('action', action);
url.searchParams.set('secret', SECRET);
let r;
if (method === 'GET') {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
r = await fetch(url);
} else {
const body = new URLSearchParams(params);
body.set('secret', SECRET);
r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body.toString()
});
}
// ★ 2026-10-11 防 HTML 污染:后端异常时可能返回 HTML 错误页,
// 统一转成带状态码与响应片段的友好错误,不再直接 r.json() 抛 "Unexpected token '<'"
const text = await r.text().catch(() => '');
const t = text.replace(/^\uFEFF/, '').trim();
if (!t) {
throw new Error('接口 ' + action + ' 返回空响应 (HTTP ' + r.status + ')');
}
if (t.charAt(0) === '<') {
console.error('[apiCall] ' + action + ' 返回 HTML 而非 JSON, HTTP ' + r.status + ':', t.substring(0, 300));
throw new Error('接口 ' + action + ' 返回了 HTML 页面 (HTTP ' + r.status
+ '),服务器可能出错,请查看后端日志。片段: ' + t.substring(0, 100));
}
try {
return JSON.parse(t);
} catch (e) {
console.error('[apiCall] ' + action + ' JSON 解析失败, HTTP ' + r.status + ':', t.substring(0, 300));
throw new Error('接口 ' + action + ' 返回无效 JSON (HTTP ' + r.status + '): ' + t.substring(0, 100));
}
}
async function loadUserGroups(el) {
el.innerHTML = '<div style="text-align:center;padding:40px;color:var(--dim)">加载中...</div>';
try {
const [res, configRes] = await Promise.all([
apiCall('list_user_groups'),
fetch('api/land_api.php?action=get_config&secret=' + SECRET + '').then(r => r.json()).catch(() => ({config:{}}))
]);
const groups = res.groups || [];
const homeCfg = (configRes.config && configRes.config.max_home_per_player) || '5';
window._homeCfgData = homeCfg;
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px">';
html += '<h2 style="margin:0;color:var(--fg)">👥 用户组管理</h2>';
html += '<div style="display:flex;gap:8px">';
html += `<button onclick="showAddUserGroup()" style="padding:6px 12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px">+ 新建用户组</button>`;
html += `<button onclick="loadUserGroups(document.getElementById('C'))" style="padding:6px 12px;background:var(--card);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px">🔄 刷新</button>`;
html += '</div></div>';
// 统计卡片
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px">';
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:var(--accent)">${groups.length}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">用户组总数</div>
</div>`;
// 全局传送点(home)上限卡片
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;text-align:center">
<div style="font-size:24px;font-weight:bold;color:#7e57c2">${escAdmHtml(String(homeCfg))}</div>
<div style="font-size:12px;color:var(--dim);margin-top:4px">全局传送点上限(home)</div>
<button onclick="showHomeConfig()" style="margin-top:8px;padding:3px 10px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">⚙️ 修改</button>
</div>`;
html += '</div>';
// 用户组列表
if (groups.length === 0) {
html += '<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">暂无用户组<br><span style="font-size:11px">点击上方"新建用户组"来创建第一个用户组</span></div>';
} else {
for (const g of groups) {
const perms = JSON.parse(g.default_perms || '{}');
const permCount = Object.keys(perms).filter(k => perms[k] === true).length;
const hasCustomPrice = g.land_price_per_sqm >= 0;
const hasCustomMax = g.max_lands >= 0;
html += `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px">`;
html += `<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">`;
html += `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">`;
html += `<span style="font-size:18px;font-weight:bold;color:${escAdmHtml(g.display_color || '#fff')}">${escAdmHtml(g.display_emoji || '👤')} ${escAdmHtml(g.display_name || g.group_name)}</span>`;
html += `<span style="font-size:11px;color:var(--dim);background:var(--bg);padding:2px 8px;border-radius:10px">ID: ${escAdmHtml(g.group_name)}</span>`;
html += `<span style="font-size:11px;color:var(--dim)">优先级: ${g.priority}</span>`;
html += `</div>`;
html += `<div style="display:flex;gap:6px;flex-wrap:wrap">`;
html += `<button onclick="showEditUserGroup('${escAdmHtml(g.group_name)}')" style="padding:4px 10px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">编辑</button>`;
html += `<button onclick="showGroupMembers('${escAdmHtml(g.group_name)}')" style="padding:4px 10px;background:#4caf50;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">成员</button>`;
html += `<button onclick="deleteUserGroup('${escAdmHtml(g.group_name)}')" style="padding:4px 10px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">删除</button>`;
html += `</div>`;
html += `</div>`;
// 详情行
html += `<div style="display:flex;gap:12px;margin-top:8px;flex-wrap:wrap;font-size:12px;color:var(--dim)">`;
html += `<span>💰 ${hasCustomPrice ? g.land_price_per_sqm+'债券/格²' : '默认'}</span>`;
html += `<span>🏗️ ${hasCustomMax ? '最多'+g.max_lands+'块' : '默认'}</span>`;
html += `<span>🔑 ${permCount}项默认权限</span>`;
html += `<span>📦 二手上架: ${(g.shop_max_listings ?? -1) >= 0 ? '最多'+g.shop_max_listings+'件' : '跟随全局'}</span>`;
html += `<span>🛡️ 保底价: ${(g.shop_min_price ?? -1) >= 0 ? g.shop_min_price+'债券' : '跟随全局'}</span>`;
html += `</div>`;
// 默认权限预览
if (permCount > 0) {
const permTags = Object.entries(perms).filter(([,v]) => v === true).map(([k]) => {
const name = PERM_NAMES[k] || k;
return `<span style="display:inline-block;padding:1px 6px;background:rgba(76,175,80,0.15);color:#4caf50;border-radius:4px;font-size:10px;margin:1px">${name}</span>`;
}).join('');
html += `<div style="margin-top:6px;line-height:1.8">${permTags}</div>`;
}
html += `</div>`;
}
}
el.innerHTML = html;
} catch(e) {
console.error('[USERGROUPS] Error:', e);
el.innerHTML = `<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:24px;text-align:center;color:var(--dim)">加载失败: ${escAdmHtml(e.message)}</div>`;
}
}
function showHomeConfig() {
const homeCfg = window._homeCfgData || '5';
const html = `<div style="padding:16px">
<h3 style="margin:0 0 12px;color:var(--fg)">⚙️ 全局传送点上限配置</h3>
<div style="margin-bottom:12px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">每名玩家最大传送点(home)数量</label>
<input id="cfgMaxHome" type="number" value="${escAdmHtml(String(homeCfg))}" min="1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<p style="font-size:11px;color:var(--dim);margin:0 0 12px;line-height:1.6">说明:该值为全局默认上限。若用户组配置了独立的Home上限(&gt;0),则用户组上限覆盖此值;若用户组Home上限=0,则跟随此全局值;若用户组Home上限=-1,则该组玩家无限制。</p>
<button onclick="saveHomeConfig()" style="width:100%;padding:8px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer">保存</button>
</div>`;
showModal('全局传送点上限', '', null, html);
}
async function saveHomeConfig() {
const maxHome = document.getElementById('cfgMaxHome')?.value || '5';
try {
await fetch('api/land_api.php?action=update_config', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'key=max_home_per_player&value=' + encodeURIComponent(maxHome) + '&secret=' + SECRET + ''
});
document.querySelector('.modal-close')?.click();
loadUserGroups(document.getElementById('C'));
} catch(e) {
glassAlert('保存失败: ' + e.message);
}
}
function showAddUserGroup() {
const html = `<div style="padding:16px">
<h3 style="margin:0 0 12px;color:var(--fg)">+ 新建用户组</h3>
<div style="margin-bottom:10px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">组ID(英文标识)</label>
<input id="ugName" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)" placeholder="如 vip, mvp, builder">
</div>
<div style="margin-bottom:10px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">显示名称</label>
<input id="ugDisplayName" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)" placeholder="如 VIP玩家">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">显示颜色</label>
<select id="ugColor" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
<option value="§f">白色</option><option value="§a">绿色</option><option value="§b">青色</option>
<option value="§e">黄色</option><option value="§6">金色</option><option value="§c">红色</option>
<option value="§5">紫色</option><option value="§9">蓝色</option><option value="§d">粉色</option>
</select>
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">优先级</label>
<input id="ugPriority" type="number" value="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">领地单价(债券/格², -1=默认)</label>
<input id="ugPrice" type="number" value="-1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">最大领地数(-1=默认)</label>
<input id="ugMaxLands" type="number" value="-1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">领地增益效果数量上限(5=普通玩家, &lt;=0=不限)</label>
<input id="ugMaxEffects" type="number" value="5" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">药效等级上限(默认256=原版)</label>
<input id="ugMaxEffectLevel" type="number" value="256" min="1" max="256" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">二手同时上架数上限(-1=跟随全局)</label>
<input id="ugShopMaxListings" type="number" value="-1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">二手保底价格(债券, -1=跟随全局)</label>
<input id="ugShopMinPrice" type="number" value="-1" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="font-size:11px;color:var(--dim);margin-bottom:10px">普通玩家按各效果的生存可获取上限封顶(如抗性提升最高 2 级、海豚的恩惠最高 1 级),严禁超限。填 256 表示不额外限制。</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">Home上限(0=跟随默认)</label>
<input id="ugHomeLimit" type="number" value="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">有效期(分钟, 0=永久)</label>
<input id="ugDuration" type="number" value="0" min="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">加入价格(债券, 0=免费)</label>
<input id="ugJoinPrice" type="number" value="0" min="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">续费价格(债券, 0=免费)</label>
<input id="ugRenewPrice" type="number" value="0" min="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>
</div>
<div style="margin-bottom:10px">
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);cursor:pointer">
<input type="checkbox" id="ugAutoRenew" style="accent-color:#4caf50;width:14px;height:14px">
启用自动续费(到期时从债券余额扣除续费价格自动延长)
</label>
</div>
<button onclick="doAddUserGroup()" style="width:100%;padding:8px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer">创建用户组</button>
</div>`;
showModal('新建用户组', '', null, html);
}
async function doAddUserGroup() {
const name = document.getElementById('ugName')?.value?.trim();
const displayName = document.getElementById('ugDisplayName')?.value?.trim() || name;
const color = document.getElementById('ugColor')?.value || '§f';
const priority = document.getElementById('ugPriority')?.value || '0';
const price = document.getElementById('ugPrice')?.value || '-1';
const maxLands = document.getElementById('ugMaxLands')?.value || '-1';
const maxEffects = document.getElementById('ugMaxEffects')?.value || '5';
const maxEffectLevel = document.getElementById('ugMaxEffectLevel')?.value || '256';
const homeLimit = document.getElementById('ugHomeLimit')?.value || '0';
const joinPrice = document.getElementById('ugJoinPrice')?.value || '0';
const renewPrice = document.getElementById('ugRenewPrice')?.value || '0';
const duration = document.getElementById('ugDuration')?.value || '0';
const autoRenew = document.getElementById('ugAutoRenew')?.checked ? '1' : '0';
const shopMaxListings = document.getElementById('ugShopMaxListings')?.value || '-1';
const shopMinPrice = document.getElementById('ugShopMinPrice')?.value || '-1';
if (!name || !/^[a-zA-Z0-9_]{2,20}$/.test(name)) {
glassAlert('组ID仅允许英文字母、数字和下划线,2-20位');
return;
}
try {
const res = await apiCall('update_user_group', {
name, display_name: displayName, display_color: color,
priority, land_price_per_sqm: price, max_lands: maxLands, max_effects: maxEffects, max_effect_level: maxEffectLevel,
home_limit: homeLimit, join_price: joinPrice, renew_price: renewPrice,
duration_minutes: duration, auto_renew: autoRenew,
shop_max_listings: shopMaxListings, shop_min_price: shopMinPrice,
default_perms: '{}'
}, 'POST');
if (res.success) {
document.querySelector('.modal-close')?.click();
loadUserGroups(document.getElementById('C'));
} else {
glassAlert('创建失败: ' + (res.error || ''));
}
} catch(e) {
glassAlert('创建失败: ' + e.message);
}
}
async function showEditUserGroup(groupName) {
try {
const res = await apiCall('get_user_group', {name: groupName});
if (!res.success || !res.group) { glassAlert('获取失败: ' + (res.error || '')); return; }
const g = res.group;
const perms = JSON.parse(g.default_perms || '{}');
let html = `<div style="padding:16px;max-height:60vh;overflow-y:auto">`;
html += `<h3 style="margin:0 0 12px;color:var(--fg)">编辑用户组: ${escAdmHtml(g.group_name)}</h3>`;
html += `<div style="margin-bottom:10px">
<label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">显示名称</label>
<input id="eugDisplayName" value="${escAdmHtml(g.display_name || '')}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">显示颜色</label>
<select id="eugColor" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
${['§f|白色','§a|绿色','§b|青色','§e|黄色','§6|金色','§c|红色','§5|紫色','§9|蓝色','§d|粉色'].map(c => {
const [v,l] = c.split('|');
return `<option value="${v}" ${g.display_color === v ? 'selected' : ''}>${l}</option>`;
}).join('')}
</select></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">优先级</label>
<input id="eugPriority" type="number" value="${g.priority}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">领地单价(债券/格²)</label>
<input id="eugPrice" type="number" value="${g.land_price_per_sqm}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">最大领地数</label>
<input id="eugMaxLands" type="number" value="${g.max_lands}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">领地增益效果数量上限(&lt;=0=不限)</label>
<input id="eugMaxEffects" type="number" value="${g.max_effects ?? 5}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">药效等级上限(默认256=原版)</label>
<input id="eugMaxEffectLevel" type="number" min="1" max="256" value="${(g.max_effect_level > 0 ? g.max_effect_level : 256)}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="font-size:11px;color:var(--dim);margin:-4px 0 12px 0">说明:普通玩家(未加入任何用户组)按各效果的<b>生存可获取上限</b>封顶,严禁超限(如抗性提升最高 2 级、海豚的恩惠最高 1 级)。加入本组后可在此基础上放宽,填 256 表示不额外限制,但仍不会突破生存上限。</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">Home上限(0=默认)</label>
<input id="eugHomeLimit" type="number" value="${g.home_limit || 0}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">有效期(分钟, 0=永久)</label>
<input id="eugDuration" type="number" value="${g.duration_minutes || 0}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">加入价格(债券)</label>
<input id="eugJoinPrice" type="number" value="${g.join_price || 0}" min="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">续费价格(债券)</label>
<input id="eugRenewPrice" type="number" value="${g.renew_price || 0}" min="0" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">二手同时上架数上限(-1=跟随全局)</label>
<input id="eugShopMaxListings" type="number" value="${(g.shop_max_listings ?? -1)}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
<div><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:4px">二手保底价格(债券, -1=跟随全局)</label>
<input id="eugShopMinPrice" type="number" value="${(g.shop_min_price ?? -1)}" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)"></div>
</div>`;
html += `<div style="margin-bottom:12px">
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);cursor:pointer">
<input type="checkbox" id="eugAutoRenew" ${g.auto_renew == 1 ? 'checked' : ''} style="accent-color:#4caf50;width:14px;height:14px">
启用自动续费
</label>
</div>`;
// 默认权限列表
html += `<div style="margin-bottom:12px"><label style="display:block;font-size:12px;color:var(--dim);margin-bottom:6px">默认权限</label>`;
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:4px">';
for (const [key, label] of Object.entries(PERM_NAMES)) {
const checked = perms[key] === true;
html += `<label style="display:flex;align-items:center;gap:4px;padding:4px 6px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;${checked ? 'background:rgba(76,175,80,0.15);border-color:#4caf50;color:#4caf50' : 'color:var(--dim)'}">
<input type="checkbox" data-perm="${key}" ${checked ? 'checked' : ''} style="accent-color:#4caf50;width:12px;height:12px">
${label}
</label>`;
}
html += '</div></div>';
html += `<button onclick="doEditUserGroup('${escAdmHtml(g.group_name)}')" style="width:100%;padding:8px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer">保存修改</button>`;
html += '</div>';
showModal('编辑用户组', '', null, html);
} catch(e) {
glassAlert('加载失败: ' + e.message);
}
}
async function doEditUserGroup(groupName) {
const displayName = document.getElementById('eugDisplayName')?.value?.trim() || '';
const color = document.getElementById('eugColor')?.value || '§f';
const priority = document.getElementById('eugPriority')?.value || '0';
const price = document.getElementById('eugPrice')?.value || '-1';
const maxLands = document.getElementById('eugMaxLands')?.value || '-1';
const maxEffects = document.getElementById('eugMaxEffects')?.value || '5';
const maxEffectLevel = document.getElementById('eugMaxEffectLevel')?.value || '256';
const homeLimit = document.getElementById('eugHomeLimit')?.value || '0';
const duration = document.getElementById('eugDuration')?.value || '0';
const joinPrice = document.getElementById('eugJoinPrice')?.value || '0';
const renewPrice = document.getElementById('eugRenewPrice')?.value || '0';
const autoRenew = document.getElementById('eugAutoRenew')?.checked ? '1' : '0';
const shopMaxListings = document.getElementById('eugShopMaxListings')?.value || '-1';
const shopMinPrice = document.getElementById('eugShopMinPrice')?.value || '-1';
// 收集权限
const perms = {};
document.querySelectorAll('#glassAlertOverlay input[type=checkbox][data-perm]').forEach(cb => {
perms[cb.dataset.perm] = cb.checked;
});
try {
const res = await apiCall('update_user_group', {
name: groupName, display_name: displayName, display_color: color,
priority, land_price_per_sqm: price, max_lands: maxLands, max_effects: maxEffects, max_effect_level: maxEffectLevel,
home_limit: homeLimit, duration_minutes: duration,
join_price: joinPrice, renew_price: renewPrice, auto_renew: autoRenew,
shop_max_listings: shopMaxListings, shop_min_price: shopMinPrice,
default_perms: JSON.stringify(perms)
}, 'POST');
if (res.success) {
document.querySelector('.modal-close')?.click();
loadUserGroups(document.getElementById('C'));
} else {
glassAlert('保存失败: ' + (res.error || ''));
}
} catch(e) {
glassAlert('保存失败: ' + e.message);
}
}
async function deleteUserGroup(groupName) {
if (!await glassConfirm('确定删除用户组 [' + groupName + '] ?\n已分配的成员将被移出该组。')) return;
try {
const res = await apiCall('delete_user_group', {name: groupName});
if (res.success) {
loadUserGroups(document.getElementById('C'));
} else {
glassAlert('删除失败: ' + (res.error || ''));
}
} catch(e) {
glassAlert('删除失败: ' + e.message);
}
}
// ===== 用户组成员管理 =====
async function showGroupMembers(groupName) {
try {
const [membersRes, groupRes] = await Promise.all([
apiCall('list_group_members', {group: groupName}),
apiCall('get_user_group', {name: groupName})
]);
const members = membersRes.members || [];
const g = groupRes.group || {};
const permCount = Object.keys(JSON.parse(g.default_perms || '{}')).filter(k => JSON.parse(g.default_perms || '{}')[k] === true).length;
let html = `<div style="padding:16px;max-height:60vh;overflow-y:auto">`;
html += `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">`;
html += `<h3 style="margin:0;color:var(--fg)">${escAdmHtml(g.display_emoji || '👥')} ${escAdmHtml(g.display_name || groupName)} 成员管理</h3>`;
html += `<span style="font-size:12px;color:var(--dim)">${members.length} 人</span>`;
html += `</div>`;
// 添加成员
html += `<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="ugNewMember" placeholder="玩家名" style="flex:1;min-width:120px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
<input id="ugNewMemberExpiry" placeholder="例如 30 或 1小时 或 2026-07-03 23:00:00" style="flex:1;min-width:200px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg)">
<button onclick="doAddGroupMember('${escAdmHtml(groupName)}')" style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px">添加</button>
</div>
<div style="font-size:11px;color:var(--dim);margin-bottom:8px">到期留空则按用户组设置的duration_minutes自动计算,支持: 数字(分钟)/1小时/30天/2026-07-03 23:00:00</div>`;
// 成员列表
if (members.length === 0) {
html += '<div style="text-align:center;padding:20px;color:var(--dim);font-size:13px">暂无成员</div>';
} else {
html += '<div style="overflow-x:auto"><table class="table"><tr><th>玩家名</th><th>到期时间</th><th>添加者</th><th>操作</th></tr>';
for (const m of members) {
const expiry = m.expiry_time ? parseInt(m.expiry_time) : 0;
let expiryStr = '-';
if (expiry > 0) {
expiryStr = new Date(expiry * 1000).toLocaleString('zh-CN');
} else {
expiryStr = '<span style="color:var(--dim)">按组期限</span>';
}
html += `<tr>
<td><strong>${escAdmHtml(m.player_name)}</strong></td>
<td style="font-size:12px;color:var(--dim)">${expiryStr}</td>
<td style="font-size:12px;color:var(--dim)">${escAdmHtml(m.added_by || '-')}</td>
<td><button onclick="doRemoveGroupMember('${escAdmHtml(groupName)}','${escAdmHtml(m.player_name)}')" style="padding:2px 8px;background:#f44336;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px">移除</button></td>
</tr>`;
}
html += '</table></div>';
}
html += '</div>';
showModal('成员管理', '', null, html);
} catch(e) {
glassAlert('加载失败: ' + e.message);
}
}
async function doAddGroupMember(groupName) {
const inputEl = document.getElementById('ugNewMember');
const expiryEl = document.getElementById('ugNewMemberExpiry');
const player = inputEl ? inputEl.value.trim() : '';
if (!player) { glassAlert('请输入玩家名'); return; }
if (!/^[a-zA-Z0-9_]{3,16}$/.test(player)) { glassAlert('玩家名格式不正确,仅支持英文字母、数字和下划线(3-16位)'); return; }
// 解析到期时间:支持多种格式 → 转换为 Unix 时间戳
const expiryRaw = expiryEl ? expiryEl.value.trim() : '';
let expiryTime = 0;
if (expiryRaw) {
// 1. 纯数字 → 时间戳或分钟数
if (/^\d{10,}$/.test(expiryRaw)) {
expiryTime = parseInt(expiryRaw); // 时间戳
} else if (/^\d+$/.test(expiryRaw)) {
expiryTime = Math.floor(Date.now() / 1000) + parseInt(expiryRaw) * 60; // 分钟数
} else {
// 2. 中文格式: 1小时, 30天, 2个月, 1周, 30分钟, 2分
const cnMatch = expiryRaw.match(/^(\d+)\s*(秒|分|分钟|小时|天|周|月|年)$/);
if (cnMatch) {
const num = parseInt(cnMatch[1]);
const unit = cnMatch[2];
let secs = 0;
if (unit === '秒') secs = num;
else if (unit === '分' || unit === '分钟') secs = num * 60;
else if (unit === '小时') secs = num * 3600;
else if (unit === '天') secs = num * 86400;
else if (unit === '周') secs = num * 604800;
else if (unit === '月') secs = num * 2592000;
else if (unit === '年') secs = num * 31536000;
expiryTime = Math.floor(Date.now() / 1000) + secs;
} else {
// 3. 标准日期格式: yyyy-MM-dd HH:mm:ss 或 yyyy/MM/dd HH:mm:ss
const dateMatch = expiryRaw.replace('/', '-').match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):?(\d{2})?$/);
if (dateMatch) {
expiryTime = Math.floor(new Date(expiryRaw.replace('/', '-')).getTime() / 1000);
} else {
glassAlert('到期时间格式不支持,请填入: 数字(分钟)/1小时/30天/2026-07-03 23:00:00');
return;
}
}
}
}
try {
const res = await apiCall('add_group_member', {group: groupName, player, expiry_time: expiryTime}, 'POST');
if (res.success) {
// pending=true 是异步验证响应,需要特殊处理
if (res.pending) {
glassAlert(res.message || '验证请求已提交,系统将在1-2分钟内自动完成验证');
showGroupMembers(groupName); // 刷新
} else {
showGroupMembers(groupName); // 刷新
}
} else {
glassAlert(res.error || '添加失败');
}
} catch(e) {
glassAlert('添加失败: ' + e.message);
}
}
async function doRemoveGroupMember(groupName, playerName) {
if (!await glassConfirm('确定将玩家 [' + playerName + '] 从用户组 [' + groupName + '] 中移除?')) return;
try {
const res = await apiCall('remove_group_member', {group: groupName, player: playerName}, 'POST');
if (res.success) {
showGroupMembers(groupName); // 刷新
} else {
glassAlert('移除失败: ' + (res.error || ''));
}
} catch(e) {
glassAlert('移除失败: ' + e.message);
}
}
</script>
<!-- 毛玻璃弹窗 -->
<div class="glass-alert-overlay" id="glassAlertOverlay" onclick="glassAlertResolve(false)">
<div class="glass-alert-card" onclick="event.stopPropagation()">
<div class="alert-icon" id="glassAlertIcon">⚠️</div>
<div class="alert-msg" id="glassAlertMsg"></div>
<div class="alert-label" id="glassAlertLabel" style="display:none"></div>
<input class="alert-input" id="glassAlertInput" style="display:none" onkeydown="if(event.key==='Enter')glassAlertResolve(true)">
<div class="alert-hint" id="glassAlertHint" style="display:none"></div>
<div class="alert-btns" id="glassAlertBtns">
<button class="ag-ok" onclick="glassAlertResolve(true)">确定</button>
</div>
</div>
</div>
<script>
let _glassAlertResolve = null;
function glassAlertResolve(val) {
const overlay = document.getElementById('glassAlertOverlay');
overlay.classList.remove('show');
if (_glassAlertResolve) {
const input = document.getElementById('glassAlertInput');
// 如果输入框可见且点了确定,返回输入值
const result = (val && input.style.display !== 'none') ? input.value : val;
_glassAlertResolve(result);
_glassAlertResolve = null;
}
}
function glassAlert(msg, icon = '⚠️') {
return new Promise(resolve => {
_glassAlertResolve = resolve;
document.getElementById('glassAlertIcon').textContent = icon;
document.getElementById('glassAlertMsg').textContent = msg;
document.getElementById('glassAlertMsg').style.display = '';
document.getElementById('glassAlertLabel').style.display = 'none';
document.getElementById('glassAlertInput').style.display = 'none';
document.getElementById('glassAlertHint').style.display = 'none';
document.getElementById('glassAlertBtns').innerHTML = '<button class="ag-ok" onclick="glassAlertResolve(true)">确定</button>';
document.getElementById('glassAlertOverlay').classList.add('show');
});
}
function glassConfirm(msg, icon = '❓') {
return new Promise(resolve => {
_glassAlertResolve = resolve;
document.getElementById('glassAlertIcon').textContent = icon;
document.getElementById('glassAlertMsg').textContent = msg;
document.getElementById('glassAlertMsg').style.display = '';
document.getElementById('glassAlertLabel').style.display = 'none';
document.getElementById('glassAlertInput').style.display = 'none';
document.getElementById('glassAlertHint').style.display = 'none';
document.getElementById('glassAlertBtns').innerHTML = '<button class="ag-cancel" onclick="glassAlertResolve(false)">取消</button><button class="ag-ok" onclick="glassAlertResolve(true)">确定</button>';
document.getElementById('glassAlertOverlay').classList.add('show');
});
}
function glassPrompt(label, currentVal, hint = '') {
return new Promise(resolve => {
_glassAlertResolve = resolve;
document.getElementById('glassAlertIcon').textContent = '✏️';
document.getElementById('glassAlertMsg').style.display = 'none';
document.getElementById('glassAlertLabel').textContent = label;
document.getElementById('glassAlertLabel').style.display = '';
const input = document.getElementById('glassAlertInput');
input.value = currentVal;
input.style.display = '';
input.selectionStart = input.selectionEnd = input.value.length;
const hintEl = document.getElementById('glassAlertHint');
if (hint) { hintEl.textContent = '💡 ' + hint; hintEl.style.display = ''; }
else { hintEl.style.display = 'none'; }
document.getElementById('glassAlertBtns').innerHTML = '<button class="ag-cancel" onclick="glassAlertResolve(false)">取消</button><button class="ag-ok" onclick="glassAlertResolve(true)">确定</button>';
document.getElementById('glassAlertOverlay').classList.add('show');
setTimeout(() => input.focus(), 100);
});
}
// ===== 收银员管理(独立收银台账号)=====
function openCashierPage() {
window.open('cashier.php', '_blank');
}
// ===== 充值订单对账(内嵌标签页)=====
let _roOrders = [];
function _roCloseDtl() {
const el = document.getElementById('roDtlOl');
if (el) el.classList.remove('show');
}
async function loadRechargeOrders(el) {
el.innerHTML = `
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2>💳 充值订单对账</h2>
<button class="btn btn-green" onclick="loadRechargeOrders(document.getElementById('C'))">🔄 刷新</button>
</div>
<div class="ro-stats">
<div class="ro-sc ro-b"><div class="ro-v" id="roSt">-</div><div class="ro-l">总订单</div></div>
<div class="ro-sc ro-g"><div class="ro-v" id="roSp">-</div><div class="ro-l">已支付</div></div>
<div class="ro-sc ro-y"><div class="ro-v" id="roSc">-</div><div class="ro-l">待支付</div></div>
<div class="ro-sc ro-r"><div class="ro-v" id="roSm">-</div><div class="ro-l">总金额(元)</div></div>
</div>
<div class="ro-srch">
<input type="text" id="roPl" placeholder="玩家名..." oninput="_roLoad()">
<input type="text" id="roOn" placeholder="订单号..." oninput="_roLoad()">
<select id="roSts" onchange="_roLoad()">
<option value="">全部状态</option>
<option value="paid">已支付</option>
<option value="created">待支付</option>
</select>
<select id="roLim" onchange="_roLoad()">
<option value="30">30条</option>
<option value="50" selected>50条</option>
<option value="100">100条</option>
<option value="200">200条</option>
</select>
<button class="btn btn-blue" onclick="_roReconcile()">🔄 立即对账</button>
<button class="btn btn-yellow" onclick="_roSyncPlatform()" style="margin-left:4px">⬇️ 从平台同步</button>
</div>
<div id="roList"><div class="ro-empty">加载中...</div></div>
</div>`;
// 创建详情毛玻璃弹窗
if (!document.getElementById('roDtlOl')) {
const d = document.createElement('div');
d.id = 'roDtlOl'; d.className = 'ro-dtl-ol';
d.onclick = function(e) { if (e.target === this) _roCloseDtl(); };
d.innerHTML = '<div class="ro-dtl-cd" onclick="event.stopPropagation()"><h2>订单详情</h2><div id="roDtlCt"></div><div style="margin-top:14px;text-align:right"><button class="btn btn-ghost" onclick="_roCloseDtl()">关闭</button></div></div>';
document.body.appendChild(d);
}
_roLoad();
}
async function _roLoad() {
const el = document.getElementById('roList');
if (!el) return;
const pl = document.getElementById('roPl').value.trim();
const on = document.getElementById('roOn').value.trim();
const sts = document.getElementById('roSts').value;
const lim = document.getElementById('roLim').value;
try {
const p = new URLSearchParams({action:'list', limit:lim});
if (pl) p.append('player', pl);
if (on) p.append('order_no', on);
if (sts) p.append('status', sts);
const r = await fetch('api/recharge_orders_api.php?'+p.toString());
const d = await r.json();
if (d.error || d.success === false) { el.innerHTML = '<div class="ro-empty">加载失败: '+(d.error?.message||d.message||'未知错误')+'</div>'; return; }
const payload = d.data || d;
_roOrders = payload.orders || [];
const st = payload.stats || {};
document.getElementById('roSt').textContent = st.total||0;
document.getElementById('roSp').textContent = st.paid||0;
document.getElementById('roSc').textContent = st.created||0;
document.getElementById('roSm').textContent = st.totalMoney||'0.00';
if (!_roOrders.length) { el.innerHTML = '<div class="ro-empty">暂无订单</div>'; return; }
el.innerHTML = _roOrders.map(o => {
const tag = o.status==='paid' ? '<span class="ro-tag ro-tag-p">已支付</span>' : '<span class="ro-tag ro-tag-c">待支付</span>';
const ct = new Date(o.created_at*1000).toLocaleString('zh-CN');
const pt = o.paid_at ? new Date(o.paid_at*1000).toLocaleString('zh-CN') : '-';
return `<div class="ro-row" onclick="_roDtl('${o.out_trade_no}')">
<div class="ro-t"><span class="ro-no">${o.out_trade_no}</span>${tag}</div>
<div class="ro-i">玩家: ${o.player_name} | ¥${o.money} | ${o.bond_amount}债券</div>
<div class="ro-i" style="margin-top:2px;font-size:10px;color:var(--purple)">创建: ${ct} | 支付: ${pt}</div>
</div>`;
}).join('');
} catch(e) { el.innerHTML = '<div class="ro-empty">请求失败: '+e.message+'</div>'; }
}
function _roDtl(no) {
const o = _roOrders.find(x => x.out_trade_no === no);
if (!o) return;
const ct = new Date(o.created_at*1000).toLocaleString('zh-CN');
const pt = o.paid_at ? new Date(o.paid_at*1000).toLocaleString('zh-CN') : '-';
document.getElementById('roDtlCt').innerHTML = `
<div class="ro-dl"><span>订单号</span><span style="color:var(--accent)">${o.out_trade_no}</span></div>
<div class="ro-dl"><span>玩家</span><span>${o.player_name}</span></div>
<div class="ro-dl"><span>金额</span><span style="color:var(--green)">¥${o.money}</span></div>
<div class="ro-dl"><span>债券</span><span style="color:var(--yellow)">${o.bond_amount}</span></div>
<div class="ro-dl"><span>状态</span><span>${o.status==='paid'?'✅ 已支付':'⏳ 待支付'}</span></div>
<div class="ro-dl"><span>平台交易号</span><span>${o.trade_no||'-'}</span></div>
<div class="ro-dl"><span>创建</span><span>${ct}</span></div>
<div class="ro-dl"><span>支付</span><span>${pt}</span></div>
<div class="ro-dl"><span>商品</span><span>${o.name||'-'}</span></div>
<div class="ro-dl"><span>签名</span><span style="font-size:10px;color:var(--dim);word-break:break-all">${o.platform_sign||'-'}</span></div>`;
document.getElementById('roDtlOl').classList.add('show');
}
async function _roReconcile() {
if (!await glassConfirm('立即对账将查询支付平台已支付订单并补写流水,是否继续?')) return;
try {
const r = await fetch('api/recharge_orders_api.php?action=reconcile');
const d = await r.json();
// poller_online.php返回格式:{result: 'ok', processed, skipped} 或 {result: 'already_running'} 或 {error: '...'}
if (d.error) {
glassAlert('对账失败: '+(d.detail||d.error));
return;
}
if (d.result === 'already_running') {
glassAlert('另一个对账进程正在运行,请稍后重试');
return;
}
if (d.result === 'ok') {
glassAlert('对账完成!处理 '+(d.processed||0)+' 笔,跳过 '+(d.skipped||0)+' 笔');
_roLoad();
} else if (d.result === 'no_paid_orders_on_platform') {
glassAlert('支付平台无已支付订单');
} else if (d.result === 'no_pending_orders') {
glassAlert('所有订单已处理,无需补单');
} else {
glassAlert('对账结果: '+(d.detail||d.result||'未知'));
}
} catch(e) { glassAlert('对账请求失败: '+e.message); }
}
async function _roSyncPlatform() {
if (!await glassConfirm('从支付平台同步所有已支付订单到本地数据库,同时写入充值流水供Java拉取。是否继续?')) return;
try {
const r = await fetch('api/recharge_orders_api.php?action=sync_platform');
const d = await r.json();
if (d.success === false) {
glassAlert('同步失败: '+(d.message||'未知错误'));
return;
}
if (d.success) {
const info = d.data || {};
glassAlert('同步完成!平台共 '+(info.platform_total||0)+' 笔,新同步 '+(info.synced||0)+' 笔,已存在 '+(info.already_paid||0)+' 笔');
_roLoad();
} else {
glassAlert('同步失败: '+(d.message||'未知错误'));
}
} catch(e) { glassAlert('同步请求失败: '+e.message); }
}
document.addEventListener('keydown', function(e) { if (e.key==='Escape') _roCloseDtl(); });
// ===== 充值商店配置 =====
async function loadShopConfig(el) {
el.innerHTML = `
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2>🛒 充值商店配置</h2>
<div>
<button class="btn btn-green" onclick="showShopConfigForm()">➕ 新增配置</button>
<button class="btn btn-blue" onclick="loadShopConfigData()" style="margin-left:4px">🔄 刷新</button>
</div>
</div>
<div id="shopConfigList"><div class="ro-empty">加载中...</div></div>
</div>
<div id="shopConfigFormOverlay" class="ro-dtl-ol" onclick="if(event.target===this)closeShopConfigForm()">
<div class="ro-dtl-cd" onclick="event.stopPropagation()" style="max-width:500px">
<h2 id="shopConfigFormTitle">新增配置</h2>
<input type="hidden" id="scfId">
<div class="form-row"><label>商品名称</label><input id="scfName" placeholder="如:债券充值包"></div>
<div class="form-row"><label>库存</label><input id="scfStock" type="text" inputmode="numeric" value="-1" placeholder="-1=无限, 0=售罄, ≥0=数量"></div>
<div class="form-row"><label>临时优惠</label><input id="scfOffer" placeholder="如:限时8折"></div>
<div class="form-row"><label>优惠有效期</label><input id="scfExpire" placeholder="yyyy-MM-dd 或 2026年7月16日"></div>
<div class="form-row"><label>售价(元)</label><input id="scfPrice" type="number" min="0.01" step="0.01" placeholder="0.01"></div>
<div class="form-row"><label>优惠后价格</label><input id="scfDiscountPrice" type="text" inputmode="decimal" placeholder="留空=无优惠, 0.9=9折, 1.5=直接价格"></div>
<div style="font-size:11px;color:var(--dim);margin-top:4px">⚠️ 优惠后价格必须小于原价,且不低于0.01元。售价低于0.01元的商品不支持折扣。</div>
<div class="form-row"><label>到账债券</label><input id="scfBond" placeholder="如:1-10 或 100"></div>
<div class="form-row"><label>上下架</label><select id="scfActive"><option value="1">上架</option><option value="0">下架</option></select></div>
<div style="margin-top:14px;text-align:right">
<button class="btn btn-ghost" onclick="closeShopConfigForm()">取消</button>
<button class="btn btn-green" onclick="saveShopConfig()" style="margin-left:4px">保存</button>
</div>
</div>
</div>`;
loadShopConfigData();
}
async function loadShopConfigData() {
const el = document.getElementById('shopConfigList');
if (!el) return;
try {
const r = await fetch('api/recharge_orders_api.php?action=shop_config&method=list');
const d = await r.json();
if (d.error || d.success === false) { el.innerHTML = '<div class="ro-empty">加载失败: '+(d.error?.message||d.message||'未知错误')+'</div>'; return; }
const configs = d.data || [];
if (!configs.length) { el.innerHTML = '<div class="ro-empty">暂无配置,点击"新增配置"添加</div>'; return; }
el.innerHTML = `<table class="table"><tr><th>ID</th><th>商品名称</th><th>库存</th><th>售价</th><th>债券范围</th><th>临时优惠</th><th>有效期</th><th>状态</th><th>操作</th></tr>
${configs.map(c => {
const stockText = c.stock === -1 ? '∞无限' : (c.stock === 0 ? '<span style="color:var(--red)">售罄</span>' : c.stock);
const activeTag = c.is_active ? '<span style="color:var(--green)">上架</span>' : '<span style="color:var(--red)">下架</span>';
const offerText = c.temporary_offer ? `${c.temporary_offer} (${c.expire_display})` : '-';
return `<tr>
<td>${c.id}</td>
<td>${c.item_name||'-'}</td>
<td>${stockText}</td>
<td>¥${c.price}</td>
<td>${c.bond_reward}</td>
<td>${offerText}</td>
<td>${c.expire_display}</td>
<td>${activeTag}</td>
<td>
<button class="btn btn-blue" style="padding:4px 10px;font-size:11px" onclick="editShopConfig(${c.id})">编辑</button>
<button class="btn btn-yellow" style="padding:4px 10px;font-size:11px" onclick="toggleShopConfig(${c.id})">${c.is_active?'下架':'上架'}</button>
<button class="btn btn-red" style="padding:4px 10px;font-size:11px" onclick="deleteShopConfig(${c.id})">删除</button>
</td>
</tr>`;
}).join('')}
</table>`;
} catch(e) { el.innerHTML = '<div class="ro-empty">请求失败: '+e.message+'</div>'; }
}
function showShopConfigForm(config) {
document.getElementById('shopConfigFormTitle').textContent = config ? '编辑配置' : '新增配置';
document.getElementById('scfId').value = config ? config.id : '';
document.getElementById('scfName').value = config ? (config.item_name||'') : '';
document.getElementById('scfStock').value = config ? config.stock : -1;
document.getElementById('scfOffer').value = config ? (config.temporary_offer||'') : '';
document.getElementById('scfExpire').value = config ? (config.offer_expire||'') : '';
document.getElementById('scfPrice').value = config ? config.price : '';
// 优惠后价格:如果原价<1认为是折扣,否则是直接价格
if (config && config.discount_price > 0) {
const ratio = config.discount_price / (config.price || 1);
// 如果是整数倍(如原价10,优惠价5),显示折扣形式
if (ratio < 1 && ratio > 0) {
document.getElementById('scfDiscountPrice').value = ratio;
} else {
document.getElementById('scfDiscountPrice').value = config.discount_price;
}
} else {
document.getElementById('scfDiscountPrice').value = '';
}
document.getElementById('scfBond').value = config ? config.bond_reward : '1-1';
document.getElementById('scfActive').value = config ? config.is_active : 1;
document.getElementById('shopConfigFormOverlay').classList.add('show');
}
function closeShopConfigForm() {
document.getElementById('shopConfigFormOverlay').classList.remove('show');
}
async function editShopConfig(id) {
try {
const r = await fetch('api/recharge_orders_api.php?action=shop_config&method=list');
const d = await r.json();
const configs = d.data || [];
const config = configs.find(c => c.id === id);
if (config) showShopConfigForm(config);
else glassAlert('配置不存在');
} catch(e) { glassAlert('加载配置失败: '+e.message); }
}
async function saveShopConfig() {
const id = document.getElementById('scfId').value;
const name = document.getElementById('scfName').value.trim();
// ★ 库存严格校验:空=-1(无限),0=售罄(合法),≥0=数量;非法输入明确报错,绝不静默转-1
const stockRaw = document.getElementById('scfStock').value.trim();
let stock;
if (stockRaw === '') {
stock = -1;
} else if (/^-?\d+$/.test(stockRaw)) {
stock = parseInt(stockRaw, 10);
if (stock < -1) { glassAlert('库存不能小于 -1(-1=无限, 0=售罄)'); return; }
} else {
glassAlert('库存必须是数字:-1=无限, 0=售罄, ≥0=数量'); return;
}
const offer = document.getElementById('scfOffer').value.trim();
const expire = document.getElementById('scfExpire').value.trim();
const price = parseFloat(document.getElementById('scfPrice').value) || 0;
const discountPriceRaw = document.getElementById('scfDiscountPrice').value.trim();
const bond = document.getElementById('scfBond').value.trim();
const active = parseInt(document.getElementById('scfActive').value) || 1;
if (!name) { glassAlert('请输入商品名称'); return; }
if (price <= 0) { glassAlert('售价必须大于0'); return; }
if (!/^\d+(-\d+)?$/.test(bond)) { glassAlert('债券范围格式错误,应为数字或范围如"1-10"'); return; }
// 折扣价格验证
if (discountPriceRaw && discountPriceRaw !== '0') {
// 0.01元以下的商品不支持折扣
if (price < 0.01) {
glassAlert('售价低于0.01元的商品不支持折扣');
return;
}
const discountValue = parseFloat(discountPriceRaw);
if (isNaN(discountValue) || discountValue <= 0) {
glassAlert('优惠后价格必须是大于0的数字');
return;
}
// 计算实际折扣价格
let actualDiscount;
if (discountValue < 1) {
// 折扣比例
actualDiscount = price * discountValue;
} else {
// 直接价格
actualDiscount = discountValue;
}
// 硬锁:优惠后价格必须小于原价
if (actualDiscount >= price) {
glassAlert('优惠后价格必须小于原价(当前原价: ' + price.toFixed(2) + '元)');
return;
}
// 硬锁:优惠后价格不能低于0.01元
if (actualDiscount < 0.01) {
glassAlert('优惠后价格不能低于0.01元');
return;
}
}
// 防重复点击:获取按钮并禁用
const btn = event.target;
btn.disabled = true;
btn.textContent = '保存中...';
const params = new URLSearchParams({
action: 'shop_config',
method: 'save',
id: id,
item_name: name,
stock: stock,
temporary_offer: offer,
offer_expire: expire,
price: price,
discount_price: discountPriceRaw,
bond_reward: bond,
is_active: active
});
try {
const r = await fetch('api/recharge_orders_api.php?'+params.toString());
const d = await r.json();
if (d.success) {
glassAlert(id ? '配置已更新' : '配置已添加');
closeShopConfigForm();
loadShopConfigData();
} else {
glassAlert('保存失败: '+(d.message||'未知错误'));
}
} catch(e) { glassAlert('请求失败: '+e.message); }
finally {
btn.disabled = false;
btn.textContent = '保存';
}
}
async function toggleShopConfig(id) {
try {
const r = await fetch('api/recharge_orders_api.php?action=shop_config&method=toggle&id='+id);
const d = await r.json();
if (d.success) {
glassAlert(d.message||'状态已切换');
loadShopConfigData();
} else {
glassAlert('操作失败: '+(d.message||'未知错误'));
}
} catch(e) { glassAlert('请求失败: '+e.message); }
}
async function deleteShopConfig(id) {
if (!await glassConfirm('确定删除此配置?')) return;
try {
const r = await fetch('api/recharge_orders_api.php?action=shop_config&method=delete&id='+id);
const d = await r.json();
if (d.success) {
glassAlert('配置已删除');
loadShopConfigData();
} else {
glassAlert('删除失败: '+(d.message||'未知错误'));
}
} catch(e) { glassAlert('请求失败: '+e.message); }
}
async function loadCashierManage(el) {
el.innerHTML = `<div class="card">
<h2>🧾 收银员管理</h2>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">收银员可登录独立收银台(cashier.php)代玩家购买商品并手动打折,折扣上限在此设置。管理员亦可登录收银台。</p>
<div class="card" style="background:var(--bg)">
<h2 style="font-size:14px;color:var(--green)">+ 新增收银员</h2>
<div class="form-row"><label>账号</label><input id="cmUser" placeholder="3-20位字母/数字/下划线"></div>
<div class="form-row"><label>密码</label><input id="cmPass" type="password" placeholder="登录密码"></div>
<div class="form-row"><label>折扣上限%</label><input id="cmLimit" type="number" min="0" max="100" value="10"></div>
<div class="form-row"><label>现金收款权限</label><select id="cmCash"><option value="0">否(仅债券收款)</option><option value="1">是(可现金收款)</option></select></div>
<button class="btn btn-green" onclick="cmAdd()">创建收银员</button>
</div>
<div id="cmList" style="margin-top:14px">加载中…</div>
</div>`;
await cmLoad();
}
async function cmAdd() {
const username = (document.getElementById('cmUser').value || '').trim();
const password = document.getElementById('cmPass').value || '';
const limit = parseInt(document.getElementById('cmLimit').value || '0', 10);
if (!username || !password) { toast('账号和密码不能为空', 'err'); return; }
if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; }
const canCash = parseInt(document.getElementById('cmCash').value || '0', 10);
try {
const d = await fetch('api/cashier.php?action=add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'username=' + encodeURIComponent(username) + '&password=' + encodeURIComponent(password) + '&discount_limit_percent=' + limit + '&can_cash=' + canCash
}).then(r => r.json());
if (d.success) { toast('收银员已创建', 'ok'); document.getElementById('cmUser').value = ''; document.getElementById('cmPass').value = ''; cmLoad(); }
else toast(d.message || '创建失败', 'err');
} catch (e) { toast(e.message, 'err'); }
}
async function cmLoad() {
const wrap = document.getElementById('cmList');
if (!wrap) return;
try {
const d = await fetch('api/cashier.php?action=cashier_list').then(r => r.json());
if (!d.success) { wrap.innerHTML = '<div style="color:var(--red);font-size:13px">' + escAdmHtml(d.message) + '</div>'; return; }
if (!d.data.length) { wrap.innerHTML = '<div style="color:var(--dim);font-size:13px">暂无收银员</div>'; return; }
wrap.innerHTML = '<table class="table"><tr><th>ID</th><th>账号</th><th>折扣上限</th><th>现金收款</th><th>创建时间</th><th>操作</th></tr>' + d.data.map(c => `
<tr>
<td>${c.id}</td>
<td>${escAdmHtml(c.username)}</td>
<td>${c.discount_limit_percent}%</td>
<td>${c.can_cash ? '✅ 可现金' : '— 仅债券'}</td>
<td style="color:var(--dim)">${c.created_at ? new Date(c.created_at * 1000).toLocaleString('zh-CN') : '-'}</td>
<td>
<button class="btn btn-yellow" style="padding:4px 10px;font-size:11px" onclick="cmEdit(${c.id},'${escAdmHtml(c.username)}',${c.discount_limit_percent},${c.can_cash ? 1 : 0})">编辑</button>
<button class="btn btn-red" style="padding:4px 10px;font-size:11px" onclick="cmDelete(${c.id})">删除</button>
</td>
</tr>`).join('') + '</table>';
} catch (e) { wrap.innerHTML = '<div style="color:var(--red);font-size:13px">' + escAdmHtml(e.message) + '</div>'; }
}
async function cmEdit(id, username, curLimit, curCash) {
const overlay = document.createElement('div');
overlay.className = 'glass-alert-overlay show';
overlay.innerHTML = `<div class="glass-alert-card">
<div class="alert-icon">✏️</div>
<div class="alert-msg">编辑收银员:<b>${escAdmHtml(username)}</b></div>
<div class="alert-label">新密码(留空则不修改)</div>
<input class="alert-input" type="password" id="cmNewPw" placeholder="留空=不修改">
<div class="alert-label">折扣上限(0-100,当前 ${curLimit}%)</div>
<input class="alert-input" type="number" id="cmNewLimit" min="0" max="100" value="${curLimit}">
<div class="alert-label">现金收款权限(当前 ${curCash ? '可现金' : '仅债券'})</div>
<select class="alert-input" id="cmNewCash">
<option value="0"${curCash ? '' : ' selected'}>否(仅债券收款)</option>
<option value="1"${curCash ? ' selected' : ''}>是(可现金收款)</option>
</select>
<div class="alert-btns">
<button class="ag-cancel" onclick="this.closest('.glass-alert-overlay').remove()">取消</button>
<button class="ag-ok" onclick="cmEditSave(${id}, this)">保存</button>
</div>
</div>`;
document.body.appendChild(overlay);
setTimeout(() => document.getElementById('cmNewPw').focus(), 50);
}
async function cmEditSave(id, btn) {
const pw = document.getElementById('cmNewPw').value;
const limit = parseInt(document.getElementById('cmNewLimit').value || '0', 10);
if (limit < 0 || limit > 100) { toast('折扣上限需在0-100', 'err'); return; }
const cash = parseInt(document.getElementById('cmNewCash').value || '0', 10);
const body = new URLSearchParams();
body.set('id', String(id));
if (pw) body.set('password', pw);
body.set('discount_limit_percent', String(limit));
body.set('can_cash', String(cash));
btn.disabled = true;
try {
const d = await fetch('api/cashier.php?action=edit', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }).then(r => r.json());
if (d.success) { toast('已更新', 'ok'); document.querySelector('.glass-alert-overlay.show')?.remove(); cmLoad(); }
else { toast(d.message || '更新失败', 'err'); btn.disabled = false; }
} catch (e) { toast(e.message, 'err'); btn.disabled = false; }
}
async function cmDelete(id) {
if (!confirm('确认删除该收银员?此操作不可恢复。')) return;
try {
const d = await fetch('api/cashier.php?action=delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'id=' + id }).then(r => r.json());
if (d.success) { toast('已删除', 'ok'); cmLoad(); }
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>;快照由 Java 同步/心跳每60秒自动采集(停服时段记0)。</div>
` : `
<div style="text-align:center;color:var(--dim);padding:50px">📊 数据收集中 — Java 同步/心跳每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>'; }
}
// ==================== SN 防伪管理 ====================
const snAdm = { tab: 'items', kw: '', status: '', page: 1, size: 20, sn: '' };
async function snAdmApi(action, params = {}) {
const url = new URL('api/sn.php', location.href);
url.searchParams.set('action', action);
for (const [k, v] of Object.entries(params)) {
if (v === '' || v === null || v === undefined) continue;
url.searchParams.set(k, v);
}
const res = await fetch(url, { credentials: 'same-origin' });
return await res.json();
}
async function loadSn(el) {
snAdm.tab = 'items';
snAdm.page = 1;
snAdm.kw = '';
snAdm.status = '';
await renderSnAdmin(el);
}
async function renderSnAdmin(el) {
el.innerHTML = '<div class="card"><p style="color:var(--dim)">加载中...</p></div>';
try {
if (snAdm.tab === 'lost') { await renderSnLost(el); return; }
if (snAdm.tab === 'stats') { await renderSnStats(el); return; }
if (snAdm.tab === 'detail') { await renderSnDetail(el, snAdm.sn); return; }
await renderSnItems(el);
} catch (e) {
el.innerHTML = '<div class="card"><p style="color:var(--red)">SN管理加载失败: ' + escAdmHtml(e.message) + '</p></div>';
}
}
function snTabs() {
const t = snAdm.tab;
const base = (t === 'detail' ? 'items' : t);
const mk = (k, label) => '<div class="tab ' + (base === k ? 'active' : '') + '" onclick="snAdmTab(\'' + k + '\')">' + label + '</div>';
return '<div class="tabs">' +
mk('items', '📦 设备列表') +
mk('lost', '🚨 报失工单') +
mk('stats', '📊 统计') +
'</div>';
}
function snAdmTab(tab) {
snAdm.tab = tab;
snAdm.page = 1;
renderSnAdmin(document.getElementById('C'));
}
const SN_STATUS_CN = {
active: '有效', destroyed: '已销毁解绑', cancelled: '已注销',
lost: '报失处理中', illegal: '非法绑定', reissued: '已补发作废'
};
const SN_TYPE_CN = {
menu_snowball: '雪球菜单', land_wand: '区域选择工具',
echo_shard: '回声碎片', pvp_tool: 'PVP圈地棒'
};
function snTypeCn(t) { return SN_TYPE_CN[t] || t; }
const SN_LOST_CN = {
checking: '⏳ 核查中', manual: '🧑‍💻 待人工', rejected: '🚫 已驳回',
auto_reissue: '♻️ 已补发', done: '✅ 已完成'
};
const SN_ACTION_CN = {
issue: '申领', issue_admin: '办理签发', backfill: '存量补登记',
cancel: '注销', cancel_force: '强制注销', reissue: '补发',
destroy: '销毁解绑', report: '报失', pickup_mismatch: '拾取归属不符',
illegal_craft: '非法合成', illegal_burn: '非法熔炼', bind: '永久绑定',
locate: '位置查询'
};
function snActionCn(a) { return SN_ACTION_CN[a] || a; }
function snStatusCn(s) { return SN_STATUS_CN[s] || s; }
function snAdmSearch() {
snAdm.kw = (document.getElementById('snKw') || {}).value || '';
snAdm.status = (document.getElementById('snSt') || {}).value || '';
snAdm.page = 1;
renderSnAdmin(document.getElementById('C'));
}
function snAdmPage(p) {
snAdm.page = p;
renderSnAdmin(document.getElementById('C'));
}
async function renderSnItems(el) {
const r = await snAdmApi('admin_list', {
kw: snAdm.kw, status: snAdm.status, page: snAdm.page, size: snAdm.size
});
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
const d = r.data || {};
const list = d.list || [];
const total = d.total || 0;
const pages = Math.max(1, Math.ceil(total / snAdm.size));
let html = '<div class="card"><h2>🔢 SN防伪管理</h2>' + snTabs() +
snIssueBox() +
'<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">' +
'<input id="snKw" type="text" placeholder="搜索 SN / 玩家 / 领地..." value="' + escAdmHtml(snAdm.kw) + '" style="flex:1;min-width:180px">' +
'<select id="snSt" style="min-width:130px">' +
'<option value="">全部状态</option>';
for (const [k, v] of Object.entries(SN_STATUS_CN)) {
html += '<option value="' + k + '"' + (snAdm.status === k ? ' selected' : '') + '>' + v + '</option>';
}
html += '</select>' +
'<button class="btn btn-primary" onclick="snAdmSearch()">🔍 查询</button>' +
'</div>';
if (!list.length) {
html += '<p style="color:var(--dim)">暂无数据。游戏服每 30 秒推送一次目录快照。</p></div>';
el.innerHTML = html;
return;
}
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">' +
'<tr style="color:var(--dim);text-align:left">' +
'<th style="padding:6px">SN码</th><th style="padding:6px">类型</th><th style="padding:6px">状态</th>' +
'<th style="padding:6px">主人</th><th style="padding:6px">位置</th><th style="padding:6px">签发时间</th>' +
'<th style="padding:6px">操作</th></tr>';
for (const row of list) {
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' + escAdmHtml(row.sn) + '</td>' +
'<td style="padding:7px 6px">' + escAdmHtml(row.type_cn || '') + '</td>' +
'<td style="padding:7px 6px;color:' + (row.status === 'active' ? 'var(--green)' : (row.status === 'illegal' ? 'var(--red)' : 'var(--yellow)')) + '">' + escAdmHtml(row.status_cn || snStatusCn(row.status)) + '</td>' +
'<td style="padding:7px 6px">' + escAdmHtml(row.owner || '') + '</td>' +
'<td style="padding:7px 6px;color:var(--dim)">' + escAdmHtml(row.loc_desc || '') + '</td>' +
'<td style="padding:7px 6px;color:var(--dim);white-space:nowrap">' + escAdmHtml(row.issue_time_str || '') + '</td>' +
'<td style="padding:7px 6px;white-space:nowrap">' +
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmDetail(\'' + escAdmHtml(row.sn) + '\')">详情</button>' +
'</td></tr>';
}
html += '</table></div>';
html += '<div style="display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap">' +
'<button class="btn" ' + (snAdm.page <= 1 ? 'disabled' : '') + ' onclick="snAdmPage(' + (snAdm.page - 1) + ')">上一页</button>' +
'<span style="color:var(--dim);font-size:13px">第 ' + snAdm.page + ' / ' + pages + ' 页 · 共 ' + total + ' 条</span>' +
'<button class="btn" ' + (snAdm.page >= pages ? 'disabled' : '') + ' onclick="snAdmPage(' + (snAdm.page + 1) + ')">下一页</button>' +
'</div></div>';
el.innerHTML = html;
}
function snAdmDetail(sn) {
snAdm.tab = 'detail';
snAdm.sn = sn;
renderSnAdmin(document.getElementById('C'));
}
function snAdmBack() {
snAdm.tab = 'items';
renderSnAdmin(document.getElementById('C'));
}
function snFmtTs(ts) {
const n = parseInt(ts, 10) || 0;
if (!n) return '';
const t = n >= 100000000000 ? Math.floor(n / 1000) : n;
const d = new Date(t * 1000);
const p = x => (x < 10 ? '0' : '') + x;
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
async function renderSnDetail(el, sn) {
const r = await snAdmApi('admin_detail', { sn: sn });
if (!r.success) {
el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p>' +
'<button class="btn" onclick="snAdmBack()">← 返回</button></div>';
return;
}
const d = r.data || {};
const it = d.sn || {};
let html = '<div class="card">' +
'<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">' +
'<h2 style="margin:0">🔢 ' + escAdmHtml(it.sn || sn) + '</h2>' +
'<button class="btn" onclick="snAdmBack()">← 返回列表</button></div>' +
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
snAdmRow('类型', it.type_cn) +
snAdmRow('状态', it.status_cn) +
snAdmRow('主人', it.owner) +
snAdmRow('签发时间', it.issue_time_str) +
snAdmRow('当前位置', it.loc_desc) +
snAdmRow('绑定原因', it.bind_reason ? (it.bind_reason + ' @ ' + snFmtTs(it.bind_time)) : '-') +
snAdmRow('报失次数', it.lost_count) +
'</table>';
// 注销 / 报失 / 补办统一门槛:三条件逐条判定(管理端专有,玩家端不可见)
const cu = it.custody || null;
if (cu) {
html += '<div style="margin-top:14px;padding:10px 12px;border:1px solid var(--border);' +
'border-radius:8px;background:rgba(255,255,255,.02)">' +
'<div style="font-size:13px;font-weight:600;margin-bottom:6px">🚪 办理门槛(三条件,同时满足才放行)</div>' +
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
snAdmRow('① 不在本人身上', cu.in_body ? '❌ 在本人身上' : '✅ 不在身上') +
snAdmRow('② 不在本人领地箱子', cu.in_own_chest ? '❌ 在本人领地箱子内' : '✅ 不在') +
snAdmRow('③ 脱离自身管控 &gt; 12 小时',
(cu.detached_ok ? '✅ 达标' : '❌ 未达标') + '(已脱离 ' + escAdmHtml(String(cu.detached_text || '-')) + ',阈值 12 小时)') +
snAdmRow('总判定', cu.pass ? '✅ 达标,可办理' : '❌ 未达标(对玩家只回通用提示)') +
'</table></div>';
}
html += '<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">' +
'<button class="btn" onclick="snAdmCmd(\'locate\')">📍 立即定位</button>' +
'<button class="btn btn-yellow" onclick="snAdmCmd(\'reissue\')">♻️ 强制补发</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\', 1)">🗑️ 强制注销</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'cancel\', 0)">📋 代办注销</button>' +
'<button class="btn btn-red" onclick="snAdmCmd(\'bind\')">🔒 永久绑定</button>' +
'<button class="btn btn-yellow" onclick="snAdmCmd(\'report_check\')">🚩 报失核查</button>' +
'</div>' +
'<p style="color:var(--dim);font-size:12px;margin-top:8px">命令通过 web_sn_commands 下发,游戏服 10~30 秒内执行并回执。' +
'<br><b>强制注销</b>:豁免三条件门槛,直接执行且<b>不进入冷静期</b>;<b>强制补发</b>:豁免三条件门槛,按定义直接执行;' +
'<b>代办注销</b>:按三条件校验,未达标会被拒绝(对玩家只提示"未达到办理条件")。' +
'<br><b>办理签发</b>在列表页顶部(只填 玩家ID + 设备类型 即可直发,不依赖本条旧SN)。</p>';
html += '<h3 style="margin:18px 0 8px;font-size:14px">📨 命令记录</h3>';
const cmds = d.commands || [];
if (!cmds.length) html += '<p style="color:var(--dim);font-size:13px">暂无</p>';
else {
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
'<tr style="color:var(--dim);text-align:left"><th style="padding:5px">ID</th><th style="padding:5px">命令</th><th style="padding:5px">状态</th>' +
'<th style="padding:5px">原因</th><th style="padding:5px">结果</th><th style="padding:5px">时间</th></tr>';
for (const c of cmds) {
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:5px">' + c.id + '</td>' +
'<td style="padding:5px">' + escAdmHtml(c.cmd) + '</td>' +
'<td style="padding:5px">' + escAdmHtml(c.status) + '</td>' +
'<td style="padding:5px;color:var(--dim)">' + escAdmHtml(c.reason) + '</td>' +
'<td style="padding:5px;color:var(--dim);word-break:break-all">' + escAdmHtml(c.result || '') + '</td>' +
'<td style="padding:5px;color:var(--dim);white-space:nowrap">' + snFmtTs(c.created_at) + '</td></tr>';
}
html += '</table></div>';
}
html += '<h3 style="margin:18px 0 8px;font-size:14px">📜 操作日志</h3>' + snAdmLogTable(d.logs || [], ['时间', '动作', '玩家', '说明'], r => [
snFmtTs(r.time), snActionCn(r.action), r.player, r.detail
]);
html += '<h3 style="margin:18px 0 8px;font-size:14px">📦 出入库登记</h3>' + snAdmLogTable(d.stock || [], ['时间', '动作', '容器', '坐标', '领地', '玩家'], r => [
snFmtTs(r.time), snActionCn(r.action), r.container_type || '-',
(r.world || '') + ' ' + r.x + ',' + r.y + ',' + r.z,
r.in_land ? (r.land_name || '是') : '-', r.player || '-'
]);
const losts = d.losts || [];
html += '<h3 style="margin:18px 0 8px;font-size:14px">🚨 报失记录</h3>' +
snAdmLogTable(losts, ['时间', '状态', '次数', '原因', '结论'], r => [
snFmtTs(r.created_at), SN_LOST_CN[r.status] || r.status, r.report_count, r.reason || '-', r.result || '-'
]);
if (d.held) {
html += '<h3 style="margin:18px 0 8px;font-size:14px">📡 最近上报(手持快照)</h3>' +
'<p style="color:var(--dim);font-size:13px">持有者 ' + escAdmHtml(d.held.holder || '-') +
' · 主人 ' + escAdmHtml(d.held.owner || '-') +
' · ' + (d.held.held ? '手持中' : '在背包') +
' · ' + snFmtTs(d.held.time) + '</p>';
}
html += '</div>';
el.innerHTML = html;
}
function snAdmRow(k, v) {
return '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:7px 6px;color:var(--dim);width:120px">' + escAdmHtml(k) + '</td>' +
'<td style="padding:7px 6px;word-break:break-all">' + escAdmHtml(v == null || v === '' ? '-' : String(v)) + '</td></tr>';
}
function snAdmLogTable(rows, heads, mapper) {
if (!rows.length) return '<p style="color:var(--dim);font-size:13px">暂无记录</p>';
let h = '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">' +
'<tr style="color:var(--dim);text-align:left">';
for (const t of heads) h += '<th style="padding:5px">' + t + '</th>';
h += '</tr>';
for (const r of rows) {
const cells = mapper(r);
h += '<tr style="border-top:1px solid var(--border)">';
for (const c of cells) {
h += '<td style="padding:5px;word-break:break-all">' + escAdmHtml(c == null ? '' : String(c)) + '</td>';
}
h += '</tr>';
}
return h + '</table></div>';
}
async function snAdmCmd(cmd, force) {
if (force === undefined || force === null) force = 1;
const sn = snAdm.sn;
if (!sn) return;
const names = { locate: '定位', reissue: '强制补发', cancel: (force ? '强制注销' : '代办注销'), bind: '永久绑定', report_check: '报失核查', issue: '办理签发' };
const warns = {
locate: '立即查询该SN在游戏世界中的位置?',
reissue: '强制补发 ' + sn + '?旧SN作废并签发新SN,玩家需在线。',
cancel: force
? '强制注销 ' + sn + '?豁免三条件门槛,将销毁对应实物并直接注销,不进入1小时冷静期。'
: '代办注销 ' + sn + '?按三条件门槛校验(不在本人身上 / 不在本人领地箱子 / 脱离自身管控超12小时),未达标会被拒绝;成功后进入1小时冷静期。',
bind: '把 ' + sn + ' 永久绑定为非法物品?该SN不可恢复。',
report_check: '立即核查 ' + sn + ' 的当前位置并给出报失结论?',
issue: '为 ' + sn + ' 办理签发新设备?前提:旧SN已注销解绑 且 注销冷静期已结束(1小时),未达标会分别提示是哪一条未通过。'
};
if (!await glassConfirm(warns[cmd] || ('确认对 ' + sn + ' 执行「' + names[cmd] + '」?'))) return;
try {
const r = await snAdmApi('admin_cmd', {
cmd: cmd, sn: sn, force: force,
reason: cmd === 'issue' ? '管理员办理签发' : (force ? '管理员强制执行' : '管理员代办(按三条件门槛校验)')
});
if (!r.success) { toast(r.message || '下发失败', 'err'); return; }
toast(r.message || '命令已下发', 'ok');
setTimeout(() => renderSnAdmin(document.getElementById('C')), 1200);
// ★ 游戏服回执是异步的(原先要等下一轮 Timer C,10~2 分钟不等)。
// 原实现只在 1.2 秒后刷新一次,那时 status 还是 pending、result 为空,
// 看起来就像"没动作 / 结果没返回"。这里持续轮询该命令回执直到出结果。
const cmdId = (r.data && r.data.id) || 0;
if (cmdId) snAdmPollCmd(cmdId, sn, 0);
} catch (e) {
toast('下发失败: ' + e.message, 'err');
}
}
/**
* 轮询 SN 命令回执(3 秒一次,最长 150 秒)。
* 游戏服执行完会把 web_sn_commands.status 置为 done 并写入 result;
* 3 分钟未回执则被后端标记为 timeout。
*/
function snAdmPollCmd(cmdId, sn, tries) {
const MAX_TRIES = 50; // 50 * 3s = 150 秒
setTimeout(async () => {
if (snAdm.tab !== 'detail' || snAdm.sn !== sn) return; // 用户已离开该详情页
let c = null;
try {
const r = await snAdmApi('admin_detail', { sn: sn });
const list = (r.success && r.data && r.data.commands) || [];
c = list.find(x => Number(x.id) === Number(cmdId)) || null;
} catch (e) { /* 网络抖动,下一轮再试 */ }
if (c && (c.status === 'done' || c.status === 'timeout')) {
if (c.status === 'timeout') toast('游戏服 3 分钟内未回执,命令已超时', 'err');
else toast('游戏服已执行:' + (c.result || '(无结果)'), 'ok');
renderSnAdmin(document.getElementById('C'));
return;
}
if (tries < MAX_TRIES) {
snAdmPollCmd(cmdId, sn, tries + 1);
} else {
toast('游戏服仍未回执,可稍后刷新查看', 'err');
renderSnAdmin(document.getElementById('C'));
}
}, 3000);
}
/**
* 顶层「办理签发」区块:只传 玩家ID + 设备类型,直接为该玩家签发一台新设备。
* 与 SN 详情里的签发(基于"旧SN已注销"的重发场景)是两条不同链路——
* 这里没有旧 SN,后端 admin_issue_new 入队 issue_new → Java doIssueNew 按类型登记发放。
*/
function snIssueBox() {
let opts = '';
for (const [k, v] of Object.entries(SN_TYPE_CN)) {
opts += '<option value="' + k + '">' + v + '</option>';
}
return '<div style="margin:10px 0 14px;padding:12px 14px;border:1px solid var(--border);' +
'border-radius:10px;background:rgba(255,255,255,.02)">' +
'<div style="font-weight:700;font-size:14px;margin-bottom:8px">🎫 办理签发(按玩家直发)</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<input id="snIssuePlayer" type="text" placeholder="玩家 ID(游戏内昵称,需在线)" ' +
'style="flex:1;min-width:170px" onkeydown="if(event.key===\'Enter\')snAdmIssueNew()">' +
'<select id="snIssueType" style="min-width:150px">' + opts + '</select>' +
'<button class="btn btn-primary" onclick="snAdmIssueNew()">🎫 办理签发</button>' +
'</div>' +
'<p style="color:var(--dim);font-size:12px;margin-top:8px;line-height:1.6">' +
'只填 <b>玩家 ID</b> + <b>设备类型</b> 即可签发。前提:玩家在线 · 该类名下没有有效登记 · 注销冷静期(1小时)已结束;' +
'任一不满足会在 10~30 秒内回执具体原因。命令由游戏服异步执行。</p>' +
'</div>';
}
/**
* 顶层「办理签发」提交:只带 玩家ID + 设备类型。
*/
async function snAdmIssueNew() {
const pEl = document.getElementById('snIssuePlayer');
const tEl = document.getElementById('snIssueType');
const player = ((pEl && pEl.value) || '').trim();
const itemType = (tEl && tEl.value) || '';
if (!player) { toast('请输入玩家 ID', 'err'); return; }
if (!itemType) { toast('请选择设备类型', 'err'); return; }
const ok = await glassConfirm('为玩家 ' + player + ' 办理签发「' + snTypeCn(itemType) + '」?' +
'前提:玩家在线、该类名下无有效登记、注销冷静期已结束;未达标会回执是哪一条。', '🎫');
if (!ok) return;
try {
const r = await snAdmApi('admin_issue_new', {
player: player, item_type: itemType, reason: '管理员顶层办理签发'
});
if (!r.success) { toast(r.message || '下发失败', 'err'); return; }
toast(r.message || '签发命令已下发', 'ok');
const cmdId = (r.data && r.data.id) || 0;
if (cmdId) snAdmPollIssue(cmdId, 0);
} catch (e) {
toast('下发失败: ' + e.message, 'err');
}
}
/**
* 顶层签发的回执轮询:这条命令没有 SN 详情页可挂,按命令 ID 直查
* (admin_cmd_result)。3 秒一次、最长 150 秒,与 snAdmPollCmd 同节奏。
*/
function snAdmPollIssue(cmdId, tries) {
const MAX_TRIES = 50;
setTimeout(async () => {
let c = null;
try {
const r = await snAdmApi('admin_cmd_result', { id: cmdId });
c = (r.success && r.data) || null;
} catch (e) { /* 网络抖动,下一轮再试 */ }
if (c && (c.status === 'done' || c.status === 'timeout')) {
const res = c.result || '';
if (c.status === 'timeout') toast('游戏服 3 分钟内未回执,命令已超时', 'err');
else toast('游戏服已执行:' + (res || '(无结果)'), snCmdLooksBad(res) ? 'err' : 'ok');
renderSnAdmin(document.getElementById('C'));
return;
}
if (tries < MAX_TRIES) snAdmPollIssue(cmdId, tries + 1);
else { toast('游戏服仍未回执,可稍后刷新查看', 'err'); renderSnAdmin(document.getElementById('C')); }
}, 3000);
}
/** 回执文本是否算失败(与 PHP snCmdOk 同口径:出现失败类词即按失败提示) */
function snCmdLooksBad(res) {
return !res || /(失败|拒绝|未|不存在|异常|未知|不在线|超时|已有有效)/.test(res);
}
async function renderSnLost(el) {
const r = await snAdmApi('admin_lost_list', { page: 1, size: 100 });
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
const list = (r.data && r.data.list) || [];
const total = (r.data && r.data.total) || 0;
let html = '<div class="card"><h2>🚨 SN 报失工单 (' + total + ')</h2>' + snTabs();
if (!list.length) {
html += '<p style="color:var(--dim)">暂无报失工单。</p></div>';
el.innerHTML = html;
return;
}
html += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">' +
'<tr style="color:var(--dim);text-align:left">' +
'<th style="padding:6px">ID</th><th style="padding:6px">SN</th><th style="padding:6px">类型</th>' +
'<th style="padding:6px">玩家</th><th style="padding:6px">状态</th><th style="padding:6px">次数</th>' +
'<th style="padding:6px">原因 / 结论</th><th style="padding:6px">时间</th><th style="padding:6px">操作</th></tr>';
for (const row of list) {
const pending = (row.status === 'checking' || row.status === 'manual');
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:7px 6px">' + row.id + '</td>' +
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' +
'<a href="javascript:void(0)" onclick="snAdmDetail(\'' + escAdmHtml(row.sn) + '\')" style="color:var(--accent)">' + escAdmHtml(row.sn) + '</a></td>' +
'<td style="padding:7px 6px">' + escAdmHtml(row.type_cn || '') + '</td>' +
'<td style="padding:7px 6px">' + escAdmHtml(row.player) + '</td>' +
'<td style="padding:7px 6px">' + escAdmHtml(SN_LOST_CN[row.status] || row.status) + '</td>' +
'<td style="padding:7px 6px">' + (row.report_count || 1) + '</td>' +
'<td style="padding:7px 6px;color:var(--dim);word-break:break-all">' + escAdmHtml((row.reason || '') + (row.result ? ' → ' + row.result : '')) + '</td>' +
'<td style="padding:7px 6px;color:var(--dim);white-space:nowrap">' + snFmtTs(row.created_at) + '</td>' +
'<td style="padding:7px 6px;white-space:nowrap">';
if (pending) {
html += '<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmLostHandle(' + row.id + ',\'approve\')">通过补发</button> ' +
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snAdmLostHandle(' + row.id + ',\'reject\')">驳回</button>';
} else {
html += '<span style="color:var(--dim)">已处理</span>';
}
html += '</td></tr>';
}
html += '</table></div></div>';
el.innerHTML = html;
}
async function snAdmLostHandle(id, act) {
const tip = act === 'approve'
? '通过该报失工单?将立即向玩家下发补发命令。'
: '驳回该报失工单?';
if (!await glassConfirm(tip)) return;
let reason = act === 'approve' ? '人工核查通过' : '人工核查未通过';
if (act === 'reject') {
try {
const v = await showModal('驳回报失', '请填写驳回原因', '人工核查未通过');
if (v === false || v === null || v === undefined) return;
if (String(v).trim()) reason = String(v).trim();
} catch (e) { /* 保持默认原因 */ }
}
try {
const r = await snAdmApi('admin_lost_handle', { id: id, act: act, reason: reason });
if (!r.success) { toast(r.message || '处理失败', 'err'); return; }
toast(r.message || '处理成功', 'ok');
renderSnAdmin(document.getElementById('C'));
} catch (e) {
toast('处理失败: ' + e.message, 'err');
}
}
async function renderSnStats(el) {
const r = await snAdmApi('admin_stats', {});
if (!r.success) { el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escAdmHtml(r.message || '加载失败') + '</p></div>'; return; }
const d = r.data || {};
let html = '<div class="card"><h2>📊 SN 统计</h2>' + snTabs() +
'<table style="width:100%;border-collapse:collapse;font-size:13px;margin-top:8px">' +
snAdmRow('SN 总数', d.total || 0) +
snAdmRow('待处理报失', d.lost_pending || 0) +
snAdmRow('事件条数', d.events || 0) +
'</table>' +
'<h3 style="margin:18px 0 8px;font-size:14px">按状态</h3>';
const bs = d.by_status || {};
if (!Object.keys(bs).length) html += '<p style="color:var(--dim)">暂无数据</p>';
else {
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
for (const [k, v] of Object.entries(bs)) html += snAdmRow(snStatusCn(k) + ' (' + k + ')', v);
html += '</table>';
}
html += '<h3 style="margin:18px 0 8px;font-size:14px">按类型</h3>';
const bt = d.by_type || {};
if (!Object.keys(bt).length) html += '<p style="color:var(--dim)">暂无数据</p>';
else {
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
for (const [k, v] of Object.entries(bt)) html += snAdmRow(k, v);
html += '</table>';
}
html += '</div>';
el.innerHTML = html;
}
// 切换页面时清理定时器
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>
<!-- ★ AGPL-3.0 合规页脚:源码地址取自 开源地址.txt(可由使用者替换为自己的仓库) -->
<footer style="min-height:44px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:6px;padding:8px 16px;font-size:12px;color:var(--dim);border-top:1px solid var(--border);background:var(--bg);text-align:center">
本后台基于 <a href="LICENSE" target="_blank" style="color:var(--accent);text-decoration:none">AGPL-3.0</a> 开源协议发布
· 源代码地址:<a href="<?php echo htmlspecialchars($OSS_REPO, ENT_QUOTES, 'UTF-8'); ?>" target="_blank" style="color:var(--accent);text-decoration:none"><?php echo htmlspecialchars($OSS_REPO, ENT_QUOTES, 'UTF-8'); ?></a>
</footer>
</body>
</html>