修复机器人聊天记录与修改机器人信息
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go build *)",
|
||||||
|
"Bash(go test *)",
|
||||||
|
"Bash(npx --no-install vue-tsc --noEmit)",
|
||||||
|
"Bash(echo \"EXIT=$?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,69 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
|||||||
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
||||||
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
||||||
})
|
})
|
||||||
|
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
|
||||||
|
// 给前端侧边栏渲染会话列表使用。
|
||||||
|
r.GET("/bot/conversations", 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
|
||||||
|
}
|
||||||
|
if _, err := store.GetBotNode(botID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := store.ListBotDirectConversations(botID, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unread, err := store.CountBotDirectUnread(botID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, botDirectConversationDTO(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "unread_total": unread})
|
||||||
|
})
|
||||||
|
// /bot/direct-messages/read 把指定 (bot, peer) 下所有未读 inbound 消息标记为已读。
|
||||||
|
r.POST("/bot/direct-messages/read", func(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
BotID uint64 `json:"bot_id"`
|
||||||
|
PeerNodeNum int64 `json:"peer_node_num"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mark-read request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.BotID == 0 || req.PeerNodeNum == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_id and peer_node_num are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := store.GetBotNode(req.BotID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := store.MarkBotDirectMessagesRead(req.BotID, req.PeerNodeNum)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||||
|
})
|
||||||
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"})
|
||||||
@@ -265,6 +328,20 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
|
|||||||
"created_by": row.CreatedBy,
|
"created_by": row.CreatedBy,
|
||||||
"published_at": row.PublishedAt,
|
"published_at": row.PublishedAt,
|
||||||
"received_at": row.ReceivedAt,
|
"received_at": row.ReceivedAt,
|
||||||
|
"read_at": row.ReadAt,
|
||||||
"created_at": row.CreatedAt,
|
"created_at": row.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func botDirectConversationDTO(row botDirectConversation) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"bot_id": row.BotID,
|
||||||
|
"peer_node_id": row.PeerNodeID,
|
||||||
|
"peer_node_num": row.PeerNodeNum,
|
||||||
|
"last_message_at": row.LastMessageAt,
|
||||||
|
"last_text": row.LastText,
|
||||||
|
"last_direction": row.LastDirection,
|
||||||
|
"unread_count": row.UnreadCount,
|
||||||
|
"total_count": row.TotalCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,6 +124,81 @@ func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, er
|
|||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// botDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。
|
||||||
|
// LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览;
|
||||||
|
// UnreadCount 仅对 inbound 计数(即未读消息数)。
|
||||||
|
type botDirectConversation struct {
|
||||||
|
BotID uint64 `gorm:"column:bot_id"`
|
||||||
|
PeerNodeID string `gorm:"column:peer_node_id"`
|
||||||
|
PeerNodeNum int64 `gorm:"column:peer_node_num"`
|
||||||
|
LastMessageAt time.Time `gorm:"column:last_message_at"`
|
||||||
|
LastText string `gorm:"column:last_text"`
|
||||||
|
LastDirection string `gorm:"column:last_direction"`
|
||||||
|
UnreadCount int64 `gorm:"column:unread_count"`
|
||||||
|
TotalCount int64 `gorm:"column:total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。
|
||||||
|
// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 listOptions。
|
||||||
|
func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]botDirectConversation, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, fmt.Errorf("store is not configured")
|
||||||
|
}
|
||||||
|
if botID == 0 {
|
||||||
|
return nil, fmt.Errorf("bot id is required")
|
||||||
|
}
|
||||||
|
opts = normalizeListOptions(opts)
|
||||||
|
var rows []botDirectConversation
|
||||||
|
// 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去;
|
||||||
|
// 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。
|
||||||
|
// 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。
|
||||||
|
subLast := s.db.Model(&botDirectMessageRecord{}).
|
||||||
|
Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", botDirectMessageDirectionInbound).
|
||||||
|
Where("bot_id = ?", botID).
|
||||||
|
Group("bot_id, peer_node_id, peer_node_num")
|
||||||
|
q := s.db.Table("(?) AS agg", subLast).
|
||||||
|
Select("agg.bot_id AS bot_id, agg.peer_node_id AS peer_node_id, agg.peer_node_num AS peer_node_num, m.created_at AS last_message_at, m.text AS last_text, m.direction AS last_direction, agg.unread_count AS unread_count, agg.total_count AS total_count").
|
||||||
|
Joins("JOIN bot_direct_messages m ON m.id = agg.last_id").
|
||||||
|
Order("m.created_at DESC").
|
||||||
|
Order("m.id DESC").
|
||||||
|
Limit(opts.Limit).
|
||||||
|
Offset(opts.Offset)
|
||||||
|
return rows, q.Scan(&rows).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。
|
||||||
|
func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return 0, fmt.Errorf("store is not configured")
|
||||||
|
}
|
||||||
|
if botID == 0 || peerNodeNum == 0 {
|
||||||
|
return 0, fmt.Errorf("bot id and peer node num are required")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
result := s.db.Model(&botDirectMessageRecord{}).
|
||||||
|
Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, botDirectMessageDirectionInbound).
|
||||||
|
Update("read_at", &now)
|
||||||
|
if result.Error != nil {
|
||||||
|
return 0, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。
|
||||||
|
func (s *store) CountBotDirectUnread(botID uint64) (int64, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return 0, fmt.Errorf("store is not configured")
|
||||||
|
}
|
||||||
|
if botID == 0 {
|
||||||
|
return 0, fmt.Errorf("bot id is required")
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
err := s.db.Model(&botDirectMessageRecord{}).
|
||||||
|
Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, botDirectMessageDirectionInbound).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
|
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
|
||||||
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
|
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
|
||||||
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
|
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
|||||||
id := msg.ID
|
id := msg.ID
|
||||||
botMessageID = &id
|
botMessageID = &id
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
dm := &botDirectMessageRecord{
|
dm := &botDirectMessageRecord{
|
||||||
BotID: bot.ID,
|
BotID: bot.ID,
|
||||||
BotNodeID: bot.NodeID,
|
BotNodeID: bot.NodeID,
|
||||||
@@ -414,6 +415,8 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
|||||||
BotMessageID: botMessageID,
|
BotMessageID: botMessageID,
|
||||||
CreatedBy: createdByPtr,
|
CreatedBy: createdByPtr,
|
||||||
PublishedAt: msg.PublishedAt,
|
PublishedAt: msg.PublishedAt,
|
||||||
|
// 出向消息从产生那一刻起就视为“已读”,未读计数只关心 inbound。
|
||||||
|
ReadAt: &now,
|
||||||
}
|
}
|
||||||
if err := s.store.InsertBotDirectMessage(dm); err != nil {
|
if err := s.store.InsertBotDirectMessage(dm); err != nil {
|
||||||
printJSON(map[string]any{
|
printJSON(map[string]any{
|
||||||
|
|||||||
+18
-6
@@ -80,7 +80,7 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
|||||||
if err := s.ensureBotNodeUnique(0, row.NodeID, row.NodeNum); err != nil {
|
if err := s.ensureBotNodeUnique(0, row.NodeID, row.NodeNum); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil {
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum, row.NodeID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := populateBotNodeKeys(row); err != nil {
|
if err := populateBotNodeKeys(row); err != nil {
|
||||||
@@ -96,7 +96,8 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
|||||||
if id == 0 {
|
if id == 0 {
|
||||||
return nil, fmt.Errorf("bot node id is required")
|
return nil, fmt.Errorf("bot node id is required")
|
||||||
}
|
}
|
||||||
if _, err := s.GetBotNode(id); err != nil {
|
existing, err := s.GetBotNode(id)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row, err := s.normalizedBotNodeRecord(input)
|
row, err := s.normalizedBotNodeRecord(input)
|
||||||
@@ -106,9 +107,14 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
|||||||
if err := s.ensureBotNodeUnique(id, row.NodeID, row.NodeNum); err != nil {
|
if err := s.ensureBotNodeUnique(id, row.NodeID, row.NodeNum); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum); err != nil {
|
// 只有当 node_num 真的发生变化时,才需要校验和 nodeinfo 表的冲突。
|
||||||
|
// 否则机器人自己广播 NodeInfo 回写到 nodeinfo 表后,UpdateBotNode 会把这条
|
||||||
|
// 自己的记录当成外部节点冲突,导致 “already exists or conflicts” 报错。
|
||||||
|
if row.NodeNum != existing.NodeNum {
|
||||||
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(row.NodeNum, row.NodeID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"node_id": row.NodeID,
|
"node_id": row.NodeID,
|
||||||
"node_num": row.NodeNum,
|
"node_num": row.NodeNum,
|
||||||
@@ -317,7 +323,7 @@ func (s *store) generateBotNodeNum() (int64, error) {
|
|||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum); err != nil {
|
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil {
|
||||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -344,9 +350,15 @@ func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64) error {
|
func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error {
|
||||||
var existing nodeInfoRecord
|
var existing nodeInfoRecord
|
||||||
err := s.db.Where("node_num = ?", nodeNum).Take(&existing).Error
|
q := s.db.Where("node_num = ?", nodeNum)
|
||||||
|
if selfNodeID != "" {
|
||||||
|
// 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo;
|
||||||
|
// 把这条自身记录从冲突检测中排除,避免把自己当成外部节点。
|
||||||
|
q = q.Where("node_id <> ?", selfNodeID)
|
||||||
|
}
|
||||||
|
err := q.Take(&existing).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return errBotNodeAlreadyExists
|
return errBotNodeAlreadyExists
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,9 @@ type botDirectMessageRecord struct {
|
|||||||
CreatedBy *string `gorm:"column:created_by"`
|
CreatedBy *string `gorm:"column:created_by"`
|
||||||
PublishedAt *time.Time `gorm:"column:published_at;index"`
|
PublishedAt *time.Time `gorm:"column:published_at;index"`
|
||||||
ReceivedAt *time.Time `gorm:"column:received_at;index"`
|
ReceivedAt *time.Time `gorm:"column:received_at;index"`
|
||||||
|
// ReadAt 仅对 inbound 消息有意义:管理员在前端打开会话视为“已读”,会通过 read API 写入此字段。
|
||||||
|
// 出向消息默认在创建时就设置为已读,避免出现在未读统计里。
|
||||||
|
ReadAt *time.Time `gorm:"column:read_at;index"`
|
||||||
ContentJSON *string `gorm:"column:content_json;type:text"`
|
ContentJSON *string `gorm:"column:content_json;type:text"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_dm_bot_created_at,priority:2"`
|
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_bot_dm_bot_created_at,priority:2"`
|
||||||
}
|
}
|
||||||
@@ -562,6 +565,9 @@ func (s *store) migrate() error {
|
|||||||
if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil {
|
if err := migrateBotNodePSK(tx, migrator, s.driver); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := migrateBotDirectMessages(tx, migrator); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
|
if err := migrateMapTileSourceHash(tx, migrator, s.driver); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -610,6 +616,29 @@ func migrateBotNodePSK(tx *gorm.DB, migrator gorm.Migrator, driver string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateBotDirectMessages(tx *gorm.DB, migrator gorm.Migrator) error {
|
||||||
|
if !migrator.HasTable(&botDirectMessageRecord{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !migrator.HasColumn(&botDirectMessageRecord{}, "ReadAt") {
|
||||||
|
if err := tx.Exec("ALTER TABLE bot_direct_messages ADD COLUMN read_at datetime").Error; err != nil {
|
||||||
|
return fmt.Errorf("migrate bot_direct_messages read_at column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 历史 outbound 消息默认视为已读,避免出现在未读统计里。
|
||||||
|
if err := tx.Exec("UPDATE bot_direct_messages SET read_at = created_at WHERE direction = ? AND read_at IS NULL", botDirectMessageDirectionOutbound).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill bot_direct_messages outbound read_at: %w", err)
|
||||||
|
}
|
||||||
|
if !migrator.HasIndex(&botDirectMessageRecord{}, "idx_bot_direct_messages_read_at") {
|
||||||
|
// HasIndex 用 struct 字段名映射的索引名匹配,column 标签写的 read_at 不一定生成此名,
|
||||||
|
// 所以直接 IF NOT EXISTS 创建(SQLite + MySQL 都支持)。
|
||||||
|
if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_bot_direct_messages_read_at ON bot_direct_messages(read_at)").Error; err != nil {
|
||||||
|
return fmt.Errorf("migrate bot_direct_messages read_at index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
|
func migrateMapTileSourceHash(tx *gorm.DB, migrator gorm.Migrator, driver string) error {
|
||||||
if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") {
|
if !migrator.HasColumn(&mapTileSourceRecord{}, "ProxyEnabled") {
|
||||||
if driver == databaseDriverSQLite {
|
if driver == databaseDriverSQLite {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import type {
|
|||||||
TelemetryRecord,
|
TelemetryRecord,
|
||||||
TextMessage,
|
TextMessage,
|
||||||
BotDirectMessage,
|
BotDirectMessage,
|
||||||
|
BotDirectConversation,
|
||||||
|
BotDirectConversationsResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -386,6 +388,18 @@ export function getBotDirectMessages(botId: number, targetNodeNum: number, limit
|
|||||||
return getJSON<ListResponse<BotDirectMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
|
return getJSON<ListResponse<BotDirectMessage>>(`/api/admin/bot/direct-messages?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBotConversations(botId: number, limit = 100, offset = 0): Promise<BotDirectConversationsResponse> {
|
||||||
|
const params = new URLSearchParams({ bot_id: String(botId), limit: String(limit), offset: String(offset) })
|
||||||
|
return getJSON<BotDirectConversationsResponse>(`/api/admin/bot/conversations?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markBotDirectMessagesRead(botId: number, peerNodeNum: number): Promise<{ updated: number }> {
|
||||||
|
return postJSON<{ updated: number }>('/api/admin/bot/direct-messages/read', { bot_id: botId, peer_node_num: peerNodeNum })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
|
||||||
|
export type { BotDirectConversation } from './types'
|
||||||
|
|
||||||
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,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, ref, watch } from 'vue'
|
||||||
import { getBotDirectMessages, getBotNodes, getNodeInfo, sendBotMessage } from '../api'
|
import { getBotConversations, getBotDirectMessages, getBotNodes, getNodeInfo, markBotDirectMessagesRead, sendBotMessage } from '../api'
|
||||||
import type { BotDirectMessage, BotNode, NodeInfo } from '../types'
|
import type { BotDirectConversation, BotDirectMessage, BotNode, NodeInfo } from '../types'
|
||||||
|
|
||||||
const chatPageSize = 30
|
const chatPageSize = 30
|
||||||
|
const conversationPageSize = 100
|
||||||
const maxTextBytes = 200
|
const maxTextBytes = 200
|
||||||
const topThreshold = 8
|
const topThreshold = 8
|
||||||
const bottomThreshold = 40
|
const bottomThreshold = 40
|
||||||
|
|
||||||
const bots = ref<BotNode[]>([])
|
const bots = ref<BotNode[]>([])
|
||||||
const targets = ref<NodeInfo[]>([])
|
const targets = ref<NodeInfo[]>([])
|
||||||
|
const conversations = ref<BotDirectConversation[]>([])
|
||||||
|
const unreadTotal = ref(0)
|
||||||
const messages = ref<BotDirectMessage[]>([])
|
const messages = ref<BotDirectMessage[]>([])
|
||||||
const selectedBotId = ref<number | null>(null)
|
const selectedBotId = ref<number | null>(null)
|
||||||
const selectedTargetId = ref('')
|
const selectedPeerNum = ref<number | null>(null)
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
@@ -30,11 +33,15 @@ let restoreScrollTop = 0
|
|||||||
let restoreMessageCount = 0
|
let restoreMessageCount = 0
|
||||||
|
|
||||||
const selectedBot = computed(() => bots.value.find((item) => item.id === selectedBotId.value) ?? null)
|
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 selectedConversation = computed(() => conversations.value.find((item) => item.peer_node_num === selectedPeerNum.value) ?? null)
|
||||||
|
const selectedPeerNode = computed(() => {
|
||||||
|
if (selectedPeerNum.value == null) return null
|
||||||
|
return targets.value.find((node) => node.node_num === selectedPeerNum.value) ?? null
|
||||||
|
})
|
||||||
const directTextBytes = computed(() => new TextEncoder().encode(text.value).length)
|
const directTextBytes = computed(() => new TextEncoder().encode(text.value).length)
|
||||||
const canSend = computed(() => !!selectedBot.value && !!selectedTarget.value && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value)
|
const canSend = computed(() => !!selectedBot.value && selectedPeerNum.value != null && !!text.value.trim() && directTextBytes.value <= maxTextBytes && !sending.value)
|
||||||
const groupedMessages = computed(() => {
|
const groupedMessages = computed(() => {
|
||||||
// 同一条 DM 在 inbound/outbound 两侧不会重复(来源是同一个表),但保留按 packet_id+text 合并的能力以容忍重复 publish。
|
// direction + packet_id + text 作为合并键,避免重复 publish 时把 inbound/outbound 误合并。
|
||||||
const groups = new Map<string, BotDirectMessage & { mergedCount: number; mergedMessages: BotDirectMessage[] }>()
|
const groups = new Map<string, BotDirectMessage & { mergedCount: number; mergedMessages: BotDirectMessage[] }>()
|
||||||
for (const item of messages.value) {
|
for (const item of messages.value) {
|
||||||
const key = `${item.direction}\n${item.packet_id ?? ''}\n${item.text ?? ''}`
|
const key = `${item.direction}\n${item.packet_id ?? ''}\n${item.text ?? ''}`
|
||||||
@@ -49,12 +56,15 @@ const groupedMessages = computed(() => {
|
|||||||
return Array.from(groups.values())
|
return Array.from(groups.values())
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedBot, () => {
|
watch(selectedBotId, async () => {
|
||||||
|
selectedPeerNum.value = null
|
||||||
|
conversations.value = []
|
||||||
|
unreadTotal.value = 0
|
||||||
resetChat()
|
resetChat()
|
||||||
loadInitialMessages()
|
await reloadConversations()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTargetId, () => {
|
watch(selectedPeerNum, () => {
|
||||||
resetChat()
|
resetChat()
|
||||||
loadInitialMessages()
|
loadInitialMessages()
|
||||||
})
|
})
|
||||||
@@ -104,6 +114,8 @@ async function refreshLists() {
|
|||||||
targets.value = nodeResponse.items
|
targets.value = nodeResponse.items
|
||||||
if (!selectedBotId.value && bots.value.length > 0) {
|
if (!selectedBotId.value && bots.value.length > 0) {
|
||||||
selectedBotId.value = bots.value[0].id
|
selectedBotId.value = bots.value[0].id
|
||||||
|
} else {
|
||||||
|
await reloadConversations()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
@@ -112,17 +124,64 @@ async function refreshLists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reloadConversations() {
|
||||||
|
if (!selectedBot.value) return
|
||||||
|
try {
|
||||||
|
const response = await getBotConversations(selectedBot.value.id, conversationPageSize, 0)
|
||||||
|
conversations.value = response.items
|
||||||
|
unreadTotal.value = response.unread_total
|
||||||
|
// 第一次进入或目标会话被删除时自动选中第一个会话,避免空白页面。
|
||||||
|
if (selectedPeerNum.value == null && response.items.length > 0) {
|
||||||
|
selectedPeerNum.value = response.items[0].peer_node_num
|
||||||
|
} else if (selectedPeerNum.value != null && !response.items.some((c) => c.peer_node_num === selectedPeerNum.value)) {
|
||||||
|
selectedPeerNum.value = response.items[0]?.peer_node_num ?? null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(conv: BotDirectConversation) {
|
||||||
|
if (selectedPeerNum.value === conv.peer_node_num) return
|
||||||
|
selectedPeerNum.value = conv.peer_node_num
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConversationFromPicker(nodeNum: number) {
|
||||||
|
if (!Number.isFinite(nodeNum) || nodeNum <= 0) return
|
||||||
|
// 如果会话不在侧边栏,先做本地占位再从后端拉一次列表
|
||||||
|
if (!conversations.value.some((c) => c.peer_node_num === nodeNum)) {
|
||||||
|
const peer = targets.value.find((n) => n.node_num === nodeNum)
|
||||||
|
if (peer) {
|
||||||
|
conversations.value = [
|
||||||
|
{
|
||||||
|
bot_id: selectedBot.value?.id ?? 0,
|
||||||
|
peer_node_id: peer.node_id,
|
||||||
|
peer_node_num: peer.node_num,
|
||||||
|
last_message_at: '',
|
||||||
|
last_text: '',
|
||||||
|
last_direction: '',
|
||||||
|
unread_count: 0,
|
||||||
|
total_count: 0,
|
||||||
|
},
|
||||||
|
...conversations.value,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedPeerNum.value = nodeNum
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInitialMessages() {
|
async function loadInitialMessages() {
|
||||||
if (!selectedBot.value || !selectedTarget.value) return
|
if (!selectedBot.value || selectedPeerNum.value == null) return
|
||||||
loadingOlder.value = true
|
loadingOlder.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
|
const response = await getBotDirectMessages(selectedBot.value.id, selectedPeerNum.value, chatPageSize, 0)
|
||||||
messages.value = toChronological(response.items)
|
messages.value = toChronological(response.items)
|
||||||
hasMore.value = response.items.length === chatPageSize
|
hasMore.value = response.items.length === chatPageSize
|
||||||
initialized.value = true
|
initialized.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const el = panelRef.value
|
const el = panelRef.value
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
await markCurrentConversationRead()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err)
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,10 +190,10 @@ async function loadInitialMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOlderMessages() {
|
async function loadOlderMessages() {
|
||||||
if (!selectedBot.value || !selectedTarget.value || loadingOlder.value || !hasMore.value) return
|
if (!selectedBot.value || selectedPeerNum.value == null || loadingOlder.value || !hasMore.value) return
|
||||||
loadingOlder.value = true
|
loadingOlder.value = true
|
||||||
try {
|
try {
|
||||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, messages.value.length)
|
const response = await getBotDirectMessages(selectedBot.value.id, selectedPeerNum.value, chatPageSize, messages.value.length)
|
||||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||||
hasMore.value = response.items.length === chatPageSize
|
hasMore.value = response.items.length === chatPageSize
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,18 +204,49 @@ async function loadOlderMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollLatestMessages() {
|
async function pollLatestMessages() {
|
||||||
if (!selectedBot.value || !selectedTarget.value) return
|
if (!selectedBot.value) return
|
||||||
const response = await getBotDirectMessages(selectedBot.value.id, selectedTarget.value.node_num, chatPageSize, 0)
|
await reloadConversations()
|
||||||
|
if (selectedPeerNum.value == null) return
|
||||||
|
const response = await getBotDirectMessages(selectedBot.value.id, selectedPeerNum.value, chatPageSize, 0)
|
||||||
|
const before = messages.value.length
|
||||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||||
|
if (messages.value.length > before) {
|
||||||
|
await markCurrentConversationRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markCurrentConversationRead() {
|
||||||
|
if (!selectedBot.value || selectedPeerNum.value == null) return
|
||||||
|
const conv = selectedConversation.value
|
||||||
|
if (conv && conv.unread_count === 0) return
|
||||||
|
try {
|
||||||
|
const result = await markBotDirectMessagesRead(selectedBot.value.id, selectedPeerNum.value)
|
||||||
|
if (result.updated > 0) {
|
||||||
|
// 本地立即清零,避免 polling 间隙仍然展示红点。
|
||||||
|
conversations.value = conversations.value.map((c) =>
|
||||||
|
c.peer_node_num === selectedPeerNum.value ? { ...c, unread_count: 0 } : c,
|
||||||
|
)
|
||||||
|
unreadTotal.value = Math.max(0, unreadTotal.value - result.updated)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 标记已读失败只打印不打断聊天体验
|
||||||
|
console.warn('mark read failed', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDirectMessage() {
|
async function sendDirectMessage() {
|
||||||
if (!selectedBot.value || !selectedTarget.value) return
|
if (!selectedBot.value || selectedPeerNum.value == null) return
|
||||||
|
const peer = selectedPeerNode.value
|
||||||
|
const peerNodeId = peer ? peer.node_id : selectedConversation.value?.peer_node_id
|
||||||
|
if (!peerNodeId) {
|
||||||
|
error.value = '找不到目标节点 ID,请等待 NodeInfo 同步后再试'
|
||||||
|
return
|
||||||
|
}
|
||||||
sending.value = true
|
sending.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
notice.value = ''
|
notice.value = ''
|
||||||
try {
|
try {
|
||||||
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: 'PKI', to_node_id: selectedTarget.value.node_id, text: text.value })
|
const response = await sendBotMessage({ bot_id: selectedBot.value.id, message_type: 'direct', channel_id: 'PKI', to_node_id: peerNodeId, text: text.value })
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
error.value = response.error
|
error.value = response.error
|
||||||
} else {
|
} else {
|
||||||
@@ -188,7 +278,8 @@ function isOwn(item: BotDirectMessage) {
|
|||||||
|
|
||||||
function senderName(item: BotDirectMessage) {
|
function senderName(item: BotDirectMessage) {
|
||||||
if (item.direction === 'outbound') return selectedBot.value?.long_name || item.bot_node_id
|
if (item.direction === 'outbound') return selectedBot.value?.long_name || item.bot_node_id
|
||||||
return selectedTarget.value?.long_name || selectedTarget.value?.short_name || item.peer_node_id
|
const peer = targets.value.find((n) => n.node_num === item.peer_node_num)
|
||||||
|
return peer?.long_name || peer?.short_name || item.peer_node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(item: BotDirectMessage) {
|
function statusLabel(item: BotDirectMessage) {
|
||||||
@@ -198,10 +289,39 @@ function statusLabel(item: BotDirectMessage) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conversationTitle(conv: BotDirectConversation) {
|
||||||
|
const peer = targets.value.find((n) => n.node_num === conv.peer_node_num)
|
||||||
|
return peer?.long_name || peer?.short_name || conv.peer_node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(conv: BotDirectConversation) {
|
||||||
|
if (!conv.last_text) return '暂无消息'
|
||||||
|
const prefix = conv.last_direction === 'outbound' ? '我: ' : ''
|
||||||
|
const text = conv.last_text.replace(/\s+/g, ' ').trim()
|
||||||
|
return prefix + (text.length > 32 ? text.slice(0, 32) + '…' : text)
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
return new Date(value).toLocaleString()
|
return new Date(value).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelative(value: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
const ts = Date.parse(value)
|
||||||
|
if (!Number.isFinite(ts)) return ''
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
if (diff < 60_000) return '刚刚'
|
||||||
|
if (diff < 3600_000) return Math.floor(diff / 60_000) + ' 分钟前'
|
||||||
|
if (diff < 86400_000) return Math.floor(diff / 3600_000) + ' 小时前'
|
||||||
|
return new Date(ts).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerNodeOptions = computed(() => {
|
||||||
|
const seen = new Set(conversations.value.map((c) => c.peer_node_num))
|
||||||
|
return targets.value.filter((node) => !seen.has(node.node_num))
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUpdate(() => {
|
onBeforeUpdate(() => {
|
||||||
const el = panelRef.value
|
const el = panelRef.value
|
||||||
if (el) shouldStickToBottom = isNearBottom(el)
|
if (el) shouldStickToBottom = isNearBottom(el)
|
||||||
@@ -239,7 +359,11 @@ onBeforeUnmount(() => {
|
|||||||
<div class="direct-header">
|
<div class="direct-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Direct Bot Chat</p>
|
<p class="eyebrow">Direct Bot Chat</p>
|
||||||
<h2>机器人私聊 <span class="pki-badge" title="使用 X25519 + AES-CCM 与目标节点端到端加密">PKI 加密</span></h2>
|
<h2>
|
||||||
|
机器人私聊
|
||||||
|
<span class="pki-badge" title="使用 X25519 + AES-CCM 与目标节点端到端加密">PKI 加密</span>
|
||||||
|
<span v-if="unreadTotal > 0" class="header-unread-badge">{{ unreadTotal > 99 ? '99+' : unreadTotal }} 未读</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="direct-actions">
|
<div class="direct-actions">
|
||||||
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a>
|
<a class="admin-button secondary" href="/admin/bot">返回频道聊天</a>
|
||||||
@@ -250,27 +374,50 @@ onBeforeUnmount(() => {
|
|||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-if="notice" class="success">{{ notice }}</p>
|
<p v-if="notice" class="success">{{ notice }}</p>
|
||||||
|
|
||||||
<div class="direct-selectors">
|
<div class="direct-bot-picker">
|
||||||
<label>机器人
|
<label>机器人
|
||||||
<select v-model="selectedBotId">
|
<select v-model="selectedBotId">
|
||||||
<option :value="null">选择机器人</option>
|
<option :value="null">选择机器人</option>
|
||||||
<option v-for="bot in bots" :key="bot.id" :value="bot.id">{{ bot.long_name }} · {{ bot.node_id }}</option>
|
<option v-for="bot in bots" :key="bot.id" :value="bot.id">{{ bot.long_name }} · {{ bot.node_id }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>目标节点
|
<label>新建私聊
|
||||||
<select v-model="selectedTargetId">
|
<select :value="''" @change="(event) => { startConversationFromPicker(Number((event.target as HTMLSelectElement).value)); (event.target as HTMLSelectElement).value = '' }">
|
||||||
<option value="">选择目标节点</option>
|
<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>
|
<option v-for="node in peerNodeOptions" :key="node.node_id" :value="node.node_num">{{ node.long_name || node.short_name || node.node_id }} · {{ node.node_id }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="direct-layout">
|
||||||
|
<aside class="conversation-list">
|
||||||
|
<p v-if="conversations.length === 0" class="empty-state">还没有私聊会话。等设备发来消息或在上方“新建私聊”开启。</p>
|
||||||
|
<button
|
||||||
|
v-for="conv in conversations"
|
||||||
|
:key="conv.peer_node_num"
|
||||||
|
class="conversation-item"
|
||||||
|
:class="{ active: conv.peer_node_num === selectedPeerNum }"
|
||||||
|
@click="selectConversation(conv)"
|
||||||
|
>
|
||||||
|
<div class="conversation-row">
|
||||||
|
<span class="conversation-title">{{ conversationTitle(conv) }}</span>
|
||||||
|
<span class="conversation-time">{{ formatRelative(conv.last_message_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-row">
|
||||||
|
<span class="conversation-preview">{{ previewText(conv) }}</span>
|
||||||
|
<span v-if="conv.unread_count > 0" class="conversation-unread">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-meta">{{ conv.peer_node_id }} · 共 {{ conv.total_count }} 条</div>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="direct-main">
|
||||||
<p class="direct-hint">私聊固定走 PKI(channel_id = "PKI"),需要目标节点已上报 NodeInfo 公钥才能加密。</p>
|
<p class="direct-hint">私聊固定走 PKI(channel_id = "PKI"),需要目标节点已上报 NodeInfo 公钥才能加密。</p>
|
||||||
|
|
||||||
<div ref="panelRef" class="direct-chat-list" @scroll.passive="handleScroll">
|
<div ref="panelRef" class="direct-chat-list" @scroll.passive="handleScroll">
|
||||||
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
|
<div v-if="loadingOlder" class="chat-loading">正在加载更早消息...</div>
|
||||||
<div v-else-if="!hasMore && messages.length > 0" class="chat-end">没有更多历史消息</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-if="groupedMessages.length === 0" class="empty-state">{{ selectedPeerNum == null ? '请选择左侧会话或新建私聊。' : '当前会话暂无消息。' }}</div>
|
||||||
<div v-for="item in groupedMessages" :key="item.id" class="chat-bubble-row" :class="{ own: isOwn(item) }">
|
<div v-for="item in groupedMessages" :key="item.id" class="chat-bubble-row" :class="{ own: isOwn(item) }">
|
||||||
<div class="chat-bubble">
|
<div class="chat-bubble">
|
||||||
<div class="bubble-meta"><strong>{{ senderName(item) }}</strong><small>{{ formatTime(item.created_at) }}</small></div>
|
<div class="bubble-meta"><strong>{{ senderName(item) }}</strong><small>{{ formatTime(item.created_at) }}</small></div>
|
||||||
@@ -282,23 +429,38 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="direct-composer">
|
<div class="direct-composer">
|
||||||
<textarea v-model="text" rows="3" placeholder="输入私聊消息"></textarea>
|
<textarea v-model="text" rows="3" :placeholder="selectedPeerNum == null ? '先选择左侧会话再输入' : '输入私聊消息'" :disabled="selectedPeerNum == null"></textarea>
|
||||||
<div class="send-actions">
|
<div class="send-actions">
|
||||||
<span class="hint" :class="{ warn: directTextBytes > maxTextBytes }">{{ directTextBytes }} / {{ maxTextBytes }} bytes</span>
|
<span class="hint" :class="{ warn: directTextBytes > maxTextBytes }">{{ directTextBytes }} / {{ maxTextBytes }} bytes</span>
|
||||||
<button class="admin-button" @click="sendDirectMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送私聊' }}</button>
|
<button class="admin-button" @click="sendDirectMessage" :disabled="!canSend">{{ sending ? '发送中...' : '发送私聊' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.direct-page { display: grid; gap: 12px; padding: 16px; }
|
.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-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(2, minmax(180px, 1fr)); gap: 12px; }
|
.direct-bot-picker { display: grid; grid-template-columns: repeat(2, minmax(200px, 1fr)); gap: 12px; }
|
||||||
.direct-hint { color: #475569; font-size: 12px; margin: 0; }
|
.direct-hint { color: #475569; font-size: 12px; margin: 0 0 8px; }
|
||||||
.pki-badge { display: inline-flex; align-items: center; margin-left: 8px; border-radius: 999px; padding: 2px 10px; color: #1d4ed8; background: #dbeafe; font-size: 12px; font-weight: 700; vertical-align: middle; }
|
.pki-badge { display: inline-flex; align-items: center; margin-left: 8px; border-radius: 999px; padding: 2px 10px; color: #1d4ed8; background: #dbeafe; font-size: 12px; font-weight: 700; vertical-align: middle; }
|
||||||
|
.header-unread-badge { display: inline-flex; align-items: center; margin-left: 8px; border-radius: 999px; padding: 2px 10px; color: #fff; background: #ef4444; font-size: 12px; font-weight: 800; vertical-align: middle; }
|
||||||
label { display: grid; gap: 5px; color: #334155; font-size: 13px; font-weight: 800; }
|
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; }
|
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-layout { display: grid; grid-template-columns: minmax(220px, 280px) 1fr; gap: 14px; align-items: start; }
|
||||||
|
.conversation-list { display: flex; flex-direction: column; gap: 6px; max-height: 620px; overflow: auto; padding: 6px; border: 1px solid #e2e8f0; border-radius: 14px; background: #fff; }
|
||||||
|
.conversation-item { all: unset; cursor: pointer; display: grid; gap: 4px; padding: 10px 12px; border-radius: 10px; border: 1px solid transparent; }
|
||||||
|
.conversation-item:hover { background: #f1f5f9; }
|
||||||
|
.conversation-item.active { background: #dbeafe; border-color: #93c5fd; }
|
||||||
|
.conversation-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.conversation-title { font-weight: 800; color: #0f172a; font-size: 14px; }
|
||||||
|
.conversation-time { color: #64748b; font-size: 11px; white-space: nowrap; }
|
||||||
|
.conversation-preview { flex: 1; color: #475569; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.conversation-unread { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 800; }
|
||||||
|
.conversation-meta { color: #94a3b8; font-size: 11px; }
|
||||||
|
.direct-main { display: grid; gap: 12px; }
|
||||||
.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%); }
|
.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-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 { display: flex; justify-content: flex-start; }
|
||||||
@@ -315,5 +477,9 @@ input, select, textarea { box-sizing: border-box; width: 100%; border: 1px solid
|
|||||||
.direct-composer { display: grid; gap: 10px; }
|
.direct-composer { display: grid; gap: 10px; }
|
||||||
.admin-button.secondary { color: #334155; text-decoration: none; background: #e2e8f0; }
|
.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; }
|
.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; } }
|
@media (max-width: 800px) {
|
||||||
|
.direct-layout { grid-template-columns: 1fr; }
|
||||||
|
.direct-bot-picker { grid-template-columns: 1fr; }
|
||||||
|
.conversation-list { max-height: 320px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -171,9 +171,29 @@ export interface BotDirectMessage {
|
|||||||
created_by: string | null
|
created_by: string | null
|
||||||
published_at: string | null
|
published_at: string | null
|
||||||
received_at: string | null
|
received_at: string | null
|
||||||
|
read_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话摘要:每个 (bot, peer) 一条,给侧边栏使用。
|
||||||
|
export interface BotDirectConversation {
|
||||||
|
bot_id: number
|
||||||
|
peer_node_id: string
|
||||||
|
peer_node_num: number
|
||||||
|
last_message_at: string
|
||||||
|
last_text: string
|
||||||
|
last_direction: 'inbound' | 'outbound' | string
|
||||||
|
unread_count: number
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BotDirectConversationsResponse {
|
||||||
|
items: BotDirectConversation[]
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
unread_total: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PositionRecord {
|
export interface PositionRecord {
|
||||||
id: number
|
id: number
|
||||||
from_id: string
|
from_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user