feat(web): UI batch — content width, latency removal, icon tooltips, avatar removal (round-2 #3/#5/#6/#9)

#3 .content: drop max-width:1460px centering, padding 24px full width
   (3x-ui content-area parity), 8px under the 760px breakpoint.
#5 servers page: remove latency column + average-latency stat cell
   (stats grid 4->3, table 15->14 cols incl. detail colspan/empty row).
#6 icon buttons: native title replaced by data-tip with a 3x-ui style
   bubble (black/white, rounded, arrow, body-level fixed layer so table
   scroll containers cannot clip it); customer QR tip renamed to 二维码;
   covers customers/servers/nodes rows, credentials pane, links pane,
   address eye toggle, drag grip, filter chip clear, logout, modal close.
#9 remove decorative topbar avatar span#avatar, its showApp assignment
   and the dead .avatar CSS rule. Refresh button untouched.

Golden re-captured: sole diff was errors.badSubscription body (the SPA
index.html served there) reflecting the avatar/data-tip changes.
This commit is contained in:
aaaamoooo
2026-10-21 11:18:00 +08:00
parent 3de4c4945f
commit 1b32b57377
4 changed files with 57 additions and 26 deletions
+46 -17
View File
@@ -144,7 +144,6 @@ function showLogin() {
function showApp() {
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
$('#user-chip').textContent = state.session.user.username;
$('#avatar').textContent = (state.session.user.username || '管').slice(0, 1).toUpperCase();
$('#version-label').textContent = `v${state.version}`;
}
@@ -197,6 +196,38 @@ function showPop(anchor, html) {
}
function hidePop() { const pop = $('#float-pop'); pop.classList.add('hidden'); pop.innerHTML = ''; pop._anchor = null; }
/* ---------- 图标悬停提示(data-tip,3x-ui 式气泡,挂 body 防裁剪) ---------- */
let tipBubble = null;
let tipTarget = null;
function showTip(el) {
const text = el.getAttribute('data-tip');
if (!text) return;
if (!tipBubble) {
tipBubble = document.createElement('div');
tipBubble.className = 'tip-bubble';
document.body.appendChild(tipBubble);
}
tipTarget = el;
tipBubble.textContent = text;
tipBubble.classList.add('show');
const rect = el.getBoundingClientRect();
const bw = tipBubble.offsetWidth;
const bh = tipBubble.offsetHeight;
let left = rect.left + rect.width / 2 - bw / 2;
left = Math.max(6, Math.min(left, window.innerWidth - bw - 6));
tipBubble.style.left = `${left}px`;
tipBubble.style.top = `${Math.max(4, rect.top - bh - 9)}px`;
tipBubble.style.setProperty('--tip-arrow-left', `${rect.left + rect.width / 2 - left}px`);
}
function hideTip() { tipTarget = null; if (tipBubble) tipBubble.classList.remove('show'); }
document.addEventListener('mouseover', (event) => {
const el = event.target && event.target.closest ? event.target.closest('[data-tip]') : null;
if (el === tipTarget) return;
if (el) showTip(el); else hideTip();
});
document.addEventListener('mousedown', hideTip);
window.addEventListener('scroll', hideTip, true);
/* ---------- 分页 ---------- */
function pageSlice(items, key) {
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
@@ -401,7 +432,7 @@ function serverDetailRow(server) {
const tls = server.tlsDomain ? `${esc(server.tlsDomain)} · ${server.engine && (server.engine.certificates || []).includes(server.tlsDomain) ? '已确认' : '待确认'}` : '未设置';
const engine = server.role === 'exit' ? '—' : (server.engine ? (server.engine.singBoxInstalled ? '已安装' : '按需安装(ng-agent engine install)') : '未上报');
const usage = server.usage ? `${fmtDate(server.usage.lastReportAt)} · ${server.usage.sampleCount} 项${server.usage.error ? ` · <span class="v err">异常:${esc(server.usage.error)}</span>` : ''}` : '尚无上报';
return `<tr class="detail-row"><td colspan="15"><div class="detail-grid">
return `<tr class="detail-row"><td colspan="14"><div class="detail-grid">
<div><span class="k">资源余量</span><span class="v">${resources}</span></div>
<div><span class="k">TLS 证书(${esc(server.tlsDomain || '—')})</span><span class="v">${tls}</span></div>
<div><span class="k">引擎状态</span><span class="v">${engine}</span></div>
@@ -424,7 +455,6 @@ function renderServers() {
<div class="stat"><div class="lab">设备总数</div><div class="val"><svg class="ic-dim" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>${state.servers.length}</div></div>
<div class="stat"><div class="lab">在线</div><div class="val"><svg class="ic-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/></svg>${onlineCount}</div></div>
<div class="stat"><div class="lab">离线</div><div class="val"><svg class="ic-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/></svg>${state.servers.length - onlineCount}</div></div>
<div class="stat"><div class="lab">平均延迟</div><div class="val"><svg class="ic-bolt" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4.5 13.5H11L9.5 22 19 10h-6.5L13 2z"/></svg>—</div></div>
</section>`;
const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q));
const { pageItems } = pageSlice(filtered, 'servers');
@@ -438,7 +468,7 @@ function renderServers() {
const row = `<tr>
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
<td><input type="checkbox" data-row-check></td>
<td><div class="op-cell"><button class="icon-btn" title="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" title="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" title="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><div class="op-cell"><button class="icon-btn" data-tip="编辑" data-action="edit-server" data-id="${esc(server.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="复制注册命令" data-action="enroll-server" data-id="${esc(server.id)}">${ICONS.copy}</button><button class="icon-btn del" data-tip="删除" data-action="delete-server" data-id="${esc(server.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-server data-id="${esc(server.id)}" ${server.enabled !== false ? 'checked' : ''}><span class="tr"></span></label></td>
<td><span class="dev-name">${esc(server.name)}</span></td>
<td><span class="addr">${addr}</span></td>
@@ -449,14 +479,13 @@ function renderServers() {
<td><span class="ver">—</span></td>
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</span></td>
<td><span class="clients-cell">${ICONS.users}${clients.total}<span class="on-n">${clients.online}</span></span></td>
<td><span class="num">—</span></td>
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
return expanded ? row + serverDetailRow(server) : row;
}).join('');
return `${stats}
<div class="page-tools"><button class="btn primary" data-action="add-server">+ 添加设备</button><input class="search" data-search placeholder="搜索设备名、地址" value="${esc(state.search)}"></div>
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" title="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>延迟</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(15, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>操作</th><th>已启用</th><th>名称</th><th>地址<button class="icon-btn eye-btn" type="button" data-action="toggle-addr" data-tip="显示 / 隐藏地址">${state.addrVisible ? ICONS.eyeOff : ICONS.eye}</button></th><th>状态</th><th>CPU</th><th>内存</th><th>Agent 版本</th><th>核心版本</th><th>运行时长</th><th>客户端</th><th>上次心跳</th></tr></thead>
<tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
}
/* ---------- 客户与订阅 ---------- */
@@ -513,7 +542,7 @@ function renderCustomers() {
const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
return `<tr>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" title="订阅二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" title="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" title="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" title="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" title="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><div class="op-cell"><input type="checkbox" data-row-check><button class="icon-btn" data-tip="二维码" data-action="customer-subscription" data-id="${esc(item.id)}">${ICONS.qr}</button><button class="icon-btn" data-tip="详情" data-action="customer-access" data-id="${esc(item.id)}">${ICONS.info}</button><button class="icon-btn" data-tip="重置流量" data-action="reset-usage" data-id="${esc(item.id)}">${ICONS.reset}</button><button class="icon-btn" data-tip="编辑" data-action="edit-customer" data-id="${esc(item.id)}">${ICONS.edit}</button><button class="icon-btn del" data-tip="删除" data-action="delete-customer" data-id="${esc(item.id)}">${ICONS.trash}</button></div></td>
<td><label class="tgl"><input type="checkbox" data-toggle-customer data-id="${esc(item.id)}" ${manualOff ? '' : 'checked'}><span class="tr"></span></label></td>
<td><span class="pres ${item.online ? 'on' : 'off'}"><span class="dot ${item.online ? 'on' : 'off'}"></span>${item.online ? '在线' : '离线'}</span>${item.status === 'suspended' ? `<span class="paused-note">已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}</span>` : ''}</td>
<td><span class="cust-name">${esc(item.name)}</span>${item.notes ? `<span class="cust-note">${esc(item.notes)}</span>` : '<span class="cust-note"></span>'}</td>
@@ -526,7 +555,7 @@ function renderCustomers() {
return `<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" title="清除">×</button></span>` : ''}
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
<span class="badge blue">${filtered.length} 位客户</span></div></div>
<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th>操作</th><th>已启用</th><th>在线</th><th>客户端</th><th>关联入站</th><th>流量<span class="sarr">⇅</span></th><th class="ctr">速度</th><th>剩余<span class="sarr">⇅</span></th><th>时长<span class="sarr">⇅</span></th></tr></thead>
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
@@ -589,7 +618,7 @@ function refreshPicker() {
function credRowHTML(label, value) {
return `<div class="cred-row"><span class="k">${esc(label)}</span><span class="v">${esc(value || '—')}</span>
${value ? `<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
${value ? `<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(value)}">${ICONS.copy}</button>` : ''}</div>`;
}
function renderCredentialsPane(customer) {
const pane = $('#tab-credentials');
@@ -599,7 +628,7 @@ function renderCredentialsPane(customer) {
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
const groups = data.items.map((entry) => `<div class="cred-group">
<div class="g-head">${esc(entry.nodeName)}<span class="badge ${entry.status === 'active' ? 'green' : 'gray'}">${esc(statusText[entry.status] || entry.status)}</span><span class="mut" style="font-weight:400;font-size:12px">${esc(entry.protocol)}</span>
<button type="button" class="icon-btn" style="margin-left:auto" title="重新生成凭据" data-action="regen-credential" data-ncid="${esc(entry.nodeClientId)}" data-node="${esc(entry.nodeName)}">${ICONS.reset}</button></div>
<button type="button" class="icon-btn" style="margin-left:auto" data-tip="重新生成凭据" data-action="regen-credential" data-ncid="${esc(entry.nodeClientId)}" data-node="${esc(entry.nodeName)}">${ICONS.reset}</button></div>
${credRowHTML('UUID', entry.clientId)}
${credRowHTML('密码', entry.password)}
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
@@ -607,8 +636,8 @@ function renderCredentialsPane(customer) {
</div>`).join('');
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
<div class="cred-row"><span class="k">订阅令牌</span><span class="v">${esc(data.subscriptionToken || '—')}</span>
<button type="button" class="icon-btn" title="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
<button type="button" class="icon-btn" title="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
<button type="button" class="icon-btn" data-tip="复制" data-action="copy-text" data-value="${esc(data.subscriptionToken || '')}">${ICONS.copy}</button>
<button type="button" class="icon-btn" data-tip="重置订阅令牌" data-action="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
${groups || '<div class="empty"><b>尚未关联节点</b></div>'}`;
}
@@ -628,12 +657,12 @@ function renderLinksPane(customer) {
<div class="row"><input type="text" data-link-uri placeholder="节点链接(vless:// / ss:// / hysteria2:// / anytls:// / socks5://)" value="${esc(link.uri || '')}">
<input type="text" class="remark" data-link-remark placeholder="备注" value="${esc(link.remark || '')}">
<label class="tgl"><input type="checkbox" data-link-enabled ${link.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" title="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-manual-link" data-index="${index}">${ICONS.trash}</button></div></div>`).join('');
const sources = customerFormState.externalSources.map((source, index) => `<div class="link-item" data-source-item="${index}">
<div class="row"><input type="text" data-source-url placeholder="订阅地址(https://…)" value="${esc(source.url || '')}">
<input type="text" class="remark" data-source-remark placeholder="备注" value="${esc(source.remark || '')}">
<label class="tgl"><input type="checkbox" data-source-enabled ${source.enabled !== false ? 'checked' : ''}><span class="tr"></span></label>
<button type="button" class="icon-btn del" title="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
<button type="button" class="icon-btn del" data-tip="删除" data-action="remove-ext-source" data-index="${index}">${ICONS.trash}</button></div>
<div class="row"><input type="text" data-source-ua placeholder="User-Agent(可选)" value="${esc(source.userAgent || '')}">
<span class="status-line" data-source-status="${esc(source.id || '')}">${linkStatusLine(customer, source)}</span>
${source.id ? `<button type="button" class="btn small" data-action="refresh-ext-source" data-sid="${esc(source.id)}">立即刷新</button>` : ''}</div></div>`).join('');
@@ -837,10 +866,10 @@ function renderNodes() {
const enabled = !['draft', 'removing'].includes(node.status);
const busy = ['deploying', 'removing'].includes(node.status);
return `<tr data-node-row="${esc(node.id)}">
<td><span class="grip" title="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><span class="grip" data-tip="拖动排序" data-grip>${ICONS.grip}</span></td>
<td><input type="checkbox" data-row-check></td>
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</td>
<td class="menu-td"><button class="icon-btn" title="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" title="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td class="menu-td"><button class="icon-btn" data-tip="编辑" data-action="edit-node" data-id="${esc(node.id)}">${ICONS.edit}</button><button class="icon-btn" data-tip="更多" data-action="node-menu" data-id="${esc(node.id)}">${ICONS.dots}</button></td>
<td><label class="tgl"><input type="checkbox" data-toggle-node data-id="${esc(node.id)}" ${enabled ? 'checked' : ''} ${busy ? 'disabled' : ''}><span class="tr"></span></label></td>
<td><span class="node-name">${esc(node.name)}</span>${node.error ? `<div class="paused-note" title="${esc(node.error)}">${esc(String(node.error).slice(0, 40))}</div>` : ''}</td>
<td><span class="srv-chip ${chipCls}">${esc(server ? server.name : '已删除')}</span></td>
+2 -3
View File
@@ -34,7 +34,7 @@
<a class="mi" data-page="commands" href="#commands"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>安装与命令</a>
<a class="mi" data-page="operations" href="#operations"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 00-2-1.2L14.2 3h-4l-.4 2.5a7 7 0 00-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 005 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 002 1.2l.4 2.7h4l.4-2.5a7 7 0 002-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/></svg>设置与运维</a>
</nav>
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" title="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
<div class="sider-foot"><span id="user-chip"></span><span class="foot-right"><span id="version-label"></span><button id="logout" class="icon-btn" type="button" data-tip="退出登录"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg></button></span></div>
</aside>
<div class="main">
@@ -44,7 +44,6 @@
<input class="top-search" placeholder="搜索服务器、客户、节点…" disabled>
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
<span class="avatar" id="avatar">管</span>
</header>
<main class="content" id="content"></main>
</div>
@@ -52,7 +51,7 @@
<div id="modal-overlay" class="modal-overlay hidden">
<section class="modal panel" role="dialog" aria-modal="true">
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<header class="modal-head"><h2 id="modal-title"></h2><button type="button" class="icon-btn" data-close aria-label="关闭" data-tip="关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6L6 18M6 6l12 12"/></svg></button></header>
<div id="modal-body" class="modal-body"></div>
</section>
</div>
+7 -4
View File
@@ -68,8 +68,7 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
.top-search{background:var(--card);border:1px solid var(--border);border-radius:6px;color:var(--text-2);padding:5px 12px;font-size:13px;width:230px}
.theme-btn{border:1px solid var(--border);background:var(--card);color:var(--text-2);border-radius:6px;padding:5px 11px;font-size:12.5px;cursor:pointer;white-space:nowrap}
.theme-btn:hover{border-color:var(--primary);color:var(--primary-text)}
.avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,#1668dc,#13c2c2);display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:#fff;font-weight:600;flex:none}
.content{padding:16px 20px 28px;max-width:1460px;width:100%;margin:0 auto}
.content{padding:24px;width:100%}
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--border-soft)}
.panel-head h2{font-size:14.5px;font-weight:600}
@@ -181,6 +180,10 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
.icon-btn:hover{color:var(--primary-text);background:var(--hover-bg)}
.icon-btn.del{color:var(--red-text)}
.icon-btn.del:hover{background:var(--red-soft);color:var(--red-text)}
/* 图标悬停提示气泡(3x-ui 式:黑底白字圆角 + 小尖角,挂 body 避免被表格滚动容器裁剪) */
.tip-bubble{position:fixed;z-index:200;pointer-events:none;background:#141414;color:#fff;font-size:12px;line-height:1.25;padding:6px 9px;border-radius:6px;white-space:nowrap;opacity:0;transition:opacity .12s;box-shadow:0 4px 14px rgba(0,0,0,.25)}
.tip-bubble.show{opacity:1}
.tip-bubble::after{content:"";position:absolute;top:100%;left:var(--tip-arrow-left,50%);transform:translateX(-50%);border:5px solid transparent;border-top-color:#141414;border-bottom:0}
.tgl{position:relative;display:inline-block;width:38px;height:21px;vertical-align:middle;flex:none}
.tgl input{opacity:0;width:0;height:0;position:absolute}
.tgl .tr{position:absolute;inset:0;background:var(--tgl-off);border-radius:99px;transition:background .2s;cursor:pointer}
@@ -206,7 +209,7 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
.empty b{display:block;color:var(--text-2);font-size:14px;margin-bottom:2px}
/* ---------- 服务器页 ---------- */
.stats{display:grid;grid-template-columns:repeat(4,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats .stat{padding:14px 22px;border:none;background:transparent}
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
@@ -399,4 +402,4 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.toast.error{border-left-color:var(--red)}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}.content{padding:8px}}