From ea96b6909af6a1a33eb47620a9be15e6cdbde632 Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 22:15:16 +0800 Subject: [PATCH] feat(platform): paginate realtime locations --- .../api/internal/platform/mysql_queries.go | 28 ++++++++++ .../api/internal/platform/production_store.go | 25 +++------ .../internal/platform/query_builders_test.go | 19 +++++++ .../apps/web/src/pages/Realtime.tsx | 51 ++++++++++++++++--- 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 47a7ac11..77728ec0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -104,6 +104,34 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { } } +func buildRealtimeLocationSQL(query url.Values) SQLQuery { + limit := parsePositive(query.Get("limit"), 20) + offset := parsePositive(query.Get("offset"), 0) + args := []any{} + where := []string{"1 = 1"} + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + where = append(where, "l.protocol = ?") + args = append(args, protocol) + } + if vin := strings.TrimSpace(query.Get("vin")); vin != "" { + where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ?)") + like := "%" + vin + "%" + args = append(args, like, like, like) + } + countArgs := append([]any(nil), args...) + args = append(args, limit, offset) + fromSQL := `FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` + strings.Join(where, " AND ") + return SQLQuery{ + Text: `SELECT l.vin, COALESCE(NULLIF(l.plate, ''), b.plate, '') AS plate, l.protocol, l.longitude, l.latitude, ` + + `COALESCE(l.speed_kmh, 0), COALESCE(l.soc_percent, 0), COALESCE(l.total_mileage_km, 0), ` + + `COALESCE(DATE_FORMAT(l.updated_at, '%Y-%m-%d %H:%i:%s'), '') ` + + fromSQL + ` ORDER BY l.updated_at DESC, l.vin ASC, l.protocol ASC LIMIT ? OFFSET ?`, + Args: args, + CountText: `SELECT COUNT(*) ` + fromSQL, + CountArgs: countArgs, + } +} + func buildDailyMileageSQL(query url.Values) SQLQuery { limit := parsePositive(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index c3a66a56..f757da06 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -125,24 +125,12 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) } func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) { - limit, offset := buildLimitOffset(query) - where := []string{"1 = 1"} - args := []any{} - if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { - where = append(where, "l.protocol = ?") - args = append(args, protocol) + built := buildRealtimeLocationSQL(query) + total := 0 + if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil { + return Page[RealtimeLocationRow]{}, err } - if vin := strings.TrimSpace(query.Get("vin")); vin != "" { - where = append(where, "l.vin = ?") - args = append(args, vin) - } - args = append(args, limit, offset) - sqlText := `SELECT l.vin, COALESCE(NULLIF(l.plate, ''), b.plate, '') AS plate, l.protocol, l.longitude, l.latitude, ` + - `COALESCE(l.speed_kmh, 0), COALESCE(l.soc_percent, 0), COALESCE(l.total_mileage_km, 0), ` + - `COALESCE(DATE_FORMAT(l.updated_at, '%Y-%m-%d %H:%i:%s'), '') ` + - `FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` + - `WHERE ` + strings.Join(where, " AND ") + ` ORDER BY l.updated_at DESC LIMIT ? OFFSET ?` - rows, err := s.db.QueryContext(ctx, sqlText, args...) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) if err != nil { return Page[RealtimeLocationRow]{}, err } @@ -158,7 +146,8 @@ func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Value if err := rows.Err(); err != nil { return Page[RealtimeLocationRow]{}, err } - return Page[RealtimeLocationRow]{Items: items, Total: len(items), Limit: limit, Offset: offset}, nil + limit, offset := buildLimitOffset(query) + return Page[RealtimeLocationRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 435d5ef2..eab264dc 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -41,6 +41,25 @@ func TestBuildVehicleCoverageSQL(t *testing.T) { } } +func TestBuildRealtimeLocationSQL(t *testing.T) { + query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}} + built := buildRealtimeLocationSQL(query) + for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "ORDER BY l.updated_at DESC, l.vin ASC, l.protocol ASC"} { + if !strings.Contains(built.Text, want) { + t.Fatalf("SQL missing %q: %s", want, built.Text) + } + } + if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") { + t.Fatalf("count SQL = %s", built.CountText) + } + if len(built.Args) != 6 || built.Args[0] != "GB32960" || built.Args[4] != 10 || built.Args[5] != 20 { + t.Fatalf("args = %#v", built.Args) + } + if len(built.CountArgs) != 4 || built.CountArgs[0] != "GB32960" { + t.Fatalf("count args = %#v", built.CountArgs) + } +} + func TestBuildDailyMileageSQL(t *testing.T) { query := url.Values{"vin": {"VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} built := buildDailyMileageSQL(query) diff --git a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx index aee7f1bb..319f8485 100644 --- a/vehicle-data-platform/apps/web/src/pages/Realtime.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Realtime.tsx @@ -1,4 +1,4 @@ -import { Card, Table, Tabs, Toast } from '@douyinfe/semi-ui'; +import { Button, Card, Form, Select, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { RealtimeLocationRow } from '../api/types'; @@ -8,18 +8,50 @@ import { PageHeader } from '../components/PageHeader'; export function Realtime() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); + const [filters, setFilters] = useState>({}); + const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 }); - useEffect(() => { - api.realtimeLocations(new URLSearchParams({ limit: '50' })) - .then((page) => setRows(page.items)) + const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { + setLoading(true); + const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) }); + if (values?.vin) params.set('vin', values.vin); + if (values?.protocol) params.set('protocol', values.protocol); + api.realtimeLocations(params) + .then((nextPage) => { + setRows(nextPage.items); + setPagination({ currentPage: page, pageSize, total: nextPage.total }); + }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoading(false)); + }; + + useEffect(() => { + load({}, 1, pagination.pageSize); }, []); return (
+
{ + const nextFilters = values as Record; + setFilters(nextFilters); + load(nextFilters, 1, pagination.pageSize); + }} style={{ marginBottom: 12 }}> + + + GB32960 + JT808 + YUTONG_MQTT + + + + + + {rows.length === 0 && !loading ? ( @@ -27,9 +59,16 @@ export function Realtime() { ) : ( `${row?.vin ?? ''}-${row?.protocol ?? ''}`} dataSource={rows} - pagination={false} + pagination={{ + currentPage: pagination.currentPage, + pageSize: pagination.pageSize, + total: pagination.total, + showSizeChanger: true, + onPageChange: (page) => load(filters, page, pagination.pageSize), + onPageSizeChange: (pageSize) => load(filters, 1, pageSize) + }} columns={[ { title: 'VIN', dataIndex: 'vin' }, { title: '车牌', dataIndex: 'plate' },