私聊功能未完成
This commit is contained in:
@@ -129,6 +129,33 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
total, err := store.CountBotMessages(opts)
|
total, err := store.CountBotMessages(opts)
|
||||||
writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO)
|
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) {
|
r.POST("/bot/messages", func(c *gin.Context) {
|
||||||
if sender == nil {
|
if sender == nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "bot sender is not configured"})
|
||||||
|
|||||||
@@ -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 { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||||
import AdminBot from './components/AdminBot.vue'
|
import AdminBot from './components/AdminBot.vue'
|
||||||
|
import AdminBotDirect from './components/AdminBotDirect.vue'
|
||||||
import AdminDashboard from './components/AdminDashboard.vue'
|
import AdminDashboard from './components/AdminDashboard.vue'
|
||||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||||
@@ -25,6 +26,7 @@ const adminPath = currentPath
|
|||||||
const isAdminPage = adminPath.startsWith('/admin')
|
const isAdminPage = adminPath.startsWith('/admin')
|
||||||
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||||
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||||
|
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
||||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||||
const isDetailedPage = !!detailedNodeId
|
const isDetailedPage = !!detailedNodeId
|
||||||
@@ -514,6 +516,7 @@ onBeforeUnmount(() => {
|
|||||||
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
<a href="/admin/blocking_management" :class="{ active: adminPath === '/admin/blocking_management' }">屏蔽管理</a>
|
||||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||||
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
||||||
|
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
||||||
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||||
@@ -555,6 +558,7 @@ onBeforeUnmount(() => {
|
|||||||
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
<AdminBlockingManagement v-else-if="adminPath === '/admin/blocking_management'" />
|
||||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||||
<AdminBot v-else-if="isBotAdminPage" />
|
<AdminBot v-else-if="isBotAdminPage" />
|
||||||
|
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
||||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
type ListQueryOptions = {
|
type ListQueryOptions = {
|
||||||
nodeId?: string
|
nodeId?: string
|
||||||
|
channelId?: string
|
||||||
since?: string
|
since?: string
|
||||||
until?: string
|
until?: string
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,9 @@ function listPath(path: string, limit: number, offset: number, nodeIdOrOptions:
|
|||||||
if (options.nodeId) {
|
if (options.nodeId) {
|
||||||
params.set('node_id', options.nodeId)
|
params.set('node_id', options.nodeId)
|
||||||
}
|
}
|
||||||
|
if (options.channelId) {
|
||||||
|
params.set('channel_id', options.channelId)
|
||||||
|
}
|
||||||
if (options.since) {
|
if (options.since) {
|
||||||
params.set('since', options.since)
|
params.set('since', options.since)
|
||||||
}
|
}
|
||||||
@@ -157,8 +161,8 @@ export function getEnabledMapSources(): Promise<PublicMapTileSourcesResponse> {
|
|||||||
return getJSON<PublicMapTileSourcesResponse>('/api/map-source/enabled')
|
return getJSON<PublicMapTileSourcesResponse>('/api/map-source/enabled')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTextMessages(limit = 100, offset = 0, nodeId = ''): Promise<ListResponse<TextMessage>> {
|
export function getTextMessages(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<TextMessage>> {
|
||||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeId))
|
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeIdOrOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
||||||
@@ -373,6 +377,14 @@ export function getBotMessages(botId = 0, limit = 100, offset = 0): Promise<List
|
|||||||
return getJSON<ListResponse<BotMessage>>(`/api/admin/bot/messages?${params.toString()}`)
|
return getJSON<ListResponse<BotMessage>>(`/api/admin/bot/messages?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBotDirectTextMessages(botId: number, targetNodeNum: number, limit = 100, offset = 0, channelId = ''): Promise<ListResponse<TextMessage>> {
|
||||||
|
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<ListResponse<TextMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
||||||
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
|
||||||
import { broadcastBotNodeInfo, createBotNode, deleteBotNode, getBotMessages, getBotNodes, getNodeInfo, regenerateBotNodeKeys, sendBotMessage, updateBotNode } from '../api'
|
import { broadcastBotNodeInfo, createBotNode, deleteBotNode, getBotNodes, getNodeInfo, getTextMessages, regenerateBotNodeKeys, sendBotMessage, updateBotNode } from '../api'
|
||||||
import type { BotMessage, BotMessageStatus, BotMessageType, BotNode, BotNodePayload, NodeInfo } from '../types'
|
import type { BotMessageType, BotNode, BotNodePayload, NodeInfo, TextMessage } from '../types'
|
||||||
|
|
||||||
const botPageSize = 100
|
const botPageSize = 100
|
||||||
const messagePageSize = 100
|
const chatPageSize = 30
|
||||||
const maxTextBytes = 200
|
const maxTextBytes = 200
|
||||||
|
const topThreshold = 8
|
||||||
|
const bottomThreshold = 40
|
||||||
|
const scrollOverflowAllowance = 1
|
||||||
|
|
||||||
const bots = ref<BotNode[]>([])
|
const bots = ref<BotNode[]>([])
|
||||||
const messages = ref<BotMessage[]>([])
|
const chatMessages = ref<TextMessage[]>([])
|
||||||
const targets = ref<NodeInfo[]>([])
|
const targets = ref<NodeInfo[]>([])
|
||||||
const selectedBotId = ref<number | null>(null)
|
const selectedBotId = ref<number | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const messageLoading = ref(false)
|
const chatLoadingOlder = ref(false)
|
||||||
|
const chatHasMore = ref(true)
|
||||||
|
const chatInitialized = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const broadcastingNodeInfo = ref(false)
|
const broadcastingNodeInfo = ref(false)
|
||||||
@@ -20,6 +25,7 @@ const regeneratingKeys = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const targetQuery = ref('')
|
const targetQuery = ref('')
|
||||||
|
const chatPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const newBot = ref({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true })
|
const newBot = ref({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true })
|
||||||
const edits = ref<Record<number, { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: string; enabled: boolean }>>({})
|
const edits = ref<Record<number, { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: string; enabled: boolean }>>({})
|
||||||
@@ -27,9 +33,30 @@ const sendForm = ref<{ message_type: BotMessageType; channel_id: string; to_node
|
|||||||
|
|
||||||
const selectedBot = computed(() => bots.value.find((bot) => bot.id === selectedBotId.value) ?? null)
|
const selectedBot = computed(() => bots.value.find((bot) => bot.id === selectedBotId.value) ?? null)
|
||||||
const enabledBots = computed(() => bots.value.filter((bot) => bot.enabled).length)
|
const enabledBots = computed(() => bots.value.filter((bot) => bot.enabled).length)
|
||||||
|
const currentChannelID = computed(() => sendForm.value.channel_id.trim() || selectedBot.value?.default_channel_id || '')
|
||||||
const sendTextBytes = computed(() => new TextEncoder().encode(sendForm.value.text).length)
|
const sendTextBytes = computed(() => new TextEncoder().encode(sendForm.value.text).length)
|
||||||
const isTextTooLong = computed(() => sendTextBytes.value > maxTextBytes)
|
const isTextTooLong = computed(() => sendTextBytes.value > maxTextBytes)
|
||||||
const recentMessages = computed(() => [...messages.value].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)))
|
const nodesById = computed(() => {
|
||||||
|
const map = new Map<string, NodeInfo>()
|
||||||
|
for (const node of targets.value) {
|
||||||
|
map.set(node.node_id, node)
|
||||||
|
}
|
||||||
|
return Object.fromEntries(map)
|
||||||
|
})
|
||||||
|
const groupedChatMessages = computed(() => {
|
||||||
|
const groups = new Map<string, TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }>()
|
||||||
|
for (const item of chatMessages.value) {
|
||||||
|
const key = `${item.packet_id ?? ''}\n${item.text ?? ''}`
|
||||||
|
const group = groups.get(key)
|
||||||
|
if (group) {
|
||||||
|
group.mergedCount += 1
|
||||||
|
group.mergedMessages.push(item)
|
||||||
|
} else {
|
||||||
|
groups.set(key, { ...item, mergedCount: 1, mergedMessages: [item] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.values())
|
||||||
|
})
|
||||||
const targetOptions = computed(() => {
|
const targetOptions = computed(() => {
|
||||||
const query = targetQuery.value.trim().toLowerCase()
|
const query = targetQuery.value.trim().toLowerCase()
|
||||||
return targets.value
|
return targets.value
|
||||||
@@ -44,13 +71,30 @@ const targetOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
const canSend = computed(() => {
|
const canSend = computed(() => {
|
||||||
if (!selectedBot.value || sending.value || isTextTooLong.value || !sendForm.value.text.trim()) return false
|
if (!selectedBot.value || sending.value || isTextTooLong.value || !sendForm.value.text.trim()) return false
|
||||||
|
if (!currentChannelID.value) return false
|
||||||
if (sendForm.value.message_type === 'direct' && !sendForm.value.to_node_id) return false
|
if (sendForm.value.message_type === 'direct' && !sendForm.value.to_node_id) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let shouldStickToBottom = true
|
||||||
|
let didInitialScroll = false
|
||||||
|
let restoreScrollHeight: number | null = null
|
||||||
|
let restoreScrollTop = 0
|
||||||
|
let restoreMessageCount = 0
|
||||||
|
let chatRefreshTimer: number | undefined
|
||||||
|
|
||||||
watch(selectedBot, (bot) => {
|
watch(selectedBot, (bot) => {
|
||||||
if (bot) {
|
if (bot) {
|
||||||
sendForm.value.channel_id = bot.default_channel_id
|
sendForm.value.channel_id = bot.default_channel_id
|
||||||
|
resetChatState()
|
||||||
|
loadInitialChatMessages()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(currentChannelID, () => {
|
||||||
|
if (selectedBot.value) {
|
||||||
|
resetChatState()
|
||||||
|
loadInitialChatMessages()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,6 +128,42 @@ function resetEdits() {
|
|||||||
}]))
|
}]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetChatState() {
|
||||||
|
chatMessages.value = []
|
||||||
|
chatHasMore.value = true
|
||||||
|
chatInitialized.value = false
|
||||||
|
didInitialScroll = false
|
||||||
|
restoreScrollHeight = null
|
||||||
|
restoreScrollTop = 0
|
||||||
|
restoreMessageCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChronological(items: TextMessage[]) {
|
||||||
|
return [...items].reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareMessages(a: TextMessage, b: TextMessage) {
|
||||||
|
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
|
||||||
|
return timeDiff !== 0 ? timeDiff : a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]) {
|
||||||
|
const byId = new Map<number, TextMessage>()
|
||||||
|
for (const item of existing) byId.set(item.id, item)
|
||||||
|
for (const item of incoming) byId.set(item.id, item)
|
||||||
|
return Array.from(byId.values()).sort(compareMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearBottom(el: HTMLElement) {
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= bottomThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestoreState() {
|
||||||
|
restoreScrollHeight = null
|
||||||
|
restoreScrollTop = 0
|
||||||
|
restoreMessageCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshBots() {
|
async function refreshBots() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -105,22 +185,44 @@ async function refreshBots() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMessages() {
|
async function loadInitialChatMessages() {
|
||||||
if (!selectedBotId.value) {
|
if (!currentChannelID.value) return
|
||||||
messages.value = []
|
chatLoadingOlder.value = true
|
||||||
return
|
|
||||||
}
|
|
||||||
messageLoading.value = true
|
|
||||||
try {
|
try {
|
||||||
const response = await getBotMessages(selectedBotId.value, messagePageSize, 0)
|
const response = await getTextMessages(chatPageSize, 0, { channelId: currentChannelID.value })
|
||||||
messages.value = response.items
|
chatMessages.value = toChronological(response.items)
|
||||||
|
chatHasMore.value = response.items.length === chatPageSize
|
||||||
|
chatInitialized.value = true
|
||||||
|
await nextTick()
|
||||||
|
const el = chatPanelRef.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
messageLoading.value = false
|
chatLoadingOlder.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOlderChatMessages() {
|
||||||
|
if (chatLoadingOlder.value || !chatHasMore.value || !currentChannelID.value) return
|
||||||
|
chatLoadingOlder.value = true
|
||||||
|
try {
|
||||||
|
const response = await getTextMessages(chatPageSize, chatMessages.value.length, { channelId: currentChannelID.value })
|
||||||
|
chatMessages.value = mergeMessages(chatMessages.value, toChronological(response.items))
|
||||||
|
chatHasMore.value = response.items.length === chatPageSize
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
chatLoadingOlder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLatestChatMessages() {
|
||||||
|
if (!currentChannelID.value) return
|
||||||
|
const response = await getTextMessages(chatPageSize, 0, { channelId: currentChannelID.value })
|
||||||
|
chatMessages.value = mergeMessages(chatMessages.value, toChronological(response.items))
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTargets() {
|
async function refreshTargets() {
|
||||||
try {
|
try {
|
||||||
const response = await getNodeInfo(500, 0)
|
const response = await getNodeInfo(500, 0)
|
||||||
@@ -133,7 +235,6 @@ async function refreshTargets() {
|
|||||||
function selectBot(bot: BotNode) {
|
function selectBot(bot: BotNode) {
|
||||||
selectedBotId.value = bot.id
|
selectedBotId.value = bot.id
|
||||||
sendForm.value.channel_id = bot.default_channel_id
|
sendForm.value.channel_id = bot.default_channel_id
|
||||||
refreshMessages()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBotUpdate(bot: BotNode) {
|
function applyBotUpdate(bot: BotNode) {
|
||||||
@@ -186,7 +287,7 @@ async function removeBot(bot: BotNode) {
|
|||||||
await deleteBotNode(bot.id)
|
await deleteBotNode(bot.id)
|
||||||
if (selectedBotId.value === bot.id) {
|
if (selectedBotId.value === bot.id) {
|
||||||
selectedBotId.value = null
|
selectedBotId.value = null
|
||||||
messages.value = []
|
resetChatState()
|
||||||
}
|
}
|
||||||
await refreshBots()
|
await refreshBots()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -251,7 +352,7 @@ async function sendMessage() {
|
|||||||
const response = await sendBotMessage({
|
const response = await sendBotMessage({
|
||||||
bot_id: selectedBot.value.id,
|
bot_id: selectedBot.value.id,
|
||||||
message_type: sendForm.value.message_type,
|
message_type: sendForm.value.message_type,
|
||||||
channel_id: sendForm.value.channel_id || selectedBot.value.default_channel_id,
|
channel_id: currentChannelID.value,
|
||||||
to_node_id: sendForm.value.message_type === 'direct' ? sendForm.value.to_node_id : undefined,
|
to_node_id: sendForm.value.message_type === 'direct' ? sendForm.value.to_node_id : undefined,
|
||||||
text: sendForm.value.text,
|
text: sendForm.value.text,
|
||||||
})
|
})
|
||||||
@@ -261,7 +362,7 @@ async function sendMessage() {
|
|||||||
message.value = '消息已发送'
|
message.value = '消息已发送'
|
||||||
sendForm.value.text = ''
|
sendForm.value.text = ''
|
||||||
}
|
}
|
||||||
await refreshMessages()
|
await pollLatestChatMessages()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,22 +370,70 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChatScroll() {
|
||||||
|
const el = chatPanelRef.value
|
||||||
|
if (!el || el.scrollTop > topThreshold) return
|
||||||
|
if (restoreScrollHeight == null) {
|
||||||
|
restoreScrollHeight = el.scrollHeight
|
||||||
|
restoreScrollTop = el.scrollTop
|
||||||
|
restoreMessageCount = groupedChatMessages.value.length
|
||||||
|
}
|
||||||
|
loadOlderChatMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
function senderName(item: TextMessage) {
|
||||||
|
if (item.from_id === selectedBot.value?.node_id) return selectedBot.value.long_name
|
||||||
|
const node = nodesById.value[item.from_id]
|
||||||
|
return node?.long_name || node?.short_name || item.from_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwnMessage(item: TextMessage) {
|
||||||
|
return item.from_id === selectedBot.value?.node_id
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(value: string | null) {
|
function formatTime(value: string | null) {
|
||||||
return value ? new Date(value).toLocaleString() : '-'
|
return value ? new Date(value).toLocaleString() : '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusText(status: BotMessageStatus) {
|
onBeforeUpdate(() => {
|
||||||
return status === 'published' ? '已发送' : status === 'failed' ? '失败' : '等待中'
|
const el = chatPanelRef.value
|
||||||
}
|
if (el) shouldStickToBottom = isNearBottom(el)
|
||||||
|
})
|
||||||
|
|
||||||
function targetLabel(item: BotMessage) {
|
onUpdated(() => {
|
||||||
if (item.message_type === 'channel') return '频道广播'
|
const el = chatPanelRef.value
|
||||||
return item.to_node_id ? `私聊 ${item.to_node_id}` : '定向消息'
|
if (!el) return
|
||||||
}
|
if (restoreScrollHeight != null) {
|
||||||
|
if (groupedChatMessages.value.length > restoreMessageCount) {
|
||||||
|
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
|
||||||
|
clearRestoreState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!chatLoadingOlder.value) clearRestoreState()
|
||||||
|
}
|
||||||
|
if (!didInitialScroll || shouldStickToBottom) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
didInitialScroll = true
|
||||||
|
}
|
||||||
|
if (chatInitialized.value && el.scrollHeight <= el.clientHeight + scrollOverflowAllowance) {
|
||||||
|
handleChatScroll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshBots()
|
refreshBots()
|
||||||
refreshTargets()
|
refreshTargets()
|
||||||
|
chatRefreshTimer = window.setInterval(() => {
|
||||||
|
if (selectedBot.value && currentChannelID.value) {
|
||||||
|
pollLatestChatMessages()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (chatRefreshTimer !== undefined) {
|
||||||
|
window.clearInterval(chatRefreshTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -394,18 +543,40 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel send-panel">
|
<section class="panel bot-chat-panel">
|
||||||
<div class="section-title">
|
<div class="bot-chat-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Compose</p>
|
<p class="eyebrow">Channel Chat</p>
|
||||||
<h3>发送消息</h3>
|
<h3>{{ currentChannelID || '未选择频道' }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="admin-button secondary" @click="pollLatestChatMessages" :disabled="chatLoadingOlder">刷新聊天</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="chatPanelRef" class="bot-chat-list" @scroll.passive="handleChatScroll">
|
||||||
|
<div v-if="chatLoadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
|
<div v-else-if="!chatHasMore && chatMessages.length > 0" class="chat-end">没有更多历史消息</div>
|
||||||
|
<div v-if="groupedChatMessages.length === 0" class="empty-state">当前频道暂无聊天消息</div>
|
||||||
|
<div v-for="item in groupedChatMessages" :key="item.id" class="chat-bubble-row" :class="{ own: isOwnMessage(item) }">
|
||||||
|
<div class="chat-bubble">
|
||||||
|
<div class="bubble-meta">
|
||||||
|
<strong>{{ senderName(item) }}</strong>
|
||||||
|
<small>{{ formatTime(item.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="bubble-text">
|
||||||
|
{{ item.text || '[binary]' }}
|
||||||
|
<span v-if="item.mergedCount > 1" class="message-merge-count">x{{ item.mergedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bubble-topic">{{ item.topic }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="segmented-control">
|
</div>
|
||||||
|
|
||||||
|
<div class="bot-chat-composer">
|
||||||
|
<!-- <div class="segmented-control">
|
||||||
<button :class="{ active: sendForm.message_type === 'channel' }" @click="sendForm.message_type = 'channel'">频道广播</button>
|
<button :class="{ active: sendForm.message_type === 'channel' }" @click="sendForm.message_type = 'channel'">频道广播</button>
|
||||||
<button :class="{ active: sendForm.message_type === 'direct' }" @click="sendForm.message_type = 'direct'">定向消息</button>
|
<a class="direct-chat-link" href="/admin/bot/direct">打开私聊窗口</a>
|
||||||
</div>
|
</div> -->
|
||||||
<div class="send-grid">
|
<div class="composer-grid">
|
||||||
<label>频道 ID<input v-model="sendForm.channel_id" /></label>
|
<label>频道 ID<input v-model="sendForm.channel_id" /></label>
|
||||||
<label v-if="sendForm.message_type === 'direct'">搜索目标<input v-model="targetQuery" placeholder="节点名 / !nodeid / node_num" /></label>
|
<label v-if="sendForm.message_type === 'direct'">搜索目标<input v-model="targetQuery" placeholder="节点名 / !nodeid / node_num" /></label>
|
||||||
<label v-if="sendForm.message_type === 'direct'" class="wide">目标节点
|
<label v-if="sendForm.message_type === 'direct'" class="wide">目标节点
|
||||||
@@ -417,41 +588,13 @@ onMounted(() => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="wide">消息内容
|
<label class="wide">消息内容
|
||||||
<textarea v-model="sendForm.text" rows="4" placeholder="输入要发送的文本,真实设备是否接受定向消息取决于固件兼容性。"></textarea>
|
<textarea v-model="sendForm.text" rows="3" placeholder="输入要发送的文本,Enter 换行。"></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="send-actions">
|
<div class="send-actions">
|
||||||
<span class="hint" :class="{ warn: isTextTooLong }">{{ sendTextBytes }} / {{ maxTextBytes }} bytes</span>
|
<span class="hint" :class="{ warn: isTextTooLong }">{{ sendTextBytes }} / {{ maxTextBytes }} bytes</span>
|
||||||
<button class="admin-button send-button" @click="sendMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送消息' }}</button>
|
<button class="admin-button send-button" @click="sendMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送消息' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel history-panel">
|
|
||||||
<div class="history-header">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">History</p>
|
|
||||||
<h3>发送历史</h3>
|
|
||||||
</div>
|
|
||||||
<button class="admin-button secondary" @click="refreshMessages" :disabled="messageLoading">{{ messageLoading ? '刷新中...' : '刷新历史' }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="message-list">
|
|
||||||
<div v-if="recentMessages.length === 0" class="empty-state">暂无发送记录</div>
|
|
||||||
<article v-for="item in recentMessages" :key="item.id" class="message-card" :class="item.status">
|
|
||||||
<div class="message-head">
|
|
||||||
<div>
|
|
||||||
<span class="message-target">{{ targetLabel(item) }}</span>
|
|
||||||
<span class="message-time">{{ formatTime(item.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="status-badge" :class="item.status">{{ statusText(item.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="message-text">{{ item.text }}</p>
|
|
||||||
<div class="message-meta">
|
|
||||||
<span>{{ item.channel_id }}</span>
|
|
||||||
<span>#{{ item.packet_id }}</span>
|
|
||||||
<span>{{ item.encrypted ? 'AES-CTR' : '明文' }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="item.error" class="message-error">{{ item.error }}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -464,7 +607,7 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-bot-page { display: grid; gap: 12px; }
|
.admin-bot-page { display: grid; gap: 12px; }
|
||||||
.bot-hero, .selected-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px; }
|
.bot-hero, .selected-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px; }
|
||||||
.bot-hero-actions, .row-actions, .history-header, .send-actions, .section-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
.bot-hero-actions, .row-actions, .send-actions, .section-title, .bot-chat-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
.hint { color: #64748b; font-size: 13px; }
|
.hint { color: #64748b; font-size: 13px; }
|
||||||
.hint.warn { color: #b91c1c; font-weight: 800; }
|
.hint.warn { color: #b91c1c; font-weight: 800; }
|
||||||
.bot-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
.bot-layout { display: grid; grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||||
@@ -504,29 +647,28 @@ input:focus, select:focus, textarea:focus { outline: 2px solid #bfdbfe; border-c
|
|||||||
.summary-grid small { color: #64748b; font-size: 12px; }
|
.summary-grid small { color: #64748b; font-size: 12px; }
|
||||||
.stat-chip { display: inline-flex; align-items: center; color: #334155; font-size: 13px; font-weight: 800; background: #e2e8f0; }
|
.stat-chip { display: inline-flex; align-items: center; color: #334155; font-size: 13px; font-weight: 800; background: #e2e8f0; }
|
||||||
.stat-chip.ok { color: #166534; background: #dcfce7; }
|
.stat-chip.ok { color: #166534; background: #dcfce7; }
|
||||||
.send-panel, .history-panel { padding: 16px; display: grid; gap: 14px; }
|
.bot-chat-panel { display: grid; grid-template-rows: auto minmax(320px, 1fr) auto; min-height: 680px; overflow: hidden; }
|
||||||
|
.bot-chat-header { padding: 14px 16px; border-bottom: 1px solid #e2e8f0; }
|
||||||
|
.bot-chat-list { min-height: 0; max-height: 520px; overflow: auto; display: flex; flex-direction: column; gap: 10px; padding: 14px; background: linear-gradient(180deg, #f8fafc 0%, #eef4ff 100%); }
|
||||||
|
.chat-loading, .chat-end { align-self: center; border-radius: 999px; padding: 6px 10px; color: #64748b; font-size: 12px; background: #e2e8f0; }
|
||||||
|
.chat-bubble-row { display: flex; justify-content: flex-start; }
|
||||||
|
.chat-bubble-row.own { justify-content: flex-end; }
|
||||||
|
.chat-bubble { max-width: min(680px, 78%); border: 1px solid #e2e8f0; border-radius: 16px 16px 16px 4px; padding: 10px 12px; background: #fff; box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); }
|
||||||
|
.chat-bubble-row.own .chat-bubble { border-color: #bfdbfe; border-radius: 16px 16px 4px 16px; background: #dbeafe; }
|
||||||
|
.bubble-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #334155; font-size: 12px; }
|
||||||
|
.bubble-meta small, .bubble-topic { color: #64748b; }
|
||||||
|
.bubble-text { margin-top: 6px; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.bubble-topic { margin-top: 6px; font-size: 11px; word-break: break-all; }
|
||||||
|
.message-merge-count { display: inline-flex; margin-left: 6px; border-radius: 999px; padding: 1px 6px; color: #1d4ed8; background: #bfdbfe; font-size: 12px; font-weight: 800; }
|
||||||
|
.bot-chat-composer { display: grid; gap: 12px; border-top: 1px solid #e2e8f0; padding: 14px 16px; background: #fff; }
|
||||||
.segmented-control { display: inline-flex; width: fit-content; border: 1px solid #cbd5e1; border-radius: 999px; padding: 3px; background: #f8fafc; }
|
.segmented-control { display: inline-flex; width: fit-content; border: 1px solid #cbd5e1; border-radius: 999px; padding: 3px; background: #f8fafc; }
|
||||||
.segmented-control button { border: 0; border-radius: 999px; padding: 8px 13px; color: #475569; font-weight: 800; background: transparent; }
|
.segmented-control button, .segmented-control .direct-chat-link { border: 0; border-radius: 999px; padding: 8px 13px; color: #475569; font-weight: 800; text-decoration: none; background: transparent; }
|
||||||
.segmented-control button.active { color: #fff; background: #2563eb; }
|
.segmented-control button.active { color: #fff; background: #2563eb; }
|
||||||
.send-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; }
|
.composer-grid { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; }
|
||||||
.send-grid .wide { grid-column: 1 / -1; }
|
.composer-grid .wide { grid-column: 1 / -1; }
|
||||||
.send-button { min-width: 120px; }
|
.send-button { min-width: 120px; }
|
||||||
.admin-button.secondary { color: #334155; background: #e2e8f0; }
|
.admin-button.secondary { color: #334155; background: #e2e8f0; }
|
||||||
.admin-button.danger { background: #dc2626; }
|
.admin-button.danger { background: #dc2626; }
|
||||||
.message-list { display: grid; gap: 10px; max-height: 520px; overflow: auto; padding-right: 4px; }
|
|
||||||
.message-card { border: 1px solid #e2e8f0; border-radius: 14px; padding: 12px; background: #fff; }
|
|
||||||
.message-card.published { border-color: #bbf7d0; }
|
|
||||||
.message-card.failed { border-color: #fecaca; background: #fff7f7; }
|
|
||||||
.message-head { display: flex; justify-content: space-between; gap: 10px; }
|
|
||||||
.message-target { display: block; color: #0f172a; font-weight: 900; }
|
|
||||||
.message-time, .message-meta { color: #64748b; font-size: 12px; }
|
|
||||||
.message-text { margin: 10px 0; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
|
||||||
.message-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.status-badge { border-radius: 999px; padding: 4px 8px; font-size: 12px; font-weight: 900; white-space: nowrap; }
|
|
||||||
.status-badge.published { color: #166534; background: #dcfce7; }
|
|
||||||
.status-badge.failed { color: #991b1b; background: #fee2e2; }
|
|
||||||
.status-badge.pending { color: #92400e; background: #fef3c7; }
|
|
||||||
.message-error { margin: 10px 0 0; border-radius: 10px; padding: 8px 10px; color: #991b1b; background: #fee2e2; }
|
|
||||||
.empty-state { color: #64748b; padding: 16px; border: 1px dashed #cbd5e1; border-radius: 14px; text-align: center; background: #f8fafc; }
|
.empty-state { color: #64748b; padding: 16px; border: 1px dashed #cbd5e1; border-radius: 14px; text-align: center; background: #f8fafc; }
|
||||||
.empty-state.large { min-height: 260px; display: grid; place-items: center; }
|
.empty-state.large { min-height: 260px; display: grid; place-items: center; }
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
@@ -535,7 +677,8 @@ input:focus, select:focus, textarea:focus { outline: 2px solid #bfdbfe; border-c
|
|||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.bot-hero, .selected-summary { align-items: stretch; flex-direction: column; }
|
.bot-hero, .selected-summary { align-items: stretch; flex-direction: column; }
|
||||||
.send-grid, .summary-grid { grid-template-columns: 1fr; }
|
.composer-grid, .summary-grid { grid-template-columns: 1fr; }
|
||||||
.bot-hero-actions { justify-content: flex-start; flex-wrap: wrap; }
|
.bot-hero-actions { justify-content: flex-start; flex-wrap: wrap; }
|
||||||
|
.chat-bubble { max-width: 92%; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
|
||||||
|
import { getBotDirectTextMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
|
||||||
|
import type { BotNode, NodeInfo, TextMessage } from '../types'
|
||||||
|
|
||||||
|
const chatPageSize = 30
|
||||||
|
const maxTextBytes = 200
|
||||||
|
const topThreshold = 8
|
||||||
|
const bottomThreshold = 40
|
||||||
|
|
||||||
|
const bots = ref<BotNode[]>([])
|
||||||
|
const targets = ref<NodeInfo[]>([])
|
||||||
|
const messages = ref<TextMessage[]>([])
|
||||||
|
const selectedBotId = ref<number | null>(null)
|
||||||
|
const selectedTargetId = ref('')
|
||||||
|
const channelId = ref('LongFast')
|
||||||
|
const text = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const sending = ref(false)
|
||||||
|
const loadingOlder = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const initialized = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
let refreshTimer: number | undefined
|
||||||
|
let shouldStickToBottom = true
|
||||||
|
let didInitialScroll = false
|
||||||
|
let restoreScrollHeight: number | null = null
|
||||||
|
let restoreScrollTop = 0
|
||||||
|
let restoreMessageCount = 0
|
||||||
|
|
||||||
|
const selectedBot = computed(() => bots.value.find((item) => item.id === selectedBotId.value) ?? null)
|
||||||
|
const selectedTarget = computed(() => targets.value.find((item) => item.node_id === selectedTargetId.value) ?? null)
|
||||||
|
const directTextBytes = computed(() => new TextEncoder().encode(text.value).length)
|
||||||
|
const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!channelId.value.trim() && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value)
|
||||||
|
const groupedMessages = computed(() => {
|
||||||
|
const groups = new Map<string, TextMessage & { mergedCount: number; mergedMessages: TextMessage[] }>()
|
||||||
|
for (const item of messages.value) {
|
||||||
|
const key = `${item.packet_id ?? ''}\n${item.text ?? ''}`
|
||||||
|
const group = groups.get(key)
|
||||||
|
if (group) {
|
||||||
|
group.mergedCount += 1
|
||||||
|
group.mergedMessages.push(item)
|
||||||
|
} else {
|
||||||
|
groups.set(key, { ...item, mergedCount: 1, mergedMessages: [item] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedBot, (bot) => {
|
||||||
|
if (bot) channelId.value = bot.default_channel_id
|
||||||
|
resetChat()
|
||||||
|
loadInitialMessages()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTargetId, () => {
|
||||||
|
resetChat()
|
||||||
|
loadInitialMessages()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(channelId, () => {
|
||||||
|
resetChat()
|
||||||
|
loadInitialMessages()
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetChat() {
|
||||||
|
messages.value = []
|
||||||
|
hasMore.value = true
|
||||||
|
initialized.value = false
|
||||||
|
didInitialScroll = false
|
||||||
|
restoreScrollHeight = null
|
||||||
|
restoreScrollTop = 0
|
||||||
|
restoreMessageCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChronological(items: TextMessage[]) {
|
||||||
|
return [...items].reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareMessages(a: TextMessage, b: TextMessage) {
|
||||||
|
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
|
||||||
|
return timeDiff !== 0 ? timeDiff : a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]) {
|
||||||
|
const byId = new Map<number, TextMessage>()
|
||||||
|
for (const item of existing) byId.set(item.id, item)
|
||||||
|
for (const item of incoming) byId.set(item.id, item)
|
||||||
|
return Array.from(byId.values()).sort(compareMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNearBottom(el: HTMLElement) {
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= bottomThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestoreState() {
|
||||||
|
restoreScrollHeight = null
|
||||||
|
restoreScrollTop = 0
|
||||||
|
restoreMessageCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLists() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [botResponse, nodeResponse] = await Promise.all([getBotNodes(100, 0), getNodeInfo(500, 0)])
|
||||||
|
bots.value = botResponse.items
|
||||||
|
targets.value = nodeResponse.items
|
||||||
|
if (!selectedBotId.value && bots.value.length > 0) {
|
||||||
|
selectedBotId.value = bots.value[0].id
|
||||||
|
channelId.value = bots.value[0].default_channel_id
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialMessages() {
|
||||||
|
if (!selectedBot.value || !selectedTarget.value) return
|
||||||
|
loadingOlder.value = true
|
||||||
|
try {
|
||||||
|
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, channelId.value)
|
||||||
|
messages.value = toChronological(response.items)
|
||||||
|
hasMore.value = response.items.length === chatPageSize
|
||||||
|
initialized.value = true
|
||||||
|
await nextTick()
|
||||||
|
const el = panelRef.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loadingOlder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderMessages() {
|
||||||
|
if (!selectedBot.value || !selectedTarget.value || loadingOlder.value || !hasMore.value) return
|
||||||
|
loadingOlder.value = true
|
||||||
|
try {
|
||||||
|
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length, channelId.value)
|
||||||
|
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||||
|
hasMore.value = response.items.length === chatPageSize
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
loadingOlder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLatestMessages() {
|
||||||
|
if (!selectedBot.value || !selectedTarget.value) return
|
||||||
|
const response = await getBotDirectTextMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0, channelId.value)
|
||||||
|
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDirectMessage() {
|
||||||
|
if (!selectedBot.value || !selectedTarget.value) return
|
||||||
|
sending.value = true
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
try {
|
||||||
|
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: channelId.value, to_node_id: selectedTarget.value.node_id, text: text.value })
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
} else {
|
||||||
|
text.value = ''
|
||||||
|
notice.value = '消息已发送'
|
||||||
|
}
|
||||||
|
await pollLatestMessages()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const el = panelRef.value
|
||||||
|
if (!el || el.scrollTop > topThreshold) return
|
||||||
|
if (restoreScrollHeight == null) {
|
||||||
|
restoreScrollHeight = el.scrollHeight
|
||||||
|
restoreScrollTop = el.scrollTop
|
||||||
|
restoreMessageCount = groupedMessages.value.length
|
||||||
|
}
|
||||||
|
loadOlderMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwn(item: TextMessage) {
|
||||||
|
return item.from_id === selectedBot.value?.node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function senderName(item: TextMessage) {
|
||||||
|
if (item.from_id === selectedBot.value?.node_id) return selectedBot.value?.long_name || item.from_id
|
||||||
|
if (item.from_id === selectedTarget.value?.node_id) return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.from_id
|
||||||
|
return item.from_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUpdate(() => {
|
||||||
|
const el = panelRef.value
|
||||||
|
if (el) shouldStickToBottom = isNearBottom(el)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUpdated(() => {
|
||||||
|
const el = panelRef.value
|
||||||
|
if (!el) return
|
||||||
|
if (restoreScrollHeight != null) {
|
||||||
|
if (groupedMessages.value.length > restoreMessageCount) {
|
||||||
|
el.scrollTop = el.scrollHeight - restoreScrollHeight + restoreScrollTop
|
||||||
|
clearRestoreState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!loadingOlder.value) clearRestoreState()
|
||||||
|
}
|
||||||
|
if (!didInitialScroll || shouldStickToBottom) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
didInitialScroll = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshLists()
|
||||||
|
refreshTimer = window.setInterval(() => pollLatestMessages(), 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (refreshTimer !== undefined) window.clearInterval(refreshTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel direct-page">
|
||||||
|
<div class="direct-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Direct Bot Chat</p>
|
||||||
|
<h2>机器人私聊</h2>
|
||||||
|
</div>
|
||||||
|
<div class="direct-actions">
|
||||||
|
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a>
|
||||||
|
<button class="admin-button" @click="refreshLists" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="success">{{ notice }}</p>
|
||||||
|
|
||||||
|
<div class="direct-selectors">
|
||||||
|
<label>机器人
|
||||||
|
<select v-model="selectedBotId">
|
||||||
|
<option :value="null">选择机器人</option>
|
||||||
|
<option v-for="bot in bots" :key="bot.id" :value="bot.id">{{ bot.long_name }} · {{ bot.node_id }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>目标节点
|
||||||
|
<select v-model="selectedTargetId">
|
||||||
|
<option value="">选择目标节点</option>
|
||||||
|
<option v-for="node in targets" :key="node.node_id" :value="node.node_id">{{ node.long_name || node.short_name || node.node_id }} · {{ node.node_id }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>频道 ID<input v-model="channelId" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="panelRef" class="direct-chat-list" @scroll.passive="handleScroll">
|
||||||
|
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
|
<div v-else-if="!hasMore && messages.length > 0" class="chat-end">没有更多历史消息</div>
|
||||||
|
<div v-if="groupedMessages.length === 0" class="empty-state">请选择机器人和目标节点,或当前会话暂无消息。</div>
|
||||||
|
<div v-for="item in groupedMessages" :key="item.id" class="chat-bubble-row" :class="{ own: isOwn(item) }">
|
||||||
|
<div class="chat-bubble">
|
||||||
|
<div class="bubble-meta"><strong>{{ senderName(item) }}</strong><small>{{ formatTime(item.created_at) }}</small></div>
|
||||||
|
<div class="bubble-text">{{ item.text || '[binary]' }} <span v-if="item.mergedCount > 1" class="message-merge-count">x{{ item.mergedCount }}</span></div>
|
||||||
|
<div class="bubble-topic">{{ item.topic }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="direct-composer">
|
||||||
|
<textarea v-model="text" rows="3" placeholder="输入私聊消息"></textarea>
|
||||||
|
<div class="send-actions">
|
||||||
|
<span class="hint" :class="{ warn: directTextBytes > maxTextBytes }">{{ directTextBytes }} / {{ maxTextBytes }} bytes</span>
|
||||||
|
<button class="admin-button" @click="sendDirectMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送私聊' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.direct-page { display: grid; gap: 12px; padding: 16px; }
|
||||||
|
.direct-header, .direct-actions, .send-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.direct-selectors { display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
label { display: grid; gap: 5px; color: #334155; font-size: 13px; font-weight: 800; }
|
||||||
|
input, select, textarea { box-sizing: border-box; width: 100%; border: 1px solid #cbd5e1; border-radius: 10px; padding: 9px 11px; color: #0f172a; font: inherit; background: #fff; }
|
||||||
|
.direct-chat-list { min-height: 420px; max-height: 560px; overflow: auto; display: flex; flex-direction: column; gap: 10px; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px; background: linear-gradient(180deg, #f8fafc 0%, #eef4ff 100%); }
|
||||||
|
.chat-loading, .chat-end { align-self: center; border-radius: 999px; padding: 6px 10px; color: #64748b; font-size: 12px; background: #e2e8f0; }
|
||||||
|
.chat-bubble-row { display: flex; justify-content: flex-start; }
|
||||||
|
.chat-bubble-row.own { justify-content: flex-end; }
|
||||||
|
.chat-bubble { max-width: min(680px, 78%); border: 1px solid #e2e8f0; border-radius: 16px 16px 16px 4px; padding: 10px 12px; background: #fff; box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); }
|
||||||
|
.chat-bubble-row.own .chat-bubble { border-color: #bfdbfe; border-radius: 16px 16px 4px 16px; background: #dbeafe; }
|
||||||
|
.bubble-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #334155; font-size: 12px; }
|
||||||
|
.bubble-meta small, .bubble-topic, .hint { color: #64748b; }
|
||||||
|
.hint.warn { color: #b91c1c; font-weight: 800; }
|
||||||
|
.bubble-text { margin-top: 6px; color: #0f172a; line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.bubble-topic { margin-top: 6px; font-size: 11px; word-break: break-all; }
|
||||||
|
.message-merge-count { display: inline-flex; margin-left: 6px; border-radius: 999px; padding: 1px 6px; color: #1d4ed8; background: #bfdbfe; font-size: 12px; font-weight: 800; }
|
||||||
|
.direct-composer { display: grid; gap: 10px; }
|
||||||
|
.admin-button.secondary { color: #334155; text-decoration: none; background: #e2e8f0; }
|
||||||
|
.empty-state { color: #64748b; padding: 16px; border: 1px dashed #cbd5e1; border-radius: 14px; text-align: center; background: #f8fafc; }
|
||||||
|
@media (max-width: 800px) { .direct-selectors { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -143,6 +143,7 @@ export interface TextMessage {
|
|||||||
packet_id: number | null
|
packet_id: number | null
|
||||||
text: string | null
|
text: string | null
|
||||||
topic: string
|
topic: string
|
||||||
|
channel_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
mqtt_remote_host: string | null
|
mqtt_remote_host: string | null
|
||||||
content_json: string
|
content_json: string
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type listOptions struct {
|
|||||||
Limit int
|
Limit int
|
||||||
Offset int
|
Offset int
|
||||||
NodeID string
|
NodeID string
|
||||||
|
ChannelID string
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
Until *time.Time
|
Until *time.Time
|
||||||
MinLat *float64
|
MinLat *float64
|
||||||
@@ -264,6 +265,27 @@ func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error)
|
|||||||
return rows, s.listAppendRows(opts, &rows).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) {
|
func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) {
|
||||||
opts = normalizeListOptions(opts)
|
opts = normalizeListOptions(opts)
|
||||||
var rows []discardDetailsRecord
|
var rows []discardDetailsRecord
|
||||||
@@ -328,6 +350,9 @@ func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB {
|
|||||||
if opts.NodeID != "" {
|
if opts.NodeID != "" {
|
||||||
q = q.Where("from_id = ?", opts.NodeID)
|
q = q.Where("from_id = ?", opts.NodeID)
|
||||||
}
|
}
|
||||||
|
if opts.ChannelID != "" {
|
||||||
|
q = q.Where("channel_id = ?", opts.ChannelID)
|
||||||
|
}
|
||||||
if opts.Since != nil {
|
if opts.Since != nil {
|
||||||
q = q.Where("created_at >= ?", *opts.Since)
|
q = q.Where("created_at >= ?", *opts.Since)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ func parseListOptions(c *gin.Context) (listOptions, bool) {
|
|||||||
if nodeID == "" {
|
if nodeID == "" {
|
||||||
nodeID = c.Query("from")
|
nodeID = c.Query("from")
|
||||||
}
|
}
|
||||||
|
channelID := c.Query("channel_id")
|
||||||
var since, until *time.Time
|
var since, until *time.Time
|
||||||
if value := c.Query("since"); value != "" {
|
if value := c.Query("since"); value != "" {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
@@ -438,7 +439,7 @@ func parseListOptions(c *gin.Context) (listOptions, bool) {
|
|||||||
}
|
}
|
||||||
until = &parsed
|
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) {
|
func parseMapReportListOptions(c *gin.Context) (listOptions, bool) {
|
||||||
@@ -599,7 +600,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, "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 {
|
func discardDetailsDTO(row discardDetailsRecord) gin.H {
|
||||||
|
|||||||
Reference in New Issue
Block a user