右键删除节点列表,好像实现了,还需要实现右键删除地图节点
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '© 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user