From 191651fce99f4ab1f6d68d88c4c8c7c4315a3eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=96=87=E5=B3=B0?= Date: Wed, 3 Jun 2026 19:59:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9C=B0=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- meshmap_frontend/README.md | 47 +- meshmap_frontend/package-lock.json | 50 +- meshmap_frontend/package.json | 2 + meshmap_frontend/src/App.vue | 160 ++++--- meshmap_frontend/src/api.ts | 12 +- meshmap_frontend/src/components/ChatPanel.vue | 51 ++ meshmap_frontend/src/components/MeshMap.vue | 152 ++++++ .../src/components/NodeDetailPanel.vue | 68 +++ .../src/components/NodeListPanel.vue | 83 ++++ meshmap_frontend/src/style.css | 438 +++++++++++++++--- meshmap_frontend/src/types.ts | 18 + store_query.go | 17 +- web.go | 21 +- 13 files changed, 946 insertions(+), 173 deletions(-) create mode 100644 meshmap_frontend/src/components/ChatPanel.vue create mode 100644 meshmap_frontend/src/components/MeshMap.vue create mode 100644 meshmap_frontend/src/components/NodeDetailPanel.vue create mode 100644 meshmap_frontend/src/components/NodeListPanel.vue diff --git a/meshmap_frontend/README.md b/meshmap_frontend/README.md index 33895ab..6b024e1 100644 --- a/meshmap_frontend/README.md +++ b/meshmap_frontend/README.md @@ -1,5 +1,46 @@ -# Vue 3 + TypeScript + Vite +# MeshMap Frontend -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 5efaade..7c8df1b 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -12,14 +12,14 @@ export function getHealth(): Promise { return getJSON('/api/health') } -export function getNodes(): Promise> { - return getJSON>('/api/nodes?limit=100') +export function getNodes(limit = 500, offset = 0): Promise> { + return getJSON>(`/api/nodes?limit=${limit}&offset=${offset}`) } -export function getTextMessages(): Promise> { - return getJSON>('/api/text-messages?limit=20') +export function getTextMessages(limit = 100): Promise> { + return getJSON>(`/api/text-messages?limit=${limit}`) } -export function getPositions(): Promise> { - return getJSON>('/api/positions?limit=200') +export function getPositions(limit = 500): Promise> { + return getJSON>(`/api/positions?limit=${limit}`) } diff --git a/meshmap_frontend/src/components/ChatPanel.vue b/meshmap_frontend/src/components/ChatPanel.vue new file mode 100644 index 0000000..5a67ea1 --- /dev/null +++ b/meshmap_frontend/src/components/ChatPanel.vue @@ -0,0 +1,51 @@ + + + diff --git a/meshmap_frontend/src/components/MeshMap.vue b/meshmap_frontend/src/components/MeshMap.vue new file mode 100644 index 0000000..368f65d --- /dev/null +++ b/meshmap_frontend/src/components/MeshMap.vue @@ -0,0 +1,152 @@ + + + diff --git a/meshmap_frontend/src/components/NodeDetailPanel.vue b/meshmap_frontend/src/components/NodeDetailPanel.vue new file mode 100644 index 0000000..4aad958 --- /dev/null +++ b/meshmap_frontend/src/components/NodeDetailPanel.vue @@ -0,0 +1,68 @@ + + + diff --git a/meshmap_frontend/src/components/NodeListPanel.vue b/meshmap_frontend/src/components/NodeListPanel.vue new file mode 100644 index 0000000..73854ed --- /dev/null +++ b/meshmap_frontend/src/components/NodeListPanel.vue @@ -0,0 +1,83 @@ + + + diff --git a/meshmap_frontend/src/style.css b/meshmap_frontend/src/style.css index 63766f5..be81a34 100644 --- a/meshmap_frontend/src/style.css +++ b/meshmap_frontend/src/style.css @@ -1,7 +1,7 @@ :root { font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #172033; - background: #f3f6fb; + background: #edf2f7; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -13,36 +13,44 @@ body { } button { - border: 0; - border-radius: 10px; - padding: 10px 18px; - background: #2563eb; - color: white; + font: inherit; +} + +button:not(:disabled) { cursor: pointer; - font-weight: 600; } -button:disabled { - opacity: 0.6; - cursor: wait; +.app-shell { + min-height: 100vh; + box-sizing: border-box; + display: grid; + grid-template-rows: auto auto 1fr auto; + gap: 12px; + padding: 14px; } -.page { - width: min(1200px, calc(100vw - 32px)); - margin: 0 auto; - padding: 32px 0; -} - -.header { +.topbar { display: flex; - justify-content: space-between; align-items: center; - gap: 24px; - margin-bottom: 24px; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border: 1px solid #dbe4ef; + border-radius: 16px; + background: #fff; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); +} + +.topbar-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; } .eyebrow { - margin: 0 0 6px; + margin: 0 0 5px; color: #64748b; text-transform: uppercase; letter-spacing: 0.12em; @@ -51,113 +59,405 @@ button:disabled { } h1, -h2 { +h2, +h3 { margin: 0; } h1 { - font-size: 34px; + font-size: 32px; + line-height: 1; } h2 { font-size: 18px; - margin-bottom: 16px; } -.status, -.error, -.card { - border: 1px solid #e2e8f0; - background: white; - border-radius: 16px; - box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); +h3 { + font-size: 14px; + color: #475569; } -.status { - display: flex; - gap: 16px; +.topbar button { + border: 0; + border-radius: 10px; + padding: 9px 16px; + background: #2563eb; + color: white; + font-weight: 700; +} + +.topbar button:disabled { + opacity: 0.6; +} + +.status-pill, +.counter, +.badge { + display: inline-flex; align-items: center; - padding: 14px 18px; - margin-bottom: 16px; + border-radius: 999px; + padding: 6px 10px; + font-size: 13px; + font-weight: 700; color: #92400e; background: #fffbeb; } -.status.ok { +.status-pill.ok { color: #166534; - background: #f0fdf4; + background: #dcfce7; +} + +.counter, +.badge { + color: #334155; + background: #e2e8f0; } .error { - padding: 14px 18px; - margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid #fecaca; + border-radius: 14px; color: #b91c1c; background: #fef2f2; } -.grid { +.workspace { + min-height: 560px; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 12px; } -.card { - padding: 18px; +.panel { + position: relative; overflow: hidden; + border: 1px solid #dbe4ef; + border-radius: 16px; + background: #fff; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06); } -.card.wide { - grid-column: 1 / -1; +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid #e2e8f0; } -table { +.chat-panel { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 560px; + overflow-y: auto; +} + +.chat-panel .panel-header { + position: sticky; + z-index: 10; + top: 0; + background: #fff; +} + +.chat-item { + display: grid; + gap: 6px; + width: 100%; + border: 0; + border-bottom: 1px solid #e2e8f0; + padding: 13px 16px; + text-align: left; + color: inherit; + background: transparent; +} + +.chat-item:hover, +.chat-item.selected { + background: #eff6ff; +} + +.chat-meta { + display: flex; + justify-content: space-between; + gap: 12px; + color: #334155; +} + +.chat-meta small, +.chat-host, +.muted { + color: #64748b; +} + +.chat-text { + color: #0f172a; + line-height: 1.35; +} + +.map-panel { + min-width: 0; +} + +.map-header { + position: absolute; + z-index: 500; + top: 12px; + left: 12px; + right: 12px; + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); +} + +.map-container { + height: 100%; + min-height: 560px; +} + +.map-empty { + position: absolute; + z-index: 450; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + padding: 10px 14px; + border-radius: 999px; + color: #475569; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.12); +} + +.node-marker { + display: flex !important; + align-items: center; + justify-content: center; + min-width: 34px !important; + width: auto !important; + height: 22px !important; + box-sizing: border-box; + padding: 0; + border: 0; + border-radius: 999px; + color: white; + background: transparent; + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.25); + font-size: 10px; + font-weight: 800; + line-height: 20px; + text-align: center; + white-space: nowrap; +} + +.node-marker span { + display: flex; + align-items: center; + justify-content: center; + min-width: 34px; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 0 4px; + border: 1px solid white; + border-radius: inherit; + background: var(--node-color, #2563eb); + line-height: 20px; + text-align: center; +} + +.node-marker.selected { + box-shadow: 0 6px 18px rgba(220, 38, 38, 0.45); + transform: scale(1.12); +} + +.node-marker.selected span { + background: #dc2626; +} + +.node-detail-panel { + min-height: 180px; +} + +.node-list-panel { + min-height: 240px; +} + +.node-table-wrap { + overflow-x: auto; +} + +.node-table { width: 100%; border-collapse: collapse; font-size: 14px; } -th, -td { +.node-table th, +.node-table td { + padding: 10px 12px; border-bottom: 1px solid #e2e8f0; - padding: 10px 8px; text-align: left; + white-space: nowrap; } -th { +.node-table th { + color: #64748b; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.node-row { + cursor: pointer; +} + +.node-row:hover, +.node-row.selected { + background: #eff6ff; +} + +.pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 12px 16px; + border-top: 1px solid #e2e8f0; + color: #475569; +} + +.pagination button { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 8px 12px; + background: #fff; + color: #0f172a; +} + +.pagination button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.node-detail-popup .leaflet-popup-content { + width: 300px !important; + max-width: 300px !important; +} + +.node-popup { + width: 100%; + line-height: 1.35; + white-space: normal; +} + +.node-popup strong { + display: block; + margin-bottom: 8px; + color: #0f172a; + font-size: 15px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.node-popup dl { + display: grid; + gap: 6px; + margin: 0; +} + +.node-popup dl div { + display: block; +} + +.node-popup dt { + color: #64748b; + font-size: 11px; + line-height: 1.35; + margin-bottom: 2px; +} + +.node-popup dd { + margin: 0; + color: #0f172a; + font-weight: 700; + line-height: 1.35; + white-space: normal; + overflow-wrap: anywhere; +} + +.detail-grid { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 16px; + padding: 16px; +} + +dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +dt { color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; } -.list { - display: flex; - flex-direction: column; - gap: 12px; - list-style: none; - margin: 0; - padding: 0; +dd { + margin: 3px 0 0; + color: #0f172a; + font-weight: 700; + overflow-wrap: anywhere; } -.list li { - display: grid; - gap: 4px; - padding-bottom: 12px; - border-bottom: 1px solid #e2e8f0; +.detail-side ul { + margin: 10px 0 0; + padding-left: 18px; } -.list small { +.detail-side li { + margin-bottom: 6px; + color: #334155; +} + +.empty { + padding: 22px 16px; color: #64748b; } -@media (max-width: 800px) { - .header, - .status { +.leaflet-container { + font: inherit; +} + +@media (max-width: 920px) { + .app-shell { + padding: 10px; + } + + .topbar, + .topbar-actions { align-items: flex-start; flex-direction: column; } - .grid { + .workspace, + .detail-grid { grid-template-columns: 1fr; } + + .pagination { + align-items: stretch; + flex-direction: column; + } + + .map-container { + min-height: 360px; + } } diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 1677534..5a1f619 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -2,6 +2,7 @@ export interface ListResponse { items: T[] limit: number offset: number + total?: number } export interface HealthStatus { @@ -17,9 +18,12 @@ export interface NodeInfoMap { short_name: string | null hw_model: string | null role: string | null + firmware_version: string | null latitude: number | null longitude: number | null altitude: number | null + position_precision: number | null + num_online_local_nodes: number | null updated_at: string content_json: string } @@ -45,3 +49,17 @@ export interface PositionRecord { created_at: string content_json: string } + +export interface MapNode { + node_id: string + label: string + latitude: number + longitude: number + altitude: number | null + source: 'node' | 'position' + updated_at: string + node: NodeInfoMap | null + latest_position: PositionRecord | null +} + +export type NodeInfoById = Record diff --git a/store_query.go b/store_query.go index 5655fb3..649633b 100644 --- a/store_query.go +++ b/store_query.go @@ -38,7 +38,20 @@ func normalizeListOptions(opts listOptions) listOptions { func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) { opts = normalizeListOptions(opts) var rows []nodeInfoMapRecord - q := s.db.Order("updated_at DESC").Limit(opts.Limit).Offset(opts.Offset) + q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts). + Order("updated_at DESC"). + Limit(opts.Limit). + Offset(opts.Offset) + return rows, q.Find(&rows).Error +} + +func (s *store) CountNodes(opts listOptions) (int64, error) { + var total int64 + q := applyNodeFilters(s.db.Model(&nodeInfoMapRecord{}), opts) + return total, q.Count(&total).Error +} + +func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB { if opts.NodeID != "" { q = q.Where("node_id = ?", opts.NodeID) } @@ -48,7 +61,7 @@ func (s *store) ListNodes(opts listOptions) ([]nodeInfoMapRecord, error) { if opts.Until != nil { q = q.Where("updated_at <= ?", *opts.Until) } - return rows, q.Find(&rows).Error + return q } func (s *store) GetNode(nodeID string) (*nodeInfoMapRecord, error) { diff --git a/web.go b/web.go index 5208e21..56d087b 100644 --- a/web.go +++ b/web.go @@ -47,7 +47,12 @@ func registerAPIRoutes(r gin.IRouter, store *store) { return } rows, err := store.ListNodes(opts) - writeListResponse(c, rows, opts, err, nodeDTO) + if err != nil { + writeListResponse(c, rows, opts, err, nodeDTO) + return + } + total, err := store.CountNodes(opts) + writeListResponseWithTotal(c, rows, opts, total, err, nodeDTO) }) r.GET("/nodes/:id", func(c *gin.Context) { row, err := store.GetNode(c.Param("id")) @@ -191,8 +196,20 @@ func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err er c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset}) } +func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) { + 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, convert(row)) + } + c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total}) +} + func nodeDTO(row nodeInfoMapRecord) gin.H { - return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "latest_type": row.LatestType, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON} + return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "latest_type": row.LatestType, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON} } func textMessageDTO(row textMessageRecord) gin.H {