From 91267eb99c6867744628239f76a93ce8993f16ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Fri, 5 Jun 2026 21:56:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=BF=AB=E9=80=9F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=8A=98=E5=8F=A0=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- meshmap_frontend/src/App.vue | 71 ++++++++++++----- meshmap_frontend/src/components/ChatPanel.vue | 15 ++-- .../src/components/NodeDetailedPage.vue | 79 +++++++++++++------ 3 files changed, 118 insertions(+), 47 deletions(-) diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index dfc9038..9403c7e 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -51,10 +51,11 @@ const currentMapZoom = ref(2) const mapReportsLoading = ref(false) const mapReportTotal = ref(0) const pendingDeleteAction = ref(null) -type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: TextMessage } +type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] } +type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage } type NodeActionPayload = NodeActionRequest & { reason: string } type PendingDeleteAction = - | { kind: 'delete-message'; message: TextMessage } + | { kind: 'delete-message'; message: DeletableTextMessage } | { kind: 'delete-node'; nodeId: string } | ({ kind: 'delete-and-block-node' } & NodeActionRequest) let refreshTimer: number | undefined @@ -122,14 +123,21 @@ const deleteModalMessage = computed(() => { return '' } if (action.kind === 'delete-message') { - return '确定要删除这条聊天消息吗?此操作不可撤销。' + const count = deleteMessageCount(action.message) + return count > 1 + ? `确定要删除这组已合并的 ${count} 条聊天消息吗?此操作不可撤销。` + : '确定要删除这条聊天消息吗?此操作不可撤销。' } if (action.kind === 'delete-node') { return '确定要删除这个节点吗?此操作不可撤销。' } - return action.message - ? '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' - : '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。' + if (!action.message) { + return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。' + } + const count = deleteMessageCount(action.message) + return count > 1 + ? `确定要删除这组已合并的 ${count} 条聊天消息并屏蔽该节点吗?请输入屏蔽原因。` + : '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' }) const deleteModalConfirmText = computed(() => { @@ -158,6 +166,15 @@ function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMe return Array.from(byId.values()).sort(compareMessages) } +function messagesForDelete(message: DeletableTextMessage): TextMessage[] { + const items = message.mergedMessages?.length ? message.mergedMessages : [message] + return Array.from(new Map(items.map((item) => [item.id, item])).values()) +} + +function deleteMessageCount(message: DeletableTextMessage): number { + return messagesForDelete(message).length +} + function isSameJSON(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right) } @@ -295,7 +312,7 @@ async function logoutAdmin() { } } -function requestDeleteMessage(message: TextMessage) { +function requestDeleteMessage(message: DeletableTextMessage) { pendingDeleteAction.value = { kind: 'delete-message', message } } @@ -340,10 +357,9 @@ async function confirmDeleteModal(payload: { reason?: string }) { }) } -async function deleteMessage(message: TextMessage) { +async function deleteMessage(message: DeletableTextMessage) { try { - await deleteTextMessage(message.id) - messages.value = messages.value.filter((item) => item.id !== message.id) + await deleteMessagesFromLocalState(message) } catch (err) { error.value = err instanceof Error ? err.message : String(err) } @@ -371,6 +387,32 @@ function isMessageNotFoundError(err: unknown): boolean { return err instanceof Error && err.message === 'message not found' } +async function deleteMessagesFromLocalState(message: DeletableTextMessage) { + const items = messagesForDelete(message) + const removableIds = new Set() + const errors: string[] = [] + + await Promise.all(items.map(async (item) => { + try { + await deleteTextMessage(item.id) + removableIds.add(item.id) + } catch (err) { + if (isMessageNotFoundError(err)) { + removableIds.add(item.id) + return + } + errors.push(err instanceof Error ? err.message : String(err)) + } + })) + + if (removableIds.size > 0) { + messages.value = messages.value.filter((item) => !removableIds.has(item.id)) + } + if (errors.length > 0) { + throw new Error(`部分消息删除失败(${errors.length}/${items.length}):${errors[0]}`) + } +} + async function deleteNodeById(nodeId: string) { try { await deleteNode(nodeId) @@ -383,14 +425,7 @@ async function deleteNodeById(nodeId: string) { async function deleteAndBlockNode(payload: NodeActionPayload) { try { if (payload.message) { - try { - await deleteTextMessage(payload.message.id) - } catch (err) { - if (!isMessageNotFoundError(err)) { - throw err - } - } - messages.value = messages.value.filter((item) => item.id !== payload.message?.id) + await deleteMessagesFromLocalState(payload.message) } try { diff --git a/meshmap_frontend/src/components/ChatPanel.vue b/meshmap_frontend/src/components/ChatPanel.vue index 5fb5042..1169712 100644 --- a/meshmap_frontend/src/components/ChatPanel.vue +++ b/meshmap_frontend/src/components/ChatPanel.vue @@ -11,22 +11,22 @@ const props = defineProps<{ isAdmin: boolean }>() +type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] } + const emit = defineEmits<{ 'select-node': [nodeId: string] 'load-older': [] - 'delete-message': [message: TextMessage] - 'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null; message: TextMessage }] + 'delete-message': [message: GroupedTextMessage] + 'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null; message: GroupedTextMessage }] }>() const panelRef = ref(null) -const menuMessage = ref(null) +const menuMessage = ref(null) const menuX = ref(0) const menuY = ref(0) const topThreshold = 8 const bottomThreshold = 40 -type GroupedTextMessage = TextMessage & { mergedCount: number } - const groupedMessages = computed(() => { const groups = new Map() for (const message of props.messages) { @@ -34,8 +34,9 @@ const groupedMessages = computed(() => { const group = groups.get(key) if (group) { group.mergedCount += 1 + group.mergedMessages.push(message) } else { - groups.set(key, { ...message, mergedCount: 1 }) + groups.set(key, { ...message, mergedCount: 1, mergedMessages: [message] }) } } return Array.from(groups.values()) @@ -74,7 +75,7 @@ function nodeDetailHref(nodeId: string): string { return `/detailed/${encodeURIComponent(nodeId)}` } -function openMessageMenu(message: TextMessage, event: MouseEvent) { +function openMessageMenu(message: GroupedTextMessage, event: MouseEvent) { emit('select-node', message.from_id) menuMessage.value = message menuX.value = event.clientX diff --git a/meshmap_frontend/src/components/NodeDetailedPage.vue b/meshmap_frontend/src/components/NodeDetailedPage.vue index 47ffe7c..d041903 100644 --- a/meshmap_frontend/src/components/NodeDetailedPage.vue +++ b/meshmap_frontend/src/components/NodeDetailedPage.vue @@ -21,13 +21,12 @@ const chatHasMore = ref(true) const error = ref('') const chatPageSize = 20 const chatHistoryRef = ref(null) +type GroupedTextMessage = TextMessage & { mergedCount: number; mergedMessages: TextMessage[] } type PendingDeleteAction = - | { kind: 'delete-message'; message: TextMessage } - | { kind: 'delete-and-block-node'; message: TextMessage; nodeId: string; nodeNum: number | null } + | { kind: 'delete-message'; message: GroupedTextMessage } + | { kind: 'delete-and-block-node'; message: GroupedTextMessage; nodeId: string; nodeNum: number | null } -type GroupedTextMessage = TextMessage & { mergedCount: number } - -const menuMessage = ref(null) +const menuMessage = ref(null) const menuX = ref(0) const menuY = ref(0) const pendingDeleteAction = ref(null) @@ -55,10 +54,19 @@ const deleteModalTitle = computed(() => { }) const deleteModalMessage = computed(() => { - if (pendingDeleteAction.value?.kind === 'delete-and-block-node') { - return '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' + const action = pendingDeleteAction.value + if (!action) { + return '' } - return '确定要删除这条聊天消息吗?此操作不可撤销。' + const count = deleteMessageCount(action.message) + if (action.kind === 'delete-and-block-node') { + return count > 1 + ? `确定要删除这组已合并的 ${count} 条聊天消息并屏蔽该节点吗?请输入屏蔽原因。` + : '确定要删除这条聊天消息并屏蔽该节点吗?请输入屏蔽原因。' + } + return count > 1 + ? `确定要删除这组已合并的 ${count} 条聊天消息吗?此操作不可撤销。` + : '确定要删除这条聊天消息吗?此操作不可撤销。' }) const deleteModalConfirmText = computed(() => { @@ -74,8 +82,9 @@ const groupedMessages = computed(() => { const group = groups.get(key) if (group) { group.mergedCount += 1 + group.mergedMessages.push(message) } else { - groups.set(key, { ...message, mergedCount: 1 }) + groups.set(key, { ...message, mergedCount: 1, mergedMessages: [message] }) } } return Array.from(groups.values()) @@ -150,6 +159,14 @@ function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMe return Array.from(byId.values()).sort(compareMessages) } +function messagesForDelete(message: GroupedTextMessage): TextMessage[] { + return Array.from(new Map(message.mergedMessages.map((item) => [item.id, item])).values()) +} + +function deleteMessageCount(message: GroupedTextMessage): number { + return messagesForDelete(message).length +} + async function optional(request: Promise): Promise { try { return await request @@ -197,7 +214,7 @@ function closeMessageMenu() { menuMessage.value = null } -function openMessageMenu(message: TextMessage, event: MouseEvent) { +function openMessageMenu(message: GroupedTextMessage, event: MouseEvent) { if (!props.isAdmin) { return } @@ -214,10 +231,9 @@ function deleteSelectedMessage() { closeMessageMenu() } -async function performDeleteMessage(message: TextMessage) { +async function performDeleteMessage(message: GroupedTextMessage) { try { - await deleteTextMessage(message.id) - messages.value = messages.value.filter((item) => item.id !== message.id) + await deleteMessagesFromLocalState(message) } catch (err) { error.value = err instanceof Error ? err.message : String(err) } @@ -235,6 +251,32 @@ function isMessageNotFoundError(err: unknown): boolean { return err instanceof Error && err.message === 'message not found' } +async function deleteMessagesFromLocalState(message: GroupedTextMessage) { + const items = messagesForDelete(message) + const removableIds = new Set() + const errors: string[] = [] + + await Promise.all(items.map(async (item) => { + try { + await deleteTextMessage(item.id) + removableIds.add(item.id) + } catch (err) { + if (isMessageNotFoundError(err)) { + removableIds.add(item.id) + return + } + errors.push(err instanceof Error ? err.message : String(err)) + } + })) + + if (removableIds.size > 0) { + messages.value = messages.value.filter((item) => !removableIds.has(item.id)) + } + if (errors.length > 0) { + throw new Error(`部分消息删除失败(${errors.length}/${items.length}):${errors[0]}`) + } +} + function deleteAndBlockSelectedMessageNode() { if (!menuMessage.value) { return @@ -249,16 +291,9 @@ function deleteAndBlockSelectedMessageNode() { closeMessageMenu() } -async function performDeleteAndBlockMessageNode(payload: { message: TextMessage; nodeId: string; nodeNum: number | null; reason: string }) { +async function performDeleteAndBlockMessageNode(payload: { message: GroupedTextMessage; nodeId: string; nodeNum: number | null; reason: string }) { try { - try { - await deleteTextMessage(payload.message.id) - } catch (err) { - if (!isMessageNotFoundError(err)) { - throw err - } - } - messages.value = messages.value.filter((item) => item.id !== payload.message.id) + await deleteMessagesFromLocalState(payload.message) try { await createNodeBlockingRule({