Files
meshtastic_mqtt_server/meshmap_frontend/src/components/AdminBlockingManagement.vue
T
2026-06-04 12:08:25 +08:00

641 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createForbiddenWordBlockingRule,
createIPBlockingRule,
createNodeBlockingRule,
deleteForbiddenWordBlockingRule,
deleteIPBlockingRule,
deleteNodeBlockingRule,
getForbiddenWordBlockingRules,
getIPBlockingRules,
getNodeBlockingRules,
updateForbiddenWordBlockingRule,
updateIPBlockingRule,
updateNodeBlockingRule,
} from '../api'
import type {
ForbiddenWordBlockingRule,
ForbiddenWordBlockingRulePayload,
IPBlockingRule,
IPBlockingRulePayload,
NodeBlockingRule,
NodeBlockingRulePayload,
} from '../types'
const pageSize = 25
const nodeRules = ref<NodeBlockingRule[]>([])
const nodeLoading = ref(false)
const nodeError = ref('')
const nodeMessage = ref('')
const nodePage = ref(1)
const nodeTotal = ref(0)
const newNodeId = ref('')
const newNodeNum = ref('')
const newNodeReason = ref('')
const newNodeEnabled = ref(true)
const nodeEdits = ref<Record<number, { node_id: string; node_num: string; reason: string; enabled: boolean }>>({})
const ipRules = ref<IPBlockingRule[]>([])
const ipLoading = ref(false)
const ipError = ref('')
const ipMessage = ref('')
const ipPage = ref(1)
const ipTotal = ref(0)
const newIPValue = ref('')
const newIPReason = ref('')
const newIPEnabled = ref(true)
const ipEdits = ref<Record<number, IPBlockingRulePayload>>({})
const wordRules = ref<ForbiddenWordBlockingRule[]>([])
const wordLoading = ref(false)
const wordError = ref('')
const wordMessage = ref('')
const wordPage = ref(1)
const wordTotal = ref(0)
const newWord = ref('')
const newWordMatchType = ref('contains')
const newWordCaseSensitive = ref(false)
const newWordReason = ref('')
const newWordEnabled = ref(true)
const wordEdits = ref<Record<number, ForbiddenWordBlockingRulePayload>>({})
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function canPrev(page: number): boolean {
return page > 1
}
function canNext(page: number, total: number, count: number): boolean {
return page * pageSize < total || count === pageSize
}
function parseOptionalInt(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) {
return null
}
const parsed = Number.parseInt(trimmed, 10)
if (!Number.isFinite(parsed) || String(parsed) !== trimmed) {
throw new Error('节点数字 ID 必须是整数')
}
return parsed
}
function nodePayload(nodeId: string, nodeNum: string, reason: string, enabled: boolean): NodeBlockingRulePayload {
if (!nodeId.trim()) {
throw new Error('节点 ID 不能为空')
}
return {
node_id: nodeId.trim(),
node_num: parseOptionalInt(nodeNum),
reason: reason.trim(),
enabled,
}
}
function ipPayload(ipValue: string, reason: string, enabled: boolean): IPBlockingRulePayload {
if (!ipValue.trim()) {
throw new Error('IP 或 CIDR 不能为空')
}
return { ip_value: ipValue.trim(), reason: reason.trim(), enabled }
}
function wordPayload(word: string, matchType: string, caseSensitive: boolean, reason: string, enabled: boolean): ForbiddenWordBlockingRulePayload {
if (!word.trim()) {
throw new Error('违禁词不能为空')
}
return {
word: word.trim(),
match_type: matchType || 'contains',
case_sensitive: caseSensitive,
reason: reason.trim(),
enabled,
}
}
function resetNodeEdits(items: NodeBlockingRule[]) {
nodeEdits.value = Object.fromEntries(
items.map((item) => [
item.id,
{
node_id: item.node_id,
node_num: item.node_num == null ? '' : String(item.node_num),
reason: item.reason,
enabled: item.enabled,
},
]),
)
}
function resetIPEdits(items: IPBlockingRule[]) {
ipEdits.value = Object.fromEntries(items.map((item) => [item.id, { ip_value: item.ip_value, reason: item.reason, enabled: item.enabled }]))
}
function resetWordEdits(items: ForbiddenWordBlockingRule[]) {
wordEdits.value = Object.fromEntries(
items.map((item) => [
item.id,
{
word: item.word,
match_type: item.match_type,
case_sensitive: item.case_sensitive,
reason: item.reason,
enabled: item.enabled,
},
]),
)
}
async function refreshNodeRules(page = nodePage.value) {
nodeLoading.value = true
nodeError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getNodeBlockingRules(pageSize, (safePage - 1) * pageSize)
nodeRules.value = response.items
nodeTotal.value = response.total ?? response.offset + response.items.length
nodePage.value = safePage
resetNodeEdits(response.items)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function createNodeRule() {
nodeError.value = ''
nodeMessage.value = ''
let payload: NodeBlockingRulePayload
try {
payload = nodePayload(newNodeId.value, newNodeNum.value, newNodeReason.value, newNodeEnabled.value)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
return
}
nodeLoading.value = true
try {
await createNodeBlockingRule(payload)
newNodeId.value = ''
newNodeNum.value = ''
newNodeReason.value = ''
newNodeEnabled.value = true
nodeMessage.value = '节点屏蔽规则已新增'
await refreshNodeRules(1)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function saveNodeRule(rule: NodeBlockingRule) {
nodeError.value = ''
nodeMessage.value = ''
const edit = nodeEdits.value[rule.id]
if (!edit) {
return
}
let payload: NodeBlockingRulePayload
try {
payload = nodePayload(edit.node_id, edit.node_num, edit.reason, edit.enabled)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
return
}
nodeLoading.value = true
try {
await updateNodeBlockingRule(rule.id, payload)
nodeMessage.value = '节点屏蔽规则已保存'
await refreshNodeRules()
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function removeNodeRule(rule: NodeBlockingRule) {
nodeError.value = ''
nodeMessage.value = ''
nodeLoading.value = true
try {
await deleteNodeBlockingRule(rule.id)
nodeMessage.value = '节点屏蔽规则已删除'
await refreshNodeRules(nodeRules.value.length === 1 ? nodePage.value - 1 : nodePage.value)
} catch (err) {
nodeError.value = err instanceof Error ? err.message : String(err)
} finally {
nodeLoading.value = false
}
}
async function refreshIPRules(page = ipPage.value) {
ipLoading.value = true
ipError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getIPBlockingRules(pageSize, (safePage - 1) * pageSize)
ipRules.value = response.items
ipTotal.value = response.total ?? response.offset + response.items.length
ipPage.value = safePage
resetIPEdits(response.items)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function createIPRule() {
ipError.value = ''
ipMessage.value = ''
let payload: IPBlockingRulePayload
try {
payload = ipPayload(newIPValue.value, newIPReason.value, newIPEnabled.value)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
return
}
ipLoading.value = true
try {
await createIPBlockingRule(payload)
newIPValue.value = ''
newIPReason.value = ''
newIPEnabled.value = true
ipMessage.value = 'IP 屏蔽规则已新增'
await refreshIPRules(1)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function saveIPRule(rule: IPBlockingRule) {
ipError.value = ''
ipMessage.value = ''
const edit = ipEdits.value[rule.id]
if (!edit) {
return
}
let payload: IPBlockingRulePayload
try {
payload = ipPayload(edit.ip_value, edit.reason, edit.enabled)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
return
}
ipLoading.value = true
try {
await updateIPBlockingRule(rule.id, payload)
ipMessage.value = 'IP 屏蔽规则已保存'
await refreshIPRules()
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function removeIPRule(rule: IPBlockingRule) {
ipError.value = ''
ipMessage.value = ''
ipLoading.value = true
try {
await deleteIPBlockingRule(rule.id)
ipMessage.value = 'IP 屏蔽规则已删除'
await refreshIPRules(ipRules.value.length === 1 ? ipPage.value - 1 : ipPage.value)
} catch (err) {
ipError.value = err instanceof Error ? err.message : String(err)
} finally {
ipLoading.value = false
}
}
async function refreshWordRules(page = wordPage.value) {
wordLoading.value = true
wordError.value = ''
try {
const safePage = Math.max(1, page)
const response = await getForbiddenWordBlockingRules(pageSize, (safePage - 1) * pageSize)
wordRules.value = response.items
wordTotal.value = response.total ?? response.offset + response.items.length
wordPage.value = safePage
resetWordEdits(response.items)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function createWordRule() {
wordError.value = ''
wordMessage.value = ''
let payload: ForbiddenWordBlockingRulePayload
try {
payload = wordPayload(newWord.value, newWordMatchType.value, newWordCaseSensitive.value, newWordReason.value, newWordEnabled.value)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
return
}
wordLoading.value = true
try {
await createForbiddenWordBlockingRule(payload)
newWord.value = ''
newWordMatchType.value = 'contains'
newWordCaseSensitive.value = false
newWordReason.value = ''
newWordEnabled.value = true
wordMessage.value = '违禁词屏蔽规则已新增'
await refreshWordRules(1)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function saveWordRule(rule: ForbiddenWordBlockingRule) {
wordError.value = ''
wordMessage.value = ''
const edit = wordEdits.value[rule.id]
if (!edit) {
return
}
let payload: ForbiddenWordBlockingRulePayload
try {
payload = wordPayload(edit.word, edit.match_type, edit.case_sensitive, edit.reason, edit.enabled)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
return
}
wordLoading.value = true
try {
await updateForbiddenWordBlockingRule(rule.id, payload)
wordMessage.value = '违禁词屏蔽规则已保存'
await refreshWordRules()
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
async function removeWordRule(rule: ForbiddenWordBlockingRule) {
wordError.value = ''
wordMessage.value = ''
wordLoading.value = true
try {
await deleteForbiddenWordBlockingRule(rule.id)
wordMessage.value = '违禁词屏蔽规则已删除'
await refreshWordRules(wordRules.value.length === 1 ? wordPage.value - 1 : wordPage.value)
} catch (err) {
wordError.value = err instanceof Error ? err.message : String(err)
} finally {
wordLoading.value = false
}
}
onMounted(() => {
refreshNodeRules()
refreshIPRules()
refreshWordRules()
})
</script>
<template>
<section class="admin-dashboard">
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Blocking</p>
<h2>屏蔽管理</h2>
</div>
</div>
<p class="empty">管理节点IP/CIDR违禁词三类屏蔽规则当前页面只维护规则不改变 MQTT 转发行为</p>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Forbidden Words</p>
<h2>违禁词屏蔽</h2>
</div>
<button class="admin-button" @click="refreshWordRules()" :disabled="wordLoading">{{ wordLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createWordRule">
<label>
<span>违禁词</span>
<input v-model="newWord" autocomplete="off" placeholder="spam" />
</label>
<label>
<span>匹配类型</span>
<select v-model="newWordMatchType" class="admin-table-input">
<option value="contains">包含</option>
</select>
</label>
<label>
<span>区分大小写</span>
<input v-model="newWordCaseSensitive" type="checkbox" />
</label>
<label>
<span>原因</span>
<input v-model="newWordReason" autocomplete="off" placeholder="policy" />
</label>
<label>
<span>启用</span>
<input v-model="newWordEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="wordLoading" type="submit">新增违禁词规则</button>
</form>
<p v-if="wordError" class="error">{{ wordError }}</p>
<p v-if="wordMessage" class="success">{{ wordMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>违禁词</th>
<th>匹配类型</th>
<th>区分大小写</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in wordRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="wordEdits[rule.id].word" class="admin-table-input" /></td>
<td>
<select v-model="wordEdits[rule.id].match_type" class="admin-table-input">
<option value="contains">包含</option>
</select>
</td>
<td><input v-model="wordEdits[rule.id].case_sensitive" type="checkbox" /></td>
<td><input v-model="wordEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="wordEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="wordLoading" @click="saveWordRule(rule)">保存</button>
<button class="admin-button" :disabled="wordLoading" @click="removeWordRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="wordRules.length === 0" class="empty">暂无违禁词屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="wordLoading || !canPrev(wordPage)" @click="refreshWordRules(wordPage - 1)">上一页</button>
<span> {{ wordPage }} · {{ wordTotal }} </span>
<button class="admin-button" :disabled="wordLoading || !canNext(wordPage, wordTotal, wordRules.length)" @click="refreshWordRules(wordPage + 1)">下一页</button>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">Nodes</p>
<h2>节点屏蔽</h2>
</div>
<button class="admin-button" @click="refreshNodeRules()" :disabled="nodeLoading">{{ nodeLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createNodeRule">
<label>
<span>节点 ID</span>
<input v-model="newNodeId" autocomplete="off" placeholder="!12345678" />
</label>
<label>
<span>节点数字 ID</span>
<input v-model="newNodeNum" autocomplete="off" placeholder="可选" />
</label>
<label>
<span>原因</span>
<input v-model="newNodeReason" autocomplete="off" placeholder="spam / abuse" />
</label>
<label>
<span>启用</span>
<input v-model="newNodeEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="nodeLoading" type="submit">新增节点规则</button>
</form>
<p v-if="nodeError" class="error">{{ nodeError }}</p>
<p v-if="nodeMessage" class="success">{{ nodeMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>节点 ID</th>
<th>数字 ID</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in nodeRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="nodeEdits[rule.id].node_id" class="admin-table-input" /></td>
<td><input v-model="nodeEdits[rule.id].node_num" class="admin-table-input" placeholder="可选" /></td>
<td><input v-model="nodeEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="nodeEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="nodeLoading" @click="saveNodeRule(rule)">保存</button>
<button class="admin-button" :disabled="nodeLoading" @click="removeNodeRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="nodeRules.length === 0" class="empty">暂无节点屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="nodeLoading || !canPrev(nodePage)" @click="refreshNodeRules(nodePage - 1)">上一页</button>
<span> {{ nodePage }} · {{ nodeTotal }} </span>
<button class="admin-button" :disabled="nodeLoading || !canNext(nodePage, nodeTotal, nodeRules.length)" @click="refreshNodeRules(nodePage + 1)">下一页</button>
</div>
</div>
<div class="panel admin-status-panel">
<div class="panel-header">
<div>
<p class="eyebrow">IP / CIDR</p>
<h2>IP 屏蔽</h2>
</div>
<button class="admin-button" @click="refreshIPRules()" :disabled="ipLoading">{{ ipLoading ? '刷新中...' : '刷新' }}</button>
</div>
<form class="admin-form admin-user-form" @submit.prevent="createIPRule">
<label>
<span>IP CIDR</span>
<input v-model="newIPValue" autocomplete="off" placeholder="127.0.0.1 或 192.168.1.0/24" />
</label>
<label>
<span>原因</span>
<input v-model="newIPReason" autocomplete="off" placeholder="abuse" />
</label>
<label>
<span>启用</span>
<input v-model="newIPEnabled" type="checkbox" />
</label>
<button class="admin-button" :disabled="ipLoading" type="submit">新增 IP 规则</button>
</form>
<p v-if="ipError" class="error">{{ ipError }}</p>
<p v-if="ipMessage" class="success">{{ ipMessage }}</p>
<div class="node-table-wrap">
<table class="node-table">
<thead>
<tr>
<th>ID</th>
<th>IP/CIDR</th>
<th>原因</th>
<th>启用</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in ipRules" :key="rule.id">
<td>{{ rule.id }}</td>
<td><input v-model="ipEdits[rule.id].ip_value" class="admin-table-input" /></td>
<td><input v-model="ipEdits[rule.id].reason" class="admin-table-input" /></td>
<td><input v-model="ipEdits[rule.id].enabled" type="checkbox" /></td>
<td>{{ formatTime(rule.created_at) }}</td>
<td>{{ formatTime(rule.updated_at) }}</td>
<td>
<button class="admin-button" :disabled="ipLoading" @click="saveIPRule(rule)">保存</button>
<button class="admin-button" :disabled="ipLoading" @click="removeIPRule(rule)">删除</button>
</td>
</tr>
</tbody>
</table>
<div v-if="ipRules.length === 0" class="empty">暂无 IP 屏蔽规则</div>
</div>
<div class="pagination">
<button class="admin-button" :disabled="ipLoading || !canPrev(ipPage)" @click="refreshIPRules(ipPage - 1)">上一页</button>
<span> {{ ipPage }} · {{ ipTotal }} </span>
<button class="admin-button" :disabled="ipLoading || !canNext(ipPage, ipTotal, ipRules.length)" @click="refreshIPRules(ipPage + 1)">下一页</button>
</div>
</div>
</section>
</template>