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}
- | | 操作 | 已启用 | 名称 | 地址 | 状态 | CPU | 内存 | Agent 版本 | 核心版本 | 运行时长 | 客户端 | 上次心跳 |
+ ${bulkBarHTML('servers')}${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')}${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 `
- | | ID⇅ | 菜单 | 启用 | 备注 | 节点 | 端口⇅ | 协议 | 客户端 | 流量⇅ | 速度 | 到期时间 |
+ ${bulkBarHTML('nodes')}${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}