77b332c037
- 修复详情面板"远程安装"按钮始终禁用:s._base_url 改为从新API获取全局API_BASE_URL - 新增 GET /api/servers/meta/api_base_url 端点 - api_base_url 加入 DB_OVERRIDE_MAP + 启动迁移逻辑 - install.sh 非 root 用户自动 sudo 检测 + NOPASSWD 配置 - 后端 _sudo_wrap 辅助函数应用于 4 个 Agent 操作端点 - install.sh venv 创建加 || true 兼容 set -e(缺 python3-venv 不再直接退出) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
858 lines
46 KiB
HTML
858 lines
46 KiB
HTML
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
|
||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||
<div class="flex-1 flex flex-col min-w-0">
|
||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
|
||
</header>
|
||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
|
||
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
|
||
</div>
|
||
|
||
<!-- ── Account Security ── -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<h2 class="text-white font-medium mb-4">账户安全</h2>
|
||
|
||
<!-- TOTP 2FA — 完整管理区块 -->
|
||
<div class="mb-5 pb-5 border-b border-slate-800 space-y-4">
|
||
|
||
<!-- 状态标题 -->
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-white text-sm font-medium">Google Authenticator 双因素认证</span>
|
||
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态卡(已启用) -->
|
||
<div id="totpEnabledCard" class="hidden rounded-xl border border-green-500/30 bg-green-900/10 p-4">
|
||
<div class="flex items-start gap-3">
|
||
<span class="text-green-400 text-xl mt-0.5">🔐</span>
|
||
<div class="flex-1">
|
||
<p class="text-green-300 text-sm font-medium">双因素认证已启用</p>
|
||
<p class="text-green-400/70 text-xs mt-1">登录时需要输入 Google Authenticator(或兼容 App)生成的 6 位动态验证码。</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-3">
|
||
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App)</button>
|
||
<button onclick="disableTotp()" class="px-3 py-1.5 bg-red-900/40 hover:bg-red-900/70 text-red-400 text-xs rounded-lg transition">禁用 TOTP</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态卡(未启用) -->
|
||
<div id="totpDisabledCard" class="hidden rounded-xl border border-amber-500/30 bg-amber-900/10 p-4">
|
||
<div class="flex items-start gap-3">
|
||
<span class="text-amber-400 text-xl mt-0.5">⚠️</span>
|
||
<div class="flex-1">
|
||
<p class="text-amber-300 text-sm font-medium">尚未启用双因素认证</p>
|
||
<p class="text-amber-400/70 text-xs mt-1">启用后,登录时须额外输入 App 生成的验证码,有效防止密码泄露导致的账户被盗。</p>
|
||
</div>
|
||
</div>
|
||
<button onclick="startTotpSetup(false)" class="mt-3 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">立即启用 TOTP →</button>
|
||
</div>
|
||
|
||
<!-- 绑定流程面板(启用和重新绑定共用) -->
|
||
<div id="totpBindPanel" class="hidden rounded-xl border border-brand/30 bg-brand/5 p-4 space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<span id="totpBindTitle" class="text-white text-sm font-semibold">绑定 Google Authenticator</span>
|
||
<button onclick="cancelTotpSetup()" class="text-slate-500 hover:text-white text-xs transition">✕ 取消</button>
|
||
</div>
|
||
|
||
<!-- 步骤说明 -->
|
||
<div class="text-slate-400 text-xs space-y-1">
|
||
<p>① 打开手机上的 <strong class="text-slate-300">Google Authenticator</strong>(或 Aegis、Microsoft Authenticator 等兼容 App)</p>
|
||
<p>② 扫描下方二维码,或手动输入密钥</p>
|
||
<p>③ 输入 App 显示的 6 位数字确认绑定</p>
|
||
</div>
|
||
|
||
<!-- QR + 密钥 -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
|
||
<div class="bg-slate-900 rounded-lg p-4 text-center border border-slate-700">
|
||
<p class="text-slate-500 text-xs mb-3">扫描二维码</p>
|
||
<canvas id="totpQrCanvas" class="mx-auto rounded" width="180" height="180"></canvas>
|
||
<p id="totpQrFallback" class="hidden text-slate-500 text-xs mt-2">二维码加载失败,请手动输入密钥</p>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label class="block text-slate-400 text-xs mb-1">手动输入密钥</label>
|
||
<div class="flex items-center gap-2">
|
||
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
|
||
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
|
||
</div>
|
||
<p class="text-slate-600 text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
|
||
</div>
|
||
<div>
|
||
<label class="block text-slate-400 text-xs mb-1">输入 App 中的验证码</label>
|
||
<div class="flex items-center gap-2">
|
||
<input id="totpVerifyCode" maxlength="6" inputmode="numeric" pattern="\d{6}" placeholder="000000"
|
||
class="w-28 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
|
||
onkeydown="if(event.key==='Enter')verifyAndEnableTotp()">
|
||
<button onclick="verifyAndEnableTotp()" class="flex-1 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition font-medium">确认绑定</button>
|
||
</div>
|
||
</div>
|
||
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-slate-500 hover:text-slate-300 transition">🔄 重新生成二维码</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Change Password -->
|
||
<div>
|
||
<span class="text-white text-sm font-medium">修改密码</span>
|
||
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。</p>
|
||
<div class="space-y-2 max-w-sm">
|
||
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||
<button onclick="changePassword()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Brand Settings -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<h2 class="text-white font-medium mb-4">品牌</h2>
|
||
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||
</div>
|
||
|
||
<!-- API Key (read-only with copy) -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<h2 class="text-white font-medium mb-4">API 密钥</h2>
|
||
<div id="apiKeySection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||
</div>
|
||
|
||
<!-- Alert Thresholds -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
|
||
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||
</div>
|
||
|
||
<!-- Infrastructure -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<h2 class="text-white font-medium mb-4">基础设施</h2>
|
||
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||
</div>
|
||
|
||
<!-- Telegram -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||
<h2 class="text-white font-medium">Telegram 通知</h2>
|
||
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
|
||
<!-- Test + Chat ID detection -->
|
||
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-800">
|
||
<button onclick="telegramTest()" id="telegramTestBtn"
|
||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||
📨 发送测试消息
|
||
</button>
|
||
<button onclick="telegramGetChats()" id="telegramChatBtn"
|
||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||
🔍 检测 Chat ID
|
||
</button>
|
||
<span class="text-slate-600 text-xs">检测前请先向 Bot 发一条消息</span>
|
||
</div>
|
||
<!-- Chat list -->
|
||
<div id="telegramChatList" class="hidden space-y-2"></div>
|
||
</div>
|
||
|
||
<!-- Login IP Allowlist -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-white font-medium">登录 IP 白名单</h2>
|
||
<p class="text-slate-500 text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
|
||
</div>
|
||
<!-- Master toggle -->
|
||
<div class="flex items-center gap-3">
|
||
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
|
||
<button id="allowlistToggleBtn" onclick="toggleAllowlist()"
|
||
class="w-12 h-6 rounded-full transition-colors bg-slate-700 relative"
|
||
data-enabled="false" title="点击启用/关闭白名单">
|
||
<span id="allowlistToggleDot" class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Disabled notice -->
|
||
<div id="allowlistDisabledNotice" class="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-slate-400 text-xs">
|
||
⚪ 白名单未启用 — 任何 IP 均可登录。开启后将仅允许下方白名单 IP。
|
||
</div>
|
||
<div id="allowlistEnabledNotice" class="hidden rounded-lg border border-green-500/30 bg-green-900/10 px-4 py-3 text-green-300 text-xs">
|
||
🟢 白名单已启用 — 仅白名单内的 IP 可登录。请确保当前 IP 已在列表中,否则自己也会被锁定!
|
||
</div>
|
||
|
||
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
|
||
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<label class="text-slate-300 text-sm font-medium">代理订阅地址(自动刷新)</label>
|
||
<span id="lastRefreshTime" class="text-slate-600 text-xs"></span>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
|
||
class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
|
||
<button onclick="saveSubscriptionUrl()" id="saveSubBtn"
|
||
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition shrink-0">
|
||
保存
|
||
</button>
|
||
<button onclick="forceRefresh()" id="refreshSubBtn"
|
||
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition shrink-0" title="立即刷新">
|
||
🔄
|
||
</button>
|
||
</div>
|
||
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
|
||
</div>
|
||
|
||
<!-- 解析结果预览 -->
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-2">
|
||
<button onclick="previewSubscription()" id="parseSubBtn"
|
||
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
|
||
🔍 预览当前订阅节点 IP
|
||
</button>
|
||
<span class="text-slate-600 text-xs">(不更改白名单,仅查看)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 解析结果 (待确认) -->
|
||
<div id="parsedIpsSection" class="hidden space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="text-slate-400 text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
|
||
<div class="flex gap-2">
|
||
<button onclick="selectAllParsed(true)" class="text-brand-light text-xs hover:underline">全选</button>
|
||
<button onclick="selectAllParsed(false)" class="text-slate-500 text-xs hover:underline">全不选</button>
|
||
</div>
|
||
</div>
|
||
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-slate-700 bg-slate-950 p-2 space-y-1"></div>
|
||
<button onclick="addSelectedToWhitelist()"
|
||
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">
|
||
➕ 添加到白名单
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 手动输入 -->
|
||
<div class="space-y-2">
|
||
<label class="block text-slate-400 text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
|
||
<textarea id="manualIps" rows="3" placeholder="1.2.3.4 10.0.0.0/8 proxy.example.com"
|
||
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
|
||
<button onclick="addManualIps()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">
|
||
➕ 添加
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 订阅节点 IP(只读,自动刷新) -->
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="text-slate-400 text-xs">订阅节点 IP <span class="text-slate-600">(只读,每 2 小时自动替换)</span></label>
|
||
<span id="subIpCount" class="text-slate-600 text-xs"></span>
|
||
</div>
|
||
<div id="subscriptionIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
|
||
</div>
|
||
|
||
<!-- 手动 IP -->
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<label class="text-slate-400 text-xs">手动添加的 IP <span class="text-slate-600">(永久保留)</span></label>
|
||
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
|
||
</div>
|
||
<div id="manualIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notification toggles -->
|
||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<h2 class="text-white font-medium">告警通知项目</h2>
|
||
<span class="text-slate-500 text-xs">通过 Telegram 推送,可逐项关闭</span>
|
||
</div>
|
||
<p class="text-slate-500 text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
|
||
|
||
<div class="space-y-1" id="notifyToggles">
|
||
<div class="text-slate-500 text-center py-4 text-sm">加载中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
<script src="/app/api.js"></script>
|
||
<script src="/app/layout.js"></script>
|
||
<script src="/app/vendor/qrious.min.js"></script>
|
||
<script>
|
||
initLayout('settings');
|
||
const SECTIONS={
|
||
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
|
||
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
|
||
infra:{keys:['api_base_url','db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{api_base_url:'主站对外 URL',db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
|
||
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
|
||
};
|
||
let _allSettings={};
|
||
let _currentAdminId=null;
|
||
let _totpSecret='';
|
||
let _totpUri='';
|
||
|
||
// ── Load settings + admin info ──
|
||
async function loadSettings(){
|
||
try{
|
||
const r=await apiFetch(API+'/settings/');if(!r)return;
|
||
const settings=await r.json();
|
||
_allSettings={};settings.forEach(s=>_allSettings[s.key]=s.value);
|
||
for(const[section,cfg]of Object.entries(SECTIONS)){
|
||
const el=document.getElementById(section+'Section');
|
||
el.innerHTML=cfg.keys.map(key=>{
|
||
const val=_allSettings[key]||'';
|
||
const isMasked=val.includes('***')||val.includes('...');
|
||
if(isMasked){
|
||
return `<div class="flex items-center gap-3">
|
||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
|
||
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
|
||
</div>`;
|
||
}
|
||
return `<div class="flex items-center gap-3">
|
||
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
|
||
<input id="set_${key}" value="${esc(val)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
||
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// API Key section (read-only + copy)
|
||
renderApiKeySection();
|
||
|
||
// Notification toggles
|
||
renderNotifyToggles(_allSettings);
|
||
|
||
}catch(e){}
|
||
}
|
||
|
||
function renderApiKeySection(){
|
||
const val=_allSettings['api_key']||'';
|
||
const isMasked=val.includes('***')||val.includes('...');
|
||
const el=document.getElementById('apiKeySection');
|
||
if(isMasked){
|
||
el.innerHTML=`
|
||
<div class="flex items-center gap-3">
|
||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
|
||
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
|
||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||
<span class="text-slate-600 text-xs">🔒 不可修改</span>
|
||
</div>
|
||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||
}else{
|
||
el.innerHTML=`
|
||
<div class="flex items-center gap-3">
|
||
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
|
||
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
|
||
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
|
||
</div>
|
||
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
|
||
}
|
||
}
|
||
|
||
let _apiKeyRaw='';
|
||
|
||
async function toggleApiKeyVisibility(){
|
||
const btn=document.getElementById('apiKeyToggleBtn');
|
||
const display=document.getElementById('apiKeyDisplay');
|
||
if(btn.textContent.trim()==='显示'){
|
||
const pwd=prompt('请输入当前管理员密码以查看 API Key:');
|
||
if(!pwd)return;
|
||
try{
|
||
const r=await apiFetch(API+'/settings/api-key/reveal',{
|
||
method:'POST',
|
||
headers:apiHeadersJSON(),
|
||
body:JSON.stringify({current_password:pwd}),
|
||
});
|
||
if(!r)return;
|
||
const data=await r.json();
|
||
_apiKeyRaw=data.value||'';
|
||
display.textContent=_apiKeyRaw||'(空)';
|
||
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
|
||
btn.textContent='隐藏';
|
||
}catch(e){}
|
||
}else{
|
||
display.textContent=_allSettings['api_key']||'';
|
||
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
|
||
btn.textContent='显示';
|
||
_apiKeyRaw='';
|
||
}
|
||
}
|
||
|
||
function copyApiKey(){
|
||
if(_apiKeyRaw){copyText(_apiKeyRaw);return}
|
||
// Read from display element (works for both masked and non-masked states)
|
||
const display=document.getElementById('apiKeyDisplay');
|
||
const text=display.textContent||'';
|
||
if(text&&text!=='(空)'&&!text.includes('***')&&!text.includes('...')){
|
||
copyText(text);
|
||
}else{
|
||
// Need to reveal first
|
||
toast('info','请先点击"显示"按钮查看完整密钥');
|
||
}
|
||
}
|
||
|
||
function copyTotpSecret(){
|
||
const el=document.getElementById('totpSecretDisplay');
|
||
copyText(el.textContent);
|
||
}
|
||
|
||
function copyText(text){
|
||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(text).then(()=>toast('success','已复制到剪贴板')).catch(()=>fallbackCopy(text));
|
||
}else{
|
||
fallbackCopy(text);
|
||
}
|
||
}
|
||
|
||
function fallbackCopy(text){
|
||
const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';
|
||
document.body.appendChild(ta);ta.select();
|
||
try{document.execCommand('copy');toast('success','已复制到剪贴板')}catch(e){toast('error','复制失败')}
|
||
document.body.removeChild(ta);
|
||
}
|
||
|
||
async function saveSetting(key){
|
||
const v=document.getElementById('set_'+key).value;
|
||
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:v})});
|
||
if(r&&r.ok)toast('success','设置已保存');else toast('error','保存失败');
|
||
}
|
||
|
||
// ── TOTP Management ──
|
||
let _isRebind = false; // true = 重新绑定(已启用时),false = 首次启用
|
||
|
||
async function loadTotpStatus(){
|
||
try{
|
||
const r=await apiFetch(API+'/auth/me');if(!r)return;
|
||
const admin=await r.json();
|
||
_currentAdminId=admin.id;
|
||
updateTotpUI(admin.totp_enabled);
|
||
}catch(e){}
|
||
}
|
||
|
||
function updateTotpUI(enabled){
|
||
const badge=document.getElementById('totpStatusBadge');
|
||
const enabledCard=document.getElementById('totpEnabledCard');
|
||
const disabledCard=document.getElementById('totpDisabledCard');
|
||
const bindPanel=document.getElementById('totpBindPanel');
|
||
|
||
bindPanel.classList.add('hidden');
|
||
document.getElementById('totpVerifyCode').value='';
|
||
|
||
if(enabled){
|
||
badge.textContent='✓ 已启用';
|
||
badge.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400';
|
||
enabledCard.classList.remove('hidden');
|
||
disabledCard.classList.add('hidden');
|
||
}else{
|
||
badge.textContent='未启用';
|
||
badge.className='text-xs px-2 py-0.5 rounded-full bg-amber-900/40 text-amber-400';
|
||
enabledCard.classList.add('hidden');
|
||
disabledCard.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
async function startTotpSetup(isRebind=false){
|
||
if(!_currentAdminId){toast('error','未获取用户信息');return}
|
||
_isRebind = isRebind;
|
||
|
||
const bindPanel=document.getElementById('totpBindPanel');
|
||
const title=document.getElementById('totpBindTitle');
|
||
const qrCanvas=document.getElementById('totpQrCanvas');
|
||
const qrFallback=document.getElementById('totpQrFallback');
|
||
|
||
title.textContent = isRebind ? '重新绑定 Google Authenticator' : '绑定 Google Authenticator';
|
||
bindPanel.classList.remove('hidden');
|
||
document.getElementById('totpSecretDisplay').textContent='生成中...';
|
||
document.getElementById('totpVerifyCode').value='';
|
||
qrFallback.classList.add('hidden');
|
||
|
||
// Scroll to panel
|
||
setTimeout(()=>bindPanel.scrollIntoView({behavior:'smooth',block:'nearest'}),100);
|
||
|
||
try{
|
||
const r=await apiFetch(API+'/auth/totp/setup',{
|
||
method:'POST',headers:apiHeadersJSON(),
|
||
body:JSON.stringify({admin_id:_currentAdminId})
|
||
});
|
||
if(!r)return;
|
||
const data=await r.json();
|
||
if(!data.success){toast('error',data.reason||'设置失败');return}
|
||
|
||
_totpSecret=data.secret;
|
||
_totpUri=data.uri;
|
||
document.getElementById('totpSecretDisplay').textContent=data.secret;
|
||
|
||
// Generate QR code
|
||
try{
|
||
new QRious({
|
||
element:qrCanvas,
|
||
value:data.uri,
|
||
size:180,
|
||
backgroundAlpha:0,
|
||
foreground:'#e2e8f0',
|
||
background:'transparent',
|
||
level:'M'
|
||
});
|
||
qrCanvas.style.display='block';
|
||
}catch(e){
|
||
qrCanvas.style.display='none';
|
||
qrFallback.classList.remove('hidden');
|
||
}
|
||
// Focus verify input
|
||
setTimeout(()=>document.getElementById('totpVerifyCode').focus(),300);
|
||
}catch(e){toast('error','TOTP生成请求失败')}
|
||
}
|
||
|
||
async function verifyAndEnableTotp(){
|
||
const code=document.getElementById('totpVerifyCode').value.trim();
|
||
if(!/^\d{6}$/.test(code)){toast('warning','请输入 6 位纯数字验证码');return}
|
||
try{
|
||
const r=await apiFetch(API+'/auth/totp/enable',{
|
||
method:'POST',headers:apiHeadersJSON(),
|
||
body:JSON.stringify({admin_id:_currentAdminId,totp_code:code})
|
||
});
|
||
if(!r)return;
|
||
const data=await r.json();
|
||
if(data.success){
|
||
toast('success', _isRebind ? '重新绑定成功!新设备已生效' : 'TOTP 已启用!下次登录需要验证码');
|
||
updateTotpUI(true);
|
||
}else{
|
||
toast('error',data.message||data.reason||'验证码错误,请检查手机时间并重试');
|
||
}
|
||
}catch(e){toast('error','启用失败')}
|
||
}
|
||
|
||
async function disableTotp(){
|
||
if(!confirm('确定要禁用双因素认证?\n禁用后登录将不再需要验证码,账户安全性降低。'))return;
|
||
const pwd=prompt('请输入当前登录密码:');
|
||
if(!pwd)return;
|
||
const code=prompt('请输入当前 TOTP 验证码(来自 App):');
|
||
if(!code)return;
|
||
try{
|
||
const r=await apiFetch(API+'/auth/totp/disable',{
|
||
method:'POST',headers:apiHeadersJSON(),
|
||
body:JSON.stringify({admin_id:_currentAdminId,current_password:pwd,totp_code:code.trim()})
|
||
});
|
||
if(!r)return;
|
||
const data=await r.json();
|
||
if(data.success){
|
||
toast('success','TOTP 已禁用');
|
||
updateTotpUI(false);
|
||
}else{
|
||
toast('error',data.message||'禁用失败,密码或验证码错误');
|
||
}
|
||
}catch(e){toast('error','禁用请求失败')}
|
||
}
|
||
|
||
function cancelTotpSetup(){
|
||
document.getElementById('totpBindPanel').classList.add('hidden');
|
||
document.getElementById('totpVerifyCode').value='';
|
||
}
|
||
|
||
// ── Change Password ──
|
||
async function changePassword(){
|
||
const current=document.getElementById('currentPassword').value;
|
||
const newPw=document.getElementById('newPassword').value;
|
||
const confirm=document.getElementById('confirmPassword').value;
|
||
|
||
if(!current||!newPw||!confirm){toast('warning','请填写所有密码字段');return}
|
||
if(newPw.length<6){toast('warning','新密码至少6位');return}
|
||
if(newPw!==confirm){toast('warning','两次输入的新密码不一致');return}
|
||
|
||
try{
|
||
const r=await apiFetch(API+'/auth/password',{
|
||
method:'PUT',headers:apiHeadersJSON(),
|
||
body:JSON.stringify({current_password:current,new_password:newPw})
|
||
});
|
||
if(!r){toast('error','请求失败');return}
|
||
if(r.ok){
|
||
const data=await r.json();
|
||
toast('success','密码已修改');
|
||
document.getElementById('currentPassword').value='';
|
||
document.getElementById('newPassword').value='';
|
||
document.getElementById('confirmPassword').value='';
|
||
}else{
|
||
const err=await r.json().catch(()=>({}));
|
||
toast('error',err.detail||'修改失败');
|
||
}
|
||
}catch(e){toast('error','修改请求失败')}
|
||
}
|
||
|
||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
||
document.addEventListener('click',e=>{
|
||
const btn=e.target.closest('.save-setting-btn');
|
||
if(!btn)return;
|
||
const key=btn.getAttribute('data-setting-key');
|
||
if(key)saveSetting(key);
|
||
});
|
||
|
||
// ── Telegram Test + Chat ID Detection ──
|
||
async function telegramTest(){
|
||
const btn=document.getElementById('telegramTestBtn');
|
||
btn.disabled=true;btn.textContent='发送中...';
|
||
try{
|
||
const r=await apiFetch(API+'/settings/telegram/test',{method:'POST',headers:apiHeadersJSON()});
|
||
if(!r)return;
|
||
const d=await r.json();
|
||
if(d.success)toast('success','Telegram 测试消息发送成功!');
|
||
else toast('error','发送失败: '+(d.detail||d.error||'未知错误'));
|
||
}catch(e){toast('error','请求失败: '+e.message)}
|
||
finally{btn.disabled=false;btn.textContent='📨 发送测试消息'}
|
||
}
|
||
|
||
async function telegramGetChats(){
|
||
const btn=document.getElementById('telegramChatBtn');
|
||
btn.disabled=true;btn.textContent='检测中...';
|
||
try{
|
||
const r=await apiFetch(API+'/settings/telegram/chats');
|
||
if(!r)return;
|
||
const d=await r.json();
|
||
const listEl=document.getElementById('telegramChatList');
|
||
if(!d.chats||d.chats.length===0){
|
||
toast('warning','未检测到消息,请先向 Bot 发一条消息后再试');
|
||
listEl.classList.add('hidden');return;
|
||
}
|
||
listEl.classList.remove('hidden');
|
||
listEl.innerHTML='<p class="text-slate-400 text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
|
||
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg">
|
||
<div class="flex-1">
|
||
<span class="text-white text-sm font-medium">${esc(c.title||c.username||'私聊')}</span>
|
||
<span class="ml-2 text-slate-500 text-xs">${esc(c.type)}</span>
|
||
<code class="ml-2 text-brand-light text-xs font-mono">${esc(String(c.id))}</code>
|
||
</div>
|
||
<button onclick="fillChatId('${escAttr(String(c.id))}')"
|
||
class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg transition">
|
||
使用此 ID
|
||
</button>
|
||
</div>`).join('');
|
||
toast('success',`检测到 ${d.chats.length} 个对话`);
|
||
}catch(e){toast('error','检测失败: '+e.message)}
|
||
finally{btn.disabled=false;btn.textContent='🔍 检测 Chat ID'}
|
||
}
|
||
|
||
async function fillChatId(chatId){
|
||
// Save chat_id to settings
|
||
const r=await apiFetch(API+'/settings/telegram_chat_id',{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:chatId})});
|
||
if(r&&r.ok){
|
||
toast('success','Chat ID 已保存: '+chatId);
|
||
// Update the input field in telegramSection
|
||
const input=document.getElementById('set_telegram_chat_id');
|
||
if(input)input.value=chatId;
|
||
loadSettings();
|
||
}else toast('error','保存失败');
|
||
}
|
||
|
||
// ── Notification Toggles ──
|
||
const NOTIFY_ITEMS = [
|
||
{ key:'notify_alert_cpu', label:'CPU 超阈值告警', desc:'CPU 使用率超过设定阈值时推送', group:'服务器资源' },
|
||
{ key:'notify_alert_mem', label:'内存超阈值告警', desc:'内存使用率超过设定阈值时推送', group:'服务器资源' },
|
||
{ key:'notify_alert_disk', label:'磁盘超阈值告警', desc:'磁盘使用率超过设定阈值时推送', group:'服务器资源' },
|
||
{ key:'notify_recovery', label:'资源恢复正常', desc:'CPU/内存/磁盘告警解除时推送', group:'服务器资源' },
|
||
{ key:'notify_time_drift', label:'时钟偏差严重 (≥60s)', desc:'子机与主机时间偏差过大影响 TOTP/cron',group:'服务器' },
|
||
{ key:'notify_system_redis', label:'Redis 连接异常 / 恢复', desc:'Redis 不可达或恢复时推送', group:'系统' },
|
||
{ key:'notify_system_mysql', label:'MySQL 连接异常 / 恢复', desc:'MySQL 不可达或恢复时推送', group:'系统' },
|
||
{ key:'notify_restart', label:'Nexus 后端重启', desc:'Python 服务崩溃重启成功/失败时推送', group:'系统' },
|
||
];
|
||
|
||
function renderNotifyToggles(settingsMap){
|
||
const container=document.getElementById('notifyToggles');
|
||
let lastGroup='';
|
||
container.innerHTML=NOTIFY_ITEMS.map(item=>{
|
||
const val=(settingsMap[item.key]||'true').trim().toLowerCase();
|
||
const enabled=!['false','0','no','off'].includes(val);
|
||
let groupHeader='';
|
||
if(item.group!==lastGroup){
|
||
lastGroup=item.group;
|
||
groupHeader=`<div class="text-slate-500 text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-slate-800 first:border-0">${esc(item.group)}</div>`;
|
||
}
|
||
return groupHeader+`
|
||
<div class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-800/50 transition group">
|
||
<div class="flex-1 min-w-0">
|
||
<span class="text-sm text-slate-200">${esc(item.label)}</span>
|
||
<span class="block text-xs text-slate-500 mt-0.5">${esc(item.desc)}</span>
|
||
</div>
|
||
<button onclick="toggleNotify('${escAttr(item.key)}',this)"
|
||
class="ml-4 shrink-0 w-11 h-6 rounded-full transition-colors ${enabled?'bg-brand':'bg-slate-700'} relative"
|
||
data-enabled="${enabled}" title="${enabled?'点击关闭':'点击启用'}">
|
||
<span class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${enabled?'translate-x-5':'translate-x-0'}"></span>
|
||
</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function toggleNotify(key, btn){
|
||
const nowEnabled=btn.getAttribute('data-enabled')==='true';
|
||
const newEnabled=!nowEnabled;
|
||
const newVal=newEnabled?'true':'false';
|
||
|
||
// Optimistic UI update
|
||
btn.setAttribute('data-enabled',String(newEnabled));
|
||
btn.className=btn.className.replace(newEnabled?'bg-slate-700':'bg-brand', newEnabled?'bg-brand':'bg-slate-700');
|
||
const dot=btn.querySelector('span');
|
||
dot.className=dot.className.replace(newEnabled?'translate-x-0':'translate-x-5', newEnabled?'translate-x-5':'translate-x-0');
|
||
btn.title=newEnabled?'点击关闭':'点击启用';
|
||
|
||
const r=await apiFetch(API+'/settings/'+key,{method:'PUT',headers:apiHeadersJSON(),body:JSON.stringify({value:newVal})});
|
||
if(!r||!r.ok){
|
||
// Revert
|
||
btn.setAttribute('data-enabled',String(nowEnabled));
|
||
toast('error','保存失败,已撤销');
|
||
renderNotifyToggles(_allSettings);
|
||
}else{
|
||
_allSettings[key]=newVal;
|
||
toast('success',newEnabled?'已启用通知':'已关闭通知');
|
||
}
|
||
}
|
||
|
||
// ── IP Allowlist ──
|
||
let _subIps = [];
|
||
let _manualIps = [];
|
||
let _parsedIps = [];
|
||
let _allowlistEnabled = false;
|
||
|
||
async function loadAllowlist(){
|
||
try{
|
||
const r=await apiFetch(API+'/settings/ip-allowlist');if(!r)return;
|
||
const d=await r.json();
|
||
_subIps=d.subscription_ips||[];
|
||
_manualIps=d.manual_ips||[];
|
||
_allowlistEnabled=d.enabled||false;
|
||
// Fill subscription URL
|
||
if(d.subscription_url)document.getElementById('subUrl').value=d.subscription_url;
|
||
// Last refresh
|
||
const lrt=document.getElementById('lastRefreshTime');
|
||
if(d.last_refresh)lrt.textContent='上次刷新: '+d.last_refresh;
|
||
else if(d.subscription_url)lrt.textContent='等待首次刷新...';
|
||
// Toggle button
|
||
const btn=document.getElementById('allowlistToggleBtn');
|
||
const dot=document.getElementById('allowlistToggleDot');
|
||
btn.setAttribute('data-enabled',String(_allowlistEnabled));
|
||
btn.className=`w-12 h-6 rounded-full transition-colors ${_allowlistEnabled?'bg-brand':'bg-slate-700'} relative`;
|
||
dot.className=`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${_allowlistEnabled?'translate-x-6':'translate-x-0.5'}`;
|
||
// Notices
|
||
document.getElementById('allowlistDisabledNotice').classList.toggle('hidden',_allowlistEnabled);
|
||
document.getElementById('allowlistEnabledNotice').classList.toggle('hidden',!_allowlistEnabled);
|
||
// Status badge
|
||
const status=document.getElementById('ipAllowlistStatus');
|
||
if(_allowlistEnabled){
|
||
status.textContent=`已启用 · ${d.total_count} 条`;
|
||
status.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400 border border-green-500/30';
|
||
}else{
|
||
status.textContent='已关闭';
|
||
status.className='text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
|
||
}
|
||
// Subscription IPs (read-only)
|
||
document.getElementById('subIpCount').textContent=`${_subIps.length} 个`;
|
||
document.getElementById('subscriptionIpsList').innerHTML=_subIps.length
|
||
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-slate-700 bg-slate-800 text-xs font-mono text-slate-400">${esc(ip)}</span>`).join('')+'</div>'
|
||
:'<span class="text-slate-600">(空 — 尚未刷新或未配置订阅)</span>';
|
||
// Manual IPs (deletable)
|
||
document.getElementById('manualIpsList').innerHTML=_manualIps.length
|
||
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
|
||
:'<span class="text-slate-600">(空)</span>';
|
||
}catch(e){}
|
||
}
|
||
|
||
async function toggleAllowlist(){
|
||
const btn=document.getElementById('allowlistToggleBtn');
|
||
const nowEnabled=btn.getAttribute('data-enabled')==='true';
|
||
const newEnabled=!nowEnabled;
|
||
if(newEnabled&&_subIps.length===0&&_manualIps.length===0){
|
||
toast('warning','白名单为空,启用后无人可以登录!请先添加 IP 再启用。');return;
|
||
}
|
||
if(newEnabled&&!confirm('确认启用 IP 白名单?\n启用后非白名单 IP 将无法登录,请确认当前 IP 已在列表中。'))return;
|
||
const r=await apiFetch(API+'/settings/ip-allowlist/toggle?enabled='+newEnabled,{method:'POST',headers:apiHeadersJSON()});
|
||
if(r&&r.ok){toast('success',newEnabled?'白名单已启用':'白名单已关闭');loadAllowlist();}
|
||
else toast('error','操作失败');
|
||
}
|
||
|
||
async function saveSubscriptionUrl(){
|
||
const url=document.getElementById('subUrl').value.trim();
|
||
const btn=document.getElementById('saveSubBtn');
|
||
btn.disabled=true;btn.textContent='保存中...';
|
||
try{
|
||
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),
|
||
body:JSON.stringify({manual_ips:_manualIps,subscription_url:url})});
|
||
if(r&&r.ok){toast('success',url?'订阅地址已保存,正在刷新节点 IP...':'订阅地址已清空');setTimeout(loadAllowlist,3500)}
|
||
else{const e=await r?.json().catch(()=>({detail:'保存失败'}));toast('error',e.detail||'保存失败')}
|
||
}finally{btn.disabled=false;btn.textContent='保存'}
|
||
}
|
||
|
||
async function forceRefresh(){
|
||
const btn=document.getElementById('refreshSubBtn');
|
||
btn.textContent='⏳';btn.disabled=true;
|
||
const url=(document.getElementById('subUrl').value||'').trim();
|
||
if(!url){toast('warning','请先填写订阅地址');btn.textContent='🔄';btn.disabled=false;return}
|
||
await saveSubscriptionUrl();
|
||
setTimeout(()=>{btn.textContent='🔄';btn.disabled=false;loadAllowlist()},4000);
|
||
}
|
||
|
||
async function previewSubscription(){
|
||
const url=(document.getElementById('subUrl').value||'').trim();
|
||
if(!url){toast('warning','请先填写订阅地址');return}
|
||
const btn=document.getElementById('parseSubBtn');
|
||
btn.disabled=true;btn.textContent='预览中...';
|
||
try{
|
||
const r=await apiFetch(API+'/settings/ip-allowlist/parse-subscription',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({url})});
|
||
if(!r||!r.ok){const e=await r?.json().catch(()=>({detail:'解析失败'}));toast('error',e.detail||'解析失败');return}
|
||
const d=await r.json();
|
||
_parsedIps=d.hosts||[];
|
||
const section=document.getElementById('parsedIpsSection');
|
||
const list=document.getElementById('parsedIpsList');
|
||
section.classList.remove('hidden');
|
||
list.innerHTML=_parsedIps.map(ip=>`
|
||
<label class="flex items-center gap-2 cursor-pointer hover:bg-slate-800 px-1 rounded py-0.5">
|
||
<input type="checkbox" checked class="parsed-ip-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" data-ip="${escAttr(ip)}">
|
||
<span class="font-mono text-xs text-slate-300">${esc(ip)}</span>
|
||
</label>`).join('');
|
||
toast('success',`预览到 ${d.count} 个节点 IP(保存订阅后自动刷新)`);
|
||
}catch(e){toast('error','请求失败: '+e.message)}
|
||
finally{btn.disabled=false;btn.textContent='🔍 预览当前订阅节点 IP'}
|
||
}
|
||
|
||
function selectAllParsed(checked){
|
||
document.querySelectorAll('.parsed-ip-cb').forEach(cb=>cb.checked=checked);
|
||
}
|
||
|
||
function addSelectedToWhitelist(){
|
||
const selected=Array.from(document.querySelectorAll('.parsed-ip-cb:checked')).map(cb=>cb.dataset.ip);
|
||
if(!selected.length){toast('warning','请勾选至少一个 IP');return}
|
||
addManualIpsToList(selected);
|
||
document.getElementById('parsedIpsSection').classList.add('hidden');
|
||
}
|
||
|
||
async function addManualIps(){
|
||
const raw=document.getElementById('manualIps').value;
|
||
const newIps=raw.split(/[\n,]/).map(s=>s.trim()).filter(Boolean);
|
||
if(!newIps.length){toast('warning','请输入 IP');return}
|
||
await addManualIpsToList(newIps);
|
||
document.getElementById('manualIps').value='';
|
||
}
|
||
|
||
async function addManualIpsToList(newIps){
|
||
const r=await apiFetch(API+'/settings/ip-allowlist/manual',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({ips:newIps})});
|
||
if(r&&r.ok){toast('success',`已添加 ${newIps.length} 条手动 IP`);setTimeout(loadAllowlist,1500)}
|
||
else toast('error','添加失败');
|
||
}
|
||
|
||
async function removeManualIp(ip){
|
||
const r=await apiFetch(API+'/settings/ip-allowlist/ip?ip='+encodeURIComponent(ip),{method:'DELETE',headers:apiHeadersJSON()});
|
||
if(r&&r.ok){toast('success','已移除: '+ip);setTimeout(loadAllowlist,1000)}
|
||
else toast('error','移除失败');
|
||
}
|
||
|
||
async function clearManualIps(){
|
||
if(!confirm('确定清空所有手动添加的 IP?订阅节点不受影响。'))return;
|
||
const r=await apiFetch(API+'/settings/ip-allowlist',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({manual_ips:[]})});
|
||
if(r&&r.ok){toast('success','手动 IP 已清空');setTimeout(loadAllowlist,1000)}
|
||
}
|
||
|
||
// ── Init ──
|
||
loadSettings();
|
||
loadTotpStatus();
|
||
loadAllowlist();
|
||
</script>
|
||
</body></html>
|