@@ -0,0 +1,461 @@
< script setup lang = "ts" >
import { computed , onMounted , ref , watch } from 'vue'
import { createBotNode , deleteBotNode , getBotMessages , getBotNodes , getNodeInfo , sendBotMessage , updateBotNode } from '../api'
import type { BotMessage , BotMessageStatus , BotMessageType , BotNode , BotNodePayload , NodeInfo } from '../types'
const botPageSize = 100
const messagePageSize = 100
const maxTextBytes = 200
const bots = ref < BotNode [ ] > ( [ ] )
const messages = ref < BotMessage [ ] > ( [ ] )
const targets = ref < NodeInfo [ ] > ( [ ] )
const selectedBotId = ref < number | null > ( null )
const loading = ref ( false )
const messageLoading = ref ( false )
const saving = ref ( false )
const sending = ref ( false )
const error = ref ( '' )
const message = ref ( '' )
const targetQuery = ref ( '' )
const newBot = ref ( { node _num : '' , long _name : '' , short _name : '' , default _channel _id : 'LongFast' , enabled : true } )
const edits = ref < Record < number , { node_num : string ; long_name : string ; short_name : string ; default_channel_id : string ; topic_prefix : string ; enabled : boolean } > > ( { } )
const sendForm = ref < { message _type : BotMessageType ; channel _id : string ; to _node _id : string ; text : string } > ( { message _type : 'channel' , channel _id : 'LongFast' , to _node _id : '' , text : '' } )
const selectedBot = computed ( ( ) => bots . value . find ( ( bot ) => bot . id === selectedBotId . value ) ? ? null )
const enabledBots = computed ( ( ) => bots . value . filter ( ( bot ) => bot . enabled ) . length )
const sendTextBytes = computed ( ( ) => new TextEncoder ( ) . encode ( sendForm . value . text ) . length )
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 targetOptions = computed ( ( ) => {
const query = targetQuery . value . trim ( ) . toLowerCase ( )
return targets . value
. filter ( ( node ) => node . node _id !== selectedBot . value ? . node _id )
. filter ( ( node ) => {
if ( ! query ) return true
return [ node . node _id , node . long _name , node . short _name , String ( node . node _num ) ]
. filter ( Boolean )
. some ( ( value ) => String ( value ) . toLowerCase ( ) . includes ( query ) )
} )
. slice ( 0 , 80 )
} )
const canSend = computed ( ( ) => {
if ( ! selectedBot . value || sending . value || isTextTooLong . value || ! sendForm . value . text . trim ( ) ) return false
if ( sendForm . value . message _type === 'direct' && ! sendForm . value . to _node _id ) return false
return true
} )
watch ( selectedBot , ( bot ) => {
if ( bot ) {
sendForm . value . channel _id = bot . default _channel _id
}
} )
function botPayload ( form : { node _num : string ; long _name : string ; short _name : string ; default _channel _id : string ; topic _prefix ? : string ; enabled : boolean } ) : BotNodePayload {
const nodeNumText = form . node _num . trim ( )
return {
node _num : nodeNumText ? Number ( nodeNumText ) : null ,
long _name : form . long _name . trim ( ) ,
short _name : form . short _name . trim ( ) ,
default _channel _id : form . default _channel _id . trim ( ) ,
topic _prefix : form . topic _prefix ? . trim ( ) || 'msh/2/e' ,
enabled : form . enabled ,
}
}
function resetEdits ( ) {
edits . value = Object . fromEntries ( bots . value . map ( ( bot ) => [ bot . id , {
node _num : String ( bot . node _num ) ,
long _name : bot . long _name ,
short _name : bot . short _name ,
default _channel _id : bot . default _channel _id ,
topic _prefix : bot . topic _prefix ,
enabled : bot . enabled ,
} ] ) )
}
async function refreshBots ( ) {
loading . value = true
error . value = ''
try {
const response = await getBotNodes ( botPageSize , 0 )
bots . value = response . items
resetEdits ( )
if ( ! selectedBotId . value && bots . value . length > 0 ) {
selectBot ( bots . value [ 0 ] )
}
if ( selectedBotId . value && ! bots . value . some ( ( bot ) => bot . id === selectedBotId . value ) ) {
selectedBotId . value = bots . value [ 0 ] ? . id ? ? null
if ( bots . value [ 0 ] ) selectBot ( bots . value [ 0 ] )
}
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
loading . value = false
}
}
async function refreshMessages ( ) {
if ( ! selectedBotId . value ) {
messages . value = [ ]
return
}
messageLoading . value = true
try {
const response = await getBotMessages ( selectedBotId . value , messagePageSize , 0 )
messages . value = response . items
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
messageLoading . value = false
}
}
async function refreshTargets ( ) {
try {
const response = await getNodeInfo ( 500 , 0 )
targets . value = response . items
} catch {
targets . value = [ ]
}
}
function selectBot ( bot : BotNode ) {
selectedBotId . value = bot . id
sendForm . value . channel _id = bot . default _channel _id
refreshMessages ( )
}
async function createBot ( ) {
saving . value = true
error . value = ''
message . value = ''
try {
await createBotNode ( botPayload ( { ... newBot . value , topic _prefix : 'msh/2/e' } ) )
newBot . value = { node _num : '' , long _name : '' , short _name : '' , default _channel _id : 'LongFast' , enabled : true }
message . value = '机器人已创建'
await refreshBots ( )
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
saving . value = false
}
}
async function saveBot ( bot : BotNode ) {
const edit = edits . value [ bot . id ]
if ( ! edit ) return
saving . value = true
error . value = ''
message . value = ''
try {
await updateBotNode ( bot . id , botPayload ( edit ) )
message . value = '机器人已保存'
await refreshBots ( )
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
saving . value = false
}
}
async function removeBot ( bot : BotNode ) {
if ( ! window . confirm ( ` 确定删除机器人 ${ bot . long _name } ( ${ bot . node _id } ) 吗? ` ) ) return
saving . value = true
error . value = ''
try {
await deleteBotNode ( bot . id )
if ( selectedBotId . value === bot . id ) {
selectedBotId . value = null
messages . value = [ ]
}
await refreshBots ( )
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
saving . value = false
}
}
async function sendMessage ( ) {
if ( ! selectedBot . value ) {
error . value = '请先选择机器人'
return
}
if ( isTextTooLong . value ) {
error . value = ` 消息过长,最多 ${ maxTextBytes } bytes `
return
}
sending . value = true
error . value = ''
message . value = ''
try {
const response = await sendBotMessage ( {
bot _id : selectedBot . value . id ,
message _type : sendForm . value . message _type ,
channel _id : sendForm . value . channel _id || selectedBot . value . default _channel _id ,
to _node _id : sendForm . value . message _type === 'direct' ? sendForm . value . to _node _id : undefined ,
text : sendForm . value . text ,
} )
if ( response . error ) {
error . value = response . error
} else {
message . value = '消息已发送'
sendForm . value . text = ''
}
await refreshMessages ( )
} catch ( err ) {
error . value = err instanceof Error ? err . message : String ( err )
} finally {
sending . value = false
}
}
function formatTime ( value : string | null ) {
return value ? new Date ( value ) . toLocaleString ( ) : '-'
}
function statusText ( status : BotMessageStatus ) {
return status === 'published' ? '已发送' : status === 'failed' ? '失败' : '等待中'
}
function targetLabel ( item : BotMessage ) {
if ( item . message _type === 'channel' ) return '频道广播'
return item . to _node _id ? ` 私聊 ${ item . to _node _id } ` : '定向消息'
}
onMounted ( ( ) => {
refreshBots ( )
refreshTargets ( )
} )
< / script >
< template >
< section class = "admin-bot-page" >
< div class = "panel bot-hero" >
< div >
< p class = "eyebrow" > Meshtastic Bot < / p >
< h2 > 机器人节点 < / h2 >
< p class = "hint" > 当前阶段使用频道 PSK 发送频道消息和定向消息 ; PKI 端到端私聊将在后续实现 。 < / p >
< / div >
< div class = "bot-hero-actions" >
< span class = "stat-chip" > 总数 { { bots . length } } < / span >
< span class = "stat-chip ok" > 启用 { { enabledBots } } < / span >
< button class = "admin-button" @click ="refreshBots" :disabled = "loading" > { { loading ? '刷新中...' : '刷新' } } < / button >
< / div >
< / div >
< p v-if = "error" class="error" > {{ error }} < / p >
< p v-if = "message" class="success" > {{ message }} < / p >
< div class = "bot-layout" >
< aside class = "panel bot-sidebar" >
< div class = "section-title" >
< div >
< p class = "eyebrow" > Create < / p >
< h3 > 新建机器人 < / h3 >
< / div >
< / div >
< div class = "bot-form compact-form" >
< label > 节点号 < small > 留空自动生成 < / small > < input v-model = "newBot.node_num" type="number" placeholder="305419896" / > < / label >
< label > 长名称 < input v-model = "newBot.long_name" placeholder="MQTT Bot" / > < / label >
< label > 短名称 < input v-model = "newBot.short_name" placeholder="BOT" / > < / label >
< label > 默认频道 < input v-model = "newBot.default_channel_id" placeholder="LongFast" / > < / label >
< label class = "inline" > < input v-model = "newBot.enabled" type="checkbox" / > 启用 < / label >
< button class = "admin-button full" @click ="createBot" :disabled = "saving" > 创建机器人 < / button >
< / div >
< div class = "section-title list-title" >
< div >
< p class = "eyebrow" > Nodes < / p >
< h3 > 机器人列表 < / h3 >
< / div >
< / div >
< div v-if = "bots.length === 0" class="empty-state" > 暂无机器人 < / div >
< div class = "bot-list" >
< article v-for = "bot in bots" :key="bot.id" class="bot-card" :class="{ selected: selectedBotId === bot.id, disabled: !bot.enabled }" >
< button class = "bot-select" @click ="selectBot(bot)" >
< span class = "avatar" > { { bot . short _name . slice ( 0 , 2 ) . toUpperCase ( ) } } < / span >
< span class = "bot-main" >
< strong > { { bot . long _name } } < / strong >
< small > { { bot . node _id } } · { { bot . default _channel _id } } < / small >
< / span >
< span class = "state-dot" : class = "{ ok: bot.enabled }" > < / span >
< / button >
< details class = "bot-details" >
< summary > 编辑节点 < / summary >
< div v-if = "edits[bot.id]" class="bot-edit compact-form" >
< label > 节点号 < input v-model = "edits[bot.id].node_num" type="number" / > < / label >
< label > 长名称 < input v-model = "edits[bot.id].long_name" / > < / label >
< label > 短名称 < input v-model = "edits[bot.id].short_name" / > < / label >
< label > 默认频道 < input v-model = "edits[bot.id].default_channel_id" / > < / label >
< label > Topic 前缀 < input v-model = "edits[bot.id].topic_prefix" / > < / label >
< label class = "inline" > < input v-model = "edits[bot.id].enabled" type="checkbox" / > 启用 < / label >
< div class = "row-actions" >
< button class = "admin-button" @click ="saveBot(bot)" :disabled = "saving" > 保存 < / button >
< button class = "admin-button danger" @click ="removeBot(bot)" :disabled = "saving" > 删除 < / button >
< / div >
< / div >
< / details >
< / article >
< / div >
< / aside >
< main class = "bot-main-panel" >
< template v-if = "selectedBot" >
< section class = "panel selected-summary" >
< div >
< p class = "eyebrow" > Selected Bot < / p >
< h2 > { { selectedBot . long _name } } < small > { { selectedBot . short _name } } < / small > < / h2 >
< / div >
< div class = "summary-grid" >
< span > < strong > { { selectedBot . node _id } } < / strong > < small > Node ID < / small > < / span >
< span > < strong > { { selectedBot . node _num } } < / strong > < small > Node Num < / small > < / span >
< span > < strong > { { selectedBot . default _channel _id } } < / strong > < small > 默认频道 < / small > < / span >
< span > < strong > { { selectedBot . enabled ? '启用' : '停用' } } < / strong > < small > 状态 < / small > < / span >
< / div >
< / section >
< section class = "panel send-panel" >
< div class = "section-title" >
< div >
< p class = "eyebrow" > Compose < / p >
< h3 > 发送消息 < / h3 >
< / div >
< / div >
< div class = "segmented-control" >
< 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 >
< / div >
< div class = "send-grid" >
< 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'" class="wide" > 目标节点
< select v-model = "sendForm.to_node_id" >
< option value = "" > 选择目标节点 < / option >
< option v-for = "node in targetOptions" :key="node.node_id" :value="node.node_id" >
{{ node.long_name | | node.short_name | | node.node_id }} · {{ node.node_id }} · {{ node.node_num }}
< / option >
< / select >
< / label >
< label class = "wide" > 消息内容
< textarea v-model = "sendForm.text" rows="4" placeholder="输入要发送的文本,真实设备是否接受定向消息取决于固件兼容性。" > < / textarea >
< / label >
< / div >
< div class = "send-actions" >
< span class = "hint" : class = "{ warn: isTextTooLong }" > { { sendTextBytes } } / { { maxTextBytes } } bytes < / span >
< button class = "admin-button send-button" @click ="sendMessage" :disabled = "!canSend" > { { sending ? '发送中...' : '发送消息' } } < / button >
< / 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 >
< / section >
< / template >
< div v-else class = "panel empty-state large" > 请选择或创建一个机器人 。 < / div >
< / main >
< / div >
< / section >
< / template >
< style scoped >
. admin - bot - page { display : grid ; gap : 12 px ; }
. bot - hero , . selected - summary { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 16 px ; }
. bot - hero - actions , . row - actions , . history - header , . send - actions , . section - title { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; }
. hint { color : # 64748 b ; font - size : 13 px ; }
. hint . warn { color : # b91c1c ; font - weight : 800 ; }
. bot - layout { display : grid ; grid - template - columns : minmax ( 300 px , 380 px ) minmax ( 0 , 1 fr ) ; gap : 12 px ; align - items : start ; }
. bot - sidebar , . bot - main - panel { display : grid ; gap : 12 px ; }
. bot - sidebar { padding : 14 px ; }
. compact - form { display : grid ; gap : 10 px ; }
. bot - form { border - bottom : 1 px solid # e2e8f0 ; padding - bottom : 14 px ; }
. list - title { margin - top : 2 px ; }
label { display : grid ; gap : 5 px ; color : # 334155 ; font - size : 13 px ; font - weight : 800 ; }
label small { color : # 64748 b ; font - weight : 600 ; }
label . inline { display : flex ; align - items : center ; gap : 8 px ; }
input , select , textarea { box - sizing : border - box ; width : 100 % ; border : 1 px solid # cbd5e1 ; border - radius : 10 px ; padding : 9 px 11 px ; color : # 0 f172a ; font : inherit ; background : # fff ; }
textarea { resize : vertical ; line - height : 1.45 ; }
input : focus , select : focus , textarea : focus { outline : 2 px solid # bfdbfe ; border - color : # 2563 eb ; }
. full { width : 100 % ; }
. bot - list { display : grid ; gap : 10 px ; }
. bot - card { border : 1 px solid # e2e8f0 ; border - radius : 14 px ; padding : 10 px ; background : # f8fafc ; transition : border - color .15 s , box - shadow .15 s , background .15 s ; }
. bot - card . selected { border - color : # 2563 eb ; background : # eff6ff ; box - shadow : 0 0 0 2 px rgba ( 37 , 99 , 235 , 0.12 ) ; }
. bot - card . disabled { opacity : 0.72 ; }
. bot - select { display : grid ; grid - template - columns : 42 px 1 fr auto ; align - items : center ; gap : 10 px ; width : 100 % ; border : 0 ; padding : 0 ; color : inherit ; text - align : left ; background : transparent ; }
. avatar { display : grid ; place - items : center ; width : 42 px ; height : 42 px ; border - radius : 12 px ; color : # 1 d4ed8 ; font - weight : 900 ; background : # dbeafe ; }
. bot - main { display : grid ; gap : 2 px ; min - width : 0 ; }
. bot - main strong , . bot - main small { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. bot - main small { color : # 64748 b ; }
. state - dot { width : 10 px ; height : 10 px ; border - radius : 999 px ; background : # cbd5e1 ; }
. state - dot . ok { background : # 22 c55e ; }
. bot - details { margin - top : 10 px ; }
. bot - details summary { color : # 2563 eb ; font - size : 13 px ; font - weight : 800 ; cursor : pointer ; }
. bot - edit { margin - top : 10 px ; }
. selected - summary small { color : # 64748 b ; font - size : 14 px ; }
. summary - grid { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 110 px , 1 fr ) ) ; gap : 8 px ; min - width : min ( 620 px , 100 % ) ; }
. summary - grid span , . stat - chip { display : grid ; gap : 3 px ; border - radius : 12 px ; padding : 10 px 12 px ; background : # f8fafc ; }
. summary - grid strong { color : # 0 f172a ; }
. summary - grid small { color : # 64748 b ; font - size : 12 px ; }
. stat - chip { display : inline - flex ; align - items : center ; color : # 334155 ; font - size : 13 px ; font - weight : 800 ; background : # e2e8f0 ; }
. stat - chip . ok { color : # 166534 ; background : # dcfce7 ; }
. send - panel , . history - panel { padding : 16 px ; display : grid ; gap : 14 px ; }
. segmented - control { display : inline - flex ; width : fit - content ; border : 1 px solid # cbd5e1 ; border - radius : 999 px ; padding : 3 px ; background : # f8fafc ; }
. segmented - control button { border : 0 ; border - radius : 999 px ; padding : 8 px 13 px ; color : # 475569 ; font - weight : 800 ; background : transparent ; }
. segmented - control button . active { color : # fff ; background : # 2563 eb ; }
. send - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 220 px , 1 fr ) ) ; gap : 12 px ; }
. send - grid . wide { grid - column : 1 / - 1 ; }
. send - button { min - width : 120 px ; }
. admin - button . secondary { color : # 334155 ; background : # e2e8f0 ; }
. admin - button . danger { background : # dc2626 ; }
. message - list { display : grid ; gap : 10 px ; max - height : 520 px ; overflow : auto ; padding - right : 4 px ; }
. message - card { border : 1 px solid # e2e8f0 ; border - radius : 14 px ; padding : 12 px ; 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 : 10 px ; }
. message - target { display : block ; color : # 0 f172a ; font - weight : 900 ; }
. message - time , . message - meta { color : # 64748 b ; font - size : 12 px ; }
. message - text { margin : 10 px 0 ; color : # 0 f172a ; line - height : 1.45 ; white - space : pre - wrap ; word - break : break - word ; }
. message - meta { display : flex ; flex - wrap : wrap ; gap : 8 px ; }
. status - badge { border - radius : 999 px ; padding : 4 px 8 px ; font - size : 12 px ; font - weight : 900 ; white - space : nowrap ; }
. status - badge . published { color : # 166534 ; background : # dcfce7 ; }
. status - badge . failed { color : # 991 b1b ; background : # fee2e2 ; }
. status - badge . pending { color : # 92400 e ; background : # fef3c7 ; }
. message - error { margin : 10 px 0 0 ; border - radius : 10 px ; padding : 8 px 10 px ; color : # 991 b1b ; background : # fee2e2 ; }
. empty - state { color : # 64748 b ; padding : 16 px ; border : 1 px dashed # cbd5e1 ; border - radius : 14 px ; text - align : center ; background : # f8fafc ; }
. empty - state . large { min - height : 260 px ; display : grid ; place - items : center ; }
@ media ( max - width : 1100 px ) {
. bot - layout { grid - template - columns : 1 fr ; }
. summary - grid { grid - template - columns : repeat ( 2 , minmax ( 120 px , 1 fr ) ) ; }
}
@ media ( max - width : 700 px ) {
. bot - hero , . selected - summary { align - items : stretch ; flex - direction : column ; }
. send - grid , . summary - grid { grid - template - columns : 1 fr ; }
. bot - hero - actions { justify - content : flex - start ; flex - wrap : wrap ; }
}
< / style >