新增删除节点功能
This commit is contained in:
@@ -232,6 +232,53 @@ func (s *Store) DeleteNode(nodeID string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurgeNode 在「删除节点」菜单触发时执行:除了 nodeinfo + map_report,
|
||||||
|
// 还要把 text_message(频道聊天)以及 position/telemetry/routing/traceroute
|
||||||
|
// 这些以 from_id 关联的数据包记录一起清理。
|
||||||
|
//
|
||||||
|
// 任一表删到记录就视为成功;全部为空才返回 ErrRecordNotFound。
|
||||||
|
func (s *Store) PurgeNode(nodeID string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var totalAffected int64
|
||||||
|
|
||||||
|
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&NodeInfoRecord{})
|
||||||
|
if nodeResult.Error != nil {
|
||||||
|
return nodeResult.Error
|
||||||
|
}
|
||||||
|
totalAffected += nodeResult.RowsAffected
|
||||||
|
|
||||||
|
reportResult := tx.Where("node_id = ?", nodeID).Delete(&MapReportRecord{})
|
||||||
|
if reportResult.Error != nil {
|
||||||
|
return reportResult.Error
|
||||||
|
}
|
||||||
|
totalAffected += reportResult.RowsAffected
|
||||||
|
|
||||||
|
// 以 from_id 关联:聊天消息 + 数据包流水
|
||||||
|
fromIDTargets := []any{
|
||||||
|
&TextMessageRecord{},
|
||||||
|
&PositionRecord{},
|
||||||
|
&TelemetryRecord{},
|
||||||
|
&RoutingRecord{},
|
||||||
|
&TracerouteRecord{},
|
||||||
|
}
|
||||||
|
for _, model := range fromIDTargets {
|
||||||
|
res := tx.Where("from_id = ?", nodeID).Delete(model)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
totalAffected += res.RowsAffected
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func applyNodeFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
func applyNodeFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("node_id = ?", opts.NodeID)
|
q = q.Where("node_id = ?", opts.NodeID)
|
||||||
|
|||||||
@@ -338,6 +338,23 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
// purge:除了 nodeinfo + map_report,还把 text_message 与 position/telemetry/routing/traceroute
|
||||||
|
// 中按 from_id 关联到该节点的记录一并删除。
|
||||||
|
protected.DELETE("/nodes/:id/purge", func(c *gin.Context) {
|
||||||
|
nodeID := c.Param("id")
|
||||||
|
if nodeID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := store.PurgeNode(nodeID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "node not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
|
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages, purgeNode } from './api'
|
||||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||||
import AdminBot from './components/AdminBot.vue'
|
import AdminBot from './components/AdminBot.vue'
|
||||||
import AdminBotDirect from './components/AdminBotDirect.vue'
|
import AdminBotDirect from './components/AdminBotDirect.vue'
|
||||||
@@ -75,6 +75,7 @@ type NodeActionPayload = NodeActionRequest & { reason: string }
|
|||||||
type PendingDeleteAction =
|
type PendingDeleteAction =
|
||||||
| { kind: 'delete-message'; message: DeletableTextMessage }
|
| { kind: 'delete-message'; message: DeletableTextMessage }
|
||||||
| { kind: 'delete-node'; nodeId: string }
|
| { kind: 'delete-node'; nodeId: string }
|
||||||
|
| { kind: 'purge-node'; nodeId: string }
|
||||||
| ({ kind: 'delete-and-block-node' } & NodeActionRequest)
|
| ({ kind: 'delete-and-block-node' } & NodeActionRequest)
|
||||||
let refreshTimer: number | undefined
|
let refreshTimer: number | undefined
|
||||||
let mapBoundsTimer: number | undefined
|
let mapBoundsTimer: number | undefined
|
||||||
@@ -202,6 +203,9 @@ const deleteModalTitle = computed(() => {
|
|||||||
if (action.kind === 'delete-node') {
|
if (action.kind === 'delete-node') {
|
||||||
return '确认删除节点'
|
return '确认删除节点'
|
||||||
}
|
}
|
||||||
|
if (action.kind === 'purge-node') {
|
||||||
|
return '确认删除节点'
|
||||||
|
}
|
||||||
return '确认删除并屏蔽节点'
|
return '确认删除并屏蔽节点'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -219,6 +223,9 @@ const deleteModalMessage = computed(() => {
|
|||||||
if (action.kind === 'delete-node') {
|
if (action.kind === 'delete-node') {
|
||||||
return '确定要删除这个节点吗?此操作不可撤销。'
|
return '确定要删除这个节点吗?此操作不可撤销。'
|
||||||
}
|
}
|
||||||
|
if (action.kind === 'purge-node') {
|
||||||
|
return '确定要删除这个节点吗?将同时清理该节点的聊天消息和位置/遥测/路由/路径追踪等数据包记录,此操作不可撤销。'
|
||||||
|
}
|
||||||
if (!action.message) {
|
if (!action.message) {
|
||||||
return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。'
|
return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。'
|
||||||
}
|
}
|
||||||
@@ -442,6 +449,10 @@ function requestDeleteNode(nodeId: string) {
|
|||||||
pendingDeleteAction.value = { kind: 'delete-node', nodeId }
|
pendingDeleteAction.value = { kind: 'delete-node', nodeId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestPurgeNode(nodeId: string) {
|
||||||
|
pendingDeleteAction.value = { kind: 'purge-node', nodeId }
|
||||||
|
}
|
||||||
|
|
||||||
function requestDeleteAndBlockNode(payload: NodeActionRequest) {
|
function requestDeleteAndBlockNode(payload: NodeActionRequest) {
|
||||||
pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload }
|
pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload }
|
||||||
}
|
}
|
||||||
@@ -467,6 +478,11 @@ async function confirmDeleteModal(payload: { reason?: string }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.kind === 'purge-node') {
|
||||||
|
await purgeNodeById(action.nodeId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const reason = payload.reason?.trim()
|
const reason = payload.reason?.trim()
|
||||||
if (!reason) {
|
if (!reason) {
|
||||||
return
|
return
|
||||||
@@ -544,6 +560,17 @@ async function deleteNodeById(nodeId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function purgeNodeById(nodeId: string) {
|
||||||
|
try {
|
||||||
|
await purgeNode(nodeId)
|
||||||
|
// 该节点的本地缓存包括聊天消息,一起从前端状态里清掉。
|
||||||
|
messages.value = messages.value.filter((message) => message.from_id !== nodeId)
|
||||||
|
await removeNodeFromLocalState(nodeId)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteAndBlockNode(payload: NodeActionPayload) {
|
async function deleteAndBlockNode(payload: NodeActionPayload) {
|
||||||
try {
|
try {
|
||||||
if (payload.message) {
|
if (payload.message) {
|
||||||
@@ -737,6 +764,7 @@ onBeforeUnmount(() => {
|
|||||||
@select-node="selectNode"
|
@select-node="selectNode"
|
||||||
@clear-node="clearSelectedNode"
|
@clear-node="clearSelectedNode"
|
||||||
@delete-node="requestDeleteNode"
|
@delete-node="requestDeleteNode"
|
||||||
|
@purge-node="requestPurgeNode"
|
||||||
@delete-and-block-node="requestDeleteAndBlockNode"
|
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -753,6 +781,7 @@ onBeforeUnmount(() => {
|
|||||||
@select-node="selectNode"
|
@select-node="selectNode"
|
||||||
@page-change="loadNodePage"
|
@page-change="loadNodePage"
|
||||||
@delete-node="requestDeleteNode"
|
@delete-node="requestDeleteNode"
|
||||||
|
@purge-node="requestPurgeNode"
|
||||||
@delete-and-block-node="requestDeleteAndBlockNode"
|
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
|
|||||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「删除节点」:除节点信息和地图上报外,再清理聊天消息以及 position/telemetry/routing/traceroute 等数据包记录。
|
||||||
|
export function purgeNode(nodeId: string): Promise<{ status: string }> {
|
||||||
|
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}/purge`)
|
||||||
|
}
|
||||||
|
|
||||||
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
|
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
|
||||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
|
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const emit = defineEmits<{
|
|||||||
'select-node': [nodeId: string]
|
'select-node': [nodeId: string]
|
||||||
'clear-node': []
|
'clear-node': []
|
||||||
'delete-node': [nodeId: string]
|
'delete-node': [nodeId: string]
|
||||||
|
'purge-node': [nodeId: string]
|
||||||
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
||||||
'bounds-change': [payload: MapBoundsChangePayload]
|
'bounds-change': [payload: MapBoundsChangePayload]
|
||||||
'map-source-change': [sourceId: number]
|
'map-source-change': [sourceId: number]
|
||||||
@@ -146,6 +147,13 @@ function deleteSelectedNode() {
|
|||||||
closeNodeMenu()
|
closeNodeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purgeSelectedNode() {
|
||||||
|
if (menuNode.value) {
|
||||||
|
emit('purge-node', menuNode.value.node_id)
|
||||||
|
}
|
||||||
|
closeNodeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
function deleteAndBlockSelectedNode() {
|
function deleteAndBlockSelectedNode() {
|
||||||
if (menuNode.value) {
|
if (menuNode.value) {
|
||||||
emit('delete-and-block-node', {
|
emit('delete-and-block-node', {
|
||||||
@@ -590,6 +598,7 @@ function escapeHTML(value: string): string {
|
|||||||
>
|
>
|
||||||
<a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
|
<a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
|
||||||
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
||||||
|
<button v-if="isAdmin" class="danger" type="button" @click="purgeSelectedNode">删除节点</button>
|
||||||
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const emit = defineEmits<{
|
|||||||
'select-node': [nodeId: string]
|
'select-node': [nodeId: string]
|
||||||
'page-change': [page: number]
|
'page-change': [page: number]
|
||||||
'delete-node': [nodeId: string]
|
'delete-node': [nodeId: string]
|
||||||
|
'purge-node': [nodeId: string]
|
||||||
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -53,6 +54,13 @@ function deleteSelectedNode() {
|
|||||||
closeNodeMenu()
|
closeNodeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purgeSelectedNode() {
|
||||||
|
if (menuNode.value) {
|
||||||
|
emit('purge-node', menuNode.value.node_id)
|
||||||
|
}
|
||||||
|
closeNodeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
function deleteAndBlockSelectedNode() {
|
function deleteAndBlockSelectedNode() {
|
||||||
if (menuNode.value) {
|
if (menuNode.value) {
|
||||||
emit('delete-and-block-node', { nodeId: menuNode.value.node_id, nodeNum: menuNode.value.node_num ?? null })
|
emit('delete-and-block-node', { nodeId: menuNode.value.node_id, nodeNum: menuNode.value.node_num ?? null })
|
||||||
@@ -130,6 +138,7 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
|
<a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
|
||||||
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
|
||||||
|
<button v-if="isAdmin" class="danger" type="button" @click="purgeSelectedNode">删除节点</button>
|
||||||
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
|
<button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user