Files
Sdf1_login/player.php
T

5598 lines
331 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
require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-30)
require_once __DIR__ . '/captcha_guard.php'; // ★ 人机验证统一接入(2026-10-02):CF/VA 由 config.php 的 CAPTCHA_PROVIDER 单点决定
// ★ 强制缓存失效:用文件修改时间作为版本号
// 每次修改player.php后,文件时间戳变化 → URL不同 → 浏览器必须获取新内容
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
header('X-Accel-Expires: 0'); // Nginx反代也禁缓存
// ★ AGPL-3.0 合规:源码地址(使用者可在 开源地址.txt 中改成自己的仓库地址)
$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; }
}
}
// ★ 第57批续3:无充值规则时隐藏在线充值入口
$HAS_RECHARGE_RULES = false;
try {
if (defined("ORDERS_DB_PATH") && file_exists(ORDERS_DB_PATH)) {
$_rechDb = new PDO("sqlite:" . ORDERS_DB_PATH);
$_cnt = $_rechDb->query("SELECT COUNT(*) FROM shop_configs WHERE is_active = 1")->fetchColumn();
$HAS_RECHARGE_RULES = ((int)$_cnt > 0);
}
} catch (Throwable $_e) {
// 库不存在/建表前:false = 隐藏入口
}
$BUILD_VERSION = 'v' . filemtime(__FILE__); // 文件修改时间 = 版本号
$currentVersion = isset($_GET['_v']) ? $_GET['_v'] : '';
if ($currentVersion !== $BUILD_VERSION) {
// 版本不匹配 → 重定向到带正确版本号的URL(浏览器视为全新请求)
// 先移除旧的 _v 参数
$params = $_GET;
unset($params['_v']);
$queryString = http_build_query($params);
$path = strtok($_SERVER['REQUEST_URI'], '?');
$newUrl = $path . ($queryString ? '?' . $queryString . '&' : '?') . '_v=' . $BUILD_VERSION;
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
header('Location: ' . $newUrl, true, 302);
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 玩家商城</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #e6edf3; --dim: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d29922;
--purple: #bc8cff;
}
.light-theme {
--bg: #f6f8fa; --card: #ffffff; --border: #d0d7de;
--text: #1f2328; --dim: #656d76; --accent: #0969da;
--green: #1a7f37; --red: #cf222e; --yellow: #9a6700;
--purple: #8250df;
}
* { 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; transition: background 0.3s, color 0.3s; }
.header {
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 100%);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex; justify-content: space-between; align-items: center;
}
.header h1 { font-size: 20px; color: var(--accent); }
.header .status { font-size: 13px; }
.preview-badge {
background: var(--yellow); color: #000; padding: 4px 12px;
border-radius: 20px; font-size: 12px; font-weight: 600;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } }
.main { display: flex; gap: 0; min-height: calc(100vh - 56px); }
.sidebar {
width: 220px; background: var(--card); border-right: 1px solid var(--border);
padding: 12px 0; flex-shrink: 0; transition: background 0.3s;
}
.sidebar-item {
padding: 10px 20px; cursor: pointer; color: var(--dim);
transition: all 0.2s; font-size: 14px; display: flex; align-items: center; gap: 8px;
}
.sidebar-item:hover { background: rgba(88,166,255,0.1); color: var(--text); }
.sidebar-item.active { color: var(--accent); border-right: 3px solid var(--accent); background: rgba(88,166,255,0.05); }
.content { flex: 1; padding: 24px; overflow-y: auto; max-height: calc(100vh - 56px); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
padding: 20px; margin-bottom: 16px;
}
/* 第47批:图片壁纸模式下卡片/侧栏自适应半透明+毛玻璃;纯色/默认模式不受影响 */
body.bg-img-mode .card, body.bg-img-mode .sidebar {
background: rgba(22,27,34,0.55);
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
}
body.bg-img-mode.light-theme .card, body.bg-img-mode.light-theme .sidebar {
background: rgba(255,255,255,0.55);
}
.card h2 { font-size: 16px; margin-bottom: 12px; color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.item-card {
background: rgba(88,166,255,0.05); border: 1px solid var(--border);
border-radius: 8px; padding: 16px; cursor: pointer;
transition: all 0.2s; position: relative;
}
.item-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
.item-card .icon { font-size: 32px; margin-bottom: 8px; }
.item-card .name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.item-card .price { color: var(--green); font-size: 13px; }
.item-card .stock { color: var(--dim); font-size: 12px; }
.item-card .stock.out { color: var(--red); }
.stat-row { display: flex; gap: 16px; margin-bottom: 16px; }
.stat-box {
flex: 1; background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 16px; text-align: center;
}
.stat-box .value { font-size: 28px; font-weight: 700; color: var(--accent); }
.stat-box .label { font-size: 12px; color: var(--dim); margin-top: 4px; }
.cdk-input {
display: flex; gap: 8px; margin-top: 12px;
}
.cdk-input input {
flex: 1; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px; outline: none;
}
.cdk-input input:focus { border-color: var(--accent); }
.btn {
padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer;
font-size: 14px; font-weight: 600; transition: all 0.2s;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #79c0ff; }
.btn-green { background: var(--green); color: #fff; }
.btn-green:hover { background: #56d364; }
.btn-red { background: var(--red); color: #fff; }
.btn-orange { background: var(--yellow); color: #000; }
.btn-dim { background: var(--card); color: var(--dim); border: 1px solid var(--border); }
.btn-dim:hover { color: var(--text); border-color: var(--accent); }
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { text-align: left; padding: 10px 12px; color: var(--dim); border-bottom: 1px solid var(--border); }
.table td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.table tr:hover td { background: rgba(88,166,255,0.05); }
.empty { text-align: center; color: var(--dim); padding: 40px; }
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.08); display: flex; justify-content: center;
align-items: center; z-index: 999; display: none;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.modal-overlay.show { display: flex; }
.modal {
background: rgba(22,27,34,0.45);
border: 1px solid rgba(88,166,255,0.2); border-radius: 16px;
padding: 24px; width: 420px; max-width: 90%;
box-shadow: 0 20px 64px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.1);
}
body.bg-img-mode .modal { background: rgba(22,27,34,0.35); }
.light-theme .modal { background: rgba(255,255,255,0.5); border-color: rgba(9,105,218,0.2); }
.light-theme.bg-img-mode .modal { background: rgba(255,255,255,0.35); }
.modal.animate-in { animation: drawerSlideDown 0.3s ease; }
.modal.animate-out { animation: drawerSlideUp 0.25s ease forwards; }
.modal h3 { margin-bottom: 16px; }
.modal .row { margin-bottom: 12px; }
.modal label { display: block; font-size: 13px; color: var(--dim); margin-bottom: 4px; }
.modal input, .modal select {
width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 14px;
}
.modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
.toast {
position: fixed; top: 20px; right: 20px; padding: 12px 20px;
border-radius: 12px; font-size: 14px; z-index: 200;
animation: slideIn 0.3s ease;
backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.toast.success { background: rgba(63,185,80,0.85); color: #fff; }
.toast.error { background: rgba(248,81,73,0.85); color: #fff; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
/* ★ 游客模式C位登录按钮 */
.guest-center-login {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 50; pointer-events: none;
}
.guest-center-login.show { display: flex; justify-content: center; align-items: center; }
.guest-center-login .glow-btn {
pointer-events: auto;
background: linear-gradient(135deg, var(--accent), #79c0ff);
color: #fff; border: none; border-radius: 16px;
padding: 20px 48px; font-size: 18px; font-weight: 700;
cursor: pointer; box-shadow: 0 8px 32px rgba(88,166,255,0.4);
animation: glowPulse 2s ease-in-out infinite;
transition: all 0.3s; text-decoration: none;
}
.guest-center-login .glow-btn:hover { transform: scale(1.08); box-shadow: 0 12px 48px rgba(88,166,255,0.6); }
@keyframes glowPulse {
0%,100% { box-shadow: 0 8px 32px rgba(88,166,255,0.4); }
50% { box-shadow: 0 8px 48px rgba(88,166,255,0.7); }
}
/* ★ 毛玻璃登录弹窗 */
.glass-login-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
z-index: 2000; justify-content: center; align-items: center;
animation: glassFadeIn 0.3s ease;
}
.glass-login-overlay.show { display: flex; }
.glass-card {
background: rgba(22,27,34,0.45);
border: 1px solid rgba(88,166,255,0.2);
border-radius: 20px; padding: 36px 32px; width: 400px; max-width: 90%;
box-shadow: 0 20px 64px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.1);
animation: glassSlideUp 0.35s ease;
position: relative;
}
.glass-card .x-btn {
position: absolute; top: 12px; right: 16px; background: none; border: none;
color: var(--dim); font-size: 22px; cursor: pointer; padding: 4px 8px;
border-radius: 6px; transition: all 0.2s; line-height: 1;
}
.glass-card .x-btn:hover { color: var(--text); background: rgba(255,255,255,0.1); }
.glass-card .logo { text-align: center; font-size: 48px; margin-bottom: 16px; }
.glass-card h2 { text-align: center; color: var(--text); margin-bottom: 6px; font-size: 20px; }
.glass-card .subtitle { text-align: center; color: var(--dim); font-size: 13px; margin-bottom: 24px; }
.glass-card input[type=text], .glass-card input[type=password] {
width: 100%; padding: 12px 16px; background: rgba(13,17,23,0.6);
border: 1px solid rgba(88,166,255,0.2); border-radius: 10px;
color: var(--text); font-size: 14px; outline: none; margin-bottom: 12px;
transition: border-color 0.2s; box-sizing: border-box;
}
.glass-card input:focus { border-color: var(--accent); }
.glass-card .login-btn {
width: 100%; padding: 14px; background: linear-gradient(135deg, var(--accent), #79c0ff);
color: #fff; border: none; border-radius: 10px; font-size: 15px;
font-weight: 700; cursor: pointer; transition: all 0.2s; margin-top: 4px;
}
.glass-card .login-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.glass-card .login-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.glass-card .hint { text-align: center; color: var(--dim); font-size: 12px; margin-top: 16px; }
.glass-card .hint code {
background: rgba(88,166,255,0.1); padding: 1px 5px; border-radius: 4px;
color: var(--accent); font-size: 11px;
}
.glass-card .status-msg {
text-align: center; padding: 8px; border-radius: 8px; font-size: 13px;
margin-bottom: 12px; display: none;
}
@keyframes glassFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes glassSlideUp {
from { opacity: 0; transform: translateY(30px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ★ 第56批:抽屉式弹窗动画(绑定邮箱专用)*/
@keyframes drawerSlideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes drawerSlideUp {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
}
.drawer-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.08); z-index: 999;
display: none; justify-content: center; align-items: flex-start;
padding-top: 15vh;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.drawer-overlay.show { display: flex; }
.drawer-card {
background: rgba(22,27,34,0.45);
border: 1px solid rgba(88,166,255,0.2); border-radius: 16px 16px 0 0; padding: 24px;
width: 420px; max-width: 90%;
box-shadow: 0 -20px 64px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.1);
}
body.bg-img-mode .drawer-card {
background: rgba(22,27,34,0.3);
}
.light-theme .drawer-card {
background: rgba(255,255,255,0.45); border-color: rgba(9,105,218,0.2);
}
.light-theme.bg-img-mode .drawer-card {
background: rgba(255,255,255,0.3);
}
.drawer-card.animate-in { animation: drawerSlideDown 0.3s ease; }
.drawer-card.animate-out { animation: drawerSlideUp 0.25s ease forwards; }
/* ★ 毛玻璃Alert弹窗 */
.glass-alert-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.08);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
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.45);
border: 1px solid rgba(88,166,255,0.2);
border-radius: 16px; padding: 28px 28px 20px; width: 360px; max-width: 88%;
box-shadow: 0 20px 64px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.1);
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); }
@media (max-width: 768px) {
.sidebar { display: none; }
.grid { grid-template-columns: 1fr; }
}
/* ===== 第十六批:封禁常态化公示卡片(从下往上滚动替换) ===== */
.ban-roll-wrap {
/* 第十六批缺陷修复:高度由 JS 按实际内容撑开(空态不再占 320px 空白) */
position: relative; height: 42px; overflow: hidden;
border-radius: 8px; margin-top: 4px;
}
.ban-roll-page { width: 100%; }
.ban-roll-inner {
position: absolute; left: 0; right: 0; top: 0;
transition: transform 0.7s cubic-bezier(.22,.61,.36,1);
}
.ban-roll-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 12px; border-bottom: 1px solid var(--border);
font-size: 13px; line-height: 1.3;
}
.ban-roll-item .bname { color: var(--red); font-weight: 700; flex-shrink: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ban-roll-item .breason { color: var(--dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ban-roll-item .btime { color: var(--yellow); font-size: 11px; flex-shrink: 0; }
.ban-roll-item .bperm { color: var(--red); font-size: 11px; flex-shrink: 0; }
</style>
</head>
<body>
<div class="header">
<h1>SDF1 玩家商城</h1>
<div class="status">
<span id="previewBadge" class="preview-badge" style="display:none">预览模式</span>
<span id="playerInfo" style="margin-left:12px;color:var(--dim)"></span>
<button class="btn btn-yellow" onclick="showThemePicker()" style="margin-left:16px;font-size:12px;padding:4px 8px">🎨 主题</button>
</div>
</div>
<div class="main">
<div class="sidebar">
<div class="sidebar-item" data-page="dashboard" onclick="switchPage('dashboard')">📊 个人中心</div>
<div class="sidebar-item" data-page="shop" onclick="switchPage('shop')">🛒 商城</div>
<div class="sidebar-item" data-page="lands" onclick="switchPage('lands')">🏡 领地管理</div>
<div class="sidebar-item" data-page="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</div>
<div class="sidebar-item" data-page="balance" onclick="switchPage('balance')">💰 余额查询</div>
<?php if ($HAS_RECHARGE_RULES): ?>
<div class="sidebar-item" data-page="recharge" onclick="switchPage('recharge')">💳 在线充值</div>
<?php endif; ?>
<div class="sidebar-item" data-page="orders" onclick="switchPage('orders')">📋 我的订单</div>
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
<div class="sidebar-item" data-page="sn" onclick="switchPage('sn')">🔢 SN设备管理</div>
<div class="sidebar-item" data-page="account" onclick="switchPage('account')">🛡️ 账号安全</div>
</div>
<div class="content" id="content">
<!-- 动态内容 -->
</div>
</div>
<!-- ★ 游客模式C位登录按钮 -->
<div class="guest-center-login" id="guestCenterLogin">
<button class="glow-btn" onclick="openGlassLogin()">🔑 立即登录</button>
</div>
<!-- ★ 毛玻璃Alert弹窗 -->
<div class="glass-alert-overlay" id="glassAlertOverlay">
<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;text-align:left;font-size:13px;color:var(--fg);margin-bottom:4px"></div>
<input type="text" id="glassAlertInput" style="display:none;width:100%;box-sizing:border-box;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--fg);font-size:14px;margin-bottom:8px;outline:none" onkeydown="if(event.key==='Enter')glassAlertResolve(true)">
<div class="alert-hint" id="glassAlertHint" style="display:none;font-size:12px;color:var(--dim);margin-bottom:8px"></div>
<div class="alert-btns" id="glassAlertBtns">
<button class="ag-ok" onclick="glassAlertResolve(true)">确定</button>
</div>
</div>
</div>
<!-- ★ 毛玻璃登录弹窗 -->
<div class="glass-login-overlay" id="glassLoginOverlay" onclick="closeGlassLogin(event)">
<div class="glass-card" onclick="event.stopPropagation()">
<button class="x-btn" onclick="closeGlassLogin()">&times;</button>
<div class="logo">🔐</div>
<h2>登录到SDF1</h2>
<p class="subtitle">使用游戏内密码登录,无需在游戏里敲指令</p>
<div class="status-msg" id="glassLoginStatus"></div>
<input type="text" id="glassPlayerName" placeholder="玩家名" maxlength="16" autocomplete="username">
<input type="password" id="glassPassword" placeholder="游戏内密码" maxlength="32" autocomplete="current-password">
<div id="cgSlotGlass"></div>
<button class="login-btn" id="glassLoginBtn" onclick="doGlassLogin()">登 录</button>
<p class="hint">也可在游戏中输入 <code>/sdf1_login weblogin</code> 获取链接</p>
</div>
</div>
<!-- ★ 第56批:抽屉式弹窗(绑定邮箱专用)-->
<div class="drawer-overlay" id="drawerOverlay" onclick="closeDrawer()">
<div class="drawer-card" id="drawerCard" onclick="event.stopPropagation()">
<h3 id="drawerTitle" style="margin-bottom:16px">📧 网页绑定邮箱</h3>
<div id="drawerBody"></div>
<div class="actions" style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeDrawer()">取消</button>
<button class="btn btn-primary" id="drawerConfirm" onclick="confirmDrawer()">确认</button>
</div>
</div>
</div>
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()">
<div class="modal" onclick="event.stopPropagation()">
<h3 id="modalTitle">确认</h3>
<div id="modalBody"></div>
<div class="actions">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="modalConfirm" onclick="confirmModal()">确认</button>
</div>
</div>
</div>
<script src="qrcode-svg.js?v=<?php echo filemtime(__DIR__ . '/qrcode-svg.js'); ?>"></script>
<script>
// ★ 人机验证挂件HTML:由 config.php 的 CAPTCHA_PROVIDER 单点决定 CF/VA(全项目只用一家,不混用)。
// 未启用(off / 密钥未配)时为空串,前端不挂件、服务端同步放行,
// 不会出现"服务端要验证、前端却没组件"的错配。
// JSON_HEX_TAG 必须带:挂件HTML里含 script 闭合标签,不转义会把本脚本块提前截断。
window.__CG_HTML = <?php echo json_encode(cgWidgetHtml(), JSON_HEX_TAG | JSON_UNESCAPED_UNICODE); ?>;
// ===== 人机验证挂件:全页唯一实例,挂到当前可见的登录表单 =====
// 三个入口(主登录页 / 毛玻璃弹窗 / 安全验证弹窗)共用一个 cgBox,避免重复 id;
// innerHTML 插入的 script 元素不会被浏览器执行 → 这里手动执行一次。
function cgMount(slotId) {
var slot = document.getElementById(slotId);
if (!slot) return false;
var html = window.__CG_HTML || '';
if (!html) return false; // 验证码未启用
if (slot.querySelector('#cgBox')) return true; // 本表单已挂过 → 保留已通过的 token
var olds = document.querySelectorAll('.cg-box'); // 其它表单里的旧实例 → 移除(全页唯一)
for (var i = 0; i < olds.length; i++) {
var p = olds[i].parentNode;
if (p) p.removeChild(olds[i]);
}
slot.innerHTML = html;
var sc = slot.querySelector('script');
if (sc) {
var s = document.createElement('script');
s.textContent = sc.textContent;
document.head.appendChild(s);
if (sc.parentNode) sc.parentNode.removeChild(sc);
}
return !!window.CaptchaGuard;
}
// 提交前取 token:未启用时返回 {}(服务端同步放行),否则等待用户完成验证
async function cgPayload() {
if (!(window.__CG_HTML || '')) return {};
if (!window.CaptchaGuard) throw new Error('验证组件未加载,请刷新页面重试');
return await window.CaptchaGuard.payload();
}
// ★ 防止bfcache(前进/后退缓存)绕过安全检查
window.addEventListener('pageshow', function(e) {
if (e.persisted) {
// 页面从bfcache恢复 → 强制刷新以获取最新代码
console.log('[BFCACHE] Page restored from bfcache, forcing reload');
location.reload();
}
});
// ★ 版本检测:强制清除浏览器缓存的旧版JS
(function() {
const buildVersion = '<?php echo $BUILD_VERSION; ?>';
// ★ 第57批续3:无充值规则时隐藏在线充值入口
const hasRechargeRules = '<?php echo $HAS_RECHARGE_RULES ? '1' : '0'; ?>';
const lastVersion = localStorage.getItem('sdf1_build_version');
if (lastVersion && lastVersion !== buildVersion) {
// 版本变化 → 清除所有缓存,强制刷新
localStorage.removeItem('sdf1_token');
localStorage.removeItem('sdf1_player');
localStorage.removeItem('sdf1_build_version');
localStorage.setItem('sdf1_build_version', buildVersion);
console.log('[VERSION] Old version detected, clearing cache and reloading. Old:', lastVersion, 'New:', buildVersion);
location.reload();
return;
}
localStorage.setItem('sdf1_build_version', buildVersion);
})();
const API = 'api/';
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('sdf1_token') || '';
let IS_PREVIEW = !TOKEN;
let currentPlayer = localStorage.getItem('sdf1_player') || new URLSearchParams(location.search).get('login') || '';
let tokenPollTimer = null; // ★ 新增:token轮询定时器(用于续期检查)
let AUTH_TOKEN = TOKEN; // ★ 使用独立的认证token,避免localStorage被意外覆盖
let authVerified = false; // ★ 记录是否已通过首次验证
let currentPage = 'dashboard';
let AUTHENTICATED = false;
let NEED_PASSWORD = false;
// 初始化 — 回档9bae160版本的token处理逻辑
(function() {
const urlToken = new URLSearchParams(location.search).get('token');
if (urlToken) {
localStorage.setItem('sdf1_token', urlToken);
TOKEN = urlToken;
IS_PREVIEW = false;
// ★ 从URL中移除token参数,避免URL暴露
const params = new URLSearchParams(location.search);
params.delete('token');
const newUrl = location.pathname + (params.toString() ? '?' + params.toString() : '');
window.history.replaceState({}, '', newUrl);
}
// ★ 安全机制:没有token → 进入游客模式(预览)
if (!TOKEN) {
IS_PREVIEW = true;
currentPlayer = '';
AUTHENTICATED = false;
document.getElementById('playerInfo').textContent = '游客模式';
document.getElementById('previewBadge').style.display = 'inline';
showLoginPrompt();
return;
}
// 有token → 验证token
document.getElementById('playerInfo').textContent = '验证中...';
checkAccess('view');
})();
// ★ 安全验证入口
async function checkAccess(action, callback) {
if (IS_PREVIEW || !TOKEN) {
document.getElementById('playerInfo').textContent = '游客模式';
if (callback) callback({ok: false, mode: 'preview'});
return;
}
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'web_access_check');
url.searchParams.set('web_token', TOKEN);
url.searchParams.set('access_action', action);
const res = await fetch(url);
const data = await res.json();
if (data.success) {
AUTHENTICATED = true;
NEED_PASSWORD = false;
currentPlayer = data.data.player || currentPlayer;
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '已登录: ' + currentPlayer;
authVerified = true; // ★ 标记已通过验证
if (callback) callback({ok: true, mode: data.data.mode});
// ★ 如果不需要密码,直接加载个人中心
if (typeof NEED_PASSWORD !== 'undefined' && !NEED_PASSWORD) {
setTimeout(() => {
// 支付回跳 ?paid=1 直接进入充值页确认到账
if (new URLSearchParams(location.search).get('paid') === '1') {
switchPage('recharge');
} else {
switchPage('dashboard');
}
// ★ Token跟随Java端配置过期时间自动过期,不续费
// startTokenPolling() 不再调用,仅保留变量声明
}, 100);
}
} else if (data.need_password) {
NEED_PASSWORD = true;
currentPlayer = data.player || currentPlayer;
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '等待登录';
if (callback) callback({ok: false, need_password: true, player: currentPlayer});
showPasswordModal(data.message || '请输入游戏登录密码');
} else if (data.need_game_login) {
AUTHENTICATED = false;
currentPlayer = data.player || currentPlayer;
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '请先在游戏内登录';
if (callback) callback({ok: false, need_game_login: true, player: currentPlayer});
showGameLoginMessage(data.message || '请先在游戏内登录');
} else if (data.need_register) {
AUTHENTICATED = false;
currentPlayer = data.player || currentPlayer;
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '请先在游戏内注册';
if (callback) callback({ok: false, need_register: true, player: currentPlayer});
showRegisterMessage(data.message || '请先在游戏内注册账号');
} else if (data.blacklisted) {
// ★ 第32批:玩家已被拉黑(Java BlacklistManager 同步)→ 明确提示,不按Token无效处理
AUTHENTICATED = false;
currentPlayer = data.player || currentPlayer;
document.getElementById('playerInfo').textContent = '已被拉黑';
if (callback) callback({ok: false, blacklisted: true, player: currentPlayer, message: data.message});
showBlacklistedMessage(data.message || '你已被拉黑');
} else {
// Token无效 → 清除缓存,进入游客模式
AUTHENTICATED = false;
TOKEN = '';
IS_PREVIEW = true;
localStorage.removeItem('sdf1_token');
localStorage.removeItem('sdf1_player');
currentPlayer = '';
document.getElementById('playerInfo').textContent = '游客模式';
document.getElementById('previewBadge').style.display = 'inline';
document.querySelector('.guest-center-login').classList.add('show');
showLoginPrompt();
startGuestLoginTimer();
if (callback) callback({ok: false, message: data.message});
}
} catch (e) {
AUTHENTICATED = false;
TOKEN = '';
IS_PREVIEW = true;
localStorage.removeItem('sdf1_token');
localStorage.removeItem('sdf1_player');
currentPlayer = '';
document.getElementById('playerInfo').textContent = '游客模式';
document.getElementById('previewBadge').style.display = 'inline';
document.querySelector('.guest-center-login').classList.add('show');
showLoginPrompt();
startGuestLoginTimer();
if (callback) callback({ok: false, message: e.message});
}
}
function showGameLoginMessage(message) {
document.getElementById('modalTitle').textContent = '需要游戏登录';
document.getElementById('modalBody').innerHTML = `
<div style="text-align:center;padding:20px">
<div style="font-size:48px;margin-bottom:16px">🎮</div>
<p style="color:var(--dim);font-size:14px;margin-bottom:20px">${message}</p>
<div style="background:rgba(59,130,246,0.1);border:1px solid var(--accent);border-radius:8px;padding:16px;margin-bottom:20px">
<p style="color:var(--text);font-size:13px;margin:0">请在Minecraft游戏中使用 <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:3px">/sdf1_login</code> 命令登录后再访问Web端</p>
</div>
<button class="btn" onclick="location.reload()" style="margin-top:8px">刷新页面</button>
</div>`;
document.getElementById('modalOverlay').style.display = 'flex';
}
// ★ 第32批:拉黑提示弹层(不清理本地缓存,保留玩家名用于展示)
function showBlacklistedMessage(message) {
document.getElementById('modalTitle').textContent = '账号已被拉黑';
document.getElementById('modalBody').innerHTML = `
<div style="text-align:center;padding:20px">
<div style="font-size:48px;margin-bottom:16px">🚫</div>
<p style="color:var(--red);font-size:14px;margin-bottom:20px">${message}</p>
<div style="background:rgba(239,68,68,0.1);border:1px solid var(--red);border-radius:8px;padding:16px;margin-bottom:20px">
<p style="color:var(--text);font-size:13px;margin:0">你已被服务器拉黑,禁止使用商城、CDK与充值功能。如有异议请联系管理员。</p>
</div>
<button class="btn" onclick="location.reload()" style="margin-top:8px">刷新页面</button>
</div>`;
document.getElementById('modalOverlay').style.display = 'flex';
}
function showRegisterMessage(message) {
document.getElementById('modalTitle').textContent = '注册新账号';
document.getElementById('modalBody').innerHTML = `
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">玩家 <b style="color:var(--accent)">${currentPlayer}</b> 尚未注册,请设置密码完成注册。</p>
<div class="row"><label>设置密码</label><input type="password" id="regPassword" placeholder="至少6位" autofocus></div>
<div class="row"><label>确认密码</label><input type="password" id="regPassword2" placeholder="再次输入密码"></div>
<div class="row"><label>绑定邮箱(可选)</label><input type="email" id="regEmail" placeholder="用于找回密码"></div>
<div id="regError" style="color:var(--red);font-size:12px;margin-top:4px"></div>
<div style="background:rgba(245,158,11,0.1);border:1px solid #f59e0b;border-radius:8px;padding:12px;margin-top:8px">
<p style="color:var(--text);font-size:12px;margin:0">💡 注册后需等待游戏服务器同步创建账号(通常几秒内完成)</p>
</div>`;
document.getElementById('modalConfirm').textContent = '提交注册';
document.getElementById('modalConfirm').onclick = () => doWebRegister();
document.getElementById('regPassword').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('regPassword2').focus(); });
document.getElementById('regPassword2').addEventListener('keydown', e => { if (e.key === 'Enter') doWebRegister(); });
document.getElementById('modalOverlay').style.display = 'flex';
setTimeout(() => document.getElementById('regPassword').focus(), 100);
}
async function doWebRegister() {
const pwd = document.getElementById('regPassword').value;
const pwd2 = document.getElementById('regPassword2').value;
const email = document.getElementById('regEmail').value.trim();
const errEl = document.getElementById('regError');
if (!pwd) { errEl.textContent = '请输入密码'; return; }
if (pwd.length < 6) { errEl.textContent = '密码至少6位'; return; }
if (pwd !== pwd2) { errEl.textContent = '两次密码不一致'; return; }
errEl.textContent = '注册中...';
document.getElementById('modalConfirm').disabled = true;
try {
const url = new URL(API + 'register.php', location.href);
url.searchParams.set('action', 'web_register');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({web_token: TOKEN, password: pwd, email: email})
});
const data = await res.json();
if (data.success) {
document.getElementById('modalConfirm').disabled = false;
closeModal();
document.getElementById('modalTitle').textContent = '注册请求已提交';
document.getElementById('modalBody').innerHTML = `
<div style="text-align:center;padding:20px">
<div style="font-size:48px;margin-bottom:16px">✅</div>
<p style="color:var(--dim);font-size:14px;margin-bottom:16px">注册请求已提交到游戏服务器</p>
<div style="background:rgba(59,130,246,0.1);border:1px solid var(--accent);border-radius:8px;padding:16px;margin-bottom:16px">
<p style="color:var(--text);font-size:13px;margin:0">请在游戏内输入 <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:3px">/sdf1_login</code> 完成登录</p>
</div>
<p style="color:var(--dim);font-size:12px">系统将自动检测注册完成,届时页面将自动刷新</p>
<button class="btn btn-primary" onclick="location.reload()" style="margin-top:12px">刷新页面</button>
</div>`;
document.getElementById('modalOverlay').style.display = 'flex';
toast('注册请求已提交', 'success');
} else {
errEl.textContent = data.message || '注册失败';
document.getElementById('modalConfirm').disabled = false;
}
} catch (e) {
errEl.textContent = '连接失败: ' + e.message;
document.getElementById('modalConfirm').disabled = false;
}
}
async function loadBoundEmail() {
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'get_player_email');
url.searchParams.set('player', currentPlayer);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await res.json();
if (data.success && data.data) {
document.getElementById('authEmail').value = data.data.masked_email || data.data.email || '';
if (data.data.masked_email) {
document.getElementById('authEmail').placeholder = '已绑定邮箱';
} else {
document.getElementById('authEmail').placeholder = '未绑定邮箱';
}
} else {
document.getElementById('authEmail').value = '未绑定邮箱';
document.getElementById('authEmail').placeholder = '未绑定邮箱';
}
} catch (e) {
document.getElementById('authEmail').value = '加载失败';
}
}
function showPasswordModal(message) {
document.getElementById('modalTitle').textContent = '安全验证';
document.getElementById('modalBody').innerHTML = `
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">${message}</p>
<div class="row"><label>玩家名</label><input type="text" id="authPlayer" value="${currentPlayer}" readonly style="background:#21262d;cursor:not-allowed"></div>
<div style="display:flex;gap:8px;margin-bottom:12px">
<button class="btn btn-blue" id="tabPassword" onclick="switchAuthTab('password')" style="flex:1">游戏密码</button>
<button class="btn" id="tabEmail" onclick="switchAuthTab('email')" style="flex:1">邮箱验证码</button>
</div>
<div id="authPasswordTab">
<div class="row"><label>游戏登录密码</label><input type="password" id="authPassword" placeholder="输入游戏内密码" autofocus></div>
<div style="text-align:right;margin-top:4px"><a href="javascript:void(0)" onclick="showResetPasswordModal()" style="color:var(--accent);font-size:12px">忘记密码?</a></div>
<div id="cgSlotAuth"></div>
</div>
<div id="authEmailTab" style="display:none">
<div class="row"><label>绑定邮箱</label>
<input type="text" id="authEmail" readonly style="background:#21262d;cursor:not-allowed;opacity:0.7" placeholder="加载中...">
</div>
<div class="row" style="display:flex;gap:8px">
<div style="flex:1"><label>验证码</label><input type="text" id="authCode" placeholder="6位验证码" maxlength="6"></div>
<div style="display:flex;align-items:flex-end"><button class="btn btn-blue" id="sendCodeBtn" onclick="sendEmailCode()" style="white-space:nowrap">发送验证码</button></div>
</div>
</div>
<div id="authError" style="color:var(--red);font-size:12px;margin-top:4px"></div>`;
cgMount('cgSlotAuth'); // ★ 挂载人机验证(唯一实例)
document.getElementById('modalConfirm').onclick = () => doAuth();
document.getElementById('authPassword').addEventListener('keydown', e => { if (e.key === 'Enter') doAuth(); });
document.getElementById('authCode').addEventListener('keydown', e => { if (e.key === 'Enter') doAuth(); });
document.getElementById('modalOverlay').style.display = 'flex';
setTimeout(() => {
document.getElementById('authPassword').focus();
loadBoundEmail();
}, 100);
switchAuthTab('password');
}
let currentAuthTab = 'password';
function switchAuthTab(tab) {
currentAuthTab = tab;
document.getElementById('authPasswordTab').style.display = tab === 'password' ? 'block' : 'none';
document.getElementById('authEmailTab').style.display = tab === 'email' ? 'block' : 'none';
document.getElementById('tabPassword').className = tab === 'password' ? 'btn btn-blue' : 'btn';
document.getElementById('tabEmail').className = tab === 'email' ? 'btn btn-blue' : 'btn';
document.getElementById('authError').textContent = '';
}
async function sendEmailCode() {
const btn = document.getElementById('sendCodeBtn');
btn.disabled = true;
btn.textContent = '发送中...';
document.getElementById('authError').textContent = '';
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'send_email_code');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: currentPlayer, web_token: TOKEN})
});
const data = await res.json();
if (data.success) {
const maskedEmail = data.data.masked_email || '**@*';
document.getElementById('authError').innerHTML = '<span style="color:var(--green)">验证码已发送至 ' + maskedEmail + '</span>';
let countdown = 60;
const timer = setInterval(() => {
btn.textContent = countdown + '秒后重发';
countdown--;
if (countdown <= 0) {
clearInterval(timer);
btn.disabled = false;
btn.textContent = '发送验证码';
}
}, 1000);
} else {
document.getElementById('authError').textContent = data.message || '发送失败';
btn.disabled = false;
btn.textContent = '发送验证码';
}
} catch (e) {
document.getElementById('authError').textContent = '连接失败: ' + e.message;
btn.disabled = false;
btn.textContent = '发送验证码';
}
}
async function doAuth() {
if (currentAuthTab === 'password') {
await doPasswordAuth();
} else {
await doEmailAuth();
}
}
async function doPasswordAuth() {
const password = document.getElementById('authPassword').value;
if (!password) { document.getElementById('authError').textContent = '请输入密码'; return; }
document.getElementById('authError').textContent = '等待人机验证完成...';
let cap;
try {
cap = await cgPayload(); // ★ 人机验证 token(config.php 单开关,未启用时为空对象)
} catch (e) {
document.getElementById('authError').textContent = e.message || '请先完成人机验证';
return;
}
document.getElementById('authError').textContent = '正在提交登录请求...';
try {
// ★ 必须走web_login_request流程(Java验证密码),不能用verify_web_password(PHP直接验证=绕过Java)
const reqRes = await fetch(API + 'sync.php?action=web_login_request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({player: currentPlayer, password: password}, cap))
});
const reqData = await reqRes.json();
if (!reqData.success) {
document.getElementById('authError').textContent = reqData.message || '提交失败';
return;
}
const requestId = reqData.data.request_id;
document.getElementById('authError').textContent = '等待游戏服务器验证密码...';
// 轮询结果
let attempts = 0;
const pollInterval = setInterval(async () => {
attempts++;
if (attempts > 60) {
clearInterval(pollInterval);
document.getElementById('authError').textContent = '验证超时,请稍后重试';
return;
}
try {
const pollRes = await fetch(API + 'sync.php?action=check_web_login_result&player=' + encodeURIComponent(currentPlayer) + '&request_id=' + requestId);
const pollData = await pollRes.json();
if (pollData.success && pollData.data) {
const result = pollData.data;
if (result.status === 'success') {
clearInterval(pollInterval);
AUTHENTICATED = true;
NEED_PASSWORD = false;
const newToken = result.token;
TOKEN = newToken;
localStorage.setItem('sdf1_token', newToken);
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '已登录: ' + currentPlayer;
closeModal();
toast('验证成功', 'success');
switchPage(currentPage);
} else if (result.status === 'failed') {
clearInterval(pollInterval);
document.getElementById('authError').textContent = result.message || '密码错误';
}
}
} catch (e) { /* 继续轮询 */ }
}, 1000);
} catch (e) {
document.getElementById('authError').textContent = '连接失败: ' + e.message;
}
}
async function doEmailAuth() {
const code = document.getElementById('authCode').value.trim();
if (!code || code.length !== 6) { document.getElementById('authError').textContent = '请输入6位验证码'; return; }
document.getElementById('authError').textContent = '验证中...';
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'verify_email_code');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: currentPlayer, code: code, web_token: TOKEN})
});
const data = await res.json();
if (data.success) {
AUTHENTICATED = true;
NEED_PASSWORD = false;
currentPlayer = data.data.player || currentPlayer;
localStorage.setItem('sdf1_player', currentPlayer);
document.getElementById('playerInfo').textContent = '已登录: ' + currentPlayer;
closeModal();
toast('邮箱验证成功', 'success');
switchPage(currentPage);
} else {
document.getElementById('authError').textContent = data.message || '验证失败';
}
} catch (e) {
document.getElementById('authError').textContent = '连接失败: ' + e.message;
}
}
async function requirePasswordForAction(action) {
if (AUTHENTICATED) return true;
return new Promise((resolve) => {
checkAccess(action, (result) => {
if (result.ok) resolve(true);
else if (result.need_password) resolve(false);
else { toast(result.message || '验证失败', 'error'); resolve(false); }
});
});
}
function showLoginPrompt() {
const c = document.getElementById('content');
const urlLogin = new URLSearchParams(location.search).get('login') || currentPlayer || '';
c.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:40px">
<div style="font-size:64px;margin-bottom:24px">🔐</div>
<h2 style="color:var(--text);margin-bottom:12px">登录到SDF1玩家商城</h2>
<p style="color:var(--dim);font-size:14px;margin-bottom:24px;max-width:400px">
输入游戏内密码登录,无需在游戏里敲指令
</p>
<!-- 密码登录表单 -->
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px;width:360px;max-width:100%">
<div style="text-align:left;margin-bottom:16px">
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">玩家名</label>
<input type="text" id="loginPlayerName" value="${urlLogin}" placeholder="输入玩家名" maxlength="16"
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
</div>
<div style="text-align:left;margin-bottom:20px">
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
<input type="password" id="loginPassword" placeholder="输入密码" maxlength="32"
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
</div>
<div id="loginStatusBox" style="display:none;margin-bottom:16px"></div>
<div id="cgSlotLogin"></div>
<button id="loginSubmitBtn" class="btn btn-primary" style="width:100%;padding:12px" onclick="doDirectLogin()">登录</button>
<p style="color:var(--dim);font-size:12px;margin-top:12px">密码由游戏服务器验证,Web端不存储密码</p>
</div>
<!-- 备用方案 -->
<div style="background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.3);border-radius:8px;padding:16px;margin-bottom:24px;max-width:400px;text-align:left">
<h3 style="color:var(--accent);margin:0 0 8px 0;font-size:14px">📱 也可以在游戏中获取链接</h3>
<p style="color:var(--dim);font-size:12px;margin:0;line-height:1.6">
在游戏聊天栏输入 <code style="background:rgba(255,255,255,0.1);padding:1px 4px;border-radius:3px">/sdf1_login weblogin</code>
</p>
</div>
<button class="btn" onclick="showGuestMode()" style="padding:10px 20px;font-size:13px">👁️ 先逛逛(游客模式)</button>
</div>`;
cgMount('cgSlotLogin'); // ★ 挂载人机验证(唯一实例)
document.querySelector('.sidebar').style.display = 'none';
// 自动聚焦密码框
setTimeout(() => { const pwd = document.getElementById('loginPassword'); if (pwd) pwd.focus(); }, 100);
}
// ★ 直接密码登录(不需要游戏内token)
async function doDirectLogin() {
const player = document.getElementById('loginPlayerName').value.trim();
const password = document.getElementById('loginPassword').value;
const statusBox = document.getElementById('loginStatusBox');
const submitBtn = document.getElementById('loginSubmitBtn');
if (!player) { showLoginStatus('请输入玩家名', 'error'); return; }
if (!password) { showLoginStatus('请输入密码', 'error'); return; }
submitBtn.disabled = true;
showLoginStatus('等待人机验证完成...', 'loading');
let cap;
try {
cap = await cgPayload(); // ★ 人机验证 token(config.php 单开关,未启用时为空对象)
} catch (e) {
showLoginStatus(e.message || '请先完成人机验证', 'error');
submitBtn.disabled = false;
return;
}
showLoginStatus('正在提交登录请求...', 'loading');
try {
// 1. 提交登录请求
const reqRes = await fetch(API + 'sync.php?action=web_login_request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({player, password}, cap))
});
const reqData = await reqRes.json();
if (!reqData.success) {
showLoginStatus(reqData.message || '提交失败', 'error');
submitBtn.disabled = false;
return;
}
const requestId = reqData.data.request_id;
showLoginStatus('等待游戏服务器验证密码...', 'loading');
// 2. 轮询结果
let attempts = 0;
const pollInterval = setInterval(async () => {
attempts++;
if (attempts > 60) {
clearInterval(pollInterval);
showLoginStatus('验证超时,请稍后重试', 'error');
submitBtn.disabled = false;
return;
}
try {
const pollRes = await fetch(API + 'sync.php?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
const pollData = await pollRes.json();
if (pollData.success && pollData.data) {
const result = pollData.data;
if (result.status === 'success') {
clearInterval(pollInterval);
showLoginStatus('✅ 登录成功!正在跳转...', 'success');
// 存储token和玩家名
const token = result.token;
localStorage.setItem('sdf1_token', token);
localStorage.setItem('sdf1_player', player);
// 跳转到player.php带token
setTimeout(() => {
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(token);
}, 500);
} else if (result.status === 'failed') {
clearInterval(pollInterval);
showLoginStatus('❌ ' + (result.message || '密码错误'), 'error');
submitBtn.disabled = false;
}
}
} catch (e) { /* 继续轮询 */ }
}, 1000);
} catch (e) {
showLoginStatus('连接失败: ' + e.message, 'error');
submitBtn.disabled = false;
}
}
function showLoginStatus(msg, type) {
const box = document.getElementById('loginStatusBox');
if (!box) return;
const colors = { loading: 'var(--accent)', success: 'var(--green)', error: 'var(--red)' };
const bgs = { loading: 'rgba(88,166,255,0.1)', success: 'rgba(63,185,80,0.1)', error: 'rgba(248,81,73,0.1)' };
box.style.display = 'block';
box.style.background = bgs[type] || bgs.loading;
box.style.border = '1px solid ' + (colors[type] || colors.loading);
box.style.borderRadius = '6px';
box.style.padding = '10px 14px';
box.style.fontSize = '13px';
box.style.color = colors[type] || 'var(--text)';
box.innerHTML = type === 'loading' ? '<span style="display:inline-block;width:14px;height:14px;border:2px solid var(--dim);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;vertical-align:middle;margin-right:6px"></span> ' + msg : msg;
}
function showGuestMode() {
IS_PREVIEW = true;
TOKEN = '';
currentPlayer = '';
AUTHENTICATED = false;
localStorage.removeItem('sdf1_token');
localStorage.removeItem('sdf1_player');
document.getElementById('playerInfo').textContent = '游客模式';
document.getElementById('previewBadge').style.display = 'inline';
document.querySelector('.sidebar').style.display = 'block';
document.querySelector('.guest-center-login').classList.add('show');
switchPage('dashboard');
// ★ 启动60秒定时弹窗
startGuestLoginTimer();
}
// ==================== 毛玻璃登录弹窗 ====================
let guestLoginTimer = null;
let glassLoginDismissed = false;
function openGlassLogin() {
const overlay = document.getElementById('glassLoginOverlay');
overlay.classList.add('show');
cgMount('cgSlotGlass'); // ★ 挂载人机验证(唯一实例)
glassLoginDismissed = false;
// 隐藏C位登录按钮
document.querySelector('.guest-center-login').classList.remove('show');
// 停止定时弹窗
if (guestLoginTimer) { clearInterval(guestLoginTimer); guestLoginTimer = null; }
setTimeout(() => {
const nameInput = document.getElementById('glassPlayerName');
const savedPlayer = localStorage.getItem('sdf1_player') || currentPlayer || '';
if (nameInput) { nameInput.value = savedPlayer; nameInput.focus(); }
}, 100);
}
function closeGlassLogin(e) {
if (e && e.target !== document.getElementById('glassLoginOverlay')) return;
document.getElementById('glassLoginOverlay').classList.remove('show');
glassLoginDismissed = true;
glassLoginReset();
// 重新显示C位登录按钮(如果还是游客模式)
if (IS_PREVIEW) document.querySelector('.guest-center-login').classList.add('show');
// 重新启动定时弹窗
if (IS_PREVIEW) startGuestLoginTimer();
}
function glassLoginReset() {
const btn = document.getElementById('glassLoginBtn');
const st = document.getElementById('glassLoginStatus');
const pwd = document.getElementById('glassPassword');
if (btn) { btn.disabled = false; btn.textContent = '登 录'; }
if (st) { st.style.display = 'none'; st.textContent = ''; }
if (pwd) pwd.value = '';
}
function setGlassStatus(msg, type) {
const st = document.getElementById('glassLoginStatus');
if (!st) return;
const colors = { loading: 'var(--accent)', success: 'var(--green)', error: 'var(--red)' };
const bgs = { loading: 'rgba(88,166,255,0.12)', success: 'rgba(63,185,80,0.12)', error: 'rgba(248,81,73,0.12)' };
st.style.display = 'block';
st.style.background = bgs[type] || bgs.loading;
st.style.border = '1px solid ' + (colors[type] || colors.loading);
st.style.borderRadius = '8px';
st.style.color = colors[type] || 'var(--text)';
st.innerHTML = type === 'loading'
? '<span style="display:inline-block;width:14px;height:14px;border:2px solid var(--dim);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;vertical-align:middle;margin-right:6px"></span> ' + msg
: msg;
}
async function doGlassLogin() {
const player = document.getElementById('glassPlayerName').value.trim();
const password = document.getElementById('glassPassword').value;
const btn = document.getElementById('glassLoginBtn');
if (!player) { setGlassStatus('请输入玩家名', 'error'); return; }
if (!password) { setGlassStatus('请输入密码', 'error'); return; }
btn.disabled = true; btn.textContent = '登录中...';
setGlassStatus('等待人机验证完成...', 'loading');
let cap;
try {
cap = await cgPayload(); // ★ 人机验证 token(config.php 单开关,未启用时为空对象)
} catch (e) {
setGlassStatus(e.message || '请先完成人机验证', 'error');
btn.disabled = false; btn.textContent = '登 录';
return;
}
setGlassStatus('正在提交登录请求...', 'loading');
try {
const reqRes = await fetch(API + 'sync.php?action=web_login_request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({player, password}, cap))
});
const reqData = await reqRes.json();
if (!reqData.success) { setGlassStatus(reqData.message || '提交失败', 'error'); btn.disabled = false; btn.textContent = '登 录'; return; }
const requestId = reqData.data.request_id;
setGlassStatus('等待游戏服务器验证...', 'loading');
let attempts = 0;
const pollInterval = setInterval(async () => {
attempts++;
if (attempts > 60) { clearInterval(pollInterval); setGlassStatus('验证超时,请稍后重试', 'error'); btn.disabled = false; btn.textContent = '登 录'; return; }
try {
const pollRes = await fetch(API + 'sync.php?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
const pollData = await pollRes.json();
if (pollData.success && pollData.data) {
const result = pollData.data;
if (result.status === 'success') {
clearInterval(pollInterval);
setGlassStatus('登录成功!正在跳转...', 'success');
const token = result.token;
localStorage.setItem('sdf1_token', token);
localStorage.setItem('sdf1_player', player);
setTimeout(() => { window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(token); }, 600);
} else if (result.status === 'failed') {
clearInterval(pollInterval);
setGlassStatus(result.message || '密码错误', 'error');
btn.disabled = false; btn.textContent = '登 录';
}
}
} catch (e) { /* 继续轮询 */ }
}, 1000);
} catch (e) {
setGlassStatus('连接失败: ' + e.message, 'error');
btn.disabled = false; btn.textContent = '登 录';
}
}
// ★ 60秒定时弹窗(游客模式专用)
function startGuestLoginTimer() {
if (guestLoginTimer) clearInterval(guestLoginTimer);
guestLoginTimer = setInterval(() => {
if (!IS_PREVIEW) { clearInterval(guestLoginTimer); guestLoginTimer = null; return; }
// 只要游客模式就弹窗,忽略glassLoginDismissed
openGlassLogin();
}, 60000);
}
function switchPage(page) {
currentPage = page;
document.querySelectorAll('.sidebar-item').forEach(el => {
el.classList.toggle('active', el.dataset.page === page);
});
document.querySelector('.sidebar').style.display = 'block';
const c = document.getElementById('content');
if (!c) return;
if (page === 'dashboard') {
// renderDashboard 是 async 且会整体重写 content.innerHTML,
// 必须等它渲染完再把封禁公示卡插到内容区顶部
const rp = renderDashboard(c);
if (rp && typeof rp.then === 'function') {
rp.then(() => { if (typeof banCardSync === 'function') banCardSync(); }).catch(() => {});
} else if (typeof banCardSync === 'function') {
banCardSync();
}
}
else if (page === 'shop') renderShop(c);
else if (page === 'lands') renderLands(c);
else if (page === 'cdk') renderCDK(c);
else if (page === 'balance') renderBalance(c);
else if (page === 'groups') renderGroups(c);
else if (page === 'account') renderAccount(c);
else if (page === 'ticket') renderTicket(c);
else if (page === 'sn') renderSn(c);
else if (page === 'recharge') renderRecharge(c);
else if (page === 'orders') renderMyOrders(c);
}
// ==================== 债券在线充值(动态加载商品) ====================
function renderRecharge(el) {
// 先显示加载状态
el.innerHTML = `
<div class="card" style="max-width:520px;margin:0 auto">
<h2 style="margin:0 0 6px">💳 债券在线充值</h2>
<p style="color:var(--dim);font-size:13px;margin:0 0 18px">支付成功后游戏内即时到账。</p>
<div id="shopProductsContainer" style="margin-bottom:16px">
<div style="text-align:center;padding:20px;color:var(--dim)">加载商品中...</div>
</div>
<button id="rechargeBtn" class="btn btn-primary" style="width:100%;font-size:15px;padding:12px;display:none" onclick="startRecharge()">支付</button>
<div id="rechargeStatus" style="margin-top:14px;font-size:13px;min-height:20px"></div>
<p style="color:var(--dim);font-size:12px;margin-top:14px;line-height:1.6">说明:点击后跳转至支付页面完成付款,浏览器会自动跳回本页并自动确认到账。若未自动到账,可点击下方按钮手动查询。</p>
<button id="rechargeQueryBtn" class="btn" style="width:100%;margin-top:8px;display:none" onclick="pollRechargeOrder(true)">🔄 查询到账状态</button>
</div>
`;
// 加载商品列表
loadShopProducts();
// 处理支付回跳 ?paid=1
const paid = new URLSearchParams(location.search).get('paid');
const lastNo = localStorage.getItem('sdf1_last_recharge_no');
if (paid === '1') {
const st = document.getElementById('rechargeStatus');
if (lastNo) {
st.innerHTML = '<span style="color:var(--accent)">检测到支付回跳,正在确认到账…</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
pollRechargeOrder(false);
} else {
// ★ 兜底:localStorage丢失时,从服务器查最近订单
st.innerHTML = '<span style="color:var(--accent)">已返回,正在从服务器查找订单…</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
(async () => {
try {
const res = await api('pay.php', {action: 'find_recent_order'});
if (res.success && res.data && res.data.orders && res.data.orders.length > 0) {
const latest = res.data.orders[0];
localStorage.setItem('sdf1_last_recharge_no', latest.out_trade_no);
if (latest.status === 'paid') {
st.innerHTML = '<span style="color:var(--green)">✅ 此订单已支付完成,可以发起新订单。</span>';
localStorage.removeItem('sdf1_last_recharge_no');
const btn = document.getElementById('rechargeBtn');
btn.disabled = false; btn.textContent = '支付'; btn.onclick = startRecharge;
} else {
st.innerHTML = '<span style="color:var(--accent)">检测到订单 ' + latest.out_trade_no + ',正在确认到账…</span>';
pollRechargeOrder(false);
}
} else {
st.innerHTML = '<span style="color:var(--yellow)">已返回,但未找到本地订单记录。如已支付,请在「余额查询」确认或联系管理员。</span>';
}
} catch (e) {
st.innerHTML = '<span style="color:var(--yellow)">已返回,但未找到本地订单记录。如已支付,请在「余额查询」确认或联系管理员。</span>';
}
})();
}
// 清除 URL 中的 paid 标记,避免刷新重复提示
try {
const u = new URL(location.href);
u.searchParams.delete('paid');
history.replaceState({}, '', u);
} catch (e) {}
} else if (lastNo) {
// ★ 2026-07-15 续支付检测:有本地缓存订单号且无 paid 回跳,查是否仍在等待支付
(async () => {
try {
const res = await api('pay.php', {action: 'query_order', out_trade_no: lastNo});
if (res.success && res.data) {
const st = document.getElementById('rechargeStatus');
const btn = document.getElementById('rechargeBtn');
if (res.data.status === 'paid') {
// 已支付:清除本地记录,恢复按钮允许新订单
localStorage.removeItem('sdf1_last_recharge_no');
st.innerHTML = '<span style="color:var(--green)">✅ 此订单已支付完成,可以发起新订单。</span>';
btn.disabled = false;
btn.textContent = '支付';
btn.onclick = startRecharge;
} else if (res.data.status === 'created') {
// 检查订单是否已过期(5分钟)
const nowSec = Math.floor(Date.now() / 1000);
const createdAt = res.data.created_at || 0;
if (createdAt && (nowSec - createdAt) > 300) {
// 已过期:清除本地记录,重置按钮
localStorage.removeItem('sdf1_last_recharge_no');
st.innerHTML = '<span style="color:var(--yellow)">订单已过期(超过5分钟),请重新下单。</span>';
btn.disabled = false;
btn.textContent = '支付';
btn.onclick = startRecharge;
} else {
// 未支付:显示继续支付
btn.textContent = '🔄 继续支付 ¥' + (res.data.money || '0.01');
btn.onclick = async function() {
try {
btn.disabled = true;
btn.textContent = '⏳ 获取支付链接…';
const r = await api('pay.php', {action: 'create_order'});
if (r.success && r.data.pay_url) {
localStorage.setItem('sdf1_last_recharge_no', r.data.out_trade_no);
const payUrl = r.data.pay_url;
st.innerHTML = '<span style="color:var(--green)">已打开支付页面…</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
window.open(payUrl, '_blank');
pollRechargeOrder(false);
} else {
st.innerHTML = '<span style="color:var(--red)">' + (r.message || '获取支付链接失败') + '</span>';
btn.disabled = false;
btn.textContent = '🔄 继续支付';
}
} catch (e) {
st.innerHTML = '<span style="color:var(--red)">请求异常: ' + e.message + '</span>';
btn.disabled = false;
btn.textContent = '🔄 继续支付';
}
};
st.innerHTML = '<span style="color:var(--yellow)">⚠️ 您有一笔未完成的订单,<a href="javascript:void(0)" onclick="document.getElementById(\'rechargeBtn\').click()" style="color:var(--accent)">点此继续支付</a></span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
}
}
// 如果是 expired 或其他状态,不处理,保持默认按钮
}
} catch (e) {
localStorage.removeItem('sdf1_last_recharge_no');
}
})();
}
}
// 加载商店商品列表
async function loadShopProducts() {
const container = document.getElementById('shopProductsContainer');
// ★ 盲盒随机语录(带动消费情绪)
const blindBoxQuotes = [
'🎲 手气未知,欧皇可能就是你!',
'✨ 下一发就是传说级债券!',
'🔥 盲盒刺激,开箱见惊喜!',
'💎 运气爆棚,开出大额债券不是梦!',
'🍀 天选之人,?? 藏着大宝藏!',
'🎁 未知才有趣,下单揭晓答案!',
'⚡ 搏一搏,债券多一摞!',
'🌟 你的专属幸运数字正在等你!'
];
try {
const res = await api('pay.php', {action: 'get_shop_products'});
if (res.success && res.data && res.data.products) {
const products = res.data.products;
if (products.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--dim)">暂无商品</div>';
return;
}
// 渲染商品列表
let html = '';
products.forEach((product, index) => {
const isSelected = index === 0; // 默认选中第一个
const isBlindBox = product.bond_min !== product.bond_max; // 盲盒:范围格式
// 活动信息显示(根据是否过期显示不同样式)
let offerText = '';
if (product.temporary_offer) {
const isExpired = product.expire_valid === false || product.expire_valid === 'false';
if (isExpired) {
// 活动已过期:显示灰色+删除线
offerText = `<div style="font-size:12px;color:var(--dim);margin-top:4px;text-decoration:line-through">🎁 ${product.temporary_offer} (已过期)</div>`;
} else {
// 活动有效:显示黄色
offerText = `<div style="font-size:12px;color:var(--yellow);margin-top:4px">🎁 ${product.temporary_offer}${product.expire_display ? ' (有效期: ' + product.expire_display + ')' : ''}</div>`;
}
}
const stockText = product.stock === -1 ? '无限库存' : (product.stock > 0 ? `库存: ${product.stock}` : '售罄');
const stockColor = product.stock === -1 ? 'var(--green)' : (product.stock > 0 ? 'var(--dim)' : 'var(--red)');
const isSoldOut = product.stock === 0; // 售罄:仍展示,但禁止购买
// ★ 盲盒不显示具体金额,用 ?? 代替
const bondDisplay = isBlindBox ? '🎲 ?? 债券' : `${product.bond_min} 债券`;
// ★ 盲盒随机语录
const quoteText = isBlindBox ? `<div style="font-size:12px;color:var(--accent);margin-top:6px;font-style:italic">${blindBoxQuotes[Math.floor(Math.random() * blindBoxQuotes.length)]}</div>` : '';
// 计算显示价格(折扣价格优先,但必须折扣有效且未过期)
const discountValid = product.discount_active === true || product.discount_active === 'true';
const displayPrice = (discountValid && product.discount_price > 0) ? product.discount_price : product.price;
const hasDiscount = discountValid && product.discount_price > 0 && product.discount_price < product.price;
html += `
<div class="shop-product-card" data-product-id="${product.id}" data-price="${displayPrice}" data-bond-min="${product.bond_min}" data-bond-max="${product.bond_max}" data-stock="${product.stock}" onclick="selectShopProduct(this)" style="border:1px solid ${isSelected ? 'var(--accent)' : 'var(--border)'};border-radius:14px;padding:16px;margin-bottom:12px;background:${isSelected ? 'linear-gradient(135deg,rgba(63,185,80,0.08),rgba(88,166,255,0.06))' : 'transparent'};cursor:pointer;transition:all 0.2s ease;${isSoldOut ? 'opacity:0.72;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:14px;color:var(--fg)">${product.item_name}</span>
<span style="font-size:12px;color:${stockColor};border:1px solid ${stockColor};border-radius:999px;padding:2px 10px">${stockText}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline">
<div>
<div style="font-size:28px;font-weight:800;color:var(--accent)">¥${displayPrice}</div>
${hasDiscount ? `<div style="font-size:12px;color:var(--dim);text-decoration:line-through">原价 ¥${product.price}</div>` : ''}
<div style="font-size:12px;color:var(--dim)">支付金额</div>
</div>
<div style="font-size:22px;color:var(--green)">→ ${bondDisplay}</div>
</div>
${offerText}
${quoteText}
</div>
`;
});
container.innerHTML = html;
// 选中第一个商品
if (products.length > 0) {
selectShopProduct(document.querySelector('.shop-product-card'));
}
} else {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--dim)">加载商品失败</div>';
}
} catch (e) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--red)">加载商品失败: ' + e.message + '</div>';
}
}
// 选择商品
function selectShopProduct(el) {
// 取消所有选中
document.querySelectorAll('.shop-product-card').forEach(card => {
card.style.borderColor = 'var(--border)';
card.style.background = 'transparent';
});
// 选中当前
el.style.borderColor = 'var(--accent)';
el.style.background = 'linear-gradient(135deg,rgba(63,185,80,0.08),rgba(88,166,255,0.06))';
// 更新支付按钮
const btn = document.getElementById('rechargeBtn');
const price = el.dataset.price;
const bondMin = el.dataset.bondMin;
const bondMax = el.dataset.bondMax;
const stock = parseInt(el.dataset.stock, 10) || 0;
// ★ 盲盒不显示具体金额,用 ?? 代替
const bondText = bondMin === bondMax ? bondMin + ' 债券' : '🎲 ?? 债券';
btn.style.display = 'block';
btn.dataset.productId = el.dataset.productId;
// ★ 售罄(stock=0):展示商品但禁止购买;只有下架(is_active=0)才不展示
if (stock === 0) {
btn.disabled = true;
btn.textContent = '😔 已售罄';
btn.onclick = null;
} else {
btn.disabled = false;
btn.textContent = `支付宝支付 ¥${price} → ${bondText}`;
btn.onclick = startRecharge;
}
// 保存选中商品信息
window.selectedShopProduct = {
id: el.dataset.productId,
price: price,
bondMin: bondMin,
bondMax: bondMax
};
}
async function startRecharge() {
const btn = document.getElementById('rechargeBtn');
const st = document.getElementById('rechargeStatus');
if (!TOKEN) { toast('请先登录', 'error'); return; }
btn.disabled = true;
btn.textContent = '⏳ 正在创建订单…';
st.innerHTML = '<span style="color:var(--dim)">正在请求支付订单…</span>';
try {
// 获取选中的商品ID
const productId = window.selectedShopProduct ? window.selectedShopProduct.id : 0;
const res = await api('pay.php', {action: 'create_order', product_id: productId});
if (!res.success) {
st.innerHTML = '<span style="color:var(--red)">创建订单失败:' + (res.message || '未知错误') + '</span>';
btn.disabled = false;
btn.textContent = '支付';
return;
}
const { pay_url, out_trade_no, reused } = res.data;
localStorage.setItem('sdf1_last_recharge_no', out_trade_no);
st.innerHTML = '<span style="color:var(--green)">' + (reused ? '恢复支付链接' : '订单已创建') + ',正在跳转支付…(如未弹出,请允许浏览器打开新窗口)</span>';
document.getElementById('rechargeQueryBtn').style.display = 'block';
const w = window.open(pay_url, '_blank');
if (!w) {
st.innerHTML = '<span style="color:var(--yellow)">浏览器拦截了新窗口,<a href="' + pay_url + '" target="_blank" style="color:var(--accent)">点此手动打开支付页面</a></span>';
}
// ★ 防重复支付:创建订单后按钮永久锁定,只有支付成功后才恢复
pollRechargeOrder(false);
} catch (e) {
st.innerHTML = '<span style="color:var(--red)">请求异常:' + e.message + '</span>';
btn.disabled = false;
btn.textContent = '支付';
}
}
let rechargePollTimer = null;
async function pollRechargeOrder(manual) {
const st = document.getElementById('rechargeStatus');
const no = localStorage.getItem('sdf1_last_recharge_no');
if (!no) { if (manual) toast('没有可查询的订单', 'error'); return; }
if (!TOKEN) { if (manual) toast('请先登录', 'error'); return; }
if (rechargePollTimer && !manual) return; // 已在轮询
const doPoll = async () => {
try {
const res = await api('pay.php', {action: 'query_order', out_trade_no: no});
if (res.success && res.data) {
if (res.data.status === 'paid') {
st.innerHTML = '<span style="color:var(--green)">✅ 支付成功!游戏内债券发放中,请稍候刷新「余额查询」。可以发起新订单。</span>';
if (rechargePollTimer) { clearInterval(rechargePollTimer); rechargePollTimer = null; }
// 支付成功后清除本地记录
localStorage.removeItem('sdf1_last_recharge_no');
// ★ 刷新商品列表:库存可能变化(如本次购买导致售罄),并正确重置按钮状态
loadShopProducts();
} else {
st.innerHTML = '<span style="color:var(--dim)">⏳ 订单处理中(' + (res.message || '等待支付平台通知') + ')…</span>';
}
} else {
st.innerHTML = '<span style="color:var(--orange)">' + (res.message || '查询失败') + '</span>';
}
} catch (e) {
st.innerHTML = '<span style="color:var(--orange)">查询异常:' + e.message + '</span>';
}
};
await doPoll();
if (!manual && !rechargePollTimer) {
rechargePollTimer = setInterval(doPoll, 3000);
setTimeout(() => { if (rechargePollTimer) { clearInterval(rechargePollTimer); rechargePollTimer = null; } }, 120000);
}
}
// ★ 新增:Token轮询检查(不续期,只检查是否被Java端主动销毁)
function startTokenPolling() {
if (tokenPollTimer) clearInterval(tokenPollTimer);
// 每1分钟检查一次token是否仍然有效
tokenPollTimer = setInterval(() => {
if (TOKEN && !IS_PREVIEW && authVerified) {
checkTokenValidity();
}
}, 60 * 1000); // 1分钟
}
// ★ 检查token有效性(不续期,只检查)
async function checkTokenValidity() {
if (!TOKEN || IS_PREVIEW) return;
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'web_access_check');
url.searchParams.set('web_token', TOKEN);
url.searchParams.set('access_action', 'view');
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
// Token可能已失效或过期
if (data.need_password || data.need_game_login || data.need_register || data.blacklisted) {
// 需要进一步验证,不视为token无效
return;
}
// Token确实无效(可能被Java端主动销毁)
console.log('[Token] Token验证失败:', data.message);
TOKEN = '';
AUTHENTICATED = false;
localStorage.removeItem('sdf1_token');
localStorage.removeItem('sdf1_player');
currentPlayer = '';
IS_PREVIEW = true;
authVerified = false;
document.getElementById('playerInfo').textContent = '游客模式';
document.getElementById('previewBadge').style.display = 'inline';
showLoginPrompt();
}
} catch (e) {
console.log('[Token] Token检查异常:', e.message);
}
}
async function api(endpoint, params = {}) {
if (TOKEN) params.token = TOKEN;
const url = new URL(API + endpoint, location.href);
Object.entries(params).forEach(([k,v]) => url.searchParams.set(k, v));
const res = await fetch(url);
return await res.json();
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transform = 'translateX(100%)'; }, 2500);
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 3000);
}
// ★ 自定义玻璃拟态确认对话框
function showConfirm(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);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:10000;display:flex;justify-content:center;align-items:center;animation:glassFadeIn 0.2s ease';
overlay.innerHTML = '<div style="background:var(--card);border:1px solid var(--border);border-radius:16px;padding:28px;width:420px;max-width:90%;box-shadow:0 16px 64px rgba(0,0,0,0.5)">';
overlay.innerHTML += '<p style="color:var(--text);white-space:pre-line;margin-bottom:20px;font-size:14px;line-height:1.6">' + escapeHtml(msg) + '</p>';
overlay.innerHTML += '<div style="display:flex;gap:10px;justify-content:flex-end">';
overlay.innerHTML += '<button id="_confirm_yes" class="btn btn-primary">确定</button>';
overlay.innerHTML += '<button id="_confirm_no" class="btn btn-dim">取消</button>';
overlay.innerHTML += '</div></div>';
document.body.appendChild(overlay);
const yesBtn = overlay.querySelector('#_confirm_yes');
const noBtn = overlay.querySelector('#_confirm_no');
const cleanup = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); };
yesBtn.onclick = () => { cleanup(); resolve(true); };
noBtn.onclick = () => { cleanup(); resolve(false); };
});
}
function escapeHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// 通知Java插件立即同步数据
async function notifyJavaSync() {
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'request_immediate_sync');
url.searchParams.set('player', currentPlayer);
await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'} });
} catch(e) {
// 静默失败,不影响用户体验
}
}
// ==================== 我的订单 ====================
async function renderMyOrders(el) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--dim)">加载订单中...</div></div>';
try {
const res = await api('pay.php', {action: 'my_orders'});
if (!res.success || !res.data || !res.data.orders) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--red)">加载失败: ' + (res.message || '未知错误') + '</div></div>';
return;
}
const orders = res.data.orders;
if (orders.length === 0) {
el.innerHTML = `
<div class="card" style="max-width:680px;margin:0 auto">
<h2 style="margin:0 0 6px">📋 我的订单</h2>
<p style="color:var(--dim);font-size:13px;margin:0 0 18px">查看您的在线充值订单记录。</p>
<div style="text-align:center;padding:30px;color:var(--dim)">
<div style="font-size:40px;margin-bottom:12px">📭</div>
<div style="font-size:14px">暂无订单记录</div>
<div style="font-size:12px;margin-top:8px;color:var(--dim)">前往「在线充值」发起第一笔订单</div>
</div>
</div>
`;
return;
}
// 状态标签样式
const statusMap = {
'paid': { text: '✅ 已支付', color: '#1a7f37', bg: '#dafbe1' },
'created': { text: '⏳ 待支付', color: '#9a6700', bg: '#fff8c5' },
'expired': { text: '❌ 已过期', color: '#656d76', bg: '#f6f8fa' },
'failed': { text: '⚠️ 失败', color: '#cf222e', bg: '#ffebe9' },
};
let html = `
<div class="card" style="max-width:680px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div>
<h2 style="margin:0 0 4px">📋 我的订单</h2>
<p style="color:var(--dim);font-size:12px;margin:0">共 ${orders.length} 条记录</p>
</div>
<button class="btn" style="font-size:12px;padding:6px 14px" onclick="renderMyOrders(document.getElementById('content'))">🔄 刷新</button>
</div>
`;
orders.forEach(order => {
const st = statusMap[order.status] || { text: order.status, color: '#656d76', bg: '#f6f8fa' };
const createdTime = order.created_at ? new Date(order.created_at * 1000).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
const paidTime = order.paid_at ? new Date(order.paid_at * 1000).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
html += `
<div style="border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;transition:all 0.2s">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px">
<div>
<div style="font-size:14px;font-weight:600;color:var(--text)">${escHtml(order.name)}</div>
<div style="font-size:11px;color:var(--dim);margin-top:2px;font-family:monospace">${escHtml(order.out_trade_no)}</div>
</div>
<span style="font-size:12px;color:${st.color};background:${st.bg};border-radius:999px;padding:3px 10px;white-space:nowrap">${st.text}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline">
<div style="font-size:13px;color:var(--dim)">
<span>创建: ${createdTime}</span>
${order.paid_at ? '<span style="margin-left:12px">支付: ' + paidTime + '</span>' : ''}
</div>
<div style="text-align:right">
<div style="font-size:16px;font-weight:700;color:var(--red)">¥${escHtml(order.money)}</div>
<div style="font-size:12px;color:var(--green)">→ ${order.bond_amount} 债券</div>
</div>
</div>
</div>
`;
});
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--red)">加载失败: ' + e.message + '</div></div>';
}
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ===== 商城(分类标签页) =====
let shopItems = [];
let shopCategories = [];
let currentShopCat = '';
let currentBuyItem = null; // 当前购买弹窗对应的商品,供"加入购物车"使用
async function renderShop(el) {
el.innerHTML = '<div class="empty">加载中...</div>';
loadCart();
try {
const res = await api('shop.php', {action: 'list'});
if (!res.success) { el.innerHTML = '<div class="empty" style="color:var(--red)">' + (res.message || '加载失败') + '</div>'; return; }
shopItems = res.data || [];
shopCategories = [...new Set(shopItems.map(i => i.category))];
currentShopCat = shopCategories[0] || '';
let html = '<div class="card">';
html += '<h2>商城</h2>';
if (res.preview) html += '<div class="preview-badge" style="margin-bottom:12px">预览模式 - 无法购买</div>';
if (shopCategories.length === 0) {
html += '<div class="empty">暂无商品</div>';
} else {
// 分类标签
html += '<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;
html += `<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>`;
});
html += '</div>';
// 商品内容区
html += '<div id="shopContent"></div>';
}
html += '</div>';
el.innerHTML = html;
// 渲染当前分类的商品
renderShopContent();
ensureCartButton();
} catch (e) {
el.innerHTML = '<div class="empty" style="color:var(--red)">商城加载失败: ' + e.message + '</div>';
}
}
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)';
});
renderShopContent();
}
function renderShopContent() {
const contentEl = document.getElementById('shopContent');
if (!contentEl) return;
const items = shopItems.filter(i => i.category === currentShopCat);
if (items.length === 0) {
contentEl.innerHTML = '<div class="empty" style="padding:20px">该分类暂无商品</div>';
return;
}
let html = '<div class="grid">';
items.forEach(item => {
const matIcon = getMaterialIcon(item.material);
const stockText = item.stock == -1 ? '∞ 无限' : (item.stock == 0 ? '售罄' : item.stock + ' 个');
const stockClass = item.stock == 0 ? 'out' : '';
html += `<div class="item-card" onclick="showBuyModal('${item.id}','${item.display_name.replace(/'/g,"\\'")}',${item.buy_price},${item.stock},'${item.material || ''}')"><div class="icon">${matIcon}</div><div class="name">${item.display_name}</div><div class="price">${item.buy_price} 债券</div><div class="stock ${stockClass}">库存: ${stockText}</div></div>`;
});
html += '</div>';
contentEl.innerHTML = html;
}
function getMaterialIcon(mat) {
const icons = {'STONE':'🪨','DIAMOND':'💎','GOLD_INGOT':'🥇','IRON_INGOT':'🥈','EMERALD':'💚','REDSTONE':'🔴','COAL':'⬛','DIAMOND_SWORD':'⚔️','GOLDEN_APPLE':'🍎','END_CRYSTAL':'🔮','TOTEM_UNDYING':'🛡️','NETHERITE_INGOT':'⬛','ENCHANTED_BOOK':'📖','SHULKER_BOX':'📦','PLAYER_HEAD':'👤','PAPER':'📄','ARROW':'🏹','BOW':'🏹','TRIDENT':'🔱','ELYTRA':'🪽','SADDLE':'🐴','NAME_TAG':'🏷️'};
return icons[mat] || '🧱';
}
function showBuyModal(id, name, price, stock, material) {
if (IS_PREVIEW) { toast('预览模式下无法购买', 'error'); return; }
if (stock == 0) { toast('商品已售罄', 'error'); return; }
currentBuyItem = {id, name, price, stock, material: material || ''};
document.getElementById('modalTitle').textContent = '购买: ' + name;
const needPwd = price > 1000;
const maxQty = stock > 0 ? stock : 999;
document.getElementById('modalBody').innerHTML = `
<div class="row"><label>单价: ${price} 债券</label></div>
<div class="row"><label>数量</label><input type="number" id="buyAmount" value="1" min="1" max="${maxQty}"></div>
<div class="row"><label>小计: <span id="buyTotal">${price}</span> 债券</label></div>
${needPwd ? '<div class="row"><label>密码确认</label><input type="password" id="buyPassword" placeholder="输入游戏登录密码"></div>' : '<div style="color:var(--dim);font-size:12px;text-align:center;margin-top:4px">小额交易(≤1000债券),免密码确认</div>'}`;
// 加入购物车按钮
const cartBtn = document.createElement('div');
cartBtn.style.cssText = 'margin-top:12px';
cartBtn.innerHTML = '<button class="btn btn-green" style="width:100%" onclick="addToCartFromModal()">🛒 加入购物车</button>';
document.getElementById('modalBody').appendChild(cartBtn);
document.getElementById('modalConfirm').onclick = () => doBuy(id);
document.getElementById('buyAmount').oninput = function() {
const total = this.value * price;
document.getElementById('buyTotal').textContent = total;
// 动态切换密码框
const pwdRow = document.getElementById('buyPassword');
const hintEl = document.querySelector('#modalBody > div:last-child');
if (total > 1000 && !pwdRow) {
// 需要密码框
const newDiv = document.createElement('div');
newDiv.className = 'row';
newDiv.innerHTML = '<label>密码确认</label><input type="password" id="buyPassword" placeholder="输入游戏登录密码">';
document.getElementById('buyTotal').parentElement.parentElement.insertAdjacentElement('afterend', newDiv);
if (hintEl) hintEl.remove();
} else if (total <= 1000 && pwdRow) {
// 不需要密码框
pwdRow.parentElement.remove();
const hint = document.createElement('div');
hint.style.cssText = 'color:var(--dim);font-size:12px;text-align:center;margin-top:4px';
hint.textContent = '小额交易(≤1000债券),免密码确认';
document.getElementById('buyTotal').parentElement.parentElement.insertAdjacentElement('afterend', hint);
}
};
document.getElementById('modalOverlay').style.display = 'flex';
}
async function doBuy(itemId) {
const amount = parseInt(document.getElementById('buyAmount').value);
const total = parseInt(document.getElementById('buyTotal').textContent);
const password = document.getElementById('buyPassword')?.value || '';
if (total > 1000 && !password) { toast('请输入密码确认', 'error'); return; }
const res = await fetch(API + 'shop.php?action=buy', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: TOKEN, item_id: itemId, amount: amount, player: currentPlayer, password: password || undefined})
});
const data = await res.json();
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
closeModal();
toast(data.message, data.success ? 'success' : 'error');
if (data.success) {
// 购买成功 → 通知Java插件立即同步 + 刷新当前页
setTimeout(() => {
notifyJavaSync();
document.getElementById('balanceResult')?.parentElement && switchPage('balance');
setTimeout(() => switchPage('shop'), 500);
}, 500);
}
}
// ===== 购物车系统 =====
let cart = [];
let cartCfg = {backpack_rate: 0.98, shulker_rate: 1.0};
let cartSettlement = 'backpack';
let cartSubtotalVal = 0;
let cartShulkerColor = 'purple'; // 潜影盒颜色(purple免费,其它+2元)
const SHULKER_COLORS = [
{id:'purple', name:'§5紫色', price:0, css:'#AA00FF'},
{id:'white', name:'§f白色', price:2, css:'#FFFFFF'},
{id:'black', name:'§8黑色', price:2, css:'#1A1A1A'},
{id:'red', name:'§c红色', price:2, css:'#FF3333'},
{id:'blue', name:'§9蓝色', price:2, css:'#3366FF'},
{id:'green', name:'§a绿色', price:2, css:'#33FF66'},
{id:'yellow', name:'§e黄色', price:2, css:'#FFFF33'},
{id:'orange', name:'§6橙色', price:2, css:'#FF9933'}
];
function loadCart() {
try { cart = JSON.parse(localStorage.getItem('sdf1_cart') || '[]'); } catch (e) { cart = []; }
if (!Array.isArray(cart)) cart = [];
updateCartButton();
}
function saveCart() { try { localStorage.setItem('sdf1_cart', JSON.stringify(cart)); } catch (e) {} updateCartButton(); }
function updateCartButton() {
const btn = document.getElementById('cartFab');
if (!btn) return;
const count = cart.reduce((s, i) => s + (i.amount || 1), 0);
const total = cart.reduce((s, i) => s + i.price * i.amount, 0);
btn.querySelector('.cart-fab-count').textContent = count;
btn.querySelector('.cart-fab-total').textContent = total;
btn.style.display = count > 0 ? 'flex' : 'none';
}
function ensureCartButton() {
if (document.getElementById('cartFab')) { updateCartButton(); return; }
const btn = document.createElement('div');
btn.id = 'cartFab';
btn.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:9000;display:none;align-items:center;gap:8px;padding:12px 18px;background:var(--accent);color:#fff;border-radius:30px;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.35);font-size:14px;transition:transform .2s cubic-bezier(0.16,1,0.3,1)';
btn.innerHTML = '<span style="font-size:18px">🛒</span><span class="cart-fab-count">0</span><span style="opacity:.85;font-size:12px">件</span><span class="cart-fab-total" style="font-weight:700">0</span><span style="opacity:.85;font-size:12px">债券</span>';
btn.onclick = openCartDrawer;
btn.onmouseenter = () => btn.style.transform = 'scale(1.06)';
btn.onmouseleave = () => btn.style.transform = 'scale(1)';
document.body.appendChild(btn);
updateCartButton();
}
function addToCartFromModal() {
if (!currentBuyItem) return;
const amount = parseInt(document.getElementById('buyAmount')?.value || '1') || 1;
addToCart(currentBuyItem.id, currentBuyItem.name, currentBuyItem.price, currentBuyItem.material || '', currentBuyItem.stock, amount);
closeModal();
}
function addToCart(id, name, price, material, stock, amount) {
if (IS_PREVIEW) { toast('预览模式下无法加入购物车', 'error'); return; }
amount = Math.max(1, parseInt(amount) || 1);
if (stock > 0 && amount > stock) amount = stock;
const exist = cart.find(i => i.item_id == id);
if (exist) {
const max = stock > 0 ? stock : 9999;
exist.amount = Math.min((exist.amount || 1) + amount, max);
} else {
cart.push({item_id: id, name: name, price: parseInt(price), material: material, stock: parseInt(stock), amount: amount});
}
saveCart();
toast('已加入购物车', 'success');
}
function changeCartQty(id, delta) {
const i = cart.find(x => x.item_id == id);
if (!i) return;
const max = i.stock > 0 ? i.stock : 9999;
i.amount = Math.min(Math.max((i.amount || 1) + delta, 1), max);
saveCart();
renderCartList();
}
function removeFromCart(id) {
cart = cart.filter(x => x.item_id != id);
saveCart();
renderCartList();
}
function clearCart() {
cart = [];
saveCart();
renderCartList();
}
function openCartDrawer() {
if (cart.length === 0) { toast('购物车是空的', 'info'); return; }
document.getElementById('cartDrawer')?.remove();
const overlay = document.createElement('div');
overlay.id = 'cartDrawer';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);z-index:9500;display:flex;justify-content:flex-end;animation:glassFadeIn .2s ease';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
let html = '<div style="width:380px;max-width:92%;height:100%;background:var(--card);border-left:1px solid var(--border);display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.4)">';
html += '<div style="padding:18px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center"><h3 style="margin:0;color:var(--fg)">🛒 购物车</h3><button onclick="document.getElementById(\'cartDrawer\').remove()" style="background:none;border:none;color:var(--dim);font-size:20px;cursor:pointer">✕</button></div>';
html += '<div id="cartList" style="flex:1;overflow-y:auto;padding:12px 16px"></div>';
html += '<div style="padding:16px 20px;border-top:1px solid var(--border)">';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:13px"><span style="color:var(--dim)">商品原价合计</span><span id="cartSubtotal">0 债券</span></div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:12px;font-size:18px;font-weight:700"><span>预估总价</span><span id="cartGrand" style="color:var(--accent)">0 债券</span></div>';
html += '<div style="display:flex;gap:10px"><button class="btn btn-dim" style="flex:1" onclick="clearCart()">清空</button><button class="btn btn-primary" style="flex:2" onclick="openSettlement()">去结算</button></div>';
html += '</div></div>';
overlay.innerHTML = html;
document.body.appendChild(overlay);
renderCartList();
}
function renderCartList() {
const list = document.getElementById('cartList');
if (!list) return;
if (cart.length === 0) {
list.innerHTML = '<div class="empty" style="padding:30px">购物车空空如也</div>';
} else {
let h = '';
cart.forEach(i => {
const matIcon = getMaterialIcon(i.material);
h += '<div style="display:flex;gap:10px;align-items:center;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:12px;margin-bottom:8px">';
h += '<div style="font-size:24px">' + matIcon + '</div>';
h += '<div style="flex:1;min-width:0"><div style="font-size:14px;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.name) + '</div>';
h += '<div style="font-size:12px;color:var(--dim)">' + i.price + ' 债券/个</div></div>';
h += '<div style="display:flex;align-items:center;gap:6px"><button onclick="changeCartQty(' + i.item_id + ',-1)" style="width:26px;height:26px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--fg);cursor:pointer;font-size:16px;line-height:1">−</button>';
h += '<span style="min-width:28px;text-align:center;font-size:14px">' + i.amount + '</span>';
h += '<button onclick="changeCartQty(' + i.item_id + ',1)" style="width:26px;height:26px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--fg);cursor:pointer;font-size:16px;line-height:1">+</button></div>';
h += '<div style="text-align:right;min-width:64px"><div style="font-size:14px;color:var(--fg);font-weight:600">' + (i.price * i.amount) + '</div><button onclick="removeFromCart(' + i.item_id + ')" style="background:none;border:none;color:var(--red);font-size:11px;cursor:pointer;margin-top:2px">移除</button></div>';
h += '</div>';
});
list.innerHTML = h;
}
const subtotal = cart.reduce((s, i) => s + i.price * i.amount, 0);
cartSubtotalVal = subtotal;
const grand = document.getElementById('cartGrand');
if (grand) grand.textContent = subtotal + ' 债券';
const sub = document.getElementById('cartSubtotal');
if (sub) sub.textContent = subtotal + ' 债券';
updateCartButton();
}
async function openSettlement() {
if (cart.length === 0) { toast('购物车是空的', 'info'); return; }
if (IS_PREVIEW) { toast('预览模式无法结算', 'error'); return; }
try { const r = await api('shop.php', {action: 'cart_config'}); if (r.success && r.data) cartCfg = r.data; } catch (e) {}
const subtotal = cart.reduce((s, i) => s + i.price * i.amount, 0);
cartSubtotalVal = subtotal;
const bpTotal = Math.round(subtotal * cartCfg.backpack_rate);
const shTotal = Math.round(subtotal * cartCfg.shulker_rate);
const overlay = document.createElement('div');
overlay.id = 'cartSettle';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:9800;display:flex;justify-content:center;align-items:center;animation:glassFadeIn .2s ease';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
let html = '<div style="width:440px;max-width:92%;max-height:88vh;overflow-y:auto;background:var(--card);border:1px solid var(--border);border-radius:18px;padding:24px;box-shadow:0 20px 64px rgba(0,0,0,.5)">';
html += '<h3 style="margin:0 0 4px;color:var(--fg)">🧾 选择结算方式</h3>';
html += '<p style="color:var(--dim);font-size:12px;margin:0 0 16px">共 ' + cart.reduce((s, i) => s + i.amount, 0) + ' 件 · 原价 ' + subtotal + ' 债券</p>';
html += '<div id="settleModes" style="display:flex;flex-direction:column;gap:10px;margin-bottom:14px">';
html += settleModeHtml('backpack', '🎒 塞背包', cartCfg.backpack_rate, bpTotal, subtotal, '直接发放到背包,享 ' + (cartCfg.backpack_rate * 10).toFixed(1) + ' 折');
html += settleModeHtml('shulker', '📦 潜影盒打包', cartCfg.shulker_rate, shTotal, subtotal, shulkerDesc(cartCfg.shulker_rate, subtotal, shTotal));
html += '</div>';
// 潜影盒颜色选择(仅shulker模式显示)
html += '<div id="shulkerColorWrap" style="display:none;margin-bottom:14px;padding:12px;border:1px solid var(--border);border-radius:10px;background:var(--bg)">';
html += '<div style="font-size:13px;color:var(--fg);font-weight:600;margin-bottom:8px">🎨 选择潜影盒颜色 <span id="colorFeeHint" style="font-weight:400;color:var(--dim);font-size:12px"></span></div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
for (const c of SHULKER_COLORS) {
const checked = c.id === 'purple' ? 'checked' : '';
const extra = c.price > 0 ? ' (+' + c.price + '元)' : ' (免费)';
html += '<label class="shulker-color-opt" data-color="' + c.id + '" onclick="selectShulkerColor(\'' + c.id + '\', ' + c.price + ')" style="display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:12px;color:var(--fg);transition:all .15s"><input type="radio" name="shcolor" value="' + c.id + '" ' + checked + ' style="margin:0"><span style="width:16px;height:16px;border-radius:3px;display:inline-block;background:' + c.css + ';border:1px solid rgba(255,255,255,.2)"></span>' + c.name.replace(/§./g, '') + '<span style="color:var(--dim)">' + extra + '</span></label>';
}
html += '</div></div>';
html += '<div id="settlePwdWrap"></div>';
html += '<div style="display:flex;gap:10px;margin-top:6px"><button class="btn btn-dim" style="flex:1" onclick="document.getElementById(\'cartSettle\').remove()">取消</button><button class="btn btn-primary" style="flex:2" id="settleConfirmBtn" onclick="doSettle()">确认结算</button></div>';
html += '</div>';
overlay.innerHTML = html;
document.body.appendChild(overlay);
selectSettleMode('backpack');
}
function shulkerDesc(rate, subtotal, total) {
const diff = total - subtotal;
if (diff > 0) return '打包进潜影盒,加价 ' + diff + ' 债券';
if (diff < 0) return '打包进潜影盒,优惠 ' + (-diff) + ' 债券';
return '打包进潜影盒,原价结算';
}
function settleModeHtml(mode, title, rate, total, subtotal, desc) {
const saved = subtotal - total;
let badge;
if (saved > 0) badge = '<span style="color:var(--green);font-size:12px">省 ' + saved + ' 债券</span>';
else if (saved < 0) badge = '<span style="color:var(--orange);font-size:12px">加价 ' + (-saved) + ' 债券</span>';
else badge = '<span style="color:var(--dim);font-size:12px">原价</span>';
const priceId = mode === 'shulker' ? 'id="shulkerPriceDisplay"' : '';
return '<div class="settle-mode" data-mode="' + mode + '" onclick="selectSettleMode(\'' + mode + '\')" style="padding:12px 14px;border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:all .15s"><div style="display:flex;justify-content:space-between;align-items:center"><div style="font-size:14px;color:var(--fg);font-weight:600">' + title + '</div><div style="text-align:right"><div style="font-size:15px;font-weight:700;color:var(--accent)" ' + priceId + '>' + total + ' 债券</div>' + badge + '</div></div><div style="font-size:12px;color:var(--dim);margin-top:4px">' + desc + '</div></div>';
}
function selectSettleMode(mode) {
cartSettlement = mode;
document.querySelectorAll('.settle-mode').forEach(c => {
const active = c.dataset.mode === mode;
c.style.borderColor = active ? 'var(--accent)' : '';
c.style.background = active ? 'color-mix(in srgb, var(--accent) 10%, transparent)' : '';
});
// 显示/隐藏潜影盒颜色选择器
const colorWrap = document.getElementById('shulkerColorWrap');
if (colorWrap) colorWrap.style.display = mode === 'shulker' ? '' : 'none';
updateSettlePwd();
}
function selectShulkerColor(colorId, price) {
cartShulkerColor = colorId;
document.querySelectorAll('.shulker-color-opt').forEach(c => {
const sel = c.dataset.color === colorId;
c.style.borderColor = sel ? 'var(--accent)' : 'var(--border)';
c.style.background = sel ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '';
const radio = c.querySelector('input[type="radio"]');
if (radio) radio.checked = sel;
});
const hint = document.getElementById('colorFeeHint');
if (hint) hint.textContent = price > 0 ? '(额外收费 +' + price + ' 债券)' : '(免费)';
// 更新价格显示
updateSettleTotal();
}
function updateSettleTotal() {
const priceEl = document.getElementById('shulkerPriceDisplay');
if (priceEl && cartSettlement === 'shulker') {
const base = cartSubtotalVal * cartCfg.shulker_rate;
const colorExtra = SHULKER_COLORS.find(c => c.id === cartShulkerColor)?.price || 0;
priceEl.textContent = Math.round(base + colorExtra) + ' 债券';
}
}
function updateSettlePwd() {
const wrap = document.getElementById('settlePwdWrap');
if (!wrap) return;
const total = Math.round(cartSubtotalVal * (cartSettlement === 'shulker' ? cartCfg.shulker_rate : cartCfg.backpack_rate));
if (total > 1000) {
wrap.innerHTML = '<div class="row" style="margin-top:8px"><label>密码确认</label><input type="password" id="settlePassword" placeholder="输入游戏登录密码" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>';
} else {
wrap.innerHTML = '<div style="color:var(--dim);font-size:12px;text-align:center;margin-top:8px">小额结算(≤1000债券),免密码确认</div>';
}
}
async function doSettle() {
const subtotal = cart.reduce((s, i) => s + i.price * i.amount, 0);
let total = Math.round(subtotal * (cartSettlement === 'shulker' ? cartCfg.shulker_rate : cartCfg.backpack_rate));
// 潜影盒颜色额外收费
if (cartSettlement === 'shulker') {
const colorExtra = SHULKER_COLORS.find(c => c.id === cartShulkerColor)?.price || 0;
total += colorExtra;
}
const password = document.getElementById('settlePassword')?.value || '';
if (total > 1000 && !password) { toast('请输入密码确认', 'error'); return; }
const itemsPayload = cart.map(i => ({item_id: i.item_id, amount: i.amount}));
const btn = document.getElementById('settleConfirmBtn');
if (btn) { btn.disabled = true; btn.textContent = '结算中...'; }
try {
const bodyData = {token: TOKEN, items: JSON.stringify(itemsPayload), settlement: cartSettlement, player: currentPlayer, password: password || undefined};
if (cartSettlement === 'shulker') bodyData.shulker_color = cartShulkerColor;
const res = await fetch(API + 'shop.php?action=buy_cart', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(bodyData)
});
const data = await res.json();
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
document.getElementById('cartSettle')?.remove();
if (data.success) {
const paid = data.data.total_price;
const modeName = data.data.mode_name;
cart = [];
saveCart();
toast('结算成功(' + modeName + '):' + paid + ' 债券', 'success');
setTimeout(() => { notifyJavaSync(); switchPage('shop'); }, 500);
} else {
toast(data.message || '结算失败', 'error');
}
} catch (e) {
toast('结算请求失败: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = '确认结算'; }
}
}
// ===== 个人中心仪表盘 =====
async function renderDashboard(el) {
if (IS_PREVIEW || !currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = `
<div class="card" style="text-align:center;padding:48px 20px">
<div style="font-size:64px;margin-bottom:16px">📊</div>
<h2 style="color:var(--accent);margin-bottom:8px">个人中心</h2>
<p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看个人信息</p>
<p style="color:var(--dim);font-size:12px">请先在游戏内登录,或输入密码登录</p>
</div>`;
return;
}
el.innerHTML = '<div class="card" style="text-align:center;padding:40px"><div class="empty">加载中...</div></div>';
try {
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
if (!res.success) {
el.innerHTML = `<div class="card" style="border-color:var(--red)"><h2>加载失败</h2><p style="color:var(--dim);margin-top:8px">${res.message || '未知错误'}</p></div>`;
return;
}
const d = res.data;
const loginTime = d.last_login_time ? new Date(d.last_login_time * 1000).toLocaleString() : '暂无';
const regTime = d.register_time ? new Date(d.register_time * 1000).toLocaleString() : '暂无';
const hours = Math.floor((d.total_online_time || 0) / 3600);
const mins = Math.floor(((d.total_online_time || 0) % 3600) / 60);
const onlineText = hours > 0 ? `${hours}小时${mins}分钟` : `${mins}分钟`;
el.innerHTML = `
<div class="card" style="margin-bottom:0">
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div style="width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--purple));display:flex;align-items:center;justify-content:center;font-size:28px;color:#fff;font-weight:700">${d.player_name.charAt(0).toUpperCase()}</div>
<div>
<h2 style="margin:0;font-size:20px">${d.player_name}</h2>
<p style="color:var(--dim);font-size:12px;margin-top:2px">注册于 ${regTime}</p>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-top:12px">
<div class="stat-box" style="border-left:3px solid var(--accent)">
<div class="value" style="font-size:24px;color:var(--accent)">📧 ${d.masked_email || '未绑定'}</div>
<div class="label">绑定邮箱</div>
</div>
<div class="stat-box" style="border-left:3px solid var(--green)">
<div class="value" style="font-size:24px;color:var(--green)">${d.bonds ?? 0}</div>
<div class="label">债券余额</div>
</div>
<div class="stat-box" style="border-left:3px solid var(--yellow)">
<div class="value" style="font-size:24px;color:var(--yellow)">${d.points ?? 0}</div>
<div class="label">积分</div>
</div>
<div class="stat-box" style="border-left:3px solid var(--purple)">
<div class="value" style="font-size:24px;color:var(--purple)">${loginTime}</div>
<div class="label">最后登录时间</div>
</div>
<div class="stat-box" style="border-left:3px solid #58a6ff">
<div class="value" style="font-size:24px;color:#58a6ff">${d.total_online_days ?? 0} 天</div>
<div class="label">累计在线天数</div>
</div>
<div class="stat-box" style="border-left:3px solid #3fb950">
<div class="value" style="font-size:24px;color:#3fb950">${d.total_checkin_days ?? 0} 天</div>
<div class="label">累计签到天数</div>
</div>
</div>
<div class="card" style="margin-top:12px">
<h2>详细信息</h2>
<table class="table" style="margin-top:8px">
<tr><td style="color:var(--dim);width:120px">累计在线时长</td><td>${onlineText}</td></tr>
<tr><td style="color:var(--dim)">礼包阶段</td><td>${d.gift_stage ?? 0}</td></tr>
</table>
</div>`;
} catch (e) {
el.innerHTML = `<div class="card" style="border-color:var(--red)"><h2>加载异常</h2><p style="color:var(--dim);margin-top:8px">${e.message}</p></div>`;
}
}
// ===== CDK =====
function renderCDK(el) {
el.innerHTML = `
<div class="card">
<h2>CDK兑换</h2>
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">输入CDK兑换码,获得债券奖励</p>
${IS_PREVIEW ? '<div class="preview-badge" style="margin-bottom:12px">预览模式 - 兑换不会生效</div>' : ''}
<div class="cdk-input" style="flex-direction:column;gap:8px">
<input type="text" id="cdkCode" placeholder="请输入CDK兑换码..." maxlength="64">
<button class="btn btn-green" onclick="doCDKExchange()">兑换</button>
</div>
<div id="cdkResult" style="margin-top:12px"></div>
</div>`;
}
async function doCDKExchange() {
const code = document.getElementById('cdkCode').value.trim();
if (!code) { toast('请输入CDK码', 'error'); return; }
const res = await fetch(API + 'cdk.php?action=exchange', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token: TOKEN, code: code, player: currentPlayer})
});
const data = await res.json();
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
if (data.success) {
document.getElementById('cdkResult').innerHTML = `<div class="card" style="border-color:var(--green)"><h2 style="color:var(--green)">兑换成功!</h2><p>获得 ${data.data.amount} 债券</p><p>当前余额: ${data.data.balance_after} 债券</p><p style="color:var(--dim);font-size:12px;margin-top:8px">数据已同步,请刷新页面查看最新余额</p></div>`;
// CDK兑换成功后 → 通知Java同步 + 3秒刷新余额
setTimeout(() => notifyJavaSync(), 500);
setTimeout(() => doQueryBalance(), 3000);
} else {
document.getElementById('cdkResult').innerHTML = `<div class="card" style="border-color:var(--red)"><h2 style="color:var(--red)">兑换失败</h2><p>${data.message}</p></div>`;
}
}
// ===== 余额 =====
function renderBalance(el) {
if (!currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = '<div class="card"><h2>余额查询</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
return;
}
el.innerHTML = `
<div class="card">
<h2>余额查询</h2>
<div id="balanceResult" style="margin-top:16px"></div>
</div>`;
doQueryBalance();
}
async function doQueryBalance() {
if (!currentPlayer) return;
try {
const url = new URL(API + 'balance.php', location.href);
url.searchParams.set('action', 'query');
url.searchParams.set('player', currentPlayer);
if (TOKEN) url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
const div = document.getElementById('balanceResult');
if (data.need_password) { showPasswordModal(data.message || '请输入游戏登录密码'); return; }
if (data.success) {
const d = data.data;
div.innerHTML = `<div class="stat-row"><div class="stat-box"><div class="value" style="color:var(--green)">${d.bonds}</div><div class="label">债券</div></div><div class="stat-box"><div class="value" style="color:var(--purple)">${d.points}</div><div class="label">积分</div></div></div><div style="text-align:center;color:var(--dim);font-size:12px;margin-top:8px">数据${d.freshness || '状态未知'} | 以游戏内实际数据为准</div>`;
} else {
div.innerHTML = `<div class="card" style="border-color:var(--red)">${data.message}</div>`;
}
} catch(e) {
document.getElementById('balanceResult').innerHTML = '<div class="card" style="border-color:var(--red)">查询失败: '+e.message+'</div>';
}
}
// ===== 用户组 =====
function renderGroups(el) {
if (!currentPlayer || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = '<div class="card"><h2>👥 用户组</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
return;
}
el.innerHTML = `
<div class="card">
<h2>👥 用户组</h2>
<div id="myGroupsResult" style="margin-top:12px"><div style="color:var(--dim);font-size:13px">加载中...</div></div>
</div>
<div class="card">
<h2>🛒 可购买的用户组</h2>
<div id="availableGroupsResult" style="margin-top:12px"><div style="color:var(--dim);font-size:13px">加载中...</div></div>
</div>`;
loadMyGroups();
loadAvailableGroups();
}
async function loadMyGroups() {
const div = document.getElementById('myGroupsResult');
try {
const res = await api('land_api.php', {action: 'get_player_groups', player: currentPlayer, token: TOKEN});
if (!res.success) { div.innerHTML = `<div style="color:var(--red);font-size:13px">${res.error || '加载失败'}</div>`; return; }
const groups = res.groups || [];
if (groups.length === 0) {
div.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">你还没有加入任何用户组</div>';
return;
}
let html = '';
groups.forEach(g => {
const now = Math.floor(Date.now() / 1000);
const expiry = parseInt(g.expiry_time) || 0;
let statusHtml = '';
let colorStyle = g.display_color ? g.display_color.replace(/§/g, '') : '';
// 将MC颜色代码转为CSS
const mcColors = {'0':'#000','1':'#00a','2':'#0a0','3':'#0aa','4':'#a00','5':'#a0a','6':'#fa0','7':'#aaa','8':'#555','9':'#55f','a':'#5f5','b':'#5ff','c':'#f55','d':'#f5f','e':'#ff5','f':'#fff'};
const mcBold = {'l':'font-weight:bold;'};
let cssColor = '';
let cssBold = '';
for (let i = 0; i < colorStyle.length; i++) {
if (mcColors[colorStyle[i]]) cssColor = mcColors[colorStyle[i]];
if (colorStyle[i] === 'l') cssBold = 'font-weight:bold;';
}
const displayName = g.display_name || g.group_name;
const displayNameHtml = `<span style="color:${cssColor};${cssBold}">${displayName}</span>`;
// 格式化有效期
const durMin = parseInt(g.duration_minutes) || 0;
let durStr = '';
if (durMin > 0) {
durStr = durMin >= 1440 ? '有效期:' + Math.floor(durMin/1440) + '天' : '有效期:' + Math.floor(durMin/60) + '小时' + (durMin%60 ? ' '+(durMin%60)+'分钟' : '');
} else {
durStr = '永久有效';
}
const homeLimit = g.home_limit != null ? g.home_limit : '默认';
const landPrice = parseInt(g.land_price_per_sqm) !== -1 ? g.land_price_per_sqm : -1;
if (expiry > 0) {
const left = expiry - now;
if (left <= 0) {
statusHtml = '<span style="color:var(--red);font-size:12px">已过期</span>';
} else {
const days = Math.floor(left / 86400);
const hours = Math.floor((left % 86400) / 3600);
const mins = Math.floor((left % 3600) / 60);
let timeStr = '';
if (days > 0) timeStr += days + '天';
if (hours > 0) timeStr += hours + '小时';
timeStr += mins + '分钟';
statusHtml = `<span style="color:var(--green);font-size:12px">剩余 ${timeStr}</span>`;
if (g.auto_renew == 1) statusHtml += ' <span style="color:var(--purple);font-size:11px">[自动续费]</span>';
}
} else {
statusHtml = '<span style="color:var(--dim);font-size:12px">永久</span>';
}
let renewBtn = '';
if (expiry > 0 && expiry - now < 86400 && parseInt(g.renew_price) > 0) {
renewBtn = ` <button class="btn btn-blue" style="font-size:11px;padding:3px 8px;margin-left:8px" onclick="doRenewGroup('${g.group_name}')">续费 ${g.renew_price}张</button>`;
}
html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);gap:8px;flex-wrap:wrap">
<div>
${displayNameHtml}
<div style="font-size:11px;color:var(--dim);margin-top:4px">
${durStr} · Home:${homeLimit} · 领地${landPrice === -1 ? '默认' : '('+landPrice+'张/格'+String.fromCharCode(178)+')'} · 加入${parseInt(g.join_price)>0?(g.join_price+'张'):((g.auto_renew==1?'可续费':'免费'))}
</div>
</div>
<div style="display:flex;align-items:center;gap:8px">${statusHtml}${renewBtn}</div>
</div>`;
});
div.innerHTML = html;
} catch(e) {
div.innerHTML = `<div style="color:var(--red);font-size:13px">加载失败: ${e.message}</div>`;
}
}
async function loadAvailableGroups() {
const div = document.getElementById('availableGroupsResult');
window._availGroupsMap = window._availGroupsMap || {};
try {
const res = await api('land_api.php', {action: 'list_available_groups', token: TOKEN});
if (!res.success) { div.innerHTML = `<div style="color:var(--red);font-size:13px">${res.error || '加载失败'}</div>`; return; }
const groups = res.groups || [];
// Cache for buy dialog display
groups.forEach(g => { window._availGroupsMap[g.group_name] = g; });
if (groups.length === 0) {
div.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">暂无可购买的用户组</div>';
return;
}
let html = '<div class="grid">';
groups.forEach(g => {
const mcColors = {'0':'#000','1':'#00a','2':'#0a0','3':'#0aa','4':'#a00','5':'#a0a','6':'#fa0','7':'#aaa','8':'#555','9':'#55f','a':'#5f5','b':'#5ff','c':'#f55','d':'#f5f','e':'#ff5','f':'#fff'};
const colorCode = (g.display_color || '§f').replace(/§/g, '');
let cssColor = '#fff';
for (let i = 0; i < colorCode.length; i++) { if (mcColors[colorCode[i]]) cssColor = mcColors[colorCode[i]]; }
const dur = parseInt(g.duration_minutes) || 0;
let durStr = dur >= 1440 ? Math.floor(dur/1440) + '天' : dur >= 60 ? Math.floor(dur/60) + '小时' : dur + '分钟';
html += `<div class="item-card" style="border-color:var(--border)">
<div style="display:flex;justify-content:space-between;align-items:start">
<div class="name" style="color:${cssColor}">${g.display_emoji || ''} ${g.display_name || g.group_name}</div>
<div class="price" style="color:var(--green);font-weight:600">${g.join_price}张</div>
</div>
<div style="color:var(--dim);font-size:12px;margin-top:6px">有效期: ${durStr} · Home:${g.home_limit || 0} · 续费:${g.renew_price}张</div>
<div style="margin-top:10px"><button class="btn btn-blue" style="width:100%" onclick="doBuyGroup('${g.group_name}')">购买加入</button></div>
</div>`;
});
html += '</div>';
div.innerHTML = html;
} catch(e) {
div.innerHTML = `<div style="color:var(--red);font-size:13px">加载失败: ${e.message}</div>`;
}
}
async function doBuyGroup(groupName) {
const g = window._availGroupsMap?.[groupName];
const price = g ? g.join_price + ' 张债券' : '(价格未知)';
const dur = g ? (g.duration_minutes == 0 ? '永久' : (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时')) : '未知';
if (!(await showConfirm(`确认付费加入用户组「${g ? (g.display_name || groupName) : groupName}」?\n价格: ${price}\n有效期: ${dur}\n将从债券余额扣费。`))) return;
try {
const res = await api('land_api.php', {action: 'buy_group', group: groupName, player: currentPlayer, token: TOKEN});
if (res.success) {
showToast(res.message || '加入成功', 'success');
loadMyGroups();
loadAvailableGroups();
} else {
showToast(res.error || '加入失败', 'error');
}
} catch(e) {
showToast('请求失败: ' + e.message, 'error');
}
}
async function doRenewGroup(groupName) {
const g = window._availGroupsMap?.[groupName];
const price = g ? (parseInt(g.renew_price) > 0 ? g.renew_price + ' 张债券' : '免费') : '未知';
const dur = g && g.duration_minutes ? (g.duration_minutes >= 1440 ? Math.floor(g.duration_minutes/1440) + '天' : Math.floor(g.duration_minutes/60) + '小时') : '由用户组配置决定';
if (!(await showConfirm(`确认续费用户组「${g ? (g.display_name || groupName) : groupName}」?\n续费价格: ${price}\n延长有效期: ${dur}\n将从债券余额扣费。`))) return;
try {
const res = await api('land_api.php', {action: 'renew_group', group: groupName, player: currentPlayer, token: TOKEN});
if (res.success) {
showToast(res.message || '续费请求已提交', 'success');
loadMyGroups();
} else {
showToast(res.error || '续费失败', 'error');
}
} catch(e) {
showToast('请求失败: ' + e.message, 'error');
}
}
// ===== 账号(2026-10-22 第44批:账号信息 + 账号安全双卡) =====
function renderAccount(el) {
if (IS_PREVIEW || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`;
return;
}
if (!currentPlayer) {
el.innerHTML = '<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">请先登录游戏</p></div></div>';
return;
}
el.innerHTML = `
<div class="card">
<h2>账号信息</h2>
<div id="accountResult" style="margin-top:16px"></div>
</div>
<div class="card">
<h2 style="display:flex;align-items:center;justify-content:space-between">🛡️ 账号安全
<button class="btn" style="font-size:12px;padding:4px 10px" onclick="acctRefreshStatus()">🔄 刷新</button>
</h2>
<p style="color:var(--dim);font-size:12px;margin:4px 0 12px">修改任何账号信息前需完成两层验证:① 输入一次完整登录密码(强制)→ ② 邮箱验证码 / 认证器动态码 / 恢复代码(三选一)。</p>
<div id="accountSecurity" style="margin-top:4px"><div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div></div>
</div>`;
doQueryAccount();
acctRefreshStatus();
}
async function doQueryAccount() {
if (!currentPlayer) return;
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
const div = document.getElementById('accountResult');
if (!div) return;
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() : '未知';
div.innerHTML = `<table class="table"><tr><td style="color:var(--dim)">用户名</td><td>${d.player_name}</td></tr><tr><td style="color:var(--dim)">注册时间</td><td>${regTime}</td></tr><tr><td style="color:var(--dim)">最后登录</td><td>${loginTime}</td></tr><tr><td style="color:var(--dim)">积分</td><td>${d.points || 0}</td></tr><tr><td style="color:var(--dim)">礼包阶段</td><td>${d.gift_stage || 0}</td></tr><tr><td style="color:var(--dim)">在线时长</td><td>${Math.floor((d.total_online_time||0)/3600)}小时</td></tr></table>`;
} else {
div.innerHTML = `<div class="card" style="border-color:var(--red)">${escapeHtml(res.message || '查询失败')}</div>`;
}
}
// ===== 账号安全状态(api/account.php?action=status) =====
let _acctStatus = null;
async function acctRefreshStatus() {
const box = document.getElementById('accountSecurity');
if (!box) return;
box.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div>';
const retry = '<button class="btn" style="font-size:12px;padding:2px 8px;margin-left:6px" onclick="acctRefreshStatus()">重试</button>';
if (!TOKEN) { box.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">请先登录游戏后再查看账号安全信息</div>'; return; }
try {
const res = await apiPost('account.php', {action: 'status'});
if (!res.success) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">${escapeHtml(res.message || '加载失败')}${retry}</div>`;
return;
}
_acctStatus = res.data;
box.innerHTML = acctStatusHtml(res.data);
} catch (e) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">连接失败: ${escapeHtml(e.message)}${retry}</div>`;
}
}
function acctRow(label, valueHtml, actionHtml) {
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)">
<div style="min-width:0"><div style="font-size:13px;color:var(--dim)">${label}</div><div style="font-size:14px;margin-top:2px;word-break:break-all">${valueHtml}</div></div>
<div style="flex-shrink:0;display:flex;gap:8px;align-items:center">${actionHtml || ''}</div>
</div>`;
}
function acctStatusHtml(d) {
let html = '';
if (!d.can_verify) {
html += `<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.35);border-radius:8px;padding:10px 12px;font-size:12.5px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(d.hint || '当前无法完成第二层验证')}</div>`;
}
html += acctRow('登录密码', '已设置(6-20位)',
`<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('change_password','修改登录密码')">修改</button>`);
html += acctRow('绑定邮箱', d.has_email ? escapeHtml(d.email_masked) : '<span style="color:var(--red)">未绑定</span>',
d.has_email ? '<span style="font-size:12px;color:var(--dim)">第二层验证可用</span>'
: '<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="acctBindEmailWizard()">网页绑定</button>');
const twofaVal = d.twofa_bound
? '✅ 已绑定认证器(TOTP)'
: (d.pending_setup ? '⏳ 密钥已生成但未确认' : '<span style="color:var(--dim)">未绑定</span>');
let twofaBtns = '';
if (d.twofa_bound) {
twofaBtns += `<button class="btn btn-dim" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_unbind','解绑二次验证')">解绑</button>`;
} else if (d.can_verify) {
twofaBtns += `<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_setup','绑定二次验证器')">绑定</button>`;
}
html += acctRow('二次验证器', twofaVal, twofaBtns);
const recVal = d.has_recovery
? '已生成(未用完)'
: (d.twofa_bound ? '<span style="color:var(--dim)">未生成</span>' : '<span style="color:var(--dim)">绑定认证器后生成</span>');
let recBtns = '';
if (d.has_recovery) {
recBtns += `<button class="btn" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_list','查看恢复代码')">查看</button>`;
}
if (d.twofa_bound || d.has_recovery) {
recBtns += `<button class="btn btn-yellow" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_regenerate','重新生成恢复代码')">${d.has_recovery ? '重新生成' : '生成'}</button>`;
}
html += acctRow('恢复代码', recVal, recBtns);
html += `<p style="color:var(--dim);font-size:12px;margin-top:12px;line-height:1.6">💡 恢复代码是认证器丢失时的备用第二层验证方式(每组一次性使用)。修改密码、绑定/解绑认证器、查看/重生成恢复代码,均需先完成两层验证。</p>`;
return html;
}
// ===== 第53批:网页绑定邮箱向导(游戏内批准流,60秒时效) =====
let _bindWizTimer = null;
let _bindWizId = 0;
function acctBindEmailWizard() {
if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; }
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
drawerOpen('📧 网页绑定邮箱');
// ★ 第57批续4:先获取邮箱后缀配置,展示可绑定的邮箱类型
apiPost('account.php', {action: 'get_email_suffix_config'}).then(res => {
let hintHtml = '';
if (res.success && res.data) {
const mode = res.data.email_mode || '默认';
const suffixes = res.data.email_suffixes || [];
if (suffixes.length > 0) {
if (mode === '白名单') {
hintHtml = '<div style="font-size:12px;color:var(--green);margin-bottom:8px;line-height:1.6">✅ 仅允许绑定以下邮箱:<br>' + suffixes.map(s => '<span style="background:rgba(63,185,80,0.15);padding:2px 8px;border-radius:4px;margin:2px;font-family:monospace">@' + s + '</span>').join('') + '</div>';
} else if (mode === '黑名单') {
hintHtml = '<div style="font-size:12px;color:var(--red);margin-bottom:8px;line-height:1.6">❌ 以下邮箱被禁止绑定:<br>' + suffixes.map(s => '<span style="background:rgba(248,81,73,0.15);padding:2px 8px;border-radius:4px;margin:2px;font-family:monospace">@' + s + '</span>').join('') + '</div>';
}
}
}
drawerSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px;line-height:1.7">输入邮箱后,你的游戏内会收到一条批准通知(需在线)。<b>1分钟内</b>点击「✔ 批准」,再回到这里输入登录密码即可完成绑定;拒绝或超时则直接折返,不做任何处置。</div>
${hintHtml}
<input type="email" id="bindEmailInput" placeholder="you@example.com" autocomplete="email" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'发送批准请求 →', acctBindEmailStart);
setTimeout(() => { const el = document.getElementById('bindEmailInput'); if (el) el.focus(); }, 120);
}).catch(() => {
// API 失败也展示基础向导
drawerSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px;line-height:1.7">输入邮箱后,你的游戏内会收到一条批准通知(需在线)。<b>1分钟内</b>点击「✔ 批准」,再回到这里输入登录密码即可完成绑定;拒绝或超时则直接折返,不做任何处置。</div>
<input type="email" id="bindEmailInput" placeholder="you@example.com" autocomplete="email" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'发送批准请求 →', acctBindEmailStart);
setTimeout(() => { const el = document.getElementById('bindEmailInput'); if (el) el.focus(); }, 120);
});
}
async function acctBindEmailStart() {
const el = document.getElementById('bindEmailInput');
const email = el ? el.value.trim() : '';
if (!email) { drawerErr('请输入邮箱地址'); return; }
const btn = document.getElementById('drawerConfirm');
btn.disabled = true; drawerErr('发送中...');
try {
const res = await apiPost('account.php', {action: 'bind_email_start', email: email});
btn.disabled = false;
if (!res.success) { drawerErr(res.message || '发起失败'); return; }
_bindWizId = res.data.id;
acctBindEmailWait(res.data.expire_in || 60);
} catch (e) {
btn.disabled = false;
drawerErr('连接失败: ' + e.message);
}
}
function acctBindEmailWait(expireIn) {
drawerSet(`
<div style="font-size:13.5px;line-height:1.8;text-align:center">
<div style="font-size:26px">🎮</div>
<div style="margin-top:4px">等待游戏内批准…</div>
<div style="color:var(--dim);font-size:12.5px;margin-top:4px">请到游戏中点击聊天栏的 <b style="color:var(--green)">✔ 批准</b>,再回来输入登录密码</div>
<div style="margin-top:10px;font-size:22px;font-family:monospace" id="bindLeft">${expireIn}s</div>
<div id="bindWizStatus" style="margin-top:6px;font-size:12.5px;color:var(--dim)">已向游戏发送通知…</div>
</div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'取消', () => { if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; } closeDrawer(); });
if (_bindWizTimer) clearInterval(_bindWizTimer);
_bindWizTimer = setInterval(acctBindEmailPollTick, 1000);
acctBindEmailPollTick();
}
async function acctBindEmailPollTick() {
const leftEl = document.getElementById('bindLeft');
if (leftEl) {
const v = parseInt(leftEl.textContent) || 0;
if (v > 0) leftEl.textContent = (v - 1) + 's';
}
try {
const res = await apiPost('account.php', {action: 'bind_email_poll', id: _bindWizId});
if (!res.success) return;
const d = res.data || {};
const stEl = document.getElementById('bindWizStatus');
if (d.status === 'pending' || d.status === 'notified') {
if (stEl) stEl.textContent = d.status === 'pending' ? '正在通知游戏…' : '已发送游戏内通知,等待点击批准…';
if (!d.left) acctBindEmailFinish('expired');
return;
}
if (d.status === 'approved') {
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
closeDrawer();
acctBindEmailPwdStep();
return;
}
if (d.status === 'completed') {
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
closeDrawer();
acctBindEmailCodeStep(d.email);
return;
}
closeDrawer();
acctBindEmailFinish(d.status, d.fail_reason);
} catch (e) { /* 网络抖动忽略,下一轮重试 */ }
}
function acctBindEmailCodeStep(email) {
closeDrawer();
const _masked = (email || '').replace(/^(.).*(@.*)$/, '$1***$2');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px;line-height:1.7">✅ 游戏内已批准,邮箱 <b>${_masked}</b> 已绑定。
<br>为确认邮箱可用,请输入验证码完成验证。</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<input type="text" id="bindCodeInput" placeholder="6位验证码" maxlength="6" inputmode="numeric" style="flex:1;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;font-family:monospace;letter-spacing:4px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<button class="btn btn-dim" id="bindCodeSend" onclick="acctBindEmailSendCode()" style="white-space:nowrap">发送验证码</button>
</div>
<div id="bindCodeTimer" style="font-size:12px;color:var(--dim);margin-bottom:8px"></div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px"></div>`,
'确认验证', acctBindEmailCodeConfirm);
document.getElementById('modalOverlay').style.display = 'flex';
setTimeout(() => { const p = document.getElementById('bindCodeInput'); if (p) p.focus(); }, 120);
}
async function acctBindEmailSendCode() {
const btn = document.getElementById('bindCodeSend');
if (!btn) return;
btn.disabled = true; btn.textContent = '发送中...';
try {
const res = await apiPost('account.php', {action: 'bind_email_send_code', id: _bindWizId});
btn.disabled = false; btn.textContent = '发送验证码';
if (!res.success) { acctErr(res.message || '发送失败'); return; }
acctErr('');
startBindCodeTimer(60);
} catch (e) {
btn.disabled = false; btn.textContent = '发送验证码';
acctErr('连接失败: ' + e.message);
}
}
let _bindCodeTimer = null;
function startBindCodeTimer(sec) {
const el = document.getElementById('bindCodeTimer');
if (!el) return;
let left = sec;
el.textContent = '验证码已发送,' + left + 's 后可重新发送';
if (_bindCodeTimer) clearInterval(_bindCodeTimer);
_bindCodeTimer = setInterval(() => {
left--;
if (left <= 0) { clearInterval(_bindCodeTimer); _bindCodeTimer = null; el.textContent = '可重新发送验证码'; return; }
el.textContent = '验证码已发送,' + left + 's 后可重新发送';
}, 1000);
}
async function acctBindEmailCodeConfirm() {
const el = document.getElementById('bindCodeInput');
const code = el ? el.value.trim() : '';
if (!code || code.length !== 6) { acctErr('请输入6位验证码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('验证中...');
try {
const res = await apiPost('account.php', {action: 'bind_email_confirm', id: _bindWizId, email_code: code});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '验证码错误'); return; }
acctModalSet(`
<div style="text-align:center;padding:10px 0;line-height:1.8">
<div style="font-size:34px">🎉</div>
<div style="font-size:15px;margin-top:4px">邮箱绑定成功</div>
<div style="font-size:13px;color:var(--dim)">${escapeHtml(res.data.email_masked || '')}</div>
<div style="font-size:12px;color:var(--dim);margin-top:6px">现在可以完成第二层验证了,请重新发起刚才的操作</div>
</div>`,
'完成', () => { closeModal(); acctRefreshStatus(); });
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
function acctBindEmailFinish(status, reason) {
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
closeDrawer();
if (status === 'rejected') {
acctModalSet(
'<div style="text-align:center;padding:20px 0;font-size:14px;line-height:1.8">' +
'<div style="font-size:28px;margin-bottom:8px">🚫</div>' +
'<div style="color:var(--red);font-size:15px">请求已被拒绝</div>' +
'<div style="color:var(--dim);font-size:12px;margin-top:6px">游戏内玩家拒绝了本次绑定</div></div>',
'关闭', () => { closeModal(); acctRefreshStatus(); });
return;
}
const msgs = {
offline: '📴 你当前不在线,无法批准。请先登录游戏后重新发起',
expired: '⌛ 1分钟内未处理,请求已超时作废(未做任何处置)',
failed: '⚠️ 绑定失败:' + (reason || '未知原因'),
};
if (status === 'completed') {
acctModalSet(`
<div style="text-align:center;padding:10px 0;line-height:1.8">
<div style="font-size:34px">🎉</div>
<div style="font-size:15px;margin-top:4px">邮箱绑定成功</div>
<div style="font-size:12px;color:var(--dim);margin-top:6px">现在可以完成第二层验证了,请重新发起刚才的操作</div>
</div>`,
'完成', () => { closeModal(); acctRefreshStatus(); });
return;
}
const msg = msgs[status] || ('状态: ' + status);
acctModalSet(`
<div style="text-align:center;padding:12px 0;font-size:14px;line-height:1.8">${escapeHtml(msg)}</div>`,
'知道了', () => { closeModal(); acctRefreshStatus(); });
}
// ===== 通用 =====
function toast(msg, type = 'success') {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
// ★ 毛玻璃Alert/Confirm弹窗(替代原生alert/confirm)
let _glassAlertResolve = null;
let _isGlassPrompt = false;
function glassAlertResolve(val) {
document.getElementById('glassAlertOverlay').classList.remove('show');
// ★ glassPrompt:如果点了确定,返回输入框的值;取消返回false
if (_isGlassPrompt && val === true) {
val = document.getElementById('glassAlertInput').value;
}
_isGlassPrompt = false;
if (_glassAlertResolve) { _glassAlertResolve(val); _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('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;
_isGlassPrompt = true;
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 glassSelect(label, options, hint = '') {
// options: [{value, label, desc?}] 或 string[](自动转为{value,label})
return new Promise(resolve => {
_glassAlertResolve = resolve;
_isGlassPrompt = false;
const icon = document.getElementById('glassAlertIcon');
icon.textContent = '📋';
const msgEl = document.getElementById('glassAlertMsg');
msgEl.style.display = 'none';
const labelEl = document.getElementById('glassAlertLabel');
labelEl.textContent = label;
labelEl.style.display = '';
const input = document.getElementById('glassAlertInput');
input.style.display = 'none';
const hintEl = document.getElementById('glassAlertHint');
if (hint) { hintEl.textContent = '💡 ' + hint; hintEl.style.display = ''; }
else { hintEl.style.display = 'none'; }
// 构建选项列表
const normalized = options.map(o => typeof o === 'string' ? {value: o, label: o} : o);
let listHtml = '<div style="max-height:300px;overflow-y:auto;margin:8px 0;display:flex;flex-direction:column;gap:4px">';
for (const opt of normalized) {
listHtml += `<div onclick="glassAlertResolve('${opt.value.replace(/'/g, "\\'")}')"
style="padding:8px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 0.15s;font-size:13px;color:var(--fg);background:var(--bg)"
onmouseover="this.style.borderColor='var(--accent)';this.style.background='rgba(99,102,241,0.1)'"
onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg)'">
<div style="font-weight:500">${opt.label}</div>
${opt.desc ? `<div style="font-size:11px;color:var(--dim);margin-top:2px">${opt.desc}</div>` : ''}
</div>`;
}
listHtml += '</div>';
document.getElementById('glassAlertBtns').innerHTML = listHtml + '<button class="ag-cancel" onclick="glassAlertResolve(false)" style="margin-top:8px">取消</button>';
document.getElementById('glassAlertOverlay').classList.add('show');
});
}
// ===== 主题切换 =====
function toggleLightTheme() {
document.body.classList.toggle('light-theme');
const isLight = document.body.classList.contains('light-theme');
localStorage.setItem('sdf1_player_theme', isLight ? 'light' : 'dark');
toast(isLight ? '浅色主题' : '深色主题', 'success');
}
// ===== 自定义背景系统(第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 getPlayerBgImg() {
try {
const s = localStorage.getItem('sdf1_player_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 applyPlayerBgImage(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';
b.classList.add('bg-img-mode');
document.documentElement.style.setProperty('--bg', 'rgba(13,17,23,' + Math.min(0.9, dim + 0.25) + ')');
localStorage.setItem('sdf1_player_bgimg', JSON.stringify({ type: 'img', url: url, dim: dim }));
if (!silent) toast('背景图片已应用', 'success');
}
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('预览就绪,点击应用生效', 'success');
};
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('壁纸已预览,点击应用生效', 'success');
})
.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;
applyPlayerBgImage(_wpPendingUrl, dim, false);
const ov = document.getElementById('themePickerOverlay');
if (ov) ov.remove();
}
// 恢复默认背景(清纯色+图片配置)
function resetPlayerBg() {
localStorage.removeItem('sdf1_player_bg');
localStorage.removeItem('sdf1_player_bgimg');
const b = document.body;
b.style.backgroundImage = 'none';
b.style.backgroundSize = '';
b.style.backgroundPosition = '';
b.style.backgroundRepeat = '';
b.style.backgroundAttachment = '';
b.classList.remove('bg-img-mode');
document.documentElement.style.removeProperty('--bg');
b.style.background = '';
const ov = document.getElementById('themePickerOverlay');
if (ov) ov.remove();
toast('已恢复默认背景', 'success');
}
function showThemePicker() {
const savedImg = getPlayerBgImg();
_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="width:36px;height:36px;border-radius:6px;background:#0d1117;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#0d1117')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#1a1e2e;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#1a1e2e')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#0f4c75;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#0f4c75')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#1b2631;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#1b2631')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#2c3e50;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#2c3e50')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#23272a;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#23272a')"></div>
<div class="color-btn" style="width:36px;height:36px;border-radius:6px;background:#ffffff;border:2px solid var(--border);cursor:pointer" onclick="setPlayerTheme('#ffffff')"></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="applyPlayerCustomColor()">应用纯色</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="resetPlayerBg()">恢复默认背景</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 setPlayerTheme(color) {
const b = document.body;
b.style.backgroundImage = 'none';
b.style.backgroundSize = '';
b.style.backgroundPosition = '';
b.style.backgroundRepeat = '';
b.style.backgroundAttachment = '';
b.classList.remove('bg-img-mode');
document.documentElement.style.setProperty('--bg', color);
b.style.background = color;
localStorage.removeItem('sdf1_player_bgimg');
localStorage.setItem('sdf1_player_bg', color);
_wpPendingUrl = null;
toast('背景颜色已应用', 'success');
}
// 浅色主题下的卡片/边框颜色也自动调整
document.querySelectorAll('.light-theme').forEach(el => {});
// 自动切换card/border颜色配合自定义背景
function applyBgToTheme(bgColor) {
// 如果背景是浅色,自动切换card颜色
document.body.classList.add('light-theme');
document.body.style.background = bgColor;
document.documentElement.style.setProperty('--bg', bgColor);
}
function applyPlayerCustomColor() {
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;
}
}
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) {
setPlayerTheme(color);
document.querySelector('[onclick*="overlay.remove"]').click();
}
}
// 加载保存的主题(第46批:图片背景优先,其次纯色)
(function() {
const savedImg = getPlayerBgImg();
if (savedImg) { applyPlayerBgImage(savedImg.url, savedImg.dim, true); return; }
const savedBg = localStorage.getItem('sdf1_player_bg');
if (savedBg) {
document.documentElement.style.setProperty('--bg', savedBg);
document.body.style.background = savedBg;
}
})();
function closeModal() {
var _mc = document.getElementById('modalOverlay');
if (_mc) { var _m = _mc.querySelector('.modal'); if (_m) { _m.classList.remove('animate-in'); _m.classList.add('animate-out'); } }
setTimeout(function() { document.getElementById('modalOverlay').style.display = 'none'; }, 200);
// ★ 密码验证被取消 → 清除token,进入游客模式(防止未认证玩家数据泄露)
if (NEED_PASSWORD && !AUTHENTICATED) {
showGuestMode();
}
}
function confirmModal() {}
// ★ 第56批:抽屉式弹窗控制
function drawerOpen(title) {
const overlay = document.getElementById("drawerOverlay");
const card = document.getElementById("drawerCard");
document.getElementById("drawerTitle").textContent = title;
overlay.style.display = "flex";
card.classList.remove("animate-out");
card.classList.add("animate-in");
}
function drawerSet(html, confirmText, onConfirm) {
document.getElementById("drawerBody").innerHTML = html;
const btn = document.getElementById("drawerConfirm");
btn.textContent = confirmText;
btn.disabled = false;
btn.onclick = onConfirm;
}
function drawerErr(msg) {
const e = document.getElementById("acctErr");
if (e) e.textContent = msg || "";
}
function closeDrawer() {
const overlay = document.getElementById("drawerOverlay");
const card = document.getElementById("drawerCard");
card.classList.remove("animate-in");
card.classList.add("animate-out");
setTimeout(() => { overlay.style.display = "none"; card.classList.remove("animate-out"); }, 250);
}
function confirmDrawer() {}
// ===== 忘记密码 =====
function showResetPasswordModal() {
closeModal();
document.getElementById('modalTitle').textContent = '重置密码';
document.getElementById('modalBody').innerHTML = `
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">系统会自动读取您注册时绑定的邮箱发送重置链接</p>
<div class="row"><label>玩家名</label><input type="text" id="resetPlayer" value="${currentPlayer}" readonly style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div id="resetError" style="color:var(--red);font-size:12px;margin-top:4px"></div>`;
document.getElementById('modalConfirm').textContent = '发送重置链接';
document.getElementById('modalConfirm').onclick = () => doResetPassword();
document.getElementById('modalOverlay').style.display = 'flex';
}
// ===== 双层验证向导(2026-10-22 第44批 · api/account.php) =====
// 流程:完整密码(第一层) → 邮箱码/动态码/恢复代码(第二层) → ticket(10分钟一次性) → 执行动作
async function apiPost(endpoint, params = {}) {
const url = new URL(API + endpoint, location.href);
// ★ 第44批修复:账号安全接口统一注入 web_token(游戏拿token登录时 TOKEN 有值)
const body = Object.assign({}, params);
if (!body.web_token && TOKEN) body.web_token = TOKEN;
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
return await res.json();
}
let _acctState = null;
let _lastRecoveryCodes = [];
let _lastChangedPwd = ''; // ★第48批#246:改密成功面板明文密码(复制按钮用)
let _acctPwdSubmitting = false; // ★第48批修复:改密弹窗提交中标志(配合 checklist 启停按钮,废除 !btn.disabled 守卫)
function startAccountVerify(action, label) {
if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; }
_acctState = { action: action, label: label || action, challenge: null, methods: [], method: null, ticket: null, emailMasked: '' };
acctStepPassword();
}
function acctModalOpen(title) {
closeModal();
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalOverlay').style.display = 'flex';
}
function acctModalSet(html, confirmText, onConfirm) {
document.getElementById('modalBody').innerHTML = html;
var _m2 = document.getElementById('modalOverlay').querySelector('.modal'); if (_m2) { _m2.classList.remove('animate-out'); _m2.classList.add('animate-in'); }
const btn = document.getElementById('modalConfirm');
btn.textContent = confirmText;
btn.disabled = false;
btn.onclick = onConfirm;
}
function acctErr(msg) {
const e = document.getElementById('acctErr');
if (e) e.textContent = msg || '';
}
// ---- 第一步:完整登录密码(强制层) ----
function acctStepPassword() {
acctModalOpen('🛡️ ' + _acctState.label);
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">第一步(必填):输入一次完整登录密码</div>
<input type="password" id="acctPwd" placeholder="当前登录密码" autocomplete="current-password" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'下一步 →', acctDoStep1);
setTimeout(() => { const p = document.getElementById('acctPwd'); if (p) p.focus(); }, 120);
}
async function acctDoStep1() {
const st = _acctState;
const pwd = document.getElementById('acctPwd') ? document.getElementById('acctPwd').value : '';
if (!pwd) { acctErr('请输入密码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('验证中...');
try {
const res = await apiPost('account.php', {action: 'verify_start', password: pwd});
btn.disabled = false;
if (!res.success) {
// ★第53批:第一层密码已过但未绑邮箱 → 不拦截,直接进入网页绑定定向导
if (res.need_bind_email) { acctBindEmailWizard(); return; }
acctErr(res.message || '密码错误'); return;
}
st.challenge = res.data.challenge;
st.methods = res.data.methods || [];
st.emailMasked = res.data.email_masked || '';
if (!st.methods.length) { acctErr('没有可用的第二层验证方式'); return; }
acctStep2(st.methods[0]);
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 第二步:三选一(邮箱码 / 动态码 / 恢复代码) ----
function acctStep2(method) {
const st = _acctState;
const methods = st.methods || [];
if (!method) method = st.method || methods[0];
st.method = method;
const names = { email: '📧 邮箱验证码', totp: '📱 认证器动态码', recovery: '🔑 恢复代码' };
let tabs = '';
if (methods.length > 1) {
tabs = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">';
for (const m of methods) {
const act = m === method;
tabs += `<button class="btn${act ? ' btn-primary' : ''}" style="font-size:12px;padding:6px 10px" onclick="acctStep2('${m}')">${names[m] || m}</button>`;
}
tabs += '</div>';
}
let body = `<div style="font-size:13px;color:var(--dim);margin-bottom:8px">第二步(三选一):${names[method] || method}</div>${tabs}`;
if (method === 'email') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">验证码将发送至 ${escapeHtml(st.emailMasked || '绑定邮箱')}(5分钟有效)</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<button class="btn" id="acctSendBtn" onclick="acctSendCode()" style="flex-shrink:0">发送验证码</button>
<input type="text" id="acctEmailCode" maxlength="6" placeholder="6位验证码" inputmode="numeric" autocomplete="off" style="flex:1;min-width:0;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:15px;letter-spacing:4px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
</div>`;
} else if (method === 'totp') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">打开认证器 App(Google/Microsoft Authenticator 等),输入当前显示的 6 位动态码</div>
<input type="text" id="acctTotpCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
} else {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">输入一组恢复代码(形如 XXXX-XXXX-XXXX-XXXX,横线可省略),每组只能用一次</div>
<input type="text" id="acctRecCode" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;letter-spacing:1px;font-family:monospace" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
}
body += '<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>';
acctModalSet(body, '验证并继续 →', acctDoStep2);
}
async function acctSendCode() {
const st = _acctState;
const btn = document.getElementById('acctSendBtn');
if (btn) { btn.disabled = true; btn.textContent = '发送中...'; }
try {
const res = await apiPost('account.php', {action: 'send_email_code', challenge: st.challenge});
if (!res.success) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr(res.message || '发送失败');
return;
}
acctErr('');
showToast(res.message || '验证码已发送', 'success');
acctCountdown(60);
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr('发送失败: ' + e.message);
}
}
function acctCountdown(sec) {
const btn = document.getElementById('acctSendBtn');
if (sec <= 0) { if (btn) { btn.disabled = false; btn.textContent = '重新发送'; } return; }
if (btn) { btn.disabled = true; btn.textContent = '重新发送(' + sec + 's)'; }
setTimeout(() => acctCountdown(sec - 1), 1000);
}
async function acctDoStep2() {
const st = _acctState;
const m = st.method;
const btn = document.getElementById('modalConfirm');
let code = '';
if (m === 'email') code = (document.getElementById('acctEmailCode') || {}).value || '';
else if (m === 'totp') code = (document.getElementById('acctTotpCode') || {}).value || '';
else code = (document.getElementById('acctRecCode') || {}).value || '';
code = (code || '').trim();
if (!code) { acctErr(m === 'email' ? '请先获取并输入邮箱验证码' : '请输入验证码'); return; }
btn.disabled = true; acctErr('验证中...');
const payload = { action: 'verify_step2', challenge: st.challenge };
if (m === 'email') payload.email_code = code;
else if (m === 'totp') payload.totp_code = code;
else payload.recovery_code = code;
try {
const res = await apiPost('account.php', payload);
btn.disabled = false;
if (!res.success) { acctErr(res.message || '验证失败'); return; }
st.ticket = res.data.ticket;
await acctRunAction();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 两层验证通过后的动作执行器(持 ticket 分发) ----
async function acctRunAction() {
const st = _acctState;
const ticket = st.ticket;
try {
if (st.action === 'change_password') { acctPromptNewPassword(ticket); return; }
if (st.action === 'totp_setup') { await acctFlowTotp(ticket); return; }
if (st.action === 'totp_unbind') {
const hasEmail = _acctStatus && _acctStatus.has_email;
const warn = hasEmail
? '确认解绑二次验证器?\n解绑后恢复代码将全部作废,第二层验证只剩邮箱验证码。'
: '⚠️ 账号未绑定邮箱!\n解绑后将没有任何第二层验证方式,届时将无法再修改密码等账号信息(需先在游戏中 /bindemail 绑定邮箱)。确认仍要解绑?';
if (!(await showConfirm(warn))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'totp_unbind', ticket: ticket});
if (!res.success) { showToast(res.message || '解绑失败', 'error'); return; }
closeModal();
showToast('已解绑二次验证器', 'success');
acctRefreshStatus();
return;
}
if (st.action === 'recovery_list') {
const res = await apiPost('account.php', {action: 'recovery_list', ticket: ticket});
if (!res.success) { showToast(res.message || '获取失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '以下为当前有效的恢复代码(未用完的):');
return;
}
if (st.action === 'recovery_regenerate') {
if (!(await showConfirm('重新生成恢复代码?\n旧的恢复代码将全部立即作废。'))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'recovery_regenerate', ticket: ticket});
if (!res.success) { showToast(res.message || '生成失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '新的恢复代码已生成(旧代码全部作废),只显示这一次:');
return;
}
showToast('未知操作: ' + st.action, 'error');
} catch (e) {
showToast('请求失败: ' + e.message, 'error');
}
}
// ---- 修改登录密码(两层验证通过后进入) ----
function acctPromptNewPassword(ticket) {
acctModalOpen('🛡️ 修改登录密码');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">两层验证已通过,请设置新密码(与游戏内同一套规则)</div>
<div class="row"><label>新密码</label><input type="password" id="acctNewPwd" placeholder="6-20位" autocomplete="new-password" oninput="acctPwdChecklist()" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div id="acctPwdRules" style="display:grid;grid-template-columns:1fr 1fr;gap:4px 10px;margin:8px 0 4px;font-size:12px">
<div id="pwrLen">○ 长度 6-20 位</div>
<div id="pwrUp">○ 含大写字母</div>
<div id="pwrLow">○ 含小写字母</div>
<div id="pwrNum">○ 含数字</div>
</div>
<div class="row"><label>确认新密码</label><input type="password" id="acctNewPwd2" placeholder="再输入一次" autocomplete="new-password" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()"></div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px"></div>`,
'确认修改', () => acctDoChangePassword(ticket));
acctPwdChecklist();
setTimeout(() => { const p = document.getElementById('acctNewPwd'); if (p) p.focus(); }, 120);
}
// ---- ★第48批#245:密码强度实时校验清单(与游戏 PasswordUtils.validate 同一把尺子) ----
function acctPwdChecklist() {
const v = (document.getElementById('acctNewPwd') || {}).value || '';
const checks = [
['pwrLen', v.length >= 6 && v.length <= 20],
['pwrUp', /[A-Z]/.test(v)],
['pwrLow', /[a-z]/.test(v)],
['pwrNum', /[0-9]/.test(v)]
];
let allOk = v.length > 0;
for (const [id, ok] of checks) {
const el = document.getElementById(id);
if (el) {
el.textContent = (ok ? '✓ ' : '✗ ') + el.textContent.slice(2);
el.style.color = ok ? 'var(--green, #4caf50)' : 'var(--dim)';
}
if (!ok) allOk = false;
}
const btn = document.getElementById('modalConfirm');
if (btn && !_acctPwdSubmitting) btn.disabled = !allOk; // 全绿才允许提交(★48批修复:原 !btn.disabled 守卫致按钮永久禁用)
return allOk;
}
async function acctDoChangePassword(ticket) {
const p1 = (document.getElementById('acctNewPwd') || {}).value || '';
const p2 = (document.getElementById('acctNewPwd2') || {}).value || '';
if (!p1 || !p2) { acctErr('请填写两遍新密码'); return; }
if (p1 !== p2) { acctErr('两次输入的密码不一致'); return; }
if (!acctPwdChecklist()) { acctErr('密码需满足全部4项要求(6-20位 + 大写 + 小写 + 数字)'); return; }
const btn = document.getElementById('modalConfirm');
_acctPwdSubmitting = true; btn.disabled = true; acctErr('提交中...');
try {
const res = await apiPost('account.php', {action: 'change_password', ticket: ticket, new_password: p1, confirm_password: p2});
_acctPwdSubmitting = false;
btn.disabled = false;
if (!res.success) { acctErr(res.message || '修改失败'); acctPwdChecklist(); return; }
try { notifyJavaSync(); } catch (e) {}
acctShowPwdSuccess(p1);
acctRefreshStatus();
} catch (e) {
_acctPwdSubmitting = false;
btn.disabled = false;
acctErr('连接失败: ' + e.message);
acctPwdChecklist();
}
}
// ---- ★第48批#246:改密成功面板(不关弹窗,明文密码留屏供截屏保存) ----
function acctShowPwdSuccess(newPwd) {
_lastChangedPwd = newPwd;
acctModalOpen('✅ 密码修改成功');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">请立即截屏或复制保存以下新密码</div>
<div style="font-family:monospace;font-size:16px;letter-spacing:1px;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;text-align:center;word-break:break-all;user-select:all">${escapeHtml(newPwd)}</div>
<button class="btn" style="width:100%;font-size:13px;margin-top:8px" onclick="acctCopy(_lastChangedPwd)">📋 复制密码</button>
<div style="color:var(--dim);font-size:12px;margin-top:12px;line-height:1.6">已请求同步到游戏端(通常 1-2 分钟内生效)。<br>若游戏内仍提示密码错误,请退出重进后再试一次。</div>`,
'我已保存,关闭', () => { closeModal(); acctRefreshStatus(); });
}
// ---- 绑定二次验证器:生成密钥 → 扫码 → 动态码确认 → 一次性返回恢复代码 ----
async function acctFlowTotp(ticket) {
acctModalOpen('🛡️ 绑定二次验证器');
acctModalSet('<div style="color:var(--dim);font-size:13px;padding:12px 0">正在生成密钥...</div>', '关闭', () => closeModal());
let res;
try {
res = await apiPost('account.php', {action: 'totp_setup', ticket: ticket});
} catch (e) {
acctModalSet('<div style="color:var(--red);font-size:13px">连接失败: ' + escapeHtml(e.message) + '</div>', '关闭', () => closeModal());
return;
}
if (!res.success) {
acctModalSet('<div style="color:var(--red);font-size:13px">' + escapeHtml(res.message || '生成失败') + '</div>', '关闭', () => closeModal());
return;
}
const secret = res.data.secret, otpauth = res.data.otpauth;
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">① 用认证器 App 扫描下方二维码(或手动输入密钥)</div>
<div id="acctQr" style="text-align:center;padding:6px 0;min-height:12px"></div>
<div style="display:flex;gap:8px;align-items:center;margin:6px 0 14px">
<input id="acctSecret" readonly value="${escapeHtml(secret)}" style="flex:1;min-width:0;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:monospace;font-size:13px" onclick="this.select()">
<button class="btn" style="flex-shrink:0;font-size:12px;padding:6px 10px" onclick="acctCopy(document.getElementById('acctSecret').value)">复制密钥</button>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">② 输入认证器显示的 6 位动态码完成确认</div>
<input type="text" id="acctBindCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'确认绑定 →', () => acctDoBind(ticket));
acctRenderQr(otpauth);
}
function acctRenderQr(text) {
const box = document.getElementById('acctQr');
if (!box) return;
if (typeof qrcode === 'undefined') {
box.innerHTML = '<div style="color:var(--red);font-size:12px;padding:6px">二维码组件未加载,请手动复制下方密钥录入认证器</div>';
return;
}
// Arase SVG 方案:与 twofa_qr.php 同款。SVG + margin:4 静区 + 白底容器 + 型号自适应,手机扫描可靠
let svg = '';
for (let n = 1; n <= 12; n++) { // 二维码型号自适应:按数据长度逐级尝试
try {
const q = qrcode(n, 'M');
q.addData(text);
q.make();
svg = q.createSvgTag({ cellSize: 5, margin: 4, scalable: true, alt: 'TOTP 绑定二维码' });
break;
} catch (e) { /* 型号容量不够,换下一个 */ }
}
if (!svg) {
box.innerHTML = '<div style="color:var(--red);font-size:12px">二维码渲染失败,请手动复制下方密钥</div>';
return;
}
// 白底容器保证四周留白(静区),固定 220px 清晰渲染,不随容器发虚
box.innerHTML = '<div style="display:inline-block;background:#fff;padding:10px;border-radius:8px">' + svg + '</div>';
const sv = box.querySelector('svg');
if (sv) { sv.style.width = '220px'; sv.style.height = '220px'; sv.style.display = 'block'; }
}
async function acctDoBind(ticket) {
const code = ((document.getElementById('acctBindCode') || {}).value || '').trim();
if (!/^\d{6}$/.test(code)) { acctErr('请输入6位动态码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('确认中...');
try {
const res = await apiPost('account.php', {action: 'totp_bind', ticket: ticket, code: code});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '动态码错误'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '✅ 绑定成功!以下恢复代码只显示这一次,请立即保存:');
acctRefreshStatus();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 恢复代码展示(明文,一次性查看/生成结果) ----
function acctShowRecoveryCodes(codes, title) {
_lastRecoveryCodes = codes.slice();
acctModalOpen('🔑 恢复代码');
let list = '';
for (const c of codes) {
list += `<div style="font-family:monospace;font-size:14px;letter-spacing:1px;padding:7px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;text-align:center">${escapeHtml(c)}</div>`;
}
acctModalSet(`
<div style="font-size:13px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(title)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px">${list || '<div style="color:var(--dim);font-size:13px">(无)</div>'}</div>
<button class="btn" style="width:100%;font-size:13px" onclick="acctCopy(_lastRecoveryCodes.join(String.fromCharCode(10)))">📋 复制全部</button>
<div style="color:var(--dim);font-size:12px;margin-top:10px;line-height:1.6">每组恢复代码只能使用一次,用后自动作废。请离线保存,勿截图外传。</div>`,
'我已保存', () => { closeModal(); acctRefreshStatus(); });
}
async function acctCopy(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
showToast('已复制到剪贴板', 'success');
} catch (e) {
showToast('复制失败,请手动选择复制', 'error');
}
}
// ===== 修改密码(旧入口已并入双层验证流程,保留函数名兼容旧调用点) =====
function showChangePasswordModal() {
startAccountVerify('change_password', '修改登录密码');
}
async function doChangePassword() {
showChangePasswordModal();
}
// ===== 重置密码(后端判断有无邮箱) =====
async function doResetPassword() {
const player = document.getElementById('resetPlayer').value.trim();
if (!player) { document.getElementById('resetError').textContent = '请输入玩家名'; return; }
document.getElementById('resetError').textContent = '发送中...';
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'send_reset_password_link');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: player})
});
const data = await res.json();
if (data.success) {
document.getElementById('resetError').innerHTML = '<span style="color:var(--green)">✅ 重置链接已发送到邮箱,请查收</span>';
setTimeout(() => { closeModal(); }, 2000);
} else {
// 根据错误信息判断情况
if (data.message && data.message.includes('未绑定邮箱')) {
// 玩家没有绑定邮箱 → 显示管理员审核提示
document.getElementById('resetError').innerHTML = `
<div style="text-align:center">
<div style="font-size:32px;margin-bottom:12px">📧</div>
<p style="color:var(--text);font-size:14px;margin-bottom:8px">该账号未绑定邮箱</p>
<p style="color:var(--dim);font-size:13px;margin-bottom:16px">
您的账号还没有绑定邮箱,无法通过邮件重置密码。<br>
已自动提交申请,<b style="color:var(--accent)">请联系管理员</b>验证身份后帮您重置密码。<br>
<span style="color:var(--yellow);font-size:12px">审核通常需要 1-24 小时</span>
</p>
<div style="background:rgba(59,130,246,0.1);border:1px solid var(--accent);border-radius:8px;padding:12px;text-align:left">
<p style="color:var(--text);font-size:12px;margin:0"><b>管理员联系方式:</b></p>
<p style="color:var(--dim);font-size:12px;margin:4px 0 0 0">请联系服务器管理群或在线管理获取帮助</p>
</div>
</div>`;
} else {
document.getElementById('resetError').textContent = data.message || '发送失败';
}
}
} catch (e) {
document.getElementById('resetError').textContent = '连接失败: ' + e.message;
}
}
// ===== 工单系统 =====
let ticketState = { view: 'list', filter: 'all', images: [] }; // images=附件图暂存(URL列表)
async function renderTicket(el) {
if (IS_PREVIEW || !AUTHENTICATED) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--dim)">请先登录后使用工单功能</p></div>`;
return;
}
if (ticketState.view === 'list') await renderTicketList(el);
else if (ticketState.view === 'create') renderTicketCreate(el);
else if (ticketState.view === 'detail') await renderTicketDetail(el, ticketState.ticketId);
}
function renderTicketNav() {
const f = ticketState.filter;
return `<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center">
<button class="btn btn-primary" onclick="ticketState.view='create';renderTicket(document.getElementById('content'))" style="font-size:13px">✏️ 提交工单</button>
<button class="btn ${f==='all'?'':'btn-dim'}" onclick="ticketState.filter='all';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">全部</button>
<button class="btn ${f==='submitted'?'':'btn-dim'}" onclick="ticketState.filter='submitted';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">⏳ 已提交</button>
<button class="btn ${f==='replied'?'':'btn-dim'}" onclick="ticketState.filter='replied';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">💬 已回复</button>
<button class="btn ${f==='withdrawn'?'':'btn-dim'}" onclick="ticketState.filter='withdrawn';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">🔙 已撤销</button>
<button class="btn ${f==='rejected'?'':'btn-dim'}" onclick="ticketState.filter='rejected';ticketState.view='list';renderTicket(document.getElementById('content'))" style="font-size:12px;padding:5px 12px">🚫 已驳回</button>
</div>`;
}
async function renderTicketList(el) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2>${renderTicketNav()}<p style="color:var(--dim)">加载中...</p></div>`;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'my_list');
url.searchParams.set('token', TOKEN);
if (ticketState.filter !== 'all') url.searchParams.set('status', ticketState.filter);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--red)">${data.message}</p></div>`; return; }
const list = data.data.list;
let html = `<div class="card"><h2>📋 我的工单 (${data.data.total})</h2>${renderTicketNav()}`;
if (list.length === 0) {
html += `<p style="color:var(--dim);text-align:center;padding:40px 0">暂无工单</p>`;
} else {
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)'}};
html += `<div style="display:flex;flex-direction:column;gap:8px">`;
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 += `<div onclick="ticketState.view='detail';ticketState.ticketId=${t.id};renderTicket(document.getElementById('content'))" style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:8px;padding:14px 16px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escHtml(t.title)}</span>
<span style="color:${s.color};font-size:12px">${s.text}</span>
</div>
<div style="display:flex;gap:16px;font-size:12px;color:var(--dim)">
<span>${typeMap[t.type]||t.type}</span>
<span>${date}</span>
${t.assigned_to ? '<span>👤 '+escHtml(t.assigned_to)+'</span>' : ''}
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><h2>📋 工单系统</h2><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
function renderTicketCreate(el) {
const types = [{v:'bug',l:'🐛 Bug反馈'},{v:'help',l:'❓ 求助'},{v:'report',l:'📢 举报'},{v:'apply',l:'📝 申请'},{v:'other',l:'📎 其他'}];
let options = types.map(t => `<option value="${t.v}">${t.l}</option>`).join('');
el.innerHTML = `<div class="card">
<h2>✏️ 提交工单</h2>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">工单类型</label>
<select id="ticketType" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px">${options}</select></div>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">标题</label>
<input id="ticketTitle" placeholder="简要描述您的问题" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px"></div>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">详细描述</label>
<textarea id="ticketDesc" rows="6" placeholder="详细描述您的问题,支持Markdown格式" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;resize:vertical;font-family:monospace"></textarea></div>
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">附件图片(最多 3 张)</label>
<input type="file" id="ticketImg" accept=".png,.jpg,.jpeg,image/png,image/jpeg" multiple onchange="uploadTicketImage(this)" style="font-size:13px">
<p style="color:var(--dim);font-size:12px;margin-top:4px">仅支持 png / jpg,单张 ≤ 2048K;选中即上传,并在上方描述光标处插入 [图片N] 占位符</p>
<div id="ticketImgList" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"></div>
<p id="ticketImgMsg" style="color:var(--red);font-size:12px;margin-top:4px"></p></div>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">💡 支持标准Markdown语法:**粗体**、*斜体*、\`代码\`、列表、标题等</p>
<div style="display:flex;gap:8px">
<button class="btn btn-primary" onclick="submitTicket()">提交工单</button>
<button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">取消</button>
</div>
<p id="ticketError" style="color:var(--red);margin-top:8px;font-size:13px"></p>
</div>`;
renderTicketImgs();
syncTicketImgInput();
}
// ===== 工单附件图:选择即上传图床 -> 暂存 -> 光标处插占位符(滚动计数)=====
async function uploadTicketImage(input) {
const msg = document.getElementById('ticketImgMsg');
if (!input.files || !input.files.length) return;
const files = Array.from(input.files);
input.value = '';
for (const file of files) {
if ((ticketState.images || []).length >= 3) { setImgMsg(msg, '最多只能上传 3 张图片', true); break; }
// ★ 2026-10-04 修复:不再用文件名后缀判类型
// (.jpeg / 全角句点 / 无后缀的真实 png、jpg 曾被误拦)
// 只有明显不是图片类型(.gif/.webp/.heic/文档等)才提前拦下,
// 其余一律上传,由服务端按图片真实内容判定。
const ext = (file.name.split('.').pop() || '').toLowerCase().trim();
const okExt = ['', 'png', 'jpg', 'jpeg', 'jpe', 'jfif'];
if (!okExt.includes(ext)) { setImgMsg(msg, '仅支持 png / jpg(.jpeg 也算):' + file.name, true); continue; }
if (file.type && !file.type.startsWith('image/')) { setImgMsg(msg, '这不是图片文件:' + file.name, true); continue; }
if (file.size > 2048 * 1024) { setImgMsg(msg, '超过 2048K:' + file.name, true); continue; }
setImgMsg(msg, '上传中… ' + file.name, false);
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'upload_image');
url.searchParams.set('token', TOKEN);
const fd = new FormData();
fd.append('image', file, file.name);
const res = await fetch(url, { method: 'POST', body: fd });
const data = await res.json();
if (!data.success) { setImgMsg(msg, data.message, true); continue; }
ticketState.images.push(data.data.url);
insertTicketPh('[图片' + ticketState.images.length + ']');
renderTicketImgs();
syncTicketImgInput();
setImgMsg(msg, '已上传 ' + ticketState.images.length + '/3 张', false);
} catch (e) {
setImgMsg(msg, '上传失败: ' + e.message, true);
}
}
syncTicketImgInput();
}
function setImgMsg(el, text, isErr) {
if (!el) return;
el.style.color = isErr ? 'var(--red)' : 'var(--dim)';
el.textContent = text;
}
// 在描述框光标位置插入占位符(没有光标则追加到末尾)
function insertTicketPh(ph) {
const ta = document.getElementById('ticketDesc');
if (!ta) return;
const s = ta.selectionStart ?? ta.value.length;
const e = ta.selectionEnd ?? s;
ta.value = ta.value.slice(0, s) + ph + ta.value.slice(e);
ta.focus();
ta.selectionStart = ta.selectionEnd = s + ph.length;
}
// 已传缩略图列表(编号贴在图角)
function renderTicketImgs() {
const box = document.getElementById('ticketImgList');
if (!box) return;
const imgs = ticketState.images || [];
box.innerHTML = imgs.map((u, i) =>
`<div style="position:relative;width:76px;height:76px;border:1px solid var(--border);border-radius:6px;overflow:hidden">
<img src="${escHtml(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 5px;border-radius:0 4px 0 0">图片${i + 1}</span>
</div>`).join('');
}
// 3 张用满则禁用选择器
function syncTicketImgInput() {
const fi = document.getElementById('ticketImg');
if (fi) fi.disabled = (ticketState.images || []).length >= 3;
}
// 详情渲染:把描述里的 [图片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 submitTicket() {
const type = document.getElementById('ticketType').value;
const title = document.getElementById('ticketTitle').value.trim();
const desc = document.getElementById('ticketDesc').value.trim();
const errEl = document.getElementById('ticketError');
if (!title) { errEl.textContent = '请输入标题'; return; }
if (!desc) { errEl.textContent = '请输入描述'; return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'create');
url.searchParams.set('token', TOKEN);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, title, description: desc, images: ticketState.images || []})
});
const data = await res.json();
if (data.success) {
ticketState.images = []; // 已提交,附件暂存清空
ticketState.view = 'list';
renderTicket(document.getElementById('content'));
} else {
errEl.textContent = data.message;
}
} catch (e) {
errEl.textContent = '提交失败: ' + e.message;
}
}
async function renderTicketDetail(el, id) {
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', 'detail');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.message}</p><button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">返回</button></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)'},completed:{text:'✅ 已完结',color:'var(--green)'},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 canReply = !['withdrawn','rejected','completed'].includes(t.status);
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">#${t.id} ${escHtml(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="display:flex;gap:16px;font-size:13px;color:var(--dim);margin-bottom:16px">
<span>${typeMap[t.type]||t.type}</span>
<span>👤 ${escHtml(t.requester)}</span>
<span>📅 ${date}</span>
${t.assigned_to ? '<span>🧑‍💻 '+escHtml(t.assigned_to)+'</span>' : ''}
</div>`;
// 描述
html += `<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">
<div style="color:var(--dim);font-size:12px;margin-bottom:8px">描述</div>
<div style="font-size:14px;line-height:1.6">${renderMarkdown(applyTicketImgs(t.description || '无描述', t.images))}</div>
</div>`;
// 驳回原因
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:8px;padding:16px;margin-bottom:16px">
<div style="color:var(--red);font-size:12px;margin-bottom:8px">🚫 驳回原因</div>
<div style="font-size:14px">${renderMarkdown(t.reject_reason)}</div>
</div>`;
}
// 回复列表
html += `<div style="margin-bottom:16px"><div style="color:var(--dim);font-size:12px;margin-bottom:8px">💬 回复记录 (${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:8px;padding:12px 16px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-size:13px"><span style="color:${roleColors[r.role]||'var(--text)'};font-weight:600">${escHtml(r.sender)}</span> <span style="color:var(--dim);font-size:11px">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim);font-size:11px">${rDate}</span>
</div>
<div style="font-size:14px;line-height:1.6">${renderMarkdown(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:13px">暂无回复</p>`;
}
html += `</div>`;
// 回复框
if (canReply) {
html += `<div style="margin-bottom:16px">
<textarea id="ticketReplyMsg" rows="3" placeholder="输入回复内容(支持Markdown)" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;resize:vertical;font-family:monospace"></textarea>
<p id="ticketReplyError" style="color:var(--red);font-size:12px;margin-top:4px"></p>
</div>`;
}
// 操作按钮
html += `<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">← 返回列表</button>`;
if (canReply) {
html += `<button class="btn btn-primary" onclick="submitTicketReply(${t.id})">发送回复</button>`;
}
if (t.status === 'submitted' || t.status === 'replied') {
html += `<button class="btn" style="color:var(--yellow);border-color:var(--yellow)" onclick="withdrawTicket(${t.id})">撤销工单</button>`;
}
html += `</div></div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p><button class="btn" onclick="ticketState.view='list';renderTicket(document.getElementById('content'))">返回</button></div>`;
}
}
async function submitTicketReply(id) {
const msg = document.getElementById('ticketReplyMsg').value.trim();
const errEl = document.getElementById('ticketReplyError');
if (!msg) { errEl.textContent = '请输入回复内容'; return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'reply');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg})
});
const data = await res.json();
if (data.success) {
renderTicket(document.getElementById('content'));
} else {
errEl.textContent = data.message;
}
} catch (e) {
errEl.textContent = '发送失败: ' + e.message;
}
}
async function withdrawTicket(id) {
if (!await glassConfirm('确定要撤销此工单吗?')) return;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'withdraw');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) {
ticketState.view = 'list';
renderTicket(document.getElementById('content'));
} else {
glassAlert(data.message);
}
} catch (e) {
glassAlert('撤销失败: ' + e.message);
}
}
// ★ 简易Markdown渲染器
function renderInlineMd(text) {
let s = text;
s = s.replace(/^######\s+(.+)$/, '<h6 style="margin:2px 0;font-size:12px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/, '<h5 style="margin:3px 0;font-size:13px">$1</h5>');
s = s.replace(/^####\s+(.+)$/, '<h4 style="margin:3px 0 2px;font-size:14px">$1</h4>');
s = s.replace(/^###\s+(.+)$/, '<h4 style="margin:4px 0 3px;font-size:14px">$1</h4>');
s = s.replace(/^##\s+(.+)$/, '<h3 style="margin:6px 0 4px;font-size:15px">$1</h3>');
s = s.replace(/^#\s+(.+)$/, '<h2 style="margin:8px 0 5px;font-size:16px">$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 renderMarkdown(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escHtml(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:12px;border-radius:6px;overflow-x:auto;font-size:13px;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:2px 6px;border-radius:3px;font-size:13px">$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 = renderInlineMd(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:4px 0;font-size:12px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:5px 0;font-size:13px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h4 style="margin:6px 0 4px;font-size:14px">$1</h4>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:8px 0 4px;font-size:14px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:10px 0 6px;font-size:15px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:12px 0 8px;font-size:16px">$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:8px 0;font-size:13px"><tr style="background:rgba(88,166,255,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:6px 10px;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:6px 10px">' + 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:20px;font-size:13px">$1</li>');
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:12px 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 escHtml(text).replace(/\n/g, '<br>'); }
}
// ==================== 领地管理 ====================
let landsState = { view: 'list', currentLand: null, currentMember: null };
async function renderLands(el) {
if (!TOKEN) {
el.innerHTML = `<div class="card" style="text-align:center;padding:40px">
<div style="font-size:48px;margin-bottom:16px">🏡</div>
<h2 style="margin:0 0 8px;color:var(--fg)">领地管理</h2>
<p style="color:var(--dim);margin-bottom:16px">请先登录以管理你的领地</p>
<button class="btn btn-blue" onclick="openGlassLogin()">🔑 登录</button>
</div>`;
return;
}
if (landsState.view === 'detail' && landsState.currentLand) {
await renderLandDetail(el, landsState.currentLand);
return;
}
if (landsState.view === 'member_perms' && landsState.currentLand) {
await renderMemberPerms(el, landsState.currentLand);
return;
}
if (landsState.view === 'member_perm_detail' && landsState.currentLand && landsState.currentMember) {
await renderMemberPermDetail(el, landsState.currentLand, landsState.currentMember);
return;
}
// 加载我的领地列表
el.innerHTML = `<div class="card"><p style="color:var(--dim)">加载领地中...</p></div>`;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'my_lands');
url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
if (data.needLogin) {
el.innerHTML = `<div class="card" style="text-align:center;padding:40px">
<div style="font-size:48px;margin-bottom:16px">🏡</div>
<h2 style="margin:0 0 8px;color:var(--fg)">领地管理</h2>
<p style="color:var(--dim);margin-bottom:16px">请先登录以管理你的领地</p>
<button class="btn btn-blue" onclick="openGlassLogin()">🔑 登录</button>
</div>`;
return;
}
if (!data.success) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.error}</p></div>`;
return;
}
const lands = data.lands || [];
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;color:var(--fg)">🏡 我的领地 (${lands.length})</h2>
<span style="color:var(--dim);font-size:13px">玩家: ${escHtml(data.player)}</span>
</div>`;
if (lands.length === 0) {
html += `<div style="text-align:center;padding:32px;color:var(--dim)">
<div style="font-size:40px;margin-bottom:12px">🏗️</div>
<p>你还没有领地</p>
<p style="font-size:12px;margin-top:8px">在游戏中输入 <code style="background:rgba(88,166,255,0.1);padding:2px 6px;border-radius:4px;color:var(--accent)">/protect create <领地名></code> 来创建领地</p>
</div>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">`;
for (const land of lands) {
const areaSize = land.area_size || 0;
const createdAt = land.created_at ? new Date(land.created_at * 1000).toLocaleDateString('zh-CN') : '未知';
const coordStr = `(${land.x1},${land.z1}) → (${land.x2},${land.z2})`;
html += `<div onclick="landsState.view='detail';landsState.currentLand='${escHtml(land.name)}';renderLands(document.getElementById('content'))"
style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;cursor:pointer;transition:all 0.2s"
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h3 style="margin:0;color:var(--fg);font-size:16px">📍 ${escHtml(land.name)}</h3>
<span style="color:var(--green);font-size:11px">✦ 所有者</span>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:4px">🌍 ${escHtml(land.world || '未知')}</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:4px">📐 ${coordStr}</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--dim);margin-top:8px">
<span>📏 ${areaSize} 格${String.fromCharCode(178)}</span>
<span>📅 ${createdAt}</span>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
// 领地商店
html += `<div class="card" style="margin-top:12px">
<h3 style="margin:0 0 12px;color:var(--fg)">🛍️ 领地权限商店</h3>`;
try {
const shopUrl = new URL(API + 'land_api.php', location.href);
shopUrl.searchParams.set('action', 'land_shop');
const shopRes = await fetch(shopUrl);
const shopData = await shopRes.json();
if (shopData.success && shopData.items && shopData.items.length > 0) {
html += `<table class="table"><thead><tr>
<th>领地</th><th>卖家</th><th>权限</th><th>价格(债券)</th><th>时长</th><th>操作</th>
</tr></thead><tbody>`;
for (const item of shopData.items) {
const dur = item.duration >= 86400 ? Math.floor(item.duration/86400)+'天' : Math.floor(item.duration/3600)+'小时';
html += `<tr>
<td>${escHtml(item.land_name)}</td>
<td>${escHtml(item.seller)}</td>
<td><span style="color:var(--accent)">${escHtml(item.permission)}</span></td>
<td style="color:var(--green)">${item.price}</td>
<td>${dur}</td>
<td><button class="btn btn-blue" style="font-size:11px;padding:3px 8px" onclick="buyLandPermission('${escHtml(item.land_name)}',${item.id})">购买</button></td>
</tr>`;
}
html += `</tbody></table>`;
} else {
html += `<p style="color:var(--dim);font-size:13px">暂无在售的领地权限</p>`;
}
} catch (e) {
html += `<p style="color:var(--dim);font-size:13px">商店加载失败</p>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function renderLandDetail(el, landName) {
el.innerHTML = `<div class="card"><p style="color:var(--dim)">加载领地详情...</p></div>`;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'land_detail');
url.searchParams.set('token', TOKEN);
url.searchParams.set('name', landName);
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.error}</p><button class="btn" onclick="landsState.view='list';landsState.currentLand=null;renderLands(document.getElementById('content'))">← 返回</button></div>`;
return;
}
const land = data.land;
const visitors = data.visitors || [];
const coordStr = `(${land.x1},${land.z1}) → (${land.x2},${land.z2})`;
let html = `<div class="card">
<button class="btn" onclick="landsState.view='list';landsState.currentLand=null;renderLands(document.getElementById('content'))" style="margin-bottom:12px;font-size:13px">← 返回我的领地</button>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0;color:var(--fg)">📍 ${escHtml(land.name)}</h2>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-bottom:16px">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px">
<div style="color:var(--dim);font-size:12px;margin-bottom:4px">🌍 世界</div>
<div style="color:var(--fg);font-size:14px">${escHtml(land.world || '未知')}</div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px">
<div style="color:var(--dim);font-size:12px;margin-bottom:4px">📐 坐标</div>
<div style="color:var(--fg);font-size:14px">${coordStr}</div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px">
<div style="color:var(--dim);font-size:12px;margin-bottom:4px">📏 面积</div>
<div style="color:var(--fg);font-size:14px">${land.area_size || 0} 格${String.fromCharCode(178)}</div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px">
<div style="color:var(--dim);font-size:12px;margin-bottom:4px">📅 创建时间</div>
<div style="color:var(--fg);font-size:14px">${land.created_at ? new Date(land.created_at*1000).toLocaleDateString('zh-CN') : '未知'}</div>
</div>
</div>`;
// 访客管理
html += `<div style="margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0;color:var(--fg)">👥 访客管理 (${visitors.length})</h3>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px">
<input type="text" id="newVisitorName" placeholder="输入玩家名" style="flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px">
<button class="btn btn-blue" onclick="addLandVisitor('${escHtml(land.name)}')">添加访客</button>
</div>`;
if (visitors.length === 0) {
html += `<p style="color:var(--dim);font-size:13px">暂无访客</p>`;
} else {
html += `<table class="table"><thead><tr><th>玩家名</th><th>角色</th><th>授权时间</th><th>操作</th></tr></thead><tbody>`;
for (const v of visitors) {
const grantDate = v.granted_at ? new Date(v.granted_at * 1000).toLocaleString('zh-CN') : '未知';
const roleMap = {admin: {label:'👑 管理员', color:'var(--accent)', bg:'rgba(99,102,241,0.15)'}, member: {label:'🔧 成员', color:'#f59e0b', bg:'rgba(245,158,11,0.15)'}, visitor: {label:'👤 访客', color:'var(--dim)', bg:'rgba(107,114,128,0.1)'}};
const r = roleMap[v.role] || roleMap.member;
html += `<tr>
<td><strong>${escHtml(v.player_name)}</strong> <button class="btn" style="font-size:10px;padding:1px 6px;color:var(--accent);border-color:var(--accent)" onclick="event.stopPropagation();landsState.view='member_perm_detail';landsState.currentLand='${escHtml(land.name)}';landsState.currentMember='${escHtml(v.player_name)}';renderLands(document.getElementById('content'))">权限</button></td>
<td><select onchange="if(this.value &&this.value!=='${v.role}')changeVisitorRole('${escHtml(land.name)}','${escHtml(v.player_name)}',this.value);this.value='${v.role}'"
style="cursor:pointer;padding:2px 6px;border-radius:12px;font-size:12px;background:${r.bg};color:${r.color};border:1px solid ${r.color};transition:all 0.2s;appearance:auto;-webkit-appearance:auto">
<option value="admin" ${v.role==='admin'?'selected':''}>👑 管理员</option>
<option value="member" ${v.role==='member'?'selected':''}>🔧 成员</option>
</select></td>
<td style="font-size:12px;color:var(--dim)">${grantDate}</td>
<td><button class="btn" style="color:var(--red);font-size:11px;padding:3px 8px" onclick="removeLandVisitor('${escHtml(land.name)}','${escHtml(v.player_name)}')">移除</button></td>
</tr>`;
}
html += `</tbody></table>`;
}
html += `</div></div>`;
// ★★★ 效果管理 ★★★
let clearEffects = [];
let giveEffects = [];
// ★ 2026-10-04:负面效果从 give_effects 拆到 bad_effects(独立子菜单)
let badEffects = [];
try { clearEffects = land.clear_effects ? JSON.parse(land.clear_effects) : []; } catch(e) { clearEffects = []; }
try { giveEffects = land.give_effects ? JSON.parse(land.give_effects) : []; } catch(e) { giveEffects = []; }
try { badEffects = land.bad_effects ? JSON.parse(land.bad_effects) : []; } catch(e) { badEffects = []; }
const clearAllBad = !!parseInt(land.clear_all_bad || 0);
const denyAll = !!parseInt(land.deny_all_effects || 0);
html += `<div class="card" style="margin-top:12px">
<h3 style="margin:0 0 12px;color:var(--fg)">✨ 效果管理</h3>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<span onclick="toggleLandField('${escHtml(land.name)}','clear_all_bad')"
style="padding:6px 14px;border-radius:20px;font-size:12px;cursor:pointer;transition:all 0.2s;
background:${clearAllBad ? 'var(--green)' : 'var(--bg)'};color:${clearAllBad ? '#fff' : 'var(--dim)'};
border:1px solid ${clearAllBad ? 'var(--green)' : 'var(--border)'}">
🧹 全清负面 ${clearAllBad ? '✓' : ''}
</span>
<span onclick="toggleLandField('${escHtml(land.name)}','deny_all_effects')"
style="padding:6px 14px;border-radius:20px;font-size:12px;cursor:pointer;transition:all 0.2s;
background:${denyAll ? 'var(--accent)' : 'var(--bg)'};color:${denyAll ? '#fff' : 'var(--dim)'};
border:1px solid ${denyAll ? 'var(--accent)' : 'var(--border)'}">
🚫 禁止所有效果 ${denyAll ? '✓' : ''}
</span>
</div>`;
// 清除效果列表
html += `<div style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:var(--fg);font-size:13px;font-weight:500">清除效果 (${clearEffects.length})</span>
<button class="btn" style="font-size:11px;padding:2px 8px" onclick="addClearEffect('${escHtml(land.name)}')">+ 添加</button>
</div>`;
if (clearEffects.length === 0) {
html += `<p style="color:var(--dim);font-size:12px">暂无清除效果</p>`;
} else {
html += `<div style="display:flex;gap:6px;flex-wrap:wrap">`;
for (const eff of clearEffects) {
html += `<span style="background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:12px;padding:3px 10px;font-size:12px;color:var(--fg)">
${escHtml(eff)}
<span onclick="removeLandEffect('${escHtml(land.name)}','clear_effects','${escHtml(eff)}')" style="cursor:pointer;color:var(--red);margin-left:4px">✕</span>
</span>`;
}
html += `</div>`;
}
html += `</div>`;
// 增益效果列表
html += `<div style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:var(--fg);font-size:13px;font-weight:500">增益效果 (${giveEffects.length})</span>
<button class="btn" style="font-size:11px;padding:2px 8px" onclick="addGiveEffect('${escHtml(land.name)}')">+ 添加</button>
</div>`;
if (giveEffects.length === 0) {
html += `<p style="color:var(--dim);font-size:12px">暂无增益效果</p>`;
} else {
html += `<div style="display:flex;gap:6px;flex-wrap:wrap">`;
for (const eff of giveEffects) {
const effName = Array.isArray(eff) ? eff[0] : eff;
const effLevel = Array.isArray(eff) && eff[1] ? ' Lv' + eff[1] : '';
const effDur = Array.isArray(eff) && eff[2] ? ' ' + eff[2] + 's' : '';
html += `<span style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);border-radius:12px;padding:3px 10px;font-size:12px;color:var(--fg)">
${escHtml(effName)}${effLevel}${effDur}
<span onclick="removeLandEffect('${escHtml(land.name)}','give_effects','${escHtml(Array.isArray(eff) ? JSON.stringify(eff) : eff)}')" style="cursor:pointer;color:var(--red);margin-left:4px">✕</span>
</span>`;
}
html += `</div>`;
}
html += `</div>`;
// ★ 负面效果列表(2026-10-04 从增益里拆出,独立子菜单)
// 注意:负面与增益共用同一份 max_effects 数量预算(服务端会按合计截断)
html += `<div style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="color:var(--fg);font-size:13px;font-weight:500">负面效果 (${badEffects.length})</span>
<button class="btn" style="font-size:11px;padding:2px 8px" onclick="addBadEffect('${escHtml(land.name)}')">+ 添加</button>
</div>
<p style="color:var(--dim);font-size:11px;margin:0 0 6px">与增益共用数量上限(当前 ${giveEffects.length}+${badEffects.length} 个)</p>`;
if (badEffects.length === 0) {
html += `<p style="color:var(--dim);font-size:12px">暂无负面效果</p>`;
} else {
html += `<div style="display:flex;gap:6px;flex-wrap:wrap">`;
for (const eff of badEffects) {
const effName = Array.isArray(eff) ? eff[0] : eff;
const effLevel = Array.isArray(eff) && eff[1] ? ' Lv' + eff[1] : '';
const effDur = Array.isArray(eff) && eff[2] ? ' ' + eff[2] + 's' : '';
html += `<span style="background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:12px;padding:3px 10px;font-size:12px;color:var(--fg)">
${escHtml(effName)}${effLevel}${effDur}
<span onclick="removeLandEffect('${escHtml(land.name)}','bad_effects','${escHtml(Array.isArray(eff) ? JSON.stringify(eff) : eff)}')" style="cursor:pointer;color:var(--red);margin-left:4px">✕</span>
</span>`;
}
html += `</div>`;
}
html += `</div>`;
// ★★★ 领地权限开关面板(与Java端GUI访客权限列表完全对齐) ★★★
const permDefs = [
// [字段名, 显示名, 是否反转(allow类)]
['deny_move', '移动', false],
['deny_block_place', '放置方块', false],
['deny_block_break', '破坏方块', false],
['deny_entity_interact', '实体交互(船/矿车/盔甲架/展示框)', false],
['deny_container', '容器管理', false],
['deny_pvp', '玩家对战', false],
['deny_mount', '骑乘坐具', false],
['deny_ender_pearl', '投掷末影珍珠', false],
['deny_thrown_projectiles', '投掷物(三叉戟/雪球/风蛋)', false],
['deny_raid', '袭击', false],
['deny_bow', '弓箭射击', false],
['deny_potion', '药水效果', false],
['deny_fire', '点燃', false],
['deny_fire_spread', '火焰蔓延', false],
['deny_pickup', '拾取物品', true], // allowPickup: true=允许
['deny_drop', '丢弃物品', true], // allowDrop: true=允许
['deny_explosion', '爆炸', false],
['deny_fall_damage', '摔落伤害', false],
['deny_hunger', '饥饿', false],
['deny_all_damage', '所有伤害', false],
['deny_all_effects', '所有效果', false],
['deny_item_frame', '展示框交互', false],
['deny_glowing', '玩家发光', false],
['deny_redstone_interaction', '红石电路(按钮/压力板/中继器)', false],
['deny_door_interaction', '门禁交互(门/栅栏门)', false],
['deny_noteblock_jukebox', '音频(音符盒/唱片机)', false],
['deny_lead', '拴绳使用', false],
['deny_crop_harvest', '农作物收获', false],
['deny_wool_shear', '剪切羊毛/生物', false],
['deny_animal_feeding', '投喂动物', false],
['deny_mob_attack', '攻击生物', false],
['deny_spawn_egg', '生物蛋(生成/孵化)', false],
['deny_wax', '涂蜡/刮蜡', false],
['deny_fluid', '流体放置', false],
['allow_visitor_teleport', '允许传送', true],
['is_public_building', '公共建筑设施', true],
['peace_mode', '和平模式(禁生物)', true],
];
html += `<div class="card" style="margin-top:12px">
<h3 style="margin:0 0 12px;color:var(--fg)">🛡️ 领地权限</h3>
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">点击切换权限状态。启用=允许该行为,禁用=阻止该行为。</p>
<div style="display:flex;gap:6px;flex-wrap:wrap">`;
for (const [field, label, isInverted] of permDefs) {
const rawVal = parseInt(land[field] || 0);
// 反转类(allow_*/is_public):1=启用;deny类:0=启用
const isEnabled = isInverted ? (rawVal === 1) : (rawVal === 0);
const bgColor = isEnabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.1)';
const borderColor = isEnabled ? 'rgba(34,197,94,0.4)' : 'rgba(239,68,68,0.3)';
const textColor = isEnabled ? '#22c55e' : '#ef4444';
const icon = isEnabled ? '✓' : '✕';
html += `<span onclick="togglePerm('${escHtml(land.name)}','${field}',${isInverted})"
style="padding:5px 12px;border-radius:16px;font-size:12px;cursor:pointer;transition:all 0.2s;
background:${bgColor};color:${textColor};
border:1px solid ${borderColor}">
${icon} ${escHtml(label)}
</span>`;
}
html += `</div></div>`;
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function renderMemberPerms(el, landName) {
el.innerHTML = `<div class="card"><p style="color:var(--dim)">加载成员权限...</p></div>`;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'get_member_perms');
url.searchParams.set('token', TOKEN);
url.searchParams.set('land', landName);
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.error}</p><button class="btn" onclick="landsState.view='detail';renderLands(document.getElementById('content'))">← 返回</button></div>`;
return;
}
const land = data.land;
const members = data.members || [];
const permTypes = data.perm_types || {};
let html = `<div class="card">
<button class="btn" onclick="landsState.view='detail';landsState.currentLand='${escHtml(landName)}';landsState.currentMember=null;renderLands(document.getElementById('content'))" style="margin-bottom:12px;font-size:13px">← 返回领地详情</button>
<h2 style="margin:0 0 8px;color:var(--fg)">🎯 ${escHtml(landName)} 成员权限</h2>
<p style="color:var(--dim);font-size:13px;margin-bottom:16px">为每个成员设置独立的权限覆盖。启用=覆盖默认值,禁用=使用领地默认值。</p>`;
if (members.length === 0) {
html += `<div style="text-align:center;padding:32px;color:var(--dim)">
<div style="font-size:40px;margin-bottom:12px">👥</div>
<p>暂无成员</p>
<p style="font-size:12px;margin-top:8px">请先在访客管理中添加成员</p>
</div>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">`;
for (const member of members) {
const permCount = member.perm_map ? Object.keys(member.perm_map).length : 0;
const hasCustom = permCount > 0;
html += `<div onclick="landsState.view='member_perm_detail';landsState.currentMember='${escHtml(member.player_name)}';renderLands(document.getElementById('content'))"
style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;cursor:pointer;transition:all 0.2s"
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h3 style="margin:0;color:var(--fg);font-size:15px">👤 ${escHtml(member.player_name)}</h3>
${hasCustom ? '<span style="background:var(--accent);color:#fff;padding:2px 8px;border-radius:12px;font-size:11px">★ 自定义</span>' : '<span style="color:var(--dim);font-size:12px">默认权限</span>'}
</div>
<div style="font-size:12px;color:var(--dim)">${permCount} 项自定义权限</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function addLandVisitor(landName) {
const input = document.getElementById('newVisitorName');
if (!input) return;
const visitor = input.value.trim();
if (!visitor) return;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'add_visitor');
url.searchParams.set('token', TOKEN);
url.searchParams.set('name', landName);
url.searchParams.set('visitor', visitor);
const res = await fetch(url);
const data = await res.json();
if (data.success) {
// ★ 参考改领地主流程:异步验证,轮询状态
if (data.pending) {
glassAlert('添加成员请求已提交,正在验证玩家...');
// 轮询等待验证结果(65秒超时)
let pollCount = 0;
const maxPolls = 13; // 65秒 / 5秒
const pollInterval = setInterval(async () => {
pollCount++;
try {
const statusUrl = new URL(API + 'land_api.php', location.href);
statusUrl.searchParams.set('action', 'get_add_visitor_status');
statusUrl.searchParams.set('name', landName);
statusUrl.searchParams.set('token', TOKEN);
const statusRes = await fetch(statusUrl);
const statusData = await statusRes.json();
if (statusData.status === 'completed') {
clearInterval(pollInterval);
glassAlert('添加成员成功!');
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else if (statusData.status === 'failed') {
clearInterval(pollInterval);
glassAlert('添加成员失败: ' + (statusData.reason || '玩家可能不存在'));
} else if (pollCount >= maxPolls) {
clearInterval(pollInterval);
glassAlert('验证超时,请稍后刷新查看');
}
} catch (e) {
// 继续轮询
}
}, 5000);
} else {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
}
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function changeVisitorRole(landName, player, newRole) {
const roleLabels = {admin: '管理员', member: '成员'};
const roleLabel = roleLabels[newRole] || newRole;
if (!await glassConfirm(`确定将 ${player} 设为${roleLabel}吗?${newRole === 'admin' ? '\n管理员将拥有领地管理权限!' : ''}${newRole === 'member' ? '\n成员将降级为普通成员。' : ''}`)) return;
try {
const url = new URL(API + 'land_api.php', location.href);
const body = new URLSearchParams();
body.set('action', 'change_visitor_role');
body.set('token', TOKEN);
body.set('name', landName);
body.set('visitor', player);
body.set('role', newRole);
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function removeLandVisitor(landName, player) {
if (!await glassConfirm(`确定要移除访客 ${player} 吗?`)) return;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'remove_visitor');
url.searchParams.set('token', TOKEN);
url.searchParams.set('name', landName);
url.searchParams.set('visitor', player);
const res = await fetch(url);
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
function buyLandPermission(landName, itemId) {
glassAlert('请在游戏中使用 /protect shop 购买领地权限', '🎮');
}
// ★★★ 效果管理辅助函数 ★★★
async function toggleLandField(landName, field) {
try {
// 先读取当前状态
const detailUrl = new URL(API + 'land_api.php', location.href);
detailUrl.searchParams.set('action', 'land_detail');
detailUrl.searchParams.set('token', TOKEN);
detailUrl.searchParams.set('name', landName);
const detailRes = await fetch(detailUrl);
const detailData = await detailRes.json();
if (!detailData.success) { glassAlert(detailData.error); return; }
const currentVal = !!parseInt(detailData.land[field] || 0);
const newVal = currentVal ? '0' : '1';
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_land_field');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('name', landName);
body.set('field', field);
body.set('value', newVal);
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
// ★ 权限开关切换(处理deny/allow反转逻辑)
// isInverted=true时:DB值1=禁用(显示红),DB值0=启用(显示绿)
// isInverted=false时:DB值1=禁用(显示红),DB值0=启用(显示绿) — 相同逻辑,反转体现在发送值
async function togglePerm(landName, field, isInverted) {
try {
// 读取当前值
const detailUrl = new URL(API + 'land_api.php', location.href);
detailUrl.searchParams.set('action', 'land_detail');
detailUrl.searchParams.set('token', TOKEN);
detailUrl.searchParams.set('name', landName);
const detailRes = await fetch(detailUrl);
const detailData = await detailRes.json();
if (!detailData.success) { glassAlert(detailData.error); return; }
const currentDbVal = parseInt(detailData.land[field] || 0);
// deny类:当前0(允许)→发送1(禁止);allow类:当前0(禁止)→发送1(允许)
// 统一取反即可
const newVal = currentDbVal ? '0' : '1';
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_land_field');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('name', landName);
body.set('field', field);
body.set('value', newVal);
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function addClearEffect(landName) {
// MC药水效果列表(用户选择而非手动输入)
const EFFECTS = [
{value:'SPEED',label:'🏃 速度',desc:'SPEED - 提升移动速度'},
{value:'SLOWNESS',label:'🐌 缓慢',desc:'SLOWNESS - 降低移动速度'},
{value:'HASTE',label:'⛏️ 急迫',desc:'HASTE - 提升挖掘和攻击速度'},
{value:'MINING_FATIGUE',label:'🔧 疲劳',desc:'MINING_FATIGUE - 降低挖掘速度'},
{value:'STRENGTH',label:'💪 力量',desc:'STRENGTH - 提升近战伤害'},
{value:'JUMP_BOOST',label:'🦘 跳跃提升',desc:'JUMP_BOOST - 提升跳跃高度'},
{value:'NAUSEA',label:'🤢 反胃',desc:'NAUSEA - 视角扭曲'},
{value:'REGENERATION',label:'❤️ 再生',desc:'REGENERATION - 缓慢恢复生命'},
{value:'RESISTANCE',label:'🛡️ 抗性提升',desc:'RESISTANCE - 减少受到的伤害'},
{value:'FIRE_RESISTANCE',label:'🔥 抗火',desc:'FIRE_RESISTANCE - 免疫火焰伤害'},
{value:'WATER_BREATHING',label:'🐠 水下呼吸',desc:'WATER_BREATHING - 水下不消耗氧气'},
{value:'INVISIBILITY',label:'👻 隐身',desc:'INVISIBILITY - 对其他玩家隐身'},
{value:'BLINDNESS',label:' blinded 失明',desc:'BLINDNESS - 屏幕全黑,无法看到远处'},
{value:'NIGHT_VISION',label:'🦉 夜视',desc:'NIGHT_VISION - 黑暗中看清一切'},
{value:'WEAKNESS',label:'😵 虚弱',desc:'WEAKNESS - 降低近战伤害'},
{value:'POISON',label:'☠️ 中毒',desc:'POISON - 持续失去生命'},
{value:'WITHER',label:'💀 凋零',desc:'WITHER - 持续失去生命(更严重)'},
{value:'HEALTH_BOOST',label:'💖 生命提升',desc:'HEALTH_BOOST - 增加最大生命值'},
{value:'ABSORPTION',label:'🥇 吸收',desc:'ABSORPTION - 提供额外黄色生命值'},
{value:'SATURATION',label:'🍖 饱和',desc:'SATURATION - 恢复饥饿值'},
{value:'GLOWING',label:'✨ 发光',desc:'GLOWING - 对所有人高亮显示'},
{value:'LEVITATION',label:'🎈 漂浮',desc:'LEVITATION - 向上飘浮'},
{value:'SLOW_FALLING',label:'🪂 缓降',desc:'SLOW_FALLING - 下落速度减慢'},
{value:'LUCK',label:'🍀 幸运',desc:'LUCK - 提升战利品品质'},
{value:'UNLUCK',label:'🤕 厄运',desc:'UNLUCK - 降低战利品品质'},
{value:'DOLPHINS_GRACE',label:'🐬 海豚的恩惠',desc:'DOLPHINS_GRACE - 水下加速游泳'},
{value:'CONDUIT_POWER',label:'🔷 潮涌能量',desc:'CONDUIT_POWER - 水下夜视+呼吸+速掘'},
{value:'BAD_OMEN',label:'💀 不祥之兆',desc:'BAD_OMEN - 进入村庄触发袭击'},
{value:'HERO_OF_THE_VILLAGE',label:'🏆 村庄英雄',desc:'HERO_OF_THE_VILLAGE - 村民打折'}
];
// 获取当前已有的清除效果,过滤掉已选的
try {
const detailUrl = new URL(API + 'land_api.php', location.href);
detailUrl.searchParams.set('action', 'land_detail');
detailUrl.searchParams.set('token', TOKEN);
detailUrl.searchParams.set('name', landName);
const detailRes = await fetch(detailUrl);
const detailData = await detailRes.json();
if (!detailData.success) { glassAlert(detailData.error); return; }
let current = [];
try { current = detailData.land.clear_effects ? JSON.parse(detailData.land.clear_effects) : []; } catch(e) { current = []; }
// 过滤掉已添加的效果(兼容中文名和英文名)
const EN_TO_CN = {SPEED:'速度',SLOWNESS:'缓慢',HASTE:'急迫',MINING_FATIGUE:'挖掘疲劳',STRENGTH:'力量',JUMP_BOOST:'跳跃提升',NAUSEA:'反胃',REGENERATION:'再生',RESISTANCE:'抗性提升',FIRE_RESISTANCE:'抗火',WATER_BREATHING:'水下呼吸',INVISIBILITY:'隐身',BLINDNESS:'失明',NIGHT_VISION:'夜视',WEAKNESS:'虚弱',POISON:'中毒',WITHER:'凋零',HEALTH_BOOST:'生命提升',ABSORPTION:'吸收',SATURATION:'饱和',GLOWING:'发光',LEVITATION:'漂浮',SLOW_FALLING:'缓降',LUCK:'幸运',UNLUCK:'厄运',DOLPHINS_GRACE:'海豚的恩惠',CONDUIT_POWER:'潮涌能量',BAD_OMEN:'不祥之兆',HERO_OF_THE_VILLAGE:'村庄英雄',INSTANT_HEALTH:'瞬间治疗',INSTANT_DAMAGE:'瞬间伤害',WORLD_BORDER:'边界',DARKNESS:'黑暗'};
const available = EFFECTS.filter(e => !current.includes(e.value) && !current.includes(EN_TO_CN[e.value]));
if (available.length === 0) { glassAlert('所有效果都已添加'); return; }
const eff = await glassSelect('选择要清除的效果', available, '点击选择效果,将在领地内自动清除该效果');
if (!eff || eff === false) return;
const cnName = EN_TO_CN[eff] || eff;
if (current.includes(cnName) || current.includes(eff)) { glassAlert('该效果已在清除列表中'); return; }
current.push(cnName);
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_land_field');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('name', landName);
body.set('field', 'clear_effects');
body.set('value', JSON.stringify(current));
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
// ★ 效果英文ID → 中文名映射(Java端 resolveEffectType 走中文→英文,两边必须对齐)
const LAND_EFFECT_EN2CN = {SPEED:'速度',SLOWNESS:'缓慢',HASTE:'急迫',MINING_FATIGUE:'挖掘疲劳',STRENGTH:'力量',JUMP_BOOST:'跳跃提升',NAUSEA:'反胃',REGENERATION:'再生',RESISTANCE:'抗性提升',FIRE_RESISTANCE:'抗火',WATER_BREATHING:'水下呼吸',INVISIBILITY:'隐身',BLINDNESS:'失明',NIGHT_VISION:'夜视',WEAKNESS:'虚弱',POISON:'中毒',WITHER:'凋零',HEALTH_BOOST:'生命提升',ABSORPTION:'吸收',SATURATION:'饱和',GLOWING:'发光',LEVITATION:'漂浮',SLOW_FALLING:'缓降',LUCK:'幸运',UNLUCK:'厄运',DOLPHINS_GRACE:'海豚的恩惠',CONDUIT_POWER:'潮涌能量',BAD_OMEN:'不祥之兆',HERO_OF_THE_VILLAGE:'村庄英雄',INSTANT_HEALTH:'瞬间治疗',INSTANT_DAMAGE:'瞬间伤害',WORLD_BORDER:'边界',DARKNESS:'黑暗',INFESTED:'寄生',OOZING:'渗浆',WEAVING:'盘丝',WIND_CHARGED:'蓄风',RAID_OMEN:'袭击之兆',TRIAL_OMEN:'试炼之兆',HUNGER:'饥饿',BREATH_OF_THE_NAUTILUS:'潮涌能量'};
// 负面 + 中性效果档(2026-10-04 从增益里拆出,与 Java 端 EFFECTS_BAD/EFFECTS_NEUTRAL 同口径)
const LAND_EFFECTS_BAD = [
{value:'SLOWNESS',label:'🐌 缓慢',desc:'SLOWNESS - 降低移动速度'},
{value:'MINING_FATIGUE',label:'🔧 挖掘疲劳',desc:'MINING_FATIGUE - 降低挖掘速度'},
{value:'INSTANT_DAMAGE',label:'💥 瞬间伤害',desc:'INSTANT_DAMAGE - 立即受到伤害'},
{value:'NAUSEA',label:'🤢 反胃',desc:'NAUSEA - 视角扭曲'},
{value:'BLINDNESS',label:'🌑 失明',desc:'BLINDNESS - 屏幕全黑,无法看到远处'},
{value:'HUNGER',label:'🍽️ 饥饿',desc:'HUNGER - 无法进食、饥饿值持续下降'},
{value:'WEAKNESS',label:'😵 虚弱',desc:'WEAKNESS - 降低近战伤害'},
{value:'POISON',label:'☠️ 中毒',desc:'POISON - 持续失去生命'},
{value:'WITHER',label:'💀 凋零',desc:'WITHER - 持续失去生命(更严重)'},
{value:'LEVITATION',label:'🎈 飘浮',desc:'LEVITATION - 向上飘浮'},
{value:'UNLUCK',label:'🤕 霉运',desc:'UNLUCK - 降低战利品品质'},
{value:'DARKNESS',label:'🌑 黑暗',desc:'DARKNESS - 视野逐渐被黑暗吞没'},
{value:'WIND_CHARGED',label:'🌪️ 蓄风',desc:'WIND_CHARGED - 可蓄力跃起'},
{value:'WEAVING',label:'🕸️ 盘丝',desc:'WEAVING - 可生成蛛网'},
{value:'OOZING',label:'🟢 渗浆',desc:'OOZING - 可放置经验球'},
{value:'INFESTED',label:'🐛 寄生',desc:'INFESTED - 可召唤恼鬼'}
];
const LAND_EFFECTS_NEUTRAL = [
{value:'BAD_OMEN',label:'💀 不祥之兆',desc:'BAD_OMEN - 进入村庄触发袭击'},
{value:'RAID_OMEN',label:'⚔️ 袭击之兆',desc:'RAID_OMEN - 袭击进行中'},
{value:'TRIAL_OMEN',label:'🏛️ 试炼之兆',desc:'TRIAL_OMEN - 试炼厅已激活'}
];
const LAND_EFFECTS_GOOD = [
{value:'SPEED',label:'🏃 迅捷',desc:'SPEED - 提升移动速度'},
{value:'HASTE',label:'⛏️ 急迫',desc:'HASTE - 提升挖掘和攻击速度'},
{value:'STRENGTH',label:'💪 力量',desc:'STRENGTH - 提升近战伤害'},
{value:'INSTANT_HEALTH',label:'❤️ 瞬间治疗',desc:'INSTANT_HEALTH - 立即恢复生命'},
{value:'JUMP_BOOST',label:'🦘 跳跃提升',desc:'JUMP_BOOST - 提升跳跃高度'},
{value:'REGENERATION',label:'💚 生命恢复',desc:'REGENERATION - 缓慢恢复生命'},
{value:'RESISTANCE',label:'🛡️ 抗性提升',desc:'RESISTANCE - 减少受到的伤害'},
{value:'FIRE_RESISTANCE',label:'🔥 抗火',desc:'FIRE_RESISTANCE - 免疫火焰伤害'},
{value:'WATER_BREATHING',label:'🐠 水下呼吸',desc:'WATER_BREATHING - 水下不消耗氧气'},
{value:'INVISIBILITY',label:'👻 隐身',desc:'INVISIBILITY - 对其他玩家隐身'},
{value:'NIGHT_VISION',label:'🦉 夜视',desc:'NIGHT_VISION - 黑暗中看清一切'},
{value:'GLOWING',label:'✨ 发光',desc:'GLOWING - 对所有人高亮显示'},
{value:'HEALTH_BOOST',label:'💖 生命提升',desc:'HEALTH_BOOST - 增加最大生命值'},
{value:'ABSORPTION',label:'🥇 伤害吸收',desc:'ABSORPTION - 提供额外黄色生命值'},
{value:'SATURATION',label:'🍖 饱和',desc:'SATURATION - 恢复饥饿值'},
{value:'LUCK',label:'🍀 幸运',desc:'LUCK - 提升战利品品质'},
{value:'HERO_OF_THE_VILLAGE',label:'🏆 村庄英雄',desc:'HERO_OF_THE_VILLAGE - 村民打折(生存上限 Lv5)'},
{value:'SLOW_FALLING',label:'🪂 缓降',desc:'SLOW_FALLING - 下落速度减慢'},
{value:'CONDUIT_POWER',label:'🔷 潮涌能量',desc:'CONDUIT_POWER - 水下夜视+呼吸+速掘'},
{value:'DOLPHINS_GRACE',label:'🐬 海豚的恩惠',desc:'DOLPHINS_GRACE - 水下加速游泳(生存上限 Lv1)'}
];
/**
* 通用:给领地添加一个效果(增益/负面共用的同一套流程)
* @param field 'give_effects' | 'bad_effects'
*/
async function landEffectAdd(landName, field, pool, title) {
try {
const detailUrl = new URL(API + 'land_api.php', location.href);
detailUrl.searchParams.set('action', 'land_detail');
detailUrl.searchParams.set('token', TOKEN);
detailUrl.searchParams.set('name', landName);
const detailRes = await fetch(detailUrl);
const detailData = await detailRes.json();
if (!detailData.success) { glassAlert(detailData.error); return; }
let current = [];
try { current = detailData.land[field] ? JSON.parse(detailData.land[field]) : []; } catch(e) { current = []; }
// 过滤掉已添加的效果(兼容中文名和英文名两种写法)
const existingNames = current.map(e => Array.isArray(e) ? e[0] : e);
const available = pool.filter(e => !existingNames.includes(e.value) && !existingNames.includes(LAND_EFFECT_EN2CN[e.value]));
if (available.length === 0) { glassAlert('所有效果都已添加'); return; }
const eff = await glassSelect(title, available, '点击选择效果');
if (!eff || eff === false) return;
const levelStr = await glassPrompt('等级 (1-255)', '1', '默认1级;超出原版生存档位会被服务端封顶');
if (levelStr === null || levelStr === false) return;
const level = parseInt(levelStr) || 1;
const durStr = await glassPrompt('时长(秒)', '300', '默认300秒,最长3600秒');
if (durStr === null || durStr === false) return;
const duration = parseInt(durStr) || 300;
// ★ 存储中文效果名(Java端resolveEffectType使用中文→英文映射)
const cnName = LAND_EFFECT_EN2CN[eff] || eff;
current.push([cnName, Math.min(Math.max(level,1),255), Math.min(Math.max(duration,1),3600)]);
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_land_field');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('name', landName);
body.set('field', field);
body.set('value', JSON.stringify(current));
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function addGiveEffect(landName) {
await landEffectAdd(landName, 'give_effects', LAND_EFFECTS_GOOD, '选择要添加的增益效果');
}
async function addBadEffect(landName) {
await landEffectAdd(landName, 'bad_effects', LAND_EFFECTS_BAD.concat(LAND_EFFECTS_NEUTRAL), '选择要添加的负面效果');
}
async function removeLandEffect(landName, field, effValue) {
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_land_field');
url.searchParams.set('token', TOKEN);
const detailUrl = new URL(API + 'land_api.php', location.href);
detailUrl.searchParams.set('action', 'land_detail');
detailUrl.searchParams.set('token', TOKEN);
detailUrl.searchParams.set('name', landName);
const detailRes = await fetch(detailUrl);
const detailData = await detailRes.json();
if (!detailData.success) { glassAlert(detailData.error); return; }
let current = [];
try { current = detailData.land[field] ? JSON.parse(detailData.land[field]) : []; } catch(e) { current = []; }
if (field === 'clear_effects') {
// clear_effects: 效果名可能是中文或英文,两种都匹配
const EN_TO_CN = {SPEED:'速度',SLOWNESS:'缓慢',HASTE:'急迫',MINING_FATIGUE:'挖掘疲劳',STRENGTH:'力量',JUMP_BOOST:'跳跃提升',NAUSEA:'反胃',REGENERATION:'再生',RESISTANCE:'抗性提升',FIRE_RESISTANCE:'抗火',WATER_BREATHING:'水下呼吸',INVISIBILITY:'隐身',BLINDNESS:'失明',NIGHT_VISION:'夜视',WEAKNESS:'虚弱',POISON:'中毒',WITHER:'凋零',HEALTH_BOOST:'生命提升',ABSORPTION:'吸收',SATURATION:'饱和',GLOWING:'发光',LEVITATION:'漂浮',SLOW_FALLING:'缓降',LUCK:'幸运',UNLUCK:'厄运',DOLPHINS_GRACE:'海豚的恩惠',CONDUIT_POWER:'潮涌能量',BAD_OMEN:'不祥之兆',HERO_OF_THE_VILLAGE:'村庄英雄',INSTANT_HEALTH:'瞬间治疗',INSTANT_DAMAGE:'瞬间伤害',WORLD_BORDER:'边界',DARKNESS:'黑暗'};
const cnName = EN_TO_CN[effValue] || effValue;
current = current.filter(e => e !== effValue && e !== cnName);
} else {
// give_effects: 通过名称或完整JSON匹配
current = current.filter(e => {
const name = Array.isArray(e) ? e[0] : e;
const jsonStr = JSON.stringify(e);
return jsonStr !== effValue && name !== effValue;
});
}
const body = new URLSearchParams();
body.set('name', landName);
body.set('field', field);
body.set('value', JSON.stringify(current));
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body.toString() });
const data = await res.json();
if (data.success) {
landsState.currentLand = landName;
await renderLandDetail(document.getElementById('content'), landName);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function renderMemberPermDetail(el, landName, targetPlayer) {
el.innerHTML = `<div class="card"><p style="color:var(--dim)">加载权限详情...</p></div>`;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'get_member_perms');
url.searchParams.set('token', TOKEN);
url.searchParams.set('land', landName);
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">${data.error}</p><button class="btn" onclick="landsState.view='member_perms';renderLands(document.getElementById('content'))">← 返回</button></div>`;
return;
}
const land = data.land;
const permTypes = data.perm_types || {};
const members = data.members || [];
const member = members.find(m => m.player_name === targetPlayer);
if (!member) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">成员不存在</p><button class="btn" onclick="landsState.view='member_perms';renderLands(document.getElementById('content'))">← 返回</button></div>`;
return;
}
const permMap = member.perm_map || {};
const defaultPerms = data.default_perms || {};
const permEntries = Object.entries(permTypes);
let html = `<div class="card">
<button class="btn" onclick="landsState.view='member_perms';landsState.currentMember=null;renderLands(document.getElementById('content'))" style="margin-bottom:12px;font-size:13px">← 返回成员列表</button>
<h2 style="margin:0 0 8px;color:var(--fg)">👤 ${escHtml(targetPlayer)} 权限</h2>
<p style="color:var(--dim);font-size:13px;margin-bottom:16px">在 ${escHtml(landName)} 中的独立权限设置</p>
<div style="display:flex;gap:8px;margin-bottom:16px">
<button class="btn" onclick="clearAllMemberPerms('${escHtml(landName)}','${escHtml(targetPlayer)}')" style="color:var(--red)">🗑️ 清除所有自定义</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
for (const [key, label] of permEntries) {
// ★ 与游戏内 getEffectiveDeny 一致:有自定义覆盖用自定义,否则回退领地默认
const hasCustom = Object.prototype.hasOwnProperty.call(permMap, key);
const denied = hasCustom ? (permMap[key] === true) : (defaultPerms[key] === true);
const isDefault = !hasCustom; // 继承领地默认
html += `<div onclick="toggleMemberPerm('${escHtml(landName)}','${escHtml(targetPlayer)}','${key}',this)"
style="background:${denied ? 'rgba(239,68,68,0.1)' : 'rgba(34,197,94,0.1)'};
border:1px solid ${denied ? 'var(--red)' : 'var(--green)'};
border-radius:8px;padding:12px;cursor:pointer;transition:all 0.2s;${isDefault ? 'opacity:.82' : ''}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<span style="color:var(--fg);font-size:13px;font-weight:500">${label}${isDefault ? ' <span style="font-size:9px;color:var(--dim)">默认</span>' : ''}</span>
<span style="font-size:11px;color:${denied ? 'var(--red)' : 'var(--green)'}">
${denied ? '✗ 禁止' : '✓ 允许'}
</span>
</div>
</div>`;
}
html += `</div></div>`;
el.innerHTML = html;
} catch (e) {
el.innerHTML = `<div class="card"><p style="color:var(--red)">加载失败: ${e.message}</p></div>`;
}
}
async function toggleMemberPerm(landName, targetPlayer, permKey, el) {
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'update_member_perm');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('land', landName);
body.set('player', targetPlayer);
body.set('perm', permKey);
// 点击"允许"→切换为禁止(enabled=true);点击"禁止"→切换为允许(enabled=false)
const spans = el.querySelectorAll('span');
const statusSpan = spans[spans.length - 1];
const statusText = statusSpan ? statusSpan.textContent : '';
const newDenied = statusText.includes('允许'); // 当前允许→新值禁止(true);当前禁止→新值允许(false)
body.set('enabled', newDenied.toString());
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
const data = await res.json();
if (data.success) {
// 刷新页面
await renderMemberPermDetail(document.getElementById('content'), landName, targetPlayer);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
async function clearAllMemberPerms(landName, targetPlayer) {
if (!await glassConfirm(`确定要清除 ${targetPlayer} 的所有自定义权限吗?`)) return;
try {
const url = new URL(API + 'land_api.php', location.href);
url.searchParams.set('action', 'clear_member_perm');
url.searchParams.set('token', TOKEN);
const body = new URLSearchParams();
body.set('land', landName);
body.set('player', targetPlayer);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
const data = await res.json();
if (data.success) {
glassAlert('已清除所有自定义权限');
await renderMemberPermDetail(document.getElementById('content'), landName, targetPlayer);
} else {
glassAlert(data.error || '操作失败');
}
} catch (e) {
glassAlert('操作失败: ' + e.message);
}
}
// ==================== SN 防伪设备管理 ====================
var snState = { view: 'list', sn: '' };
async function renderSn(el) {
if (snState.view === 'detail' && snState.sn) { await renderSnDetail(el, snState.sn); return; }
snState.view = 'list';
await renderSnList(el);
}
function snStatusBadge(s) {
const m = {
active: ['有效', 'var(--green)'],
destroyed: ['已销毁解绑', 'var(--dim)'],
cancelled: ['已注销', 'var(--yellow)'],
lost: ['报失处理中', 'var(--yellow)'],
illegal: ['非法绑定', 'var(--red)'],
reissued: ['已补发作废', 'var(--dim)']
};
const v = m[s] || [s, 'var(--dim)'];
return '<span style="color:' + v[1] + '">' + v[0] + '</span>';
}
async function renderSnList(el) {
el.innerHTML = '<div class="card"><h2>🔢 SN设备管理</h2><p style="color:var(--dim)">加载中...</p></div>';
try {
const r = await api('sn.php', { action: 'my_list' });
if (!r.success) {
el.innerHTML = '<div class="card"><h2>🔢 SN设备管理</h2><p style="color:var(--red)">' + escHtml(r.message || '加载失败') + '</p></div>';
return;
}
const d = r.data || {};
const list = d.list || [];
const cdLeft = d.cooldown_left || 0;
let html = '<div class="card">' +
'<h2>🔢 我的SN设备 (' + list.length + ')</h2>' +
'<p style="color:var(--dim);font-size:13px;margin-bottom:10px">每件插件签发的设备都带有唯一SN码(玩家名 + 10位时间戳 + 2位识别码)。游戏内使用 <b>/printer item</b> 可导出SN记录,<b>/printer</b> 查看流水。</p>';
if (cdLeft > 0) {
html += '<div style="background:rgba(255,180,0,0.08);border:1px solid rgba(255,180,0,0.45);border-radius:8px;padding:10px 12px;color:var(--yellow);font-size:13px;margin-bottom:12px">⚠️ 注销冷静期剩余 ' + Math.ceil(cdLeft / 60) + ' 分钟,期间无法申领新设备。</div>';
}
html += '<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">' +
'<input id="snQueryInput" type="text" placeholder="输入SN码查询 / 办理..." maxlength="64" style="flex:1;min-width:200px">' +
'<button class="btn btn-primary" onclick="snDoAction(\'query\')">🔍 查询</button>' +
'</div>';
if (!list.length) {
html += '<p style="color:var(--dim)">暂无名下设备。可在游戏内申领雪球菜单 / 区域选择工具 / 回声碎片 / PVP圈地棒。</p>';
} else {
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></tr>';
for (const row of list) {
const canDo = row.is_owner && (row.status === 'active' || row.status === 'lost');
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:7px 6px;font-family:monospace;word-break:break-all">' +
'<a href="javascript:void(0)" onclick="snOpen(\'' + escHtml(row.sn) + '\')" style="color:var(--accent)">' + escHtml(row.sn) + '</a></td>' +
'<td style="padding:7px 6px">' + escHtml(row.type_cn || '') + '</td>' +
'<td style="padding:7px 6px">' + snStatusBadge(row.status) + '</td>' +
'<td style="padding:7px 6px;color:var(--dim)">' + escHtml(row.loc_desc || '') + '</td>' +
'<td style="padding:7px 6px;color:var(--dim)">' + escHtml(row.issue_time_str || '') + '</td>' +
'<td style="padding:7px 6px;white-space:nowrap">';
if (canDo) {
html += '<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'report_lost\',\'' + escHtml(row.sn) + '\')">报失</button> ' +
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'reissue\',\'' + escHtml(row.sn) + '\')">补发</button> ' +
'<button class="btn btn-dim" style="padding:3px 8px;font-size:12px" onclick="snDoAction(\'cancel\',\'' + escHtml(row.sn) + '\')">注销</button>';
}
html += '</td></tr>';
}
html += '</table></div>';
}
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="card"><p style="color:var(--red)">加载失败: ' + escHtml(e.message) + '</p></div>';
}
}
function snOpen(sn) {
snState.view = 'detail';
snState.sn = sn;
renderSn(document.getElementById('content'));
}
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; }
async function snDoAction(action, sn) {
const box = document.getElementById('snQueryInput');
sn = sn || (box ? box.value.trim() : '');
if (!sn) { toast('请输入SN码', 'error'); return; }
if (action !== 'query') {
const tips = {
report_lost: '确定对 ' + sn + ' 报失吗?系统会立即核对物品位置:若在容器内或在其他玩家身上将被拒绝;确认丢失后自动补发新SN。',
reissue: '确定申请补发 ' + sn + ' 吗?旧SN将作废,并签发带新SN的同类物品。',
cancel: '确定注销 ' + sn + ' 吗?将立即销毁对应实物,并进入1小时冷静期,期间无法申领新设备。'
};
if (!await glassConfirm(tips[action] || '确认执行该操作?')) return;
}
try {
const r = await api('sn.php', { action: action, sn: sn });
if (!r.success) { toast(r.message || '操作失败', 'error'); return; }
toast(r.message || '操作成功');
if (action === 'query') snOpen(sn);
else renderSn(document.getElementById('content'));
} catch (e) {
toast('操作失败: ' + e.message, 'error');
}
}
function snBack() {
snState.view = 'list';
snState.sn = '';
renderSn(document.getElementById('content'));
}
async function renderSnDetail(el, sn) {
el.innerHTML = '<div class="card"><h2>SN详情</h2><p style="color:var(--dim)">加载中...</p></div>';
try {
const r = await api('sn.php', { action: 'query', sn: sn });
if (!r.success) {
el.innerHTML = '<div class="card"><p style="color:var(--red)">' + escHtml(r.message || '查询失败') + '</p>' +
'<button class="btn" onclick="snBack()">← 返回</button></div>';
return;
}
const d = r.data || {};
const head = '<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">' +
'<h2 style="margin:0">🔢 ' + escHtml(d.sn || sn) + '</h2>' +
'<button class="btn" onclick="snBack()">← 返回列表</button></div>';
let html = '<div class="card">' + head;
if (d.is_owner === false) {
html += '<p style="color:var(--dim);font-size:13px">该SN不属于你,仅展示脱敏信息。</p>' +
'<table style="width:100%;border-collapse:collapse;font-size:13px">' +
snRow('类型', d.type_cn) + snRow('状态', d.status_cn) + snRow('持有人', d.owner_mask) +
'</table></div>';
el.innerHTML = html;
return;
}
const canDo = (d.status === 'active' || d.status === 'lost');
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">' +
snRow('类型', d.type_cn) +
snRow('状态', d.status_cn) +
snRow('签发时间', d.issue_time_str) +
snRow('最近出现', d.last_seen_str) +
snRow('当前位置', d.loc_desc) +
'</table>';
if (canDo) {
html += '<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">' +
'<button class="btn btn-yellow" onclick="snDoAction(\'report_lost\',\'' + escHtml(d.sn) + '\')">🚩 报失</button>' +
'<button class="btn" onclick="snDoAction(\'reissue\',\'' + escHtml(d.sn) + '\')">♻️ 申请补发</button>' +
'<button class="btn btn-red" onclick="snDoAction(\'cancel\',\'' + escHtml(d.sn) + '\')">🗑️ 注销</button>' +
'</div>';
}
html += '<h3 style="margin:18px 0 8px;font-size:14px">📜 操作日志</h3>';
const logs = d.logs || [];
if (!logs.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">时间</th><th style="padding:5px">动作</th><th style="padding:5px">玩家</th><th style="padding:5px">说明</th></tr>';
for (const l of logs) {
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:5px;white-space:nowrap;color:var(--dim)">' + escHtml(snFmtTime(l.time)) + '</td>' +
'<td style="padding:5px">' + escHtml(snActionCn(l.action)) + '</td>' +
'<td style="padding:5px">' + escHtml(l.player) + '</td>' +
'<td style="padding:5px;color:var(--dim);word-break:break-all">' + escHtml(l.detail) + '</td></tr>';
}
html += '</table></div>';
}
html += '<h3 style="margin:18px 0 8px;font-size:14px">📦 出入库登记</h3>';
const stock = d.stock || [];
if (!stock.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">时间</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 st of stock) {
const land = st.in_land ? (st.land_name || '是') : '-';
html += '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:5px;white-space:nowrap;color:var(--dim)">' + escHtml(snFmtTime(st.time)) + '</td>' +
'<td style="padding:5px">' + escHtml(snActionCn(st.action)) + '</td>' +
'<td style="padding:5px">' + escHtml(st.container_type || '-') + '</td>' +
'<td style="padding:5px;font-family:monospace">' + escHtml((st.world || '') + ' ' + st.x + ',' + st.y + ',' + st.z) + '</td>' +
'<td style="padding:5px">' + escHtml(land) + '</td>' +
'<td style="padding:5px">' + escHtml(st.player || '-') + '</td></tr>';
}
html += '</table></div>';
}
const losts = d.losts || [];
if (losts.length) {
html += '<h3 style="margin:18px 0 8px;font-size:14px">🚨 报失记录</h3><ul style="font-size:13px;color:var(--dim);padding-left:18px;margin:0">';
for (const lo of losts) {
html += '<li>' + escHtml(snFmtTime(lo.created_at)) + ' · ' + escHtml(lo.status) +
(lo.result ? ' · ' + escHtml(lo.result) : '') + '</li>';
}
html += '</ul>';
}
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="card"><p style="color:var(--red)">加载失败: ' + escHtml(e.message) + '</p>' +
'<button class="btn" onclick="snBack()">← 返回</button></div>';
}
}
function snRow(k, v) {
return '<tr style="border-top:1px solid var(--border)">' +
'<td style="padding:7px 6px;color:var(--dim);width:110px">' + escHtml(k) + '</td>' +
'<td style="padding:7px 6px;word-break:break-all">' + escHtml(v == null ? '' : String(v)) + '</td></tr>';
}
function snFmtTime(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());
}
function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ===== 第十六批:封禁常态化公示卡片(独立卡片,每15秒一个窗口期,从下往上滚动替换) =====
const BAN_CARD_ID = 'banBoardCard';
const BAN_SLOT_ID = 'banBoardSlot';
const BAN_PAGE_SIZE = 9; // 卡片内展示9名被封禁玩家
const BAN_WINDOW_MS = 15000; // 每15秒一个窗口期
let banBoardRows = []; // 当前生效封禁(从近到远)
let banBoardShown = false; // 是否已渲染过(控制动画方向)
let banBoardTimer = null;
let banBoardLoaded = false; // 是否成功拉到过一次公示数据(未拉到时显示"同步中"而非"暂无")
// ★ 第十七批:卡片插到 #content 末尾(「详细信息」板块下面),
// 不再 insertBefore(.main) 横跨整页宽度把侧边栏和正文一起顶下去;
// 只在 dashboard 显示——其它页面 content.innerHTML 被整体重写,卡片自然不残留。
function ensureBanCard() {
if (currentPage !== 'dashboard') return null;
const box = document.getElementById('content');
if (!box) return null;
let card = document.getElementById(BAN_CARD_ID);
if (!card || card.parentNode !== box) {
if (card && card.parentNode) card.parentNode.removeChild(card);
card = document.createElement('div');
card.className = 'card';
card.id = BAN_CARD_ID;
card.style.borderColor = 'var(--red)';
card.innerHTML = '<h2 style="color:var(--red)">⚠ 封禁常态化公示</h2>' +
'<p style="color:var(--dim);font-size:12px;margin-bottom:8px">违规玩家将被公示,杀鸡儆猴,请各位引以为戒</p>' +
'<div class="ban-roll-wrap"><div class="ban-roll-inner" id="' + BAN_SLOT_ID + '"></div></div>';
box.appendChild(card);
}
return document.getElementById(BAN_SLOT_ID);
}
// 页面切换 / 内容被重写后,立刻补位并渲染已有数据(不用干等下一个 15 秒窗口)
function banCardSync() {
const slot = ensureBanCard();
if (!slot) return;
if (!slot.children.length) {
banRollUpdate(slot, banPageHtml(banBoardRows.slice(0, BAN_PAGE_SIZE)));
}
}
function banItemHtml(b) {
const name = escHtml(b.target || '');
const reason = escHtml(b.reason || '违反服务器规则');
let expire = parseInt(b.expire_time, 10) || 0;
// 防御:后端若漏归一(Java 推的是毫秒),这里兜一手,避免显示成 68260 年
if (expire > 100000000000) expire = Math.floor(expire / 1000);
const perm = expire === 0 ? '<span class="bperm">永久</span>' : '';
let when = '';
if (expire > 0) {
const d = new Date(expire * 1000);
when = '<span class="btime">至 ' + d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate() + '</span>';
}
return '<div class="ban-roll-item"><span class="bname">☠ ' + name + '</span>' +
'<span class="breason">' + reason + '</span>' + perm + when + '</div>';
}
function banPageHtml(rows) {
if (!banBoardLoaded) return '<div class="ban-roll-item"><span class="breason">⏳ 正在同步公示数据...</span></div>';
if (!rows.length) return '<div class="ban-roll-item"><span class="breason" style="color:var(--green)">✓ 当前暂无被封禁玩家</span></div>';
return rows.map(banItemHtml).join('');
}
// 从下往上滚动替换:旧页滚出上方的同时新页从下方滚入,容器高度同步过渡
// ★ 缺陷修复:按"页"操作而非整块 innerHTML 覆写,高度按内容实测,空态只有一行高
function banRollUpdate(slot, html) {
const wrap = slot.parentNode;
const page = document.createElement('div');
page.className = 'ban-roll-page';
page.innerHTML = html;
const direct = () => {
while (slot.firstChild) slot.removeChild(slot.firstChild);
slot.appendChild(page);
slot.style.transition = 'none';
slot.style.transform = 'translateY(0)';
if (wrap) { wrap.style.transition = 'none'; wrap.style.height = page.offsetHeight + 'px'; }
banBoardShown = true;
slot._banBusy = false;
};
if (!banBoardShown || !slot.children.length || slot._banBusy) { direct(); return; }
const oldH = slot.offsetHeight; // 旧页高度
slot.appendChild(page);
const newH = page.offsetHeight; // 新页高度
slot._banBusy = true;
void slot.offsetHeight; // 强制回流,让过渡从旧状态起步
slot.style.transition = 'transform 0.7s cubic-bezier(.22,.61,.36,1)';
slot.style.transform = 'translateY(-' + oldH + 'px)';
if (wrap) {
wrap.style.transition = 'height 0.7s cubic-bezier(.22,.61,.36,1)';
wrap.style.height = newH + 'px';
}
setTimeout(() => {
slot.style.transition = 'none';
slot.style.transform = 'translateY(0)';
while (slot.firstChild && slot.firstChild !== page) slot.removeChild(slot.firstChild);
if (wrap) { wrap.style.transition = 'none'; wrap.style.height = page.offsetHeight + 'px'; }
slot._banBusy = false;
}, 760);
}
async function banBoardLoadAndRoll() {
try {
const res = await fetch(API + 'sync.php?action=get_ban_board&limit=' + BAN_PAGE_SIZE, {cache: 'no-store'});
const j = await res.json();
if (j && j.success && j.data && Array.isArray(j.data.bans)) {
banBoardRows = j.data.bans;
banBoardLoaded = true;
}
} catch (e) { /* 网络抖动沿用上一帧 */ }
const slot = ensureBanCard();
if (!slot || slot._banBusy) return;
banRollUpdate(slot, banPageHtml(banBoardRows.slice(0, BAN_PAGE_SIZE)));
}
function banBoardStart() {
if (banBoardTimer) return;
banBoardLoadAndRoll();
banBoardTimer = setInterval(banBoardLoadAndRoll, BAN_WINDOW_MS);
}
// DOM 就绪后启动(脚本在 body 末尾,此时 .main 已存在;保险起见仍判断 readyState)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', banBoardStart);
} else {
banBoardStart();
}
</script>
<!-- ★ AGPL-3.0 合规页脚:源码地址取自 开源地址.txt(可由使用者替换为自己的仓库) -->
<footer style="text-align:center;padding:14px 16px;font-size:12px;color:var(--dim);border-top:1px solid var(--border)">
本后台基于 <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>