From 6c0285c015d5ffe8327afc54f169e049a0710471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Fri, 12 Jun 2026 20:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=81=E8=81=8A=E5=8A=9F=E8=83=BD=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_bot_routes.go | 27 ++ meshmap_frontend/src/App.vue | 4 + meshmap_frontend/src/api.ts | 16 +- meshmap_frontend/src/components/AdminBot.vue | 341 +++++++++++++----- .../src/components/AdminBotDirect.vue | 315 ++++++++++++++++ meshmap_frontend/src/types.ts | 1 + store_query.go | 43 ++- web.go | 5 +- 8 files changed, 640 insertions(+), 112 deletions(-) create mode 100644 meshmap_frontend/src/components/AdminBotDirect.vue diff --git a/admin_bot_routes.go b/admin_bot_routes.go index 0f3cc0f..443c8bf 100644 --- a/admin_bot_routes.go +++ b/admin_bot_routes.go @@ -129,6 +129,33 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) { total, err := store.CountBotMessages(opts) writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO) }) + r.GET("/bot/direct-messages", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + botID, err := strconv.ParseUint(c.Query("bot_id"), 10, 64) + if err != nil || botID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) + return + } + bot, err := store.GetBotNode(botID) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + target, err := strconv.ParseInt(c.Query("target_node_num"), 10, 64) + if err != nil || target <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"}) + return + } + rows, err := store.ListBotDirectTextMessages(bot.NodeNum, target, opts) + writeListResponse(c, rows, opts, err, textMessageDTO) + }) r.POST("/bot/messages", func(c *gin.Context) { if sender == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"}) diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index b636a1c..dec51af 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api' import AdminBlockingManagement from './components/AdminBlockingManagement.vue' import AdminBot from './components/AdminBot.vue' +import AdminBotDirect from './components/AdminBotDirect.vue' import AdminDashboard from './components/AdminDashboard.vue' import AdminDiscardDetails from './components/AdminDiscardDetails.vue' import AdminHelpEdit from './components/AdminHelpEdit.vue' @@ -25,6 +26,7 @@ const adminPath = currentPath const isAdminPage = adminPath.startsWith('/admin') const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/' const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/' +const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/' const detailMatch = currentPath.match(/^\/detailed\/(.+)$/) const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : '' const isDetailedPage = !!detailedNodeId @@ -514,6 +516,7 @@ onBeforeUnmount(() => { 屏蔽管理 MQTT转发 机器人 + 机器人私聊 地图图源 帮助编辑 登录日志 @@ -555,6 +558,7 @@ onBeforeUnmount(() => { + diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index d7cd979..17843a4 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -63,6 +63,7 @@ async function requestJSON(path: string, init?: RequestInit): Promise { type ListQueryOptions = { nodeId?: string + channelId?: string since?: string until?: string } @@ -73,6 +74,9 @@ function listPath(path: string, limit: number, offset: number, nodeIdOrOptions: if (options.nodeId) { params.set('node_id', options.nodeId) } + if (options.channelId) { + params.set('channel_id', options.channelId) + } if (options.since) { params.set('since', options.since) } @@ -157,8 +161,8 @@ export function getEnabledMapSources(): Promise { return getJSON('/api/map-source/enabled') } -export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise> { - return getJSON>(listPath('/api/text-messages', limit, offset, nodeId)) +export function getTextMessages(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise> { + return getJSON>(listPath('/api/text-messages', limit, offset, nodeIdOrOptions)) } export function deleteTextMessage(id: number): Promise<{ status: string }> { @@ -373,6 +377,14 @@ export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise>(`/api/admin/bot/messages?${params.toString()}`) } +export function getBotDirectTextMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, channelId = ''): Promise> { + const params = new URLSearchParams({ bot_id: String(botId), target_node_num: String(targetNodeNum), limit: String(limit), offset: String(offset) }) + if (channelId) { + params.set('channel_id', channelId) + } + return getJSON>(`/api/admin/bot/direct-messages?${params.toString()}`) +} + export function sendBotMessage(payload: BotSendMessagePayload): Promise { return postJSON('/api/admin/bot/messages', payload) } diff --git a/meshmap_frontend/src/components/AdminBot.vue b/meshmap_frontend/src/components/AdminBot.vue index 84c3d61..79b0cb7 100644 --- a/meshmap_frontend/src/components/AdminBot.vue +++ b/meshmap_frontend/src/components/AdminBot.vue @@ -1,18 +1,23 @@ @@ -394,64 +543,58 @@ onMounted(() => { -
-
+
+
-

Compose

-

发送消息

+

Channel Chat

+

{{ currentChannelID || '未选择频道' }}

+
-
- - -
-
- - - - -
-
- {{ sendTextBytes }} / {{ maxTextBytes }} bytes - -
-
-
-
-
-

History

-

发送历史

-
- -
-
-
暂无发送记录
-
-
-
- {{ targetLabel(item) }} - {{ formatTime(item.created_at) }} +
+
正在加载更早消息...
+
没有更多历史消息
+
当前频道暂无聊天消息
+
+
+
+ {{ senderName(item) }} + {{ formatTime(item.created_at) }}
- {{ statusText(item.status) }} +
+ {{ item.text || '[binary]' }} + x{{ item.mergedCount }} +
+
{{ item.topic }}
-

{{ item.text }}

-
- {{ item.channel_id }} - #{{ item.packet_id }} - {{ item.encrypted ? 'AES-CTR' : '明文' }} -
-

{{ item.error }}

-
+
+
+ +
+ +
+ + + + +
+
+ {{ sendTextBytes }} / {{ maxTextBytes }} bytes + +
@@ -464,7 +607,7 @@ onMounted(() => { diff --git a/meshmap_frontend/src/components/AdminBotDirect.vue b/meshmap_frontend/src/components/AdminBotDirect.vue new file mode 100644 index 0000000..1400858 --- /dev/null +++ b/meshmap_frontend/src/components/AdminBotDirect.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index e84550e..ff1878a 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -143,6 +143,7 @@ export interface TextMessage { packet_id: number | null text: string | null topic: string + channel_id: string | null created_at: string mqtt_remote_host: string | null content_json: string diff --git a/store_query.go b/store_query.go index 6d6c6b9..50c53a3 100644 --- a/store_query.go +++ b/store_query.go @@ -9,15 +9,16 @@ import ( ) type listOptions struct { - Limit int - Offset int - NodeID string - Since *time.Time - Until *time.Time - MinLat *float64 - MaxLat *float64 - MinLng *float64 - MaxLng *float64 + Limit int + Offset int + NodeID string + ChannelID string + Since *time.Time + Until *time.Time + MinLat *float64 + MaxLat *float64 + MinLng *float64 + MaxLng *float64 } type mapReportViewportOptions struct { @@ -264,6 +265,27 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) return rows, s.listAppendRows(opts, &rows).Error } +func (s *store) ListBotDirectTextMessages(botNodeNum, targetNodeNum int64, opts listOptions) ([]textMessageRecord, error) { + opts = normalizeListOptions(opts) + var rows []textMessageRecord + q := s.db.Model(&textMessageRecord{}). + Where("(from_num = ? AND packet_to_num = ?) OR (from_num = ? AND packet_to_num = ?)", botNodeNum, targetNodeNum, targetNodeNum, botNodeNum). + Order("created_at DESC"). + Order("id DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + if opts.ChannelID != "" { + q = q.Where("channel_id = ?", opts.ChannelID) + } + if opts.Since != nil { + q = q.Where("created_at >= ?", *opts.Since) + } + if opts.Until != nil { + q = q.Where("created_at <= ?", *opts.Until) + } + return rows, q.Find(&rows).Error +} + func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) { opts = normalizeListOptions(opts) var rows []discardDetailsRecord @@ -328,6 +350,9 @@ func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB { if opts.NodeID != "" { q = q.Where("from_id = ?", opts.NodeID) } + if opts.ChannelID != "" { + q = q.Where("channel_id = ?", opts.ChannelID) + } if opts.Since != nil { q = q.Where("created_at >= ?", *opts.Since) } diff --git a/web.go b/web.go index b4bbfa5..c734a67 100644 --- a/web.go +++ b/web.go @@ -421,6 +421,7 @@ func parseListOptions(c *gin.Context) (listOptions, bool) { if nodeID == "" { nodeID = c.Query("from") } + channelID := c.Query("channel_id") var since, until *time.Time if value := c.Query("since"); value != "" { parsed, err := time.Parse(time.RFC3339, value) @@ -438,7 +439,7 @@ func parseListOptions(c *gin.Context) (listOptions, bool) { } until = &parsed } - return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, Since: since, Until: until}), true + return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true } func parseMapReportListOptions(c *gin.Context) (listOptions, bool) { @@ -599,7 +600,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, "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} + 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, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} } func discardDetailsDTO(row discardDetailsRecord) gin.H {