合并重复消息
This commit is contained in:
@@ -8,6 +8,7 @@ import AdminLogin from './components/AdminLogin.vue'
|
|||||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||||
import AdminUsers from './components/AdminUsers.vue'
|
import AdminUsers from './components/AdminUsers.vue'
|
||||||
import ChatPanel from './components/ChatPanel.vue'
|
import ChatPanel from './components/ChatPanel.vue'
|
||||||
|
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
||||||
import HelpPage from './components/HelpPage.vue'
|
import HelpPage from './components/HelpPage.vue'
|
||||||
import MeshMap from './components/MeshMap.vue'
|
import MeshMap from './components/MeshMap.vue'
|
||||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||||
@@ -46,7 +47,13 @@ const currentMapBounds = ref<MapBoundsQuery | null>(null)
|
|||||||
const currentMapZoom = ref(2)
|
const currentMapZoom = ref(2)
|
||||||
const mapReportsLoading = ref(false)
|
const mapReportsLoading = ref(false)
|
||||||
const mapReportTotal = ref(0)
|
const mapReportTotal = ref(0)
|
||||||
type NodeActionPayload = { nodeId: string; nodeNum: number | null; message?: TextMessage }
|
const pendingDeleteAction = ref<PendingDeleteAction | null>(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 refreshTimer: number | undefined
|
||||||
let mapBoundsTimer: number | undefined
|
let mapBoundsTimer: number | undefined
|
||||||
let mapReportRequestSeq = 0
|
let mapReportRequestSeq = 0
|
||||||
@@ -92,6 +99,42 @@ const mapItems = computed<MapRenderable[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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[] {
|
function toChronological(items: TextMessage[]): TextMessage[] {
|
||||||
return [...items].reverse()
|
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) {
|
async function deleteMessage(message: TextMessage) {
|
||||||
try {
|
try {
|
||||||
await deleteTextMessage(message.id)
|
await deleteTextMessage(message.id)
|
||||||
@@ -306,7 +394,7 @@ async function deleteAndBlockNode(payload: NodeActionPayload) {
|
|||||||
await createNodeBlockingRule({
|
await createNodeBlockingRule({
|
||||||
node_id: payload.nodeId,
|
node_id: payload.nodeId,
|
||||||
node_num: payload.nodeNum,
|
node_num: payload.nodeNum,
|
||||||
reason: '管理员右键删除并屏蔽节点',
|
reason: payload.reason,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -431,8 +519,8 @@ onBeforeUnmount(() => {
|
|||||||
:is-admin="!!adminUser"
|
:is-admin="!!adminUser"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@load-older="loadOlderMessages"
|
@load-older="loadOlderMessages"
|
||||||
@delete-message="deleteMessage"
|
@delete-message="requestDeleteMessage"
|
||||||
@delete-and-block-node="deleteAndBlockNode"
|
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||||
/>
|
/>
|
||||||
<MeshMap
|
<MeshMap
|
||||||
:items="mapItems"
|
:items="mapItems"
|
||||||
@@ -443,8 +531,8 @@ onBeforeUnmount(() => {
|
|||||||
@bounds-change="handleMapBoundsChange"
|
@bounds-change="handleMapBoundsChange"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@clear-node="selectedNodeId = null"
|
@clear-node="selectedNodeId = null"
|
||||||
@delete-node="deleteNodeById"
|
@delete-node="requestDeleteNode"
|
||||||
@delete-and-block-node="deleteAndBlockNode"
|
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -458,9 +546,21 @@ onBeforeUnmount(() => {
|
|||||||
:is-admin="!!adminUser"
|
:is-admin="!!adminUser"
|
||||||
@select-node="selectedNodeId = $event"
|
@select-node="selectedNodeId = $event"
|
||||||
@page-change="loadNodePage"
|
@page-change="loadNodePage"
|
||||||
@delete-node="deleteNodeById"
|
@delete-node="requestDeleteNode"
|
||||||
@delete-and-block-node="deleteAndBlockNode"
|
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
:open="!!pendingDeleteAction"
|
||||||
|
:title="deleteModalTitle"
|
||||||
|
:message="deleteModalMessage"
|
||||||
|
:confirm-text="deleteModalConfirmText"
|
||||||
|
:require-reason="deleteModalRequiresReason"
|
||||||
|
reason-label="屏蔽原因"
|
||||||
|
reason-placeholder="请输入屏蔽原因"
|
||||||
|
@cancel="cancelDeleteModal"
|
||||||
|
@confirm="confirmDeleteModal"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref } from 'vue'
|
||||||
import type { NodeInfoById, TextMessage } from '../types'
|
import type { NodeInfoById, TextMessage } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -25,6 +25,22 @@ const menuY = ref(0)
|
|||||||
const topThreshold = 8
|
const topThreshold = 8
|
||||||
const bottomThreshold = 40
|
const bottomThreshold = 40
|
||||||
|
|
||||||
|
type GroupedTextMessage = TextMessage & { mergedCount: number }
|
||||||
|
|
||||||
|
const groupedMessages = computed<GroupedTextMessage[]>(() => {
|
||||||
|
const groups = new Map<string, GroupedTextMessage>()
|
||||||
|
for (const message of props.messages) {
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
|
||||||
let didInitialScroll = false
|
let didInitialScroll = false
|
||||||
let shouldStickToBottom = true
|
let shouldStickToBottom = true
|
||||||
let restoreScrollHeight: number | null = null
|
let restoreScrollHeight: number | null = null
|
||||||
@@ -160,14 +176,14 @@ onUpdated(() => {
|
|||||||
<p class="eyebrow">Chat</p>
|
<p class="eyebrow">Chat</p>
|
||||||
<h2>聊天信息</h2>
|
<h2>聊天信息</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ messages.length }}</span>
|
<span class="badge">{{ groupedMessages.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
|
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
<div v-else-if="!hasMoreMessages && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
<div v-else-if="!hasMoreMessages && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
||||||
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
|
<div v-if="messages.length === 0" class="empty">暂无聊天消息</div>
|
||||||
<button
|
<button
|
||||||
v-for="message in messages"
|
v-for="message in groupedMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
class="chat-item"
|
class="chat-item"
|
||||||
:class="{ selected: selectedNodeId === message.from_id }"
|
:class="{ selected: selectedNodeId === message.from_id }"
|
||||||
@@ -180,7 +196,10 @@ onUpdated(() => {
|
|||||||
<small>{{ formatTime(message.created_at) }}</small>
|
<small>{{ formatTime(message.created_at) }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="chat-topic">{{ message.topic }}</span>
|
<span class="chat-topic">{{ message.topic }}</span>
|
||||||
<span class="chat-text">{{ message.text || '[binary]' }}</span>
|
<span class="chat-text">
|
||||||
|
{{ message.text || '[binary]' }}
|
||||||
|
<span v-if="message.mergedCount > 1" class="message-merge-count">x{{ message.mergedCount }}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
requireReason?: boolean
|
||||||
|
reasonLabel?: string
|
||||||
|
reasonPlaceholder?: string
|
||||||
|
}>(), {
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
requireReason: false,
|
||||||
|
reasonLabel: '屏蔽原因',
|
||||||
|
reasonPlaceholder: '请输入屏蔽原因',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: []
|
||||||
|
confirm: [payload: { reason?: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const reason = ref('')
|
||||||
|
const reasonInputRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
const trimmedReason = computed(() => reason.value.trim())
|
||||||
|
const confirmDisabled = computed(() => props.requireReason && !trimmedReason.value)
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (confirmDisabled.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('confirm', props.requireReason ? { reason: trimmedReason.value } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!props.open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (open) => {
|
||||||
|
reason.value = ''
|
||||||
|
if (open && props.requireReason) {
|
||||||
|
await nextTick()
|
||||||
|
reasonInputRef.value?.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-backdrop" @click.self="cancel">
|
||||||
|
<section class="confirm-modal" role="dialog" aria-modal="true" :aria-label="title">
|
||||||
|
<div class="confirm-modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Confirm</p>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="confirm-modal-close" type="button" aria-label="关闭" @click="cancel">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-modal-body">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
<label v-if="requireReason" class="confirm-modal-reason">
|
||||||
|
<span>{{ reasonLabel }}</span>
|
||||||
|
<textarea
|
||||||
|
ref="reasonInputRef"
|
||||||
|
v-model="reason"
|
||||||
|
rows="3"
|
||||||
|
:placeholder="reasonPlaceholder"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-modal-actions">
|
||||||
|
<button class="confirm-modal-secondary" type="button" @click="cancel">{{ cancelText }}</button>
|
||||||
|
<button class="confirm-modal-danger" type="button" :disabled="confirmDisabled" @click="confirm">
|
||||||
|
{{ confirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
import { createNodeBlockingRule, deleteNode, deleteTextMessage, getMapReportById, getNodeInfoById, getPositions, getTelemetry, getTextMessages } from '../api'
|
||||||
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
|
import type { MapReport, NodeInfo, PositionRecord, TelemetryRecord, TextMessage } from '../types'
|
||||||
|
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||||
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
import NodeTrajectoryMap from './NodeTrajectoryMap.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -20,9 +21,16 @@ const chatHasMore = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const chatPageSize = 20
|
const chatPageSize = 20
|
||||||
const chatHistoryRef = ref<HTMLElement | null>(null)
|
const chatHistoryRef = ref<HTMLElement | null>(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<TextMessage | null>(null)
|
const menuMessage = ref<TextMessage | null>(null)
|
||||||
const menuX = ref(0)
|
const menuX = ref(0)
|
||||||
const menuY = ref(0)
|
const menuY = ref(0)
|
||||||
|
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
||||||
|
|
||||||
const nodeTitle = computed(() => {
|
const nodeTitle = computed(() => {
|
||||||
return nodeInfo.value?.long_name || nodeInfo.value?.short_name || mapReport.value?.long_name || mapReport.value?.short_name || props.nodeId
|
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<GroupedTextMessage[]>(() => {
|
||||||
|
const groups = new Map<string, GroupedTextMessage>()
|
||||||
|
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 {
|
function formatTime(value: string): string {
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
}
|
}
|
||||||
@@ -164,12 +206,15 @@ function openMessageMenu(message: TextMessage, event: MouseEvent) {
|
|||||||
menuY.value = event.clientY
|
menuY.value = event.clientY
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelectedMessage() {
|
function deleteSelectedMessage() {
|
||||||
if (!menuMessage.value) {
|
if (!menuMessage.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const message = menuMessage.value
|
pendingDeleteAction.value = { kind: 'delete-message', message: menuMessage.value }
|
||||||
closeMessageMenu()
|
closeMessageMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performDeleteMessage(message: TextMessage) {
|
||||||
try {
|
try {
|
||||||
await deleteTextMessage(message.id)
|
await deleteTextMessage(message.id)
|
||||||
messages.value = messages.value.filter((item) => item.id !== 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'
|
return err instanceof Error && err.message === 'message not found'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAndBlockSelectedMessageNode() {
|
function deleteAndBlockSelectedMessageNode() {
|
||||||
if (!menuMessage.value) {
|
if (!menuMessage.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const message = menuMessage.value
|
const message = menuMessage.value
|
||||||
const nodeId = message.from_id || props.nodeId
|
pendingDeleteAction.value = {
|
||||||
const nodeNum = message.from_num ?? mergedNode.value.node_num ?? null
|
kind: 'delete-and-block-node',
|
||||||
|
message,
|
||||||
|
nodeId: message.from_id || props.nodeId,
|
||||||
|
nodeNum: message.from_num ?? mergedNode.value.node_num ?? null,
|
||||||
|
}
|
||||||
closeMessageMenu()
|
closeMessageMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performDeleteAndBlockMessageNode(payload: { message: TextMessage; nodeId: string; nodeNum: number | null; reason: string }) {
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
await deleteTextMessage(message.id)
|
await deleteTextMessage(payload.message.id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isMessageNotFoundError(err)) {
|
if (!isMessageNotFoundError(err)) {
|
||||||
throw 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 {
|
try {
|
||||||
await createNodeBlockingRule({
|
await createNodeBlockingRule({
|
||||||
node_id: nodeId,
|
node_id: payload.nodeId,
|
||||||
node_num: nodeNum,
|
node_num: payload.nodeNum,
|
||||||
reason: '管理员右键删除并屏蔽节点',
|
reason: payload.reason,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -222,13 +274,13 @@ async function deleteAndBlockSelectedMessageNode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteNode(nodeId)
|
await deleteNode(payload.nodeId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isNodeNotFoundError(err)) {
|
if (!isNodeNotFoundError(err)) {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (nodeId === props.nodeId) {
|
if (payload.nodeId === props.nodeId) {
|
||||||
nodeInfo.value = null
|
nodeInfo.value = null
|
||||||
mapReport.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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
closeMessageMenu()
|
closeMessageMenu()
|
||||||
@@ -336,14 +416,14 @@ onBeforeUnmount(() => {
|
|||||||
<p class="eyebrow">Chat</p>
|
<p class="eyebrow">Chat</p>
|
||||||
<h2>历史聊天记录:{{ nodeTitle }}</h2>
|
<h2>历史聊天记录:{{ nodeTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge">{{ messages.length }}</span>
|
<span class="badge">{{ groupedMessages.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div ref="chatHistoryRef" class="detail-chat-history" @scroll.passive="handleChatScroll">
|
<div ref="chatHistoryRef" class="detail-chat-history" @scroll.passive="handleChatScroll">
|
||||||
<div v-if="chatLoadingOlder" class="chat-loading">正在加载更早消息...</div>
|
<div v-if="chatLoadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
<div v-else-if="!chatHasMore && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
<div v-else-if="!chatHasMore && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
||||||
<div v-if="messages.length === 0" class="empty">暂无聊天记录</div>
|
<div v-if="messages.length === 0" class="empty">暂无聊天记录</div>
|
||||||
<div
|
<div
|
||||||
v-for="message in messages"
|
v-for="message in groupedMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
class="detail-chat-item"
|
class="detail-chat-item"
|
||||||
@contextmenu.prevent.stop="openMessageMenu(message, $event)"
|
@contextmenu.prevent.stop="openMessageMenu(message, $event)"
|
||||||
@@ -352,7 +432,10 @@ onBeforeUnmount(() => {
|
|||||||
<strong>{{ formatTime(message.created_at) }}</strong>
|
<strong>{{ formatTime(message.created_at) }}</strong>
|
||||||
<small>{{ message.topic }}</small>
|
<small>{{ message.topic }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="chat-text">{{ message.text || '[binary]' }}</span>
|
<span class="chat-text">
|
||||||
|
{{ message.text || '[binary]' }}
|
||||||
|
<span v-if="message.mergedCount > 1" class="message-merge-count">x{{ message.mergedCount }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -413,5 +496,17 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
|
<div v-if="telemetry.length === 0" class="empty">暂无遥测数据</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
:open="!!pendingDeleteAction"
|
||||||
|
:title="deleteModalTitle"
|
||||||
|
:message="deleteModalMessage"
|
||||||
|
:confirm-text="deleteModalConfirmText"
|
||||||
|
:require-reason="deleteModalRequiresReason"
|
||||||
|
reason-label="屏蔽原因"
|
||||||
|
reason-placeholder="请输入屏蔽原因"
|
||||||
|
@cancel="cancelDeleteModal"
|
||||||
|
@confirm="confirmDeleteModal"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -220,6 +220,18 @@ h3 {
|
|||||||
line-height: 1.35;
|
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 {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
@@ -255,6 +267,123 @@ h3 {
|
|||||||
color: #b91c1c;
|
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 {
|
.map-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface TextMessage {
|
|||||||
id: number
|
id: number
|
||||||
from_id: string
|
from_id: string
|
||||||
from_num: number
|
from_num: number
|
||||||
|
packet_id: number | null
|
||||||
text: string | null
|
text: string | null
|
||||||
topic: string
|
topic: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func textMessageDTO(row textMessageRecord) 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 {
|
func discardDetailsDTO(row discardDetailsRecord) gin.H {
|
||||||
|
|||||||
Reference in New Issue
Block a user