From 1e7ccd3cbe98e27bf456d81c59ecabe9e1d25aa0 Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Wed, 21 Oct 2026 13:31:58 +0000 Subject: [PATCH] feat(ui): working bulk operations on servers/customers/nodes lists (item 24) --- public/app.js | 129 +++++++++++++++++++++++++++++++++++++++++++--- public/styles.css | 2 + 2 files changed, 123 insertions(+), 8 deletions(-) diff --git a/public/app.js b/public/app.js index 01c31a0..4e2eb9b 100644 --- a/public/app.js +++ b/public/app.js @@ -9,7 +9,9 @@ const state = { protocols: [], realityPresets: [], version: '0.6.8', search: '', groupFilter: '', customerNodeFilter: '', pages: { servers: 1, customers: 1, nodes: 1 }, - addrVisible: false, expandedServers: new Set() + addrVisible: false, expandedServers: new Set(), + selection: { servers: new Set(), customers: new Set(), nodes: new Set() }, + bulkRunning: false }; const PAGE_SIZE = 50; @@ -471,6 +473,102 @@ function serverDetailRow(server) { `; } +/* ---------- 列表批量操作(第 24 条) ---------- + 每页一份选中集合(state.selection),行勾选框与表头全选维护它; + 选中 ≥1 时工具条下出现批量条。批量动作逐项调用既有单项接口, + 语义与守卫与单项完全一致:服务器 启停=PATCH enabled / 删除=DELETE + (仍被节点引用等守卫由服务端拒绝);客户 启用=status active / + 停用=status suspended(与行内开关的 manual 口径一致)/ 删除= + DELETE;入站 启用=deploy / 停用=remove / 删除=DELETE。 + 选择状态口径:按页独立保存,重渲染与 45 秒自动刷新后按集合恢复 + 勾选;翻页/筛选不清空(全选只作用于当前渲染的行);批量执行后 + 清空本页集合。 */ +const BULK_DEFS = { + servers: { + get: (id) => serverById(id), + label: (item) => item.name, + isOn: (item) => item.enabled !== false, + enable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: true }) }), + disable: (item) => api(`/api/servers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: false }) }), + remove: (item) => api(`/api/servers/${item.id}`, { method: 'DELETE' }), + deleteConfirm: (n) => `删除选中的 ${n} 台设备?将删除控制台登记并吊销其 Agent 密钥,此操作不可恢复。`, + page: 'servers' + }, + customers: { + get: (id) => customerById(id), + label: (item) => item.name, + isOn: (item) => !(item.status === 'suspended' && item.suspendReason === 'manual'), + enable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'active' }) }), + disable: (item) => api(`/api/customers/${item.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'suspended' }) }), + remove: (item) => api(`/api/customers/${item.id}`, { method: 'DELETE' }), + deleteConfirm: (n) => `删除选中的 ${n} 位客户及订阅地址?此操作不可恢复(仍有节点分配的客户会被逐项拒绝)。`, + page: 'customers' + }, + nodes: { + get: (id) => nodeById(id), + label: (item) => item.name, + isOn: (item) => !['draft', 'removing'].includes(item.status), + busy: (item) => ['deploying', 'removing'].includes(item.status), + enable: (item) => api(`/api/nodes/${item.id}/deploy`, { method: 'POST', body: '{}' }), + disable: (item) => api(`/api/nodes/${item.id}/remove`, { method: 'POST', body: '{}' }), + remove: (item) => api(`/api/nodes/${item.id}`, { method: 'DELETE' }), + deleteConfirm: (n) => `删除选中的 ${n} 个入站?会移除全部分配并从设备清理资源,此操作不可恢复。`, + page: 'chains' + } +}; + +function bulkBarHTML(key) { + const n = state.selection[key].size; + if (!n) return ''; + return `
已选 ${n} 项 + + + +
`; +} + +async function runBulk(key, op) { + if (state.bulkRunning) return; + const def = BULK_DEFS[key]; if (!def) return; + const ids = [...state.selection[key]]; + if (!ids.length) return; + if (op === 'delete' && !await confirmModal(def.deleteConfirm(ids.length), '删除')) return; + state.bulkRunning = true; + const done = []; const failed = []; const skipped = []; + try { + for (const id of ids) { + const item = def.get(id); + if (!item) { skipped.push('已不存在'); continue; } + const name = def.label(item) || id; + if (op !== 'delete') { + if (def.busy && def.busy(item)) { skipped.push(`${name}:正忙`); continue; } + if ((op === 'enable') === def.isOn(item)) { skipped.push(`${name}:已是${op === 'enable' ? '启用' : '停用'}状态`); continue; } + } + try { await def[op === 'delete' ? 'remove' : op](item); done.push(name); } + catch (error) { failed.push(`${name}:${error.message}`); } + } + } finally { state.bulkRunning = false; } + state.selection[key].clear(); + const verb = op === 'delete' ? '删除' : op === 'enable' ? '启用' : '停用'; + const brief = (list) => list.slice(0, 3).join(';') + (list.length > 3 ? `;等 ${list.length} 项` : ''); + let msg = `批量${verb}:成功 ${done.length} 项`; + if (skipped.length) msg += `,跳过 ${skipped.length} 项(${brief(skipped)})`; + if (failed.length) msg += `,失败 ${failed.length} 项(${brief(failed)})`; + toast(msg, failed.length > 0); + await load(def.page); +} + +/* render 后同步表头全选框的三态(全选/半选/未选)。 */ +function syncCheckAll() { + const all = $('[data-check-all]'); + if (!all) return; + const boxes = $$('[data-row-check]'); + if (!boxes.length) { all.checked = false; all.indeterminate = false; return; } + const n = boxes.filter((box) => box.checked).length; + all.checked = n === boxes.length; + all.indeterminate = n > 0 && n < boxes.length; +} + function renderServers() { const q = state.search.toLowerCase(); const onlineCount = state.servers.filter((s) => s.status === 'online').length; @@ -490,7 +588,7 @@ function renderServers() { const addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress)); const row = ` - +
${esc(server.name)} @@ -507,7 +605,7 @@ function renderServers() { }).join(''); return `${stats}
-
+ ${bulkBarHTML('servers')}
操作已启用名称地址状态CPU内存Agent 版本核心版本运行时长客户端上次心跳
${rows || emptyRow(14, '没有匹配设备')}
操作已启用名称地址状态CPU内存Agent 版本核心版本运行时长客户端上次心跳
${pagerHTML('servers', filtered.length)}
`; } @@ -565,7 +663,7 @@ function renderCustomers() { const speedTotal = (item.speedUpBps || 0) + (item.speedDownBps || 0); const manualOff = item.status === 'suspended' && item.suspendReason === 'manual'; return ` -
+
${item.online ? '在线' : '离线'}${item.status === 'suspended' ? `已暂停 · ${esc(suspendReasonText[item.suspendReason] || '待恢复')}` : ''} ${esc(item.name)}${item.notes ? `${esc(item.notes)}` : ''} @@ -590,7 +688,7 @@ function renderCustomers() { ${filterNode ? `节点:${esc(filterNode.name)}` : ''} ${filtered.length} 位客户 -
+ ${bulkBarHTML('customers')}
操作已启用在线客户端关联入站流量⇅速度剩余⇅时长⇅
${rows || emptyRow(9, '还没有客户')}
操作已启用在线客户端关联入站流量⇅速度剩余⇅时长⇅
${pagerHTML('customers', filtered.length)}
`; } @@ -902,7 +1000,7 @@ function renderNodes() { const busy = ['deploying', 'removing'].includes(node.status); return ` ${ICONS.grip} - + ${(node.sortIndex != null ? node.sortIndex : index) + 1} @@ -918,7 +1016,7 @@ function renderNodes() { return `
${state.nodes.length} 个入站在线 ${active}${degraded ? `降级 ${degraded}` : ''}
-
+ ${bulkBarHTML('nodes')}
ID⇅菜单启用备注节点端口⇅协议客户端流量⇅速度到期时间
${rows || emptyRow(13, '还没有节点')}
ID⇅菜单启用备注节点端口⇅协议客户端流量⇅速度到期时间
${pagerHTML('nodes', filtered.length)}
`; } @@ -1158,6 +1256,7 @@ function renderOperations() { function render() { const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations }; $('#content').innerHTML = views[state.page](); + syncCheckAll(); } /* ---------- 表单提交 ---------- */ @@ -1384,6 +1483,10 @@ document.addEventListener('click', async (event) => { else if (action === 'repair-node') { hidePop(); if (!await confirmModal('重新下发该节点配置?', '修复')) return; await api(`/api/nodes/${itemId}/repair`, { method: 'POST', body: '{}' }); toast('修复任务已进入队列'); await load(); } else if (action === 'remove-node') { hidePop(); if (!await confirmModal('停用会从相关设备移除配置,节点保留为草稿,确认继续?', '停用')) return; await api(`/api/nodes/${itemId}/remove`, { method: 'POST', body: '{}' }); toast('停用任务已进入队列'); await load(); } else if (action === 'delete-node') { hidePop(); if (!await confirmModal('确定删除这个节点?会移除全部分配并从设备清理资源。', '删除')) return; await api(`/api/nodes/${itemId}`, { method: 'DELETE' }); toast('节点已删除,设备资源待 Agent 确认清理'); await load(); } + else if (action === 'bulk-clear') { state.selection[button.dataset.bulkPage].clear(); render(); } + else if (action === 'bulk-enable') await runBulk(button.dataset.bulkPage, 'enable'); + else if (action === 'bulk-disable') await runBulk(button.dataset.bulkPage, 'disable'); + else if (action === 'bulk-delete') await runBulk(button.dataset.bulkPage, 'delete'); else if (action === 'download-backup') location.href = '/api/backup'; else if (action === 'restore-backup') { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; @@ -1423,8 +1526,18 @@ document.addEventListener('change', async (event) => { await load('chains'); } else if (target.matches('[data-group-filter]')) { state.groupFilter = target.value; state.pages.customers = 1; render(); + } else if (target.matches('[data-row-check]')) { + const set = state.selection[target.dataset.rowCheck]; + if (set) { + if (target.checked) set.add(target.dataset.id); else set.delete(target.dataset.id); + render(); + } } else if (target.matches('[data-check-all]')) { - $$('[data-row-check]').forEach((box) => { box.checked = target.checked; }); + const set = state.selection[target.dataset.checkAll]; + if (set) { + $$('[data-row-check]').forEach((box) => { if (target.checked) set.add(box.dataset.id); else set.delete(box.dataset.id); }); + render(); + } } else if (target.matches('[data-node-sync]')) syncNodeForm(); else if (target.matches('[data-exit-link]')) applyExitLink(target); else if (target.matches('[data-reality-preset]')) { diff --git a/public/styles.css b/public/styles.css index 98f0b52..1648f16 100644 --- a/public/styles.css +++ b/public/styles.css @@ -74,6 +74,8 @@ input,select,textarea{font-family:var(--font);color:var(--text)} .panel-body{padding:14px 16px} .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .page-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px} +.bulk-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:8px 12px;border:1px solid var(--primary);border-radius:8px;background:var(--card-hover)} +.bulk-bar .bulk-count{font-size:13px;color:var(--text)} .search{background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 12px;font-size:13px;width:260px} select.search{width:auto} .btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--card-hover);color:var(--text);border-radius:6px;padding:5px 14px;font-size:13px;cursor:pointer;white-space:nowrap}