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:
File diff suppressed because one or more lines are too long
+46
-17
@@ -144,7 +144,6 @@ function showLogin() {
|
|||||||
function showApp() {
|
function showApp() {
|
||||||
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
|
$('#login').classList.add('hidden'); $('#app').classList.remove('hidden');
|
||||||
$('#user-chip').textContent = state.session.user.username;
|
$('#user-chip').textContent = state.session.user.username;
|
||||||
$('#avatar').textContent = (state.session.user.username || '管').slice(0, 1).toUpperCase();
|
|
||||||
$('#version-label').textContent = `v${state.version}`;
|
$('#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; }
|
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) {
|
function pageSlice(items, key) {
|
||||||
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
|
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 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 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>` : ''}` : '尚无上报';
|
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">资源余量</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">TLS 证书(${esc(server.tlsDomain || '—')})</span><span class="v">${tls}</span></div>
|
||||||
<div><span class="k">引擎状态</span><span class="v">${engine}</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-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-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-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>`;
|
</section>`;
|
||||||
const filtered = state.servers.filter((item) => [item.name, item.region, item.publicAddress, item.publicAddressV6, ...(item.labels || [])].join(' ').toLowerCase().includes(q));
|
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');
|
const { pageItems } = pageSlice(filtered, 'servers');
|
||||||
@@ -438,7 +468,7 @@ function renderServers() {
|
|||||||
const row = `<tr>
|
const row = `<tr>
|
||||||
<td><button class="exp" type="button" data-action="expand-server" data-id="${esc(server.id)}">${expanded ? '−' : '+'}</button></td>
|
<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><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><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="dev-name">${esc(server.name)}</span></td>
|
||||||
<td><span class="addr">${addr}</span></td>
|
<td><span class="addr">${addr}</span></td>
|
||||||
@@ -449,14 +479,13 @@ function renderServers() {
|
|||||||
<td><span class="ver">—</span></td>
|
<td><span class="ver">—</span></td>
|
||||||
<td><span class="num">${system.uptimeSeconds ? fmtUptime(system.uptimeSeconds) : '—'}</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="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>`;
|
<td><span class="hb">${server.lastSeenAt ? relTime(server.lastSeenAt) : '等待注册'}</span></td></tr>`;
|
||||||
return expanded ? row + serverDetailRow(server) : row;
|
return expanded ? row + serverDetailRow(server) : row;
|
||||||
}).join('');
|
}).join('');
|
||||||
return `${stats}
|
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>
|
<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>
|
<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(15, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
|
<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 speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0);
|
||||||
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
|
const manualOff = item.status === 'suspended' && item.suspendReason === 'manual';
|
||||||
return `<tr>
|
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><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="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>
|
<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>
|
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)}">
|
<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>
|
<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>
|
<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>
|
<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>`;
|
<tbody>${rows || emptyRow(9, '还没有客户')}</tbody></table></div>${pagerHTML('customers', filtered.length)}</section>`;
|
||||||
@@ -589,7 +618,7 @@ function refreshPicker() {
|
|||||||
|
|
||||||
function credRowHTML(label, value) {
|
function credRowHTML(label, value) {
|
||||||
return `<div class="cred-row"><span class="k">${esc(label)}</span><span class="v">${esc(value || '—')}</span>
|
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) {
|
function renderCredentialsPane(customer) {
|
||||||
const pane = $('#tab-credentials');
|
const pane = $('#tab-credentials');
|
||||||
@@ -599,7 +628,7 @@ function renderCredentialsPane(customer) {
|
|||||||
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
|
if (!data) { pane.innerHTML = '<div class="empty"><b>加载中…</b></div>'; return; }
|
||||||
const groups = data.items.map((entry) => `<div class="cred-group">
|
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>
|
<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('UUID', entry.clientId)}
|
||||||
${credRowHTML('密码', entry.password)}
|
${credRowHTML('密码', entry.password)}
|
||||||
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
|
${entry.shortId ? credRowHTML('Reality Short ID', entry.shortId) : ''}
|
||||||
@@ -607,8 +636,8 @@ function renderCredentialsPane(customer) {
|
|||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
pane.innerHTML = `<div class="cred-group"><div class="g-head">订阅</div>
|
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>
|
<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" data-tip="复制" 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="rotate-subscription" data-id="${esc(customer.id)}">${ICONS.reset}</button></div></div>
|
||||||
${groups || '<div class="empty"><b>尚未关联节点</b></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 || '')}">
|
<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 || '')}">
|
<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>
|
<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}">
|
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 || '')}">
|
<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 || '')}">
|
<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>
|
<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 || '')}">
|
<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>
|
<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('');
|
${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 enabled = !['draft', 'removing'].includes(node.status);
|
||||||
const busy = ['deploying', 'removing'].includes(node.status);
|
const busy = ['deploying', 'removing'].includes(node.status);
|
||||||
return `<tr data-node-row="${esc(node.id)}">
|
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><input type="checkbox" data-row-check></td>
|
||||||
<td class="num">${(node.sortIndex != null ? node.sortIndex : index) + 1}</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><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="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>
|
<td><span class="srv-chip ${chipCls}">${esc(server ? server.name : '已删除')}</span></td>
|
||||||
|
|||||||
+2
-3
@@ -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="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>
|
<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>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
<input class="top-search" placeholder="搜索服务器、客户、节点…" disabled>
|
<input class="top-search" placeholder="搜索服务器、客户、节点…" disabled>
|
||||||
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
|
<button class="theme-btn" id="refresh" type="button" title="刷新数据">刷新</button>
|
||||||
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
|
<button class="theme-btn" type="button" data-theme-toggle>🌙 深色</button>
|
||||||
<span class="avatar" id="avatar">管</span>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="content" id="content"></main>
|
<main class="content" id="content"></main>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
|
|
||||||
<div id="modal-overlay" class="modal-overlay hidden">
|
<div id="modal-overlay" class="modal-overlay hidden">
|
||||||
<section class="modal panel" role="dialog" aria-modal="true">
|
<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>
|
<div id="modal-body" class="modal-body"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-4
@@ -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}
|
.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{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)}
|
.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:24px;width:100%}
|
||||||
.content{padding:16px 20px 28px;max-width:1460px;width:100%;margin:0 auto}
|
|
||||||
.panel{background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:16px}
|
.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{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}
|
.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:hover{color:var(--primary-text);background:var(--hover-bg)}
|
||||||
.icon-btn.del{color:var(--red-text)}
|
.icon-btn.del{color:var(--red-text)}
|
||||||
.icon-btn.del:hover{background:var(--red-soft);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{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 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}
|
.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}
|
.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{padding:14px 22px;border:none;background:transparent}
|
||||||
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
|
.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}
|
.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)}
|
.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: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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user