diff --git a/admin_sign_routes.go b/admin_sign_routes.go new file mode 100644 index 0000000..76b089e --- /dev/null +++ b/admin_sign_routes.go @@ -0,0 +1,114 @@ +package main + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type signRequest struct { + NodeID string `json:"node_id"` + LongName string `json:"long_name"` + ShortName string `json:"short_name"` + SignText string `json:"sign_text"` + SignTime string `json:"sign_time"` +} + +func registerAdminSignRoutes(r gin.IRouter, store *store) { + r.GET("/signs", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListSigns(opts) + if err != nil { + writeListResponse(c, rows, opts, err, signDTO) + return + } + total, err := store.CountSigns(opts) + writeListResponseWithTotal(c, rows, opts, total, err, signDTO) + }) + r.POST("/signs", func(c *gin.Context) { + var req signRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"}) + return + } + signTime, ok := parseSignRequestTime(c, req.SignTime) + if !ok { + return + } + row, err := store.CreateSign(req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime) + writeSignMutationResponse(c, http.StatusCreated, row, err) + }) + r.PUT("/signs/:id", func(c *gin.Context) { + id, ok := parseSignID(c) + if !ok { + return + } + var req signRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"}) + return + } + signTime, ok := parseSignRequestTime(c, req.SignTime) + if !ok { + return + } + row, err := store.UpdateSign(id, req.NodeID, nullableString(req.LongName), nullableString(req.ShortName), req.SignText, signTime) + writeSignMutationResponse(c, http.StatusOK, row, err) + }) + r.DELETE("/signs/:id", func(c *gin.Context) { + id, ok := parseSignID(c) + if !ok { + return + } + err := store.DeleteSign(id) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) +} + +func parseSignID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign id"}) + return 0, false + } + return id, true +} + +func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) { + if value == "" { + return time.Time{}, true + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign_time: use RFC3339"}) + return time.Time{}, false + } + return parsed, true +} + +func writeSignMutationResponse(c *gin.Context, status int, row *signRecord, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(status, gin.H{"item": signDTO(*row)}) +} diff --git a/db.go b/db.go index c2494a2..2f5059c 100644 --- a/db.go +++ b/db.go @@ -153,6 +153,19 @@ func (discardDetailsRecord) TableName() string { return "discard_details" } +type signRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + NodeID string `gorm:"column:node_id;not null;index"` + LongName *string `gorm:"column:long_name"` + ShortName *string `gorm:"column:short_name"` + SignText string `gorm:"column:sign_text;type:text;not null"` + SignTime time.Time `gorm:"column:sign_time;not null;index"` +} + +func (signRecord) TableName() string { + return "signs" +} + type nodeBlockingRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` NodeID string `gorm:"column:node_id;not null;uniqueIndex"` @@ -288,32 +301,32 @@ func (botMessageRecord) TableName() string { // botDirectMessageRecord 专门保存机器人参与的 PKI 私聊(DM)。 // -// - 设计原因:text_message 表只存频道消息;DM 是端到端的,逻辑上属于 “一对会话”,需要按 -// bot+对端聚合渲染,与 text_message 全表浏览的形态不一样。 -// - direction = "outbound" 表示 bot → device;"inbound" 表示 device → bot。 -// - 出向消息在发送时插入 status=pending,发送成功后更新为 published;入向消息默认直接 -// published。两种方向都通过 bot_id/peer_node_num 索引快速回放会话。 +// - 设计原因:text_message 表只存频道消息;DM 是端到端的,逻辑上属于 “一对会话”,需要按 +// bot+对端聚合渲染,与 text_message 全表浏览的形态不一样。 +// - direction = "outbound" 表示 bot → device;"inbound" 表示 device → bot。 +// - 出向消息在发送时插入 status=pending,发送成功后更新为 published;入向消息默认直接 +// published。两种方向都通过 bot_id/peer_node_num 索引快速回放会话。 type botDirectMessageRecord struct { - ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` - BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_dm_bot_peer,priority:1;index:idx_bot_dm_bot_created_at,priority:1"` - BotNodeID string `gorm:"column:bot_node_id;not null;index"` - BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"` - PeerNodeID string `gorm:"column:peer_node_id;not null;index:idx_bot_dm_bot_peer,priority:2"` - PeerNodeNum int64 `gorm:"column:peer_node_num;not null;index"` - Direction string `gorm:"column:direction;not null;index"` - Topic string `gorm:"column:topic;not null"` - PacketID int64 `gorm:"column:packet_id;not null;index"` - Text string `gorm:"column:text;type:text;not null"` - PayloadLen int64 `gorm:"column:payload_len;not null"` - PKIEncrypted bool `gorm:"column:pki_encrypted;not null"` - WantAck bool `gorm:"column:want_ack;not null"` - GatewayID *string `gorm:"column:gateway_id"` - Status string `gorm:"column:status;not null;index"` - Error string `gorm:"column:error;type:text"` - BotMessageID *uint64 `gorm:"column:bot_message_id;index"` - CreatedBy *string `gorm:"column:created_by"` - PublishedAt *time.Time `gorm:"column:published_at;index"` - ReceivedAt *time.Time `gorm:"column:received_at;index"` + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + BotID uint64 `gorm:"column:bot_id;not null;index:idx_bot_dm_bot_peer,priority:1;index:idx_bot_dm_bot_created_at,priority:1"` + BotNodeID string `gorm:"column:bot_node_id;not null;index"` + BotNodeNum int64 `gorm:"column:bot_node_num;not null;index"` + PeerNodeID string `gorm:"column:peer_node_id;not null;index:idx_bot_dm_bot_peer,priority:2"` + PeerNodeNum int64 `gorm:"column:peer_node_num;not null;index"` + Direction string `gorm:"column:direction;not null;index"` + Topic string `gorm:"column:topic;not null"` + PacketID int64 `gorm:"column:packet_id;not null;index"` + Text string `gorm:"column:text;type:text;not null"` + PayloadLen int64 `gorm:"column:payload_len;not null"` + PKIEncrypted bool `gorm:"column:pki_encrypted;not null"` + WantAck bool `gorm:"column:want_ack;not null"` + GatewayID *string `gorm:"column:gateway_id"` + Status string `gorm:"column:status;not null;index"` + Error string `gorm:"column:error;type:text"` + BotMessageID *uint64 `gorm:"column:bot_message_id;index"` + CreatedBy *string `gorm:"column:created_by"` + PublishedAt *time.Time `gorm:"column:published_at;index"` + ReceivedAt *time.Time `gorm:"column:received_at;index"` // ReadAt 仅对 inbound 消息有意义:管理员在前端打开会话视为“已读”,会通过 read API 写入此字段。 // 出向消息默认在创建时就设置为已读,避免出现在未读统计里。 ReadAt *time.Time `gorm:"column:read_at;index"` @@ -528,6 +541,7 @@ func (s *store) migrate() error { {label: "runtime_settings", model: &runtimeSettingRecord{}}, {label: "map_tile_sources", model: &mapTileSourceRecord{}}, {label: "discard_details", model: &discardDetailsRecord{}}, + {label: "signs", model: &signRecord{}}, {label: "node_blocking", model: &nodeBlockingRecord{}}, {label: "ip_blocking", model: &ipBlockingRecord{}}, {label: "forbidden_word_blocking", model: &forbiddenWordBlockingRecord{}}, diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 596fbe3..988eb4d 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -11,6 +11,7 @@ import AdminLogin from './components/AdminLogin.vue' import AdminLoginLogs from './components/AdminLoginLogs.vue' import AdminMapSource from './components/AdminMapSource.vue' import AdminMqttForward from './components/AdminMqttForward.vue' +import AdminSignManagement from './components/AdminSignManagement.vue' import AdminUsers from './components/AdminUsers.vue' import ChatPanel from './components/ChatPanel.vue' import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue' @@ -18,6 +19,7 @@ import HelpPage from './components/HelpPage.vue' import MeshMap from './components/MeshMap.vue' import NodeDetailedPage from './components/NodeDetailedPage.vue' import NodeListPanel from './components/NodeListPanel.vue' +import SignedPage from './components/SignedPage.vue' import { fallbackMapSource, loadEnabledMapSources } from './mapSource' import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, MapViewportPoint, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types' @@ -27,10 +29,12 @@ const isAdminPage = adminPath.startsWith('/admin') const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/' const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/' const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/' +const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/' const detailMatch = currentPath.match(/^\/detailed\/(.+)$/) const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : '' const isDetailedPage = !!detailedNodeId const isHelpPage = currentPath === '/help' +const isSignedPage = currentPath === '/signed' const adminUser = ref(null) const adminChecking = ref(false) @@ -509,7 +513,7 @@ onMounted(() => { return } checkAdminSession() - if (isDetailedPage || isHelpPage) { + if (isDetailedPage || isHelpPage || isSignedPage) { return } loadMapSource() @@ -545,6 +549,7 @@ onBeforeUnmount(() => { MQTT转发 机器人 机器人私聊 + 签到管理 地图图源 帮助编辑 登录日志 @@ -564,6 +569,7 @@ onBeforeUnmount(() => { + +