屏蔽表已创建

This commit is contained in:
2026-06-04 12:08:25 +08:00
parent e6fa998854
commit acd03a614a
10 changed files with 1492 additions and 1 deletions
@@ -0,0 +1,640 @@
<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>