diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 0eb2e2a..e2d11d4 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -8,6 +8,7 @@ import AdminLogin from './components/AdminLogin.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminUsers from './components/AdminUsers.vue' import ChatPanel from './components/ChatPanel.vue' +import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue' import HelpPage from './components/HelpPage.vue' import MeshMap from './components/MeshMap.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue' @@ -46,7 +47,13 @@ const currentMapBounds = ref(null) const currentMapZoom = ref(2) const mapReportsLoading = ref(false) const mapReportTotal = ref(0) -type NodeActionPayload = { nodeId: string; nodeNum: number | null; message?: TextMessage } +const pendingDeleteAction = ref(null) +type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: TextMessage } +type NodeActionPayload = NodeActionRequest & { reason: string } +type PendingDeleteAction = + | { kind: 'delete-message'; message: TextMessage } + | { kind: 'delete-node'; nodeId: string } + | ({ kind: 'delete-and-block-node' } & NodeActionRequest) let refreshTimer: number | undefined let mapBoundsTimer: number | undefined let mapReportRequestSeq = 0 @@ -92,6 +99,42 @@ const mapItems = computed(() => { }) }) +const deleteModalTitle = computed(() => { + const action = pendingDeleteAction.value + if (!action) { + return '' + } + if (action.kind === 'delete-message') { + return '确认删除消息' + } + if (action.kind === 'delete-node') { + return '确认删除节点' + } + return '确认删除并屏蔽节点' +}) + +const deleteModalMessage = computed(() => { + const action = pendingDeleteAction.value + if (!action) { + return '' + } + if (action.kind === 'delete-message') { + return '确定要删除这条聊天消息吗?此操作不可撤销。' + } + if (action.kind === 'delete-node') { + return '确定要删除这个节点吗?此操作不可撤销。' + } + return action.message + ? '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' + : '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。' +}) + +const deleteModalConfirmText = computed(() => { + return pendingDeleteAction.value?.kind === 'delete-and-block-node' ? '删除并屏蔽' : '删除' +}) + +const deleteModalRequiresReason = computed(() => pendingDeleteAction.value?.kind === 'delete-and-block-node') + function toChronological(items: TextMessage[]): TextMessage[] { return [...items].reverse() } @@ -249,6 +292,51 @@ async function logoutAdmin() { } } +function requestDeleteMessage(message: TextMessage) { + pendingDeleteAction.value = { kind: 'delete-message', message } +} + +function requestDeleteNode(nodeId: string) { + pendingDeleteAction.value = { kind: 'delete-node', nodeId } +} + +function requestDeleteAndBlockNode(payload: NodeActionRequest) { + pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload } +} + +function cancelDeleteModal() { + pendingDeleteAction.value = null +} + +async function confirmDeleteModal(payload: { reason?: string }) { + const action = pendingDeleteAction.value + pendingDeleteAction.value = null + if (!action) { + return + } + + if (action.kind === 'delete-message') { + await deleteMessage(action.message) + return + } + + if (action.kind === 'delete-node') { + await deleteNodeById(action.nodeId) + return + } + + const reason = payload.reason?.trim() + if (!reason) { + return + } + await deleteAndBlockNode({ + nodeId: action.nodeId, + nodeNum: action.nodeNum, + message: action.message, + reason, + }) +} + async function deleteMessage(message: TextMessage) { try { await deleteTextMessage(message.id) @@ -306,7 +394,7 @@ async function deleteAndBlockNode(payload: NodeActionPayload) { await createNodeBlockingRule({ node_id: payload.nodeId, node_num: payload.nodeNum, - reason: '管理员右键删除并屏蔽节点', + reason: payload.reason, enabled: true, }) } catch (err) { @@ -431,8 +519,8 @@ onBeforeUnmount(() => { :is-admin="!!adminUser" @select-node="selectedNodeId = $event" @load-older="loadOlderMessages" - @delete-message="deleteMessage" - @delete-and-block-node="deleteAndBlockNode" + @delete-message="requestDeleteMessage" + @delete-and-block-node="requestDeleteAndBlockNode" /> { @bounds-change="handleMapBoundsChange" @select-node="selectedNodeId = $event" @clear-node="selectedNodeId = null" - @delete-node="deleteNodeById" - @delete-and-block-node="deleteAndBlockNode" + @delete-node="requestDeleteNode" + @delete-and-block-node="requestDeleteAndBlockNode" /> @@ -458,9 +546,21 @@ onBeforeUnmount(() => { :is-admin="!!adminUser" @select-node="selectedNodeId = $event" @page-change="loadNodePage" - @delete-node="deleteNodeById" - @delete-and-block-node="deleteAndBlockNode" + @delete-node="requestDeleteNode" + @delete-and-block-node="requestDeleteAndBlockNode" /> + + diff --git a/meshmap_frontend/src/components/ChatPanel.vue b/meshmap_frontend/src/components/ChatPanel.vue index d77a4d2..5fb5042 100644 --- a/meshmap_frontend/src/components/ChatPanel.vue +++ b/meshmap_frontend/src/components/ChatPanel.vue @@ -1,5 +1,5 @@ + + diff --git a/meshmap_frontend/src/components/NodeDetailedPage.vue b/meshmap_frontend/src/components/NodeDetailedPage.vue index fdda807..47ffe7c 100644 --- a/meshmap_frontend/src/components/NodeDetailedPage.vue +++ b/meshmap_frontend/src/components/NodeDetailedPage.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api' import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types' +import ConfirmDeleteModal from './ConfirmDeleteModal.vue' import NodeTrajectoryMap from './NodeTrajectoryMap.vue' const props = defineProps<{ @@ -20,9 +21,16 @@ const chatHasMore = ref(true) const error = ref('') const chatPageSize = 20 const chatHistoryRef = ref(null) +type PendingDeleteAction = + | { kind: 'delete-message'; message: TextMessage } + | { kind: 'delete-and-block-node'; message: TextMessage; nodeId: string; nodeNum: number | null } + +type GroupedTextMessage = TextMessage & { mergedCount: number } + const menuMessage = ref(null) const menuX = ref(0) const menuY = ref(0) +const pendingDeleteAction = ref(null) const nodeTitle = computed(() => { return nodeInfo.value?.long_name || nodeInfo.value?.short_name || mapReport.value?.long_name || mapReport.value?.short_name || props.nodeId @@ -39,6 +47,40 @@ const mergedNode = computed(() => { } }) +const deleteModalTitle = computed(() => { + if (pendingDeleteAction.value?.kind === 'delete-and-block-node') { + return '确认删除并屏蔽节点' + } + return '确认删除消息' +}) + +const deleteModalMessage = computed(() => { + if (pendingDeleteAction.value?.kind === 'delete-and-block-node') { + return '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' + } + return '确定要删除这条聊天消息吗?此操作不可撤销。' +}) + +const deleteModalConfirmText = computed(() => { + return pendingDeleteAction.value?.kind === 'delete-and-block-node' ? '删除并屏蔽' : '删除' +}) + +const deleteModalRequiresReason = computed(() => pendingDeleteAction.value?.kind === 'delete-and-block-node') + +const groupedMessages = computed(() => { + const groups = new Map() + for (const message of messages.value) { + const key = `${message.packet_id ?? ''}\n${message.text ?? ''}` + const group = groups.get(key) + if (group) { + group.mergedCount += 1 + } else { + groups.set(key, { ...message, mergedCount: 1 }) + } + } + return Array.from(groups.values()) +}) + function formatTime(value: string): string { return new Date(value).toLocaleString() } @@ -164,12 +206,15 @@ function openMessageMenu(message: TextMessage, event: MouseEvent) { menuY.value = event.clientY } -async function deleteSelectedMessage() { +function deleteSelectedMessage() { if (!menuMessage.value) { return } - const message = menuMessage.value + pendingDeleteAction.value = { kind: 'delete-message', message: menuMessage.value } closeMessageMenu() +} + +async function performDeleteMessage(message: TextMessage) { try { await deleteTextMessage(message.id) messages.value = messages.value.filter((item) => item.id !== message.id) @@ -190,29 +235,36 @@ function isMessageNotFoundError(err: unknown): boolean { return err instanceof Error && err.message === 'message not found' } -async function deleteAndBlockSelectedMessageNode() { +function deleteAndBlockSelectedMessageNode() { if (!menuMessage.value) { return } const message = menuMessage.value - const nodeId = message.from_id || props.nodeId - const nodeNum = message.from_num ?? mergedNode.value.node_num ?? null + pendingDeleteAction.value = { + kind: 'delete-and-block-node', + message, + nodeId: message.from_id || props.nodeId, + nodeNum: message.from_num ?? mergedNode.value.node_num ?? null, + } closeMessageMenu() +} + +async function performDeleteAndBlockMessageNode(payload: { message: TextMessage; nodeId: string; nodeNum: number | null; reason: string }) { try { try { - await deleteTextMessage(message.id) + await deleteTextMessage(payload.message.id) } catch (err) { if (!isMessageNotFoundError(err)) { throw err } } - messages.value = messages.value.filter((item) => item.id !== message.id) + messages.value = messages.value.filter((item) => item.id !== payload.message.id) try { await createNodeBlockingRule({ - node_id: nodeId, - node_num: nodeNum, - reason: '管理员右键删除并屏蔽节点', + node_id: payload.nodeId, + node_num: payload.nodeNum, + reason: payload.reason, enabled: true, }) } catch (err) { @@ -222,13 +274,13 @@ async function deleteAndBlockSelectedMessageNode() { } try { - await deleteNode(nodeId) + await deleteNode(payload.nodeId) } catch (err) { if (!isNodeNotFoundError(err)) { throw err } } - if (nodeId === props.nodeId) { + if (payload.nodeId === props.nodeId) { nodeInfo.value = null mapReport.value = null } @@ -237,6 +289,34 @@ async function deleteAndBlockSelectedMessageNode() { } } +async function confirmDeleteModal(payload: { reason?: string }) { + const action = pendingDeleteAction.value + pendingDeleteAction.value = null + if (!action) { + return + } + + if (action.kind === 'delete-message') { + await performDeleteMessage(action.message) + return + } + + const reason = payload.reason?.trim() + if (!reason) { + return + } + await performDeleteAndBlockMessageNode({ + message: action.message, + nodeId: action.nodeId, + nodeNum: action.nodeNum, + reason, + }) +} + +function cancelDeleteModal() { + pendingDeleteAction.value = null +} + function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { closeMessageMenu() @@ -336,14 +416,14 @@ onBeforeUnmount(() => {

Chat

历史聊天记录:{{ nodeTitle }}

- {{ messages.length }} + {{ groupedMessages.length }}
正在加载更早消息...
没有更多历史消息
暂无聊天记录
{ {{ formatTime(message.created_at) }} {{ message.topic }} - {{ message.text || '[binary]' }} + + {{ message.text || '[binary]' }} + x{{ message.mergedCount }} +
{
暂无遥测数据
+ + diff --git a/meshmap_frontend/src/style.css b/meshmap_frontend/src/style.css index 74536df..4744eb0 100644 --- a/meshmap_frontend/src/style.css +++ b/meshmap_frontend/src/style.css @@ -220,6 +220,18 @@ h3 { line-height: 1.35; } +.message-merge-count { + display: inline-flex; + align-items: center; + margin-left: 6px; + border-radius: 999px; + padding: 1px 6px; + color: #1d4ed8; + background: #dbeafe; + font-size: 12px; + font-weight: 700; +} + .context-menu { position: fixed; z-index: 2000; @@ -255,6 +267,123 @@ h3 { color: #b91c1c; } +.modal-backdrop { + position: fixed; + z-index: 3000; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(3px); +} + +.confirm-modal { + width: min(460px, 100%); + overflow: hidden; + border: 1px solid #dbe4ef; + border-radius: 18px; + background: #fff; + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); +} + +.confirm-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid #e2e8f0; +} + +.confirm-modal-header h2 { + margin: 2px 0 0; + color: #0f172a; +} + +.confirm-modal-close { + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + color: #64748b; + font-size: 24px; + line-height: 1; + background: #f8fafc; +} + +.confirm-modal-close:hover { + color: #0f172a; + background: #e2e8f0; +} + +.confirm-modal-body { + display: grid; + gap: 14px; + padding: 18px; + color: #334155; +} + +.confirm-modal-body p { + margin: 0; + line-height: 1.6; +} + +.confirm-modal-reason { + display: grid; + gap: 8px; + color: #334155; + font-size: 14px; + font-weight: 700; +} + +.confirm-modal-reason textarea { + min-height: 88px; + resize: vertical; + border: 1px solid #cbd5e1; + border-radius: 12px; + padding: 10px 12px; + font: inherit; + font-weight: 400; +} + +.confirm-modal-reason textarea:focus { + outline: 2px solid #bfdbfe; + border-color: #60a5fa; +} + +.confirm-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px 18px; + border-top: 1px solid #e2e8f0; + background: #f8fafc; +} + +.confirm-modal-secondary, +.confirm-modal-danger { + border: 0; + border-radius: 10px; + padding: 9px 16px; + font-weight: 700; +} + +.confirm-modal-secondary { + color: #334155; + background: #e2e8f0; +} + +.confirm-modal-danger { + color: #fff; + background: #dc2626; +} + +.confirm-modal-danger:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .map-panel { min-width: 0; } diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index dc8605b..83a1a02 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -82,6 +82,7 @@ export interface TextMessage { id: number from_id: string from_num: number + packet_id: number | null text: string | null topic: string created_at: string diff --git a/web.go b/web.go index cdc8492..788785e 100644 --- a/web.go +++ b/web.go @@ -591,7 +591,7 @@ func mapReportClusterDTO(row mapReportClusterRecord) gin.H { } func textMessageDTO(row textMessageRecord) gin.H { - return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} + return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} } func discardDetailsDTO(row discardDetailsRecord) gin.H {