feat(ui): working bulk operations on servers/customers/nodes lists (item 24)

This commit is contained in:
aaaamoooo
2026-10-21 13:31:58 +00:00
parent 408b03ee68
commit 1e7ccd3cbe
2 changed files with 123 additions and 8 deletions
+121 -8
View File
@@ -9,7 +9,9 @@ const state = {
protocols: [], realityPresets: [], version: '0.6.8', protocols: [], realityPresets: [], version: '0.6.8',
search: '', groupFilter: '', customerNodeFilter: '', search: '', groupFilter: '', customerNodeFilter: '',
pages: { servers: 1, customers: 1, nodes: 1 }, 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; const PAGE_SIZE = 50;
@@ -471,6 +473,102 @@ function serverDetailRow(server) {
<button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button> <button class="btn small" data-action="agent-uninstall" data-id="${esc(server.id)}">SSH 卸载 Agent</button>
</div></td></tr>`; </div></td></tr>`;
} }
/* ---------- 列表批量操作(第 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 `<div class="bulk-bar"><span class="bulk-count">已选 <b>${n}</b> 项</span>
<button class="btn small" data-action="bulk-enable" data-bulk-page="${key}">启用</button>
<button class="btn small" data-action="bulk-disable" data-bulk-page="${key}">停用</button>
<button class="btn small danger" data-action="bulk-delete" data-bulk-page="${key}">删除</button>
<button class="btn small" data-action="bulk-clear" data-bulk-page="${key}">取消选择</button></div>`;
}
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() { function renderServers() {
const q = state.search.toLowerCase(); const q = state.search.toLowerCase();
const onlineCount = state.servers.filter((s) => s.status === 'online').length; 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 addr = state.addrVisible ? esc(server.publicAddress) : esc(maskAddr(server.publicAddress));
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="servers" data-id="${esc(server.id)}"${state.selection.servers.has(server.id) ? ' checked' : ''}></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><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>
@@ -507,7 +605,7 @@ function renderServers() {
}).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" 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> ${bulkBarHTML('servers')}<section class="panel"><div class="table-wrap"><table class="w-servers"><thead><tr><th></th><th><input type="checkbox" data-check-all="servers"></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>`; <tbody>${rows || emptyRow(14, '没有匹配设备')}</tbody></table></div>${pagerHTML('servers', filtered.length)}</section>`;
} }
@@ -565,7 +663,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" 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><div class="op-cell"><input type="checkbox" data-row-check="customers" data-id="${esc(item.id)}"${state.selection.customers.has(item.id) ? ' checked' : ''}><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>
@@ -590,7 +688,7 @@ function renderCustomers() {
<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" data-tip="清除">×</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> ${bulkBarHTML('customers')}<section class="panel"><div class="table-wrap"><table class="w-customers"><thead><tr><th><input type="checkbox" data-check-all="customers"> 操作</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>`;
} }
@@ -902,7 +1000,7 @@ function renderNodes() {
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" data-tip="拖动排序" 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="nodes" data-id="${esc(node.id)}"${state.selection.nodes.has(node.id) ? ' checked' : ''}></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" 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 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>
@@ -918,7 +1016,7 @@ function renderNodes() {
return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</button> return `<div class="page-tools"><button class="btn primary" data-action="add-node">+ 添加入站</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)}">
<span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div> <span class="badge blue">${state.nodes.length} 个入站</span><span class="badge green">在线 ${active}</span>${degraded ? `<span class="badge orange">降级 ${degraded}</span>` : ''}</div></div>
<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead> ${bulkBarHTML('nodes')}<section class="panel"><div class="table-wrap visible"><table class="w-nodes"><thead><tr><th></th><th><input type="checkbox" data-check-all="nodes"></th><th>ID<span class="sarr">⇅</span></th><th>菜单</th><th>启用</th><th>备注</th><th>节点</th><th>端口<span class="sarr">⇅</span></th><th>协议</th><th>客户端</th><th>流量<span class="sarr">⇅</span></th><th>速度</th><th>到期时间</th></tr></thead>
<tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`; <tbody>${rows || emptyRow(13, '还没有节点')}</tbody></table></div>${pagerHTML('nodes', filtered.length)}</section>`;
} }
@@ -1158,6 +1256,7 @@ function renderOperations() {
function render() { function render() {
const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations }; const views = { overview: renderOverview, servers: renderServers, customers: renderCustomers, chains: renderNodes, operations: renderOperations };
$('#content').innerHTML = views[state.page](); $('#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 === '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 === '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 === '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 === 'download-backup') location.href = '/api/backup';
else if (action === 'restore-backup') { else if (action === 'restore-backup') {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; 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'); await load('chains');
} else if (target.matches('[data-group-filter]')) { } else if (target.matches('[data-group-filter]')) {
state.groupFilter = target.value; state.pages.customers = 1; render(); 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]')) { } 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-node-sync]')) syncNodeForm();
else if (target.matches('[data-exit-link]')) applyExitLink(target); else if (target.matches('[data-exit-link]')) applyExitLink(target);
else if (target.matches('[data-reality-preset]')) { else if (target.matches('[data-reality-preset]')) {
+2
View File
@@ -74,6 +74,8 @@ input,select,textarea{font-family:var(--font);color:var(--text)}
.panel-body{padding:14px 16px} .panel-body{padding:14px 16px}
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap} .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} .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} .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} 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} .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}