批量删除节点,一键屏蔽ip

This commit is contained in:
2026-06-19 20:37:19 +08:00
parent b56cfb7b1e
commit 2afd890de4
6 changed files with 328 additions and 1 deletions
+52
View File
@@ -76,6 +76,7 @@ type PendingDeleteAction =
| { kind: 'delete-message'; message: DeletableTextMessage }
| { kind: 'delete-node'; nodeId: string }
| { kind: 'purge-node'; nodeId: string }
| { kind: 'delete-displayed-nodes'; nodeIds: string[] }
| ({ kind: 'delete-and-block-node' } & NodeActionRequest)
let refreshTimer: number | undefined
let mapBoundsTimer: number | undefined
@@ -206,6 +207,9 @@ const deleteModalTitle = computed(() => {
if (action.kind === 'purge-node') {
return '确认删除节点'
}
if (action.kind === 'delete-displayed-nodes') {
return '确认删除所有显示的节点'
}
return '确认删除并屏蔽节点'
})
@@ -226,6 +230,9 @@ const deleteModalMessage = computed(() => {
if (action.kind === 'purge-node') {
return '确定要删除这个节点吗?将同时清理该节点的聊天消息和位置/遥测/路由/路径追踪等数据包记录,此操作不可撤销。'
}
if (action.kind === 'delete-displayed-nodes') {
return `确定要删除当前地图上显示的全部 ${action.nodeIds.length} 个节点吗?将逐个调用删除接口,此操作不可撤销。`
}
if (!action.message) {
return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。'
}
@@ -453,6 +460,22 @@ function requestPurgeNode(nodeId: string) {
pendingDeleteAction.value = { kind: 'purge-node', nodeId }
}
function requestDeleteDisplayedNodes() {
// 收集当前地图上正在显示的节点 ID(仅 type=node,跳过聚合点),并按筛选后的视图顺序去重。
const nodeIds = Array.from(
new Set(
mapItems.value
.filter((item): item is Extract<MapRenderable, { type: 'node' }> => item.type === 'node')
.map((item) => item.node_id),
),
)
if (nodeIds.length === 0) {
error.value = '当前地图上没有可删除的节点。'
return
}
pendingDeleteAction.value = { kind: 'delete-displayed-nodes', nodeIds }
}
function requestDeleteAndBlockNode(payload: NodeActionRequest) {
pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload }
}
@@ -483,6 +506,11 @@ async function confirmDeleteModal(payload: { reason?: string }) {
return
}
if (action.kind === 'delete-displayed-nodes') {
await deleteDisplayedNodes(action.nodeIds)
return
}
const reason = payload.reason?.trim()
if (!reason) {
return
@@ -571,6 +599,29 @@ async function purgeNodeById(nodeId: string) {
}
}
// 逐个调用 deleteNode 串行删除当前地图上显示的节点;某一个失败不阻断后续,最后汇总错误。
async function deleteDisplayedNodes(nodeIds: string[]) {
const failures: string[] = []
let lastErrorMessage = ''
for (const nodeId of nodeIds) {
try {
await deleteNode(nodeId)
await removeNodeFromLocalState(nodeId)
} catch (err) {
if (isNodeNotFoundError(err)) {
// 已经不在了,本地状态也同步移除即可
await removeNodeFromLocalState(nodeId)
continue
}
failures.push(nodeId)
lastErrorMessage = err instanceof Error ? err.message : String(err)
}
}
if (failures.length > 0) {
error.value = `部分节点删除失败(${failures.length}/${nodeIds.length}):${lastErrorMessage}`
}
}
async function deleteAndBlockNode(payload: NodeActionPayload) {
try {
if (payload.message) {
@@ -765,6 +816,7 @@ onBeforeUnmount(() => {
@clear-node="clearSelectedNode"
@delete-node="requestDeleteNode"
@purge-node="requestPurgeNode"
@delete-displayed-nodes="requestDeleteDisplayedNodes"
@delete-and-block-node="requestDeleteAndBlockNode"
/>
</section>
+15
View File
@@ -244,6 +244,21 @@ export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
}
// 一键断开 MQTT 客户端并把它的远端 IP 加入屏蔽表。
export function disconnectAndBlockMqttClient(
clientId: string,
payload: { reason?: string } = {},
): Promise<{
status: string
client_id: string
ip_value: string
disconnected: boolean
ip_rule_created: boolean
ip_rule_id?: number
}> {
return postJSON(`/api/admin/mqtt/clients/${encodeURIComponent(clientId)}/disconnect-and-block`, payload)
}
export function getAdminRuntimeSettings(): Promise<AdminRuntimeSettingsResponse> {
return getJSON<AdminRuntimeSettingsResponse>('/api/admin/runtime-settings')
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import { disconnectAndBlockMqttClient, getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
import type { AdminMqttClient, AdminMqttStatus, AdminRuntimeSettings } from '../types'
const status = ref<AdminMqttStatus | null>(null)
@@ -10,6 +11,10 @@ const settingsLoading = ref(false)
const error = ref('')
const settingsError = ref('')
const settingsMessage = ref('')
const clientActionMessage = ref('')
const clientActionError = ref('')
const pendingClient = ref<AdminMqttClient | null>(null)
const clientActionInProgress = ref(false)
let timer: number | undefined
type ClientSortKey = 'client_id' | 'username' | 'listener' | 'remote_addr' | 'packets_in' | 'packets_out'
@@ -100,6 +105,64 @@ async function saveEncryptedForwarding(value: boolean) {
}
}
function requestDisconnectAndBlock(client: AdminMqttClient) {
clientActionMessage.value = ''
clientActionError.value = ''
pendingClient.value = client
}
function cancelClientAction() {
if (clientActionInProgress.value) {
return
}
pendingClient.value = null
}
async function confirmClientAction(payload: { reason?: string }) {
const client = pendingClient.value
if (!client) {
return
}
const reason = payload.reason?.trim()
if (!reason) {
return
}
clientActionInProgress.value = true
clientActionError.value = ''
clientActionMessage.value = ''
try {
const result = await disconnectAndBlockMqttClient(client.client_id, { reason })
const ipText = result.ip_value || client.remote_addr || '-'
const ruleText = result.ip_rule_created ? '已新增屏蔽规则' : '屏蔽规则已存在'
const disconnectText = result.disconnected ? '已断开连接' : '连接已不存在'
clientActionMessage.value = `${disconnectText}IP ${ipText} ${ruleText}`
pendingClient.value = null
await refreshStatus()
} catch (err) {
clientActionError.value = err instanceof Error ? err.message : String(err)
} finally {
clientActionInProgress.value = false
}
}
const pendingClientLabel = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr || '-'
return `Client ID: ${client.client_id || '-'}\n远端: ${ip}`
})
const clientActionMessageText = computed(() => {
const client = pendingClient.value
if (!client) {
return ''
}
const ip = client.remote_addr ? client.remote_addr.replace(/:\d+$/, '') : '该客户端的 IP'
return `确定要立即断开 MQTT 客户端 “${client.client_id || '-'}” 并将 ${ip} 加入 IP 屏蔽表吗?此操作会立即生效,请输入屏蔽原因。\n\n${pendingClientLabel.value}`
})
onMounted(() => {
refreshStatus()
refreshRuntimeSettings()
@@ -199,6 +262,7 @@ onBeforeUnmount(() => {
<th class="sortable" @click="toggleClientSort('remote_addr')">Remote Addr <span class="sort-indicator">{{ sortIndicator('remote_addr') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_in')">客户端服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
<th class="sortable" @click="toggleClientSort('packets_out')">服务器客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -209,12 +273,34 @@ onBeforeUnmount(() => {
<td>{{ client.remote_addr || '-' }}</td>
<td>{{ client.packets_in ?? 0 }}</td>
<td>{{ client.packets_out ?? 0 }}</td>
<td>
<button
type="button"
class="client-danger-action"
:disabled="clientActionInProgress"
@click="requestDisconnectAndBlock(client)"
>断开并屏蔽 IP</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
<p v-if="clientActionMessage" class="success client-action-feedback">{{ clientActionMessage }}</p>
<p v-if="clientActionError" class="error client-action-feedback">{{ clientActionError }}</p>
</div>
</div>
<ConfirmDeleteModal
:open="!!pendingClient"
title="确认断开并屏蔽 IP"
:message="clientActionMessageText"
confirm-text="断开并屏蔽"
:require-reason="true"
reason-label="屏蔽原因"
reason-placeholder="请输入屏蔽原因必填"
@cancel="cancelClientAction"
@confirm="confirmClientAction"
/>
</section>
</template>
@@ -403,4 +489,31 @@ onBeforeUnmount(() => {
font-size: 11px;
color: var(--color-primary);
}
.client-danger-action {
border: 1px solid color-mix(in srgb, var(--color-danger, #d04848) 36%, white);
border-radius: 6px;
padding: 4px 10px;
background: color-mix(in srgb, var(--color-danger, #d04848) 10%, white);
color: var(--color-danger, #d04848);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
}
.client-danger-action:hover:not(:disabled) {
background: var(--color-danger, #d04848);
color: #fff;
}
.client-danger-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.client-action-feedback {
margin: 0.5rem 0 0;
white-space: pre-line;
}
</style>
@@ -25,6 +25,7 @@ const emit = defineEmits<{
'clear-node': []
'delete-node': [nodeId: string]
'purge-node': [nodeId: string]
'delete-displayed-nodes': []
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
'bounds-change': [payload: MapBoundsChangePayload]
'map-source-change': [sourceId: number]
@@ -32,6 +33,7 @@ const emit = defineEmits<{
const mapEl = ref<HTMLElement | null>(null)
const menuNode = ref<MapNode | null>(null)
const menuMap = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const lastRaisedNodeId = ref<string | null>(null)
@@ -73,6 +75,12 @@ onMounted(async () => {
shuffledSelectedNodeIds.clear()
emit('clear-node')
})
// 地图空白处右键:在管理员视图下展示「删除所有显示的节点」入口。
// 必须在原生 contextmenu 上 preventDefaultLeaflet 的事件包装层不会阻止浏览器默认菜单。
mapEl.value.addEventListener('contextmenu', handleMapContextMenu)
map.on('contextmenu', (event) => {
L.DomEvent.stopPropagation(event)
})
map.on('moveend', emitBoundsChange)
markerLayer = L.layerGroup().addTo(map)
renderMarkers(true)
@@ -82,6 +90,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener('click', closeNodeMenu)
window.removeEventListener('keydown', handleKeydown)
mapEl.value?.removeEventListener('contextmenu', handleMapContextMenu)
map?.remove()
map = null
tileLayer = null
@@ -125,6 +134,33 @@ function applyTileLayer() {
function closeNodeMenu() {
menuNode.value = null
menuMap.value = false
}
function openMapMenu(event: MouseEvent) {
if (!props.isAdmin) {
return
}
menuNode.value = null
menuMap.value = true
menuX.value = event.clientX
menuY.value = event.clientY
}
function handleMapContextMenu(event: MouseEvent) {
// 总是阻止浏览器默认菜单。即使非管理员也阻止,避免在 marker 上右键漏到地图后又弹默认菜单。
event.preventDefault()
// marker 自身已通过 marker.on('contextmenu') 处理;这里仅响应空白处。
const target = event.target as HTMLElement | null
if (target?.closest('.leaflet-marker-icon, .leaflet-popup, .map-source-control, .context-menu')) {
return
}
openMapMenu(event)
}
function deleteAllDisplayedNodes() {
emit('delete-displayed-nodes')
closeNodeMenu()
}
function nodeDetailHref(nodeId: string): string {
@@ -601,5 +637,13 @@ function escapeHTML(value: string): string {
<button v-if="isAdmin" class="danger" type="button" @click="purgeSelectedNode">删除节点</button>
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
</div>
<div
v-if="menuMap && isAdmin"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<button class="danger" type="button" @click="deleteAllDisplayedNodes">删除所有显示的节点</button>
</div>
</section>
</template>