右键删除节点列表,好像实现了,还需要实现右键删除地图节点

This commit is contained in:
2026-06-04 01:16:33 +08:00
parent 7c1b30b3a0
commit e945222519
10 changed files with 272 additions and 5 deletions
+1
View File
@@ -131,6 +131,7 @@ GET /api/admin/log/login
GET /api/admin/users
POST /api/admin/users
PUT /api/admin/users/:id/password
DELETE /api/admin/text-messages/:id
GET /api/nodeinfo
GET /api/nodeinfo/:id
GET /api/map-reports
+26
View File
@@ -361,6 +361,32 @@ func TestInsertTextMessageAppendsRows(t *testing.T) {
}
}
func TestDeleteTextMessageDeletesRows(t *testing.T) {
st := openTestStore(t)
defer st.Close()
if err := st.InsertTextMessage(textMessageTestRecord("hello"), mqttClientInfo{}); err != nil {
t.Fatalf("InsertTextMessage() error = %v", err)
}
var id uint64
if err := rawTestDB(t, st).QueryRow("SELECT id FROM text_message LIMIT 1").Scan(&id); err != nil {
t.Fatal(err)
}
if err := st.DeleteTextMessage(id); err != nil {
t.Fatalf("DeleteTextMessage() error = %v", err)
}
var count int
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM text_message WHERE id = ?", id).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("text_message count = %d, want 0", count)
}
if err := st.DeleteTextMessage(id); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("DeleteTextMessage(missing) error = %v, want record not found", err)
}
}
func TestInsertTextMessageStoresClientInfo(t *testing.T) {
st := openTestStore(t)
defer st.Close()
+32 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { adminLogout, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import { adminLogout, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
import AdminDashboard from './components/AdminDashboard.vue'
import AdminLogin from './components/AdminLogin.vue'
import AdminLoginLogs from './components/AdminLoginLogs.vue'
@@ -182,11 +182,36 @@ async function logoutAdmin() {
}
}
async function deleteMessage(message: TextMessage) {
try {
await deleteTextMessage(message.id)
messages.value = messages.value.filter((item) => item.id !== message.id)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
async function deleteNodeById(nodeId: string) {
try {
await deleteNode(nodeId)
nodeInfoSource.value = nodeInfoSource.value.filter((node) => node.node_id !== nodeId)
pagedNodeInfo.value = pagedNodeInfo.value.filter((node) => node.node_id !== nodeId)
mapReportSource.value = mapReportSource.value.filter((report) => report.node_id !== nodeId)
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null
}
await loadNodePage(nodePage.value, false)
} catch (err) {
error.value = err instanceof Error ? err.message : String(err)
}
}
onMounted(() => {
if (isAdminPage) {
checkAdminSession()
return
}
checkAdminSession()
refresh()
refreshTimer = window.setInterval(() => refresh(false), 5000)
})
@@ -252,14 +277,18 @@ onBeforeUnmount(() => {
:selected-node-id="selectedNodeId"
:loading-older="chatLoadingOlder"
:has-more-messages="chatHasMore"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@load-older="loadOlderMessages"
@delete-message="deleteMessage"
/>
<MeshMap
:nodes="mapNodes"
:selected-node-id="selectedNodeId"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@clear-node="selectedNodeId = null"
@delete-node="deleteNodeById"
/>
</section>
@@ -270,8 +299,10 @@ onBeforeUnmount(() => {
:page-size="nodePageSize"
:total="nodeTotal"
:loading="nodePageLoading || loading"
:is-admin="!!adminUser"
@select-node="selectedNodeId = $event"
@page-change="loadNodePage"
@delete-node="deleteNodeById"
/>
</template>
</main>
+12
View File
@@ -49,6 +49,10 @@ function putJSON<T>(path: string, body?: unknown): Promise<T> {
})
}
function deleteJSON<T>(path: string): Promise<T> {
return requestJSON<T>(path, { method: 'DELETE' })
}
export function getHealth(): Promise<HealthStatus> {
return getJSON<HealthStatus>('/api/health')
}
@@ -65,6 +69,14 @@ export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<T
return getJSON<ListResponse<TextMessage>>(`/api/text-messages?limit=${limit}&offset=${offset}`)
}
export function deleteTextMessage(id: number): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/text-messages/${id}`)
}
export function deleteNode(nodeId: string): Promise<{ status: string }> {
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
}
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
}
+51 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
import { nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
import type { NodeInfoById, TextMessage } from '../types'
const props = defineProps<{
@@ -8,14 +8,19 @@ const props = defineProps<{
selectedNodeId: string | null
loadingOlder: boolean
hasMoreMessages: boolean
isAdmin: boolean
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'load-older': []
'delete-message': [message: TextMessage]
}>()
const panelRef = ref<HTMLElement | null>(null)
const menuMessage = ref<TextMessage | null>(null)
const menuX = ref(0)
const menuY = ref(0)
const topThreshold = 8
const bottomThreshold = 40
@@ -44,7 +49,35 @@ function clearRestoreState() {
restoreMessageCount = 0
}
function closeMessageMenu() {
menuMessage.value = null
}
function openMessageMenu(message: TextMessage, event: MouseEvent) {
if (!props.isAdmin) {
return
}
emit('select-node', message.from_id)
menuMessage.value = message
menuX.value = event.clientX
menuY.value = event.clientY
}
function deleteSelectedMessage() {
if (menuMessage.value) {
emit('delete-message', menuMessage.value)
}
closeMessageMenu()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMessageMenu()
}
}
function handleScroll() {
closeMessageMenu()
const el = panelRef.value
if (
!el ||
@@ -72,6 +105,8 @@ onBeforeUpdate(() => {
})
onMounted(async () => {
window.addEventListener('click', closeMessageMenu)
window.addEventListener('keydown', handleKeydown)
await nextTick()
const el = panelRef.value
if (el) {
@@ -80,6 +115,11 @@ onMounted(async () => {
}
})
onBeforeUnmount(() => {
window.removeEventListener('click', closeMessageMenu)
window.removeEventListener('keydown', handleKeydown)
})
onUpdated(() => {
const el = panelRef.value
if (!el) {
@@ -124,6 +164,7 @@ onUpdated(() => {
:class="{ selected: selectedNodeId === message.from_id }"
type="button"
@click="emit('select-node', message.from_id)"
@contextmenu.prevent.stop="openMessageMenu(message, $event)"
>
<span class="chat-meta">
<strong>{{ senderName(message) }}</strong>
@@ -132,5 +173,14 @@ onUpdated(() => {
<span class="chat-topic">{{ message.topic }}</span>
<span class="chat-text">{{ message.text || '[binary]' }}</span>
</button>
<div
v-if="menuMessage"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<button class="danger" type="button" @click="deleteSelectedMessage">删除</button>
</div>
</aside>
</template>
+9 -1
View File
@@ -7,14 +7,19 @@ import type { MapNode } from '../types'
const props = defineProps<{
nodes: MapNode[]
selectedNodeId: string | null
isAdmin: boolean
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'clear-node': []
'delete-node': [nodeId: string]
}>()
const mapEl = ref<HTMLElement | null>(null)
const menuNodeId = ref<string | null>(null)
const menuX = ref(0)
const menuY = ref(0)
let map: L.Map | null = null
let markerLayer: L.LayerGroup | null = null
let hasFitBounds = false
@@ -37,7 +42,10 @@ onMounted(async () => {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map)
map.on('click', () => emit('clear-node'))
map.on('click', () => {
closeNodeMenu()
emit('clear-node')
})
markerLayer = L.layerGroup().addTo(map)
renderMarkers(true)
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { NodeInfo } from '../types'
const props = defineProps<{
@@ -9,20 +9,62 @@ const props = defineProps<{
pageSize: number
total: number
loading: boolean
isAdmin: boolean
}>()
const emit = defineEmits<{
'select-node': [nodeId: string]
'page-change': [page: number]
'delete-node': [nodeId: string]
}>()
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
const canPrev = computed(() => props.page > 1)
const canNext = computed(() => props.page < totalPages.value)
const menuNode = ref<NodeInfo | null>(null)
const menuX = ref(0)
const menuY = ref(0)
function formatTime(value: string): string {
return new Date(value).toLocaleString()
}
function closeNodeMenu() {
menuNode.value = null
}
function openNodeMenu(node: NodeInfo, event: MouseEvent) {
if (!props.isAdmin) {
return
}
emit('select-node', node.node_id)
menuNode.value = node
menuX.value = event.clientX
menuY.value = event.clientY
}
function deleteSelectedNode() {
if (menuNode.value) {
emit('delete-node', menuNode.value.node_id)
}
closeNodeMenu()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeNodeMenu()
}
}
onMounted(() => {
window.addEventListener('click', closeNodeMenu)
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('click', closeNodeMenu)
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
@@ -35,7 +77,7 @@ function formatTime(value: string): string {
<span class="badge"> {{ total }} </span>
</div>
<div class="node-table-wrap">
<div class="node-table-wrap" @scroll="closeNodeMenu">
<table class="node-table">
<thead>
<tr>
@@ -55,6 +97,7 @@ function formatTime(value: string): string {
class="node-row"
:class="{ selected: selectedNodeId === node.node_id }"
@click="emit('select-node', node.node_id)"
@contextmenu.prevent.stop="openNodeMenu(node, $event)"
>
<td>{{ node.node_id }}</td>
<td>{{ node.long_name || '-' }}</td>
@@ -69,6 +112,15 @@ function formatTime(value: string): string {
<div v-if="nodes.length === 0" class="empty">暂无节点数据</div>
</div>
<div
v-if="menuNode"
class="context-menu"
:style="{ left: `${menuX}px`, top: `${menuY}px` }"
@click.stop
>
<button class="danger" type="button" @click="deleteSelectedNode">删除</button>
</div>
<div class="pagination">
<button :disabled="loading || !canPrev" @click="emit('page-change', page - 1)">上一页</button>
<span> {{ page }} / {{ totalPages }} </span>
+29
View File
@@ -220,6 +220,35 @@ h3 {
line-height: 1.35;
}
.context-menu {
position: fixed;
z-index: 2000;
min-width: 120px;
border: 1px solid #dbe4ef;
border-radius: 10px;
padding: 6px;
background: #fff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
}
.context-menu button {
width: 100%;
border: 0;
border-radius: 8px;
padding: 8px 10px;
text-align: left;
font: inherit;
background: transparent;
}
.context-menu button:hover {
background: #f8fafc;
}
.context-menu button.danger {
color: #b91c1c;
}
.map-panel {
min-width: 0;
}
+28
View File
@@ -83,6 +83,23 @@ func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) {
return &row, nil
}
func (s *store) DeleteNode(nodeID string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{})
if nodeResult.Error != nil {
return nodeResult.Error
}
reportResult := tx.Where("node_id = ?", nodeID).Delete(&mapReportRecord{})
if reportResult.Error != nil {
return reportResult.Error
}
if nodeResult.RowsAffected+reportResult.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
if opts.NodeID != "" {
q = q.Where("node_id = ?", opts.NodeID)
@@ -101,6 +118,17 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error)
return rows, s.listAppendRows(opts, &rows).Error
}
func (s *store) DeleteTextMessage(id uint64) error {
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *store) ListPositions(opts listOptions) ([]positionRecord, error) {
var rows []positionRecord
return rows, s.listAppendRows(opts, &rows).Error
+30
View File
@@ -218,6 +218,36 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
rows, err := store.ListLoginLogs(opts)
writeListResponse(c, rows, opts, err, loginLogDTO)
})
protected.DELETE("/text-messages/:id", func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
return
}
if err := store.DeleteTextMessage(id); errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "message 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"})
})
protected.DELETE("/nodes/:id", func(c *gin.Context) {
nodeID := c.Param("id")
if nodeID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
if err := store.DeleteNode(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 *store, path string) {