feat(platform): add vehicle data management console
This commit is contained in:
6
vehicle-data-platform/.gitignore
vendored
Normal file
6
vehicle-data-platform/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
apps/web/node_modules/
|
||||||
|
apps/web/dist/
|
||||||
|
apps/web/tsconfig.tsbuildinfo
|
||||||
|
*.log
|
||||||
17
vehicle-data-platform/apps/api/cmd/platform-api/main.go
Normal file
17
vehicle-data-platform/apps/api/cmd/platform-api/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/app"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
log.Printf("vehicle data platform listening on %s", cfg.HTTPAddr)
|
||||||
|
if err := http.ListenAndServe(cfg.HTTPAddr, app.NewServer(cfg)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
vehicle-data-platform/apps/api/go.mod
Normal file
4
vehicle-data-platform/apps/api/go.mod
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module lingniu/vehicle-data-platform/apps/api
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
15
vehicle-data-platform/apps/api/internal/app/server.go
Normal file
15
vehicle-data-platform/apps/api/internal/app/server.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewServer(cfg config.Config) http.Handler {
|
||||||
|
store := platform.NewMockStore()
|
||||||
|
api := platform.NewHandler(platform.NewService(store))
|
||||||
|
return static.Handler(cfg.StaticDir, api)
|
||||||
|
}
|
||||||
55
vehicle-data-platform/apps/api/internal/config/config.go
Normal file
55
vehicle-data-platform/apps/api/internal/config/config.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddr string
|
||||||
|
StaticDir string
|
||||||
|
MySQLDSN string
|
||||||
|
RedisAddr string
|
||||||
|
RedisUsername string
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
TDengineDSN string
|
||||||
|
TDengineDatabase string
|
||||||
|
CapacityCheckBin string
|
||||||
|
AuthToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
HTTPAddr: env("HTTP_ADDR", ":20300"),
|
||||||
|
StaticDir: env("STATIC_DIR", ""),
|
||||||
|
MySQLDSN: os.Getenv("MYSQL_DSN"),
|
||||||
|
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||||
|
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||||
|
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||||
|
RedisDB: envInt("REDIS_DB", 50),
|
||||||
|
TDengineDSN: os.Getenv("TDENGINE_DSN"),
|
||||||
|
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||||
|
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
|
||||||
|
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
raw := os.Getenv(key)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
46
vehicle-data-platform/apps/api/internal/httpx/response.go
Normal file
46
vehicle-data-platform/apps/api/internal/httpx/response.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Envelope struct {
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
Error *Error `json:"error,omitempty"`
|
||||||
|
TraceID string `json:"traceId"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteOK(w http.ResponseWriter, traceID string, data any) {
|
||||||
|
writeJSON(w, http.StatusOK, Envelope{
|
||||||
|
Data: data,
|
||||||
|
TraceID: traceID,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteError(w http.ResponseWriter, status int, code, message, detail, traceID string) {
|
||||||
|
writeJSON(w, status, Envelope{
|
||||||
|
Error: &Error{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
TraceID: traceID,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body Envelope) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteOKWrapsData(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteOK(rec, "trace-1", map[string]any{"online": 12})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["traceId"] != "trace-1" {
|
||||||
|
t.Fatalf("traceId = %#v", body["traceId"])
|
||||||
|
}
|
||||||
|
data := body["data"].(map[string]any)
|
||||||
|
if data["online"].(float64) != 12 {
|
||||||
|
t.Fatalf("data = %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteErrorWrapsError(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
WriteError(rec, http.StatusBadRequest, "BAD_QUERY", "查询失败", "vin required", "trace-2")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); !containsAll(got, []string{"BAD_QUERY", "查询失败", "vin required", "trace-2"}) {
|
||||||
|
t.Fatalf("body = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(value string, parts []string) bool {
|
||||||
|
for _, part := range parts {
|
||||||
|
if !strings.Contains(value, part) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
128
vehicle-data-platform/apps/api/internal/platform/handler.go
Normal file
128
vehicle-data-platform/apps/api/internal/platform/handler.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service) *Handler {
|
||||||
|
h := &Handler{service: service, mux: http.NewServeMux()}
|
||||||
|
h.routes()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) routes() {
|
||||||
|
h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary)
|
||||||
|
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
||||||
|
h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations)
|
||||||
|
h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations)
|
||||||
|
h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet)
|
||||||
|
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||||
|
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||||
|
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
||||||
|
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.DashboardSummary(r.Context())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleVehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.Vehicles(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleRealtimeLocations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.RealtimeLocations(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleHistoryLocations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.HistoryLocations(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleRawFramesGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
query := RawFrameQuery{
|
||||||
|
Protocol: q.Get("protocol"),
|
||||||
|
VIN: q.Get("vin"),
|
||||||
|
DateFrom: q.Get("dateFrom"),
|
||||||
|
DateTo: q.Get("dateTo"),
|
||||||
|
Fields: splitCSV(q.Get("fields")),
|
||||||
|
IncludeFields: q.Get("includeFields") == "true",
|
||||||
|
Limit: parsePositive(q.Get("limit"), 100),
|
||||||
|
Offset: parsePositive(q.Get("offset"), 0),
|
||||||
|
}
|
||||||
|
data, err := h.service.RawFrames(r.Context(), query)
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleRawFramesPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
var query RawFrameQuery
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&query); err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.RawFrames(r.Context(), query)
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.DailyMileage(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.QualityIssues(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleOpsHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.OpsHealth(r.Context())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||||
|
if err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL", "服务处理失败", err.Error(), traceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.WriteOK(w, traceID(r), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func traceID(r *http.Request) string {
|
||||||
|
if value := r.Header.Get("X-Trace-Id"); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "trace-" + time.Now().Format("20060102150405.000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerDashboardSummary(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/dashboard/summary", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "onlineVehicles") {
|
||||||
|
t.Fatalf("response missing onlineVehicles: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerVehicles(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/vehicles?limit=10", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") {
|
||||||
|
t.Fatalf("response missing vehicle: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRealtimeLocations(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/realtime/locations?limit=10", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") {
|
||||||
|
t.Fatalf("response missing vehicle: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/api/history/locations?limit=10", "totalMileageKm"},
|
||||||
|
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "rawSizeBytes"},
|
||||||
|
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
||||||
|
{"/api/quality/issues?limit=10", "issueType"},
|
||||||
|
{"/api/ops/health", "linkHealth"},
|
||||||
|
}
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), tc.want) {
|
||||||
|
t.Fatalf("response missing %q: %s", tc.want, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
193
vehicle-data-platform/apps/api/internal/platform/mock_store.go
Normal file
193
vehicle-data-platform/apps/api/internal/platform/mock_store.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockStore struct {
|
||||||
|
vehicles []VehicleRow
|
||||||
|
locations []RealtimeLocationRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockStore() *MockStore {
|
||||||
|
vehicles := []VehicleRow{
|
||||||
|
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Phone: "13307795425", OEM: "G7s", Protocol: "JT808", Online: true, LastSeen: "2026-07-03 20:12:10", LocationText: "广东省广州市", BindingScore: 96},
|
||||||
|
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Phone: "", OEM: "Hyundai", Protocol: "GB32960", Online: true, LastSeen: "2026-07-03 20:12:06", LocationText: "四川省成都市", BindingScore: 100},
|
||||||
|
{VIN: "LMRKH9AC2R1004087", Plate: "豫A88888", Phone: "", OEM: "宇通", Protocol: "YUTONG_MQTT", Online: true, LastSeen: "2026-07-03 20:11:59", LocationText: "上海市临港", BindingScore: 92},
|
||||||
|
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
|
||||||
|
}
|
||||||
|
return &MockStore{
|
||||||
|
vehicles: vehicles,
|
||||||
|
locations: []RealtimeLocationRow{
|
||||||
|
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
|
||||||
|
{VIN: vehicles[1].VIN, Plate: vehicles[1].Plate, Protocol: vehicles[1].Protocol, Longitude: 104.0668, Latitude: 30.5728, SpeedKmh: 18.3, SOCPercent: 64.8, TotalMileageKm: 119925, LastSeen: vehicles[1].LastSeen},
|
||||||
|
{VIN: vehicles[2].VIN, Plate: vehicles[2].Plate, Protocol: vehicles[2].Protocol, Longitude: 121.075044, Latitude: 30.590921, SpeedKmh: 27, SOCPercent: 78.4, TotalMileageKm: 119925, LastSeen: vehicles[2].LastSeen},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error) {
|
||||||
|
return DashboardSummary{
|
||||||
|
OnlineVehicles: 3,
|
||||||
|
ActiveToday: 4,
|
||||||
|
FrameToday: 1286320,
|
||||||
|
IssueVehicles: 7,
|
||||||
|
KafkaLag: 0,
|
||||||
|
Protocols: []ProtocolStat{
|
||||||
|
{Protocol: "GB32960", Online: 18, Total: 64},
|
||||||
|
{Protocol: "JT808", Online: 73, Total: 318},
|
||||||
|
{Protocol: "YUTONG_MQTT", Online: 1, Total: 8},
|
||||||
|
},
|
||||||
|
LinkHealth: []LinkHealth{
|
||||||
|
{Name: "Redis realtime", Status: "ok", Detail: "在线状态 1 分钟 TTL 正常"},
|
||||||
|
{Name: "TDengine raw_frames", Status: "ok", Detail: "最近 5 分钟有写入"},
|
||||||
|
{Name: "Kafka vehicle.fields", Status: "ok", Detail: "consumer lag 0"},
|
||||||
|
{Name: "MySQL identity", Status: "warning", Detail: "部分 808 phone 未绑定 VIN"},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) Vehicles(_ context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||||
|
items := filterVehicles(m.vehicles, query)
|
||||||
|
return page(items, query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) RealtimeLocations(_ context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||||
|
items := m.locations
|
||||||
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
|
items = keep(items, func(row RealtimeLocationRow) bool { return row.VIN == vin })
|
||||||
|
}
|
||||||
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||||
|
items = keep(items, func(row RealtimeLocationRow) bool { return row.Protocol == protocol })
|
||||||
|
}
|
||||||
|
return page(items, query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||||
|
realtime, _ := m.RealtimeLocations(ctx, query)
|
||||||
|
rows := make([]HistoryLocationRow, 0, len(realtime.Items))
|
||||||
|
for _, row := range realtime.Items {
|
||||||
|
rows = append(rows, HistoryLocationRow{
|
||||||
|
VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude, Latitude: row.Latitude,
|
||||||
|
SpeedKmh: row.SpeedKmh, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Page[HistoryLocationRow]{Items: rows, Total: len(rows), Limit: realtime.Limit, Offset: realtime.Offset}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
fields := map[string]any{
|
||||||
|
"gb32960.vehicle.speed_kmh": 42.5,
|
||||||
|
"gb32960.vehicle.total_mileage_km": 48798.9,
|
||||||
|
"gb32960.fuel_cell.stack_1.avg_voltage_v": 0.79,
|
||||||
|
"jt808.location.additional.total_mileage_km": 48798.9,
|
||||||
|
}
|
||||||
|
if len(query.Fields) > 0 {
|
||||||
|
filtered := map[string]any{}
|
||||||
|
for _, key := range query.Fields {
|
||||||
|
if value, ok := fields[key]; ok {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields = filtered
|
||||||
|
}
|
||||||
|
if !query.IncludeFields && len(query.Fields) == 0 {
|
||||||
|
fields = nil
|
||||||
|
}
|
||||||
|
protocol := defaultString(query.Protocol, "GB32960")
|
||||||
|
vin := defaultString(query.VIN, "LB9A32A24R0LS1426")
|
||||||
|
rows := []RawFrameRow{
|
||||||
|
{ID: "raw-20260703-001", VIN: vin, Protocol: protocol, FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields},
|
||||||
|
}
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
return Page[RawFrameRow]{Items: rows, Total: len(rows), Limit: limit, Offset: query.Offset}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
||||||
|
rows := []DailyMileageRow{
|
||||||
|
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Date: "2026-07-03", StartMileageKm: 48710.2, EndMileageKm: 48798.9, DailyMileageKm: 88.7, Source: "JT808"},
|
||||||
|
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Date: "2026-07-03", StartMileageKm: 119820.4, EndMileageKm: 119925, DailyMileageKm: 104.6, Source: "GB32960"},
|
||||||
|
}
|
||||||
|
return page(rows, query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
|
rows := []QualityIssueRow{
|
||||||
|
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 未命中 binding 表"},
|
||||||
|
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
|
||||||
|
}
|
||||||
|
return page(rows, query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
||||||
|
summary, _ := m.DashboardSummary(context.Background())
|
||||||
|
return OpsHealth{
|
||||||
|
LinkHealth: summary.LinkHealth,
|
||||||
|
KafkaLag: summary.KafkaLag,
|
||||||
|
RedisOnlineKeys: 92,
|
||||||
|
TDengineWritable: true,
|
||||||
|
MySQLWritable: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterVehicles(rows []VehicleRow, query url.Values) []VehicleRow {
|
||||||
|
keyword := strings.ToLower(strings.TrimSpace(query.Get("keyword")))
|
||||||
|
protocol := strings.TrimSpace(query.Get("protocol"))
|
||||||
|
if keyword == "" && protocol == "" {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
return keep(rows, func(row VehicleRow) bool {
|
||||||
|
if protocol != "" && row.Protocol != protocol {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if keyword == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := strings.ToLower(row.VIN + row.Plate + row.Phone + row.OEM)
|
||||||
|
return strings.Contains(value, keyword)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func page[T any](rows []T, query url.Values) Page[T] {
|
||||||
|
limit := parsePositive(query.Get("limit"), 20)
|
||||||
|
offset := parsePositive(query.Get("offset"), 0)
|
||||||
|
if offset > len(rows) {
|
||||||
|
offset = len(rows)
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(rows) {
|
||||||
|
end = len(rows)
|
||||||
|
}
|
||||||
|
return Page[T]{Items: rows[offset:end], Total: len(rows), Limit: limit, Offset: offset}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keep[T any](rows []T, fn func(T) bool) []T {
|
||||||
|
out := make([]T, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if fn(row) {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositive(raw string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || value < 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
106
vehicle-data-platform/apps/api/internal/platform/model.go
Normal file
106
vehicle-data-platform/apps/api/internal/platform/model.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
type Page[T any] struct {
|
||||||
|
Items []T `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardSummary struct {
|
||||||
|
OnlineVehicles int `json:"onlineVehicles"`
|
||||||
|
ActiveToday int `json:"activeToday"`
|
||||||
|
FrameToday int `json:"frameToday"`
|
||||||
|
IssueVehicles int `json:"issueVehicles"`
|
||||||
|
KafkaLag int `json:"kafkaLag"`
|
||||||
|
Protocols []ProtocolStat `json:"protocols"`
|
||||||
|
LinkHealth []LinkHealth `json:"linkHealth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtocolStat struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Online int `json:"online"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkHealth struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VehicleRow struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
OEM string `json:"oem"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
LastSeen string `json:"lastSeen"`
|
||||||
|
LocationText string `json:"locationText"`
|
||||||
|
BindingScore int `json:"bindingScore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RealtimeLocationRow struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
SpeedKmh float64 `json:"speedKmh"`
|
||||||
|
SOCPercent float64 `json:"socPercent"`
|
||||||
|
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||||
|
LastSeen string `json:"lastSeen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryLocationRow struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
SpeedKmh float64 `json:"speedKmh"`
|
||||||
|
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||||
|
DeviceTime string `json:"deviceTime"`
|
||||||
|
ServerTime string `json:"serverTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawFrameRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
FrameType string `json:"frameType"`
|
||||||
|
DeviceTime string `json:"deviceTime"`
|
||||||
|
ServerTime string `json:"serverTime"`
|
||||||
|
RawSizeBytes int `json:"rawSizeBytes"`
|
||||||
|
ParsedFields map[string]any `json:"parsedFields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyMileageRow struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
StartMileageKm float64 `json:"startMileageKm"`
|
||||||
|
EndMileageKm float64 `json:"endMileageKm"`
|
||||||
|
DailyMileageKm float64 `json:"dailyMileageKm"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
AnomalySeverity string `json:"anomalySeverity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QualityIssueRow struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
Plate string `json:"plate"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
IssueType string `json:"issueType"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
LastSeen string `json:"lastSeen"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpsHealth struct {
|
||||||
|
LinkHealth []LinkHealth `json:"linkHealth"`
|
||||||
|
KafkaLag int `json:"kafkaLag"`
|
||||||
|
RedisOnlineKeys int `json:"redisOnlineKeys"`
|
||||||
|
TDengineWritable bool `json:"tdengineWritable"`
|
||||||
|
MySQLWritable bool `json:"mysqlWritable"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SQLQuery struct {
|
||||||
|
Text string
|
||||||
|
Args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVehicleListSQL(query url.Values) SQLQuery {
|
||||||
|
limit := parsePositive(query.Get("limit"), 20)
|
||||||
|
offset := parsePositive(query.Get("offset"), 0)
|
||||||
|
args := []any{}
|
||||||
|
where := []string{"1 = 1"}
|
||||||
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||||
|
where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||||
|
where = append(where, "s.protocol = ?")
|
||||||
|
args = append(args, protocol)
|
||||||
|
}
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
return SQLQuery{
|
||||||
|
Text: `SELECT b.vin, b.plate, b.phone, b.oem, COALESCE(s.protocol, '') AS protocol, ` +
|
||||||
|
`CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END AS online, ` +
|
||||||
|
`COALESCE(DATE_FORMAT(s.updated_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||||
|
`COALESCE(CONCAT(l.longitude, ',', l.latitude), '') AS location_text, ` +
|
||||||
|
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 0 END AS binding_score ` +
|
||||||
|
`FROM vehicle_identity_binding b ` +
|
||||||
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = b.vin ` +
|
||||||
|
`LEFT JOIN vehicle_realtime_location l ON l.vin = b.vin ` +
|
||||||
|
`WHERE ` + strings.Join(where, " AND ") + ` ORDER BY s.updated_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||||
|
limit := parsePositive(query.Get("limit"), 20)
|
||||||
|
offset := parsePositive(query.Get("offset"), 0)
|
||||||
|
args := []any{}
|
||||||
|
where := []string{"1 = 1"}
|
||||||
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
|
where = append(where, "m.vin = ?")
|
||||||
|
args = append(args, vin)
|
||||||
|
}
|
||||||
|
if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" {
|
||||||
|
where = append(where, "m.stat_date >= ?")
|
||||||
|
args = append(args, dateFrom)
|
||||||
|
}
|
||||||
|
if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" {
|
||||||
|
where = append(where, "m.stat_date <= ?")
|
||||||
|
args = append(args, dateTo)
|
||||||
|
}
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
return SQLQuery{
|
||||||
|
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||||
|
`m.first_total_mileage_km, m.latest_total_mileage_km, m.daily_mileage_km, m.protocol ` +
|
||||||
|
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||||
|
`WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.stat_date DESC LIMIT ? OFFSET ?`,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLimitOffset(query url.Values) (int, int) {
|
||||||
|
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimitOffset(rawLimit, rawOffset string) (int, int) {
|
||||||
|
return parsePositive(rawLimit, 20), parsePositive(rawOffset, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustInt(value string) int {
|
||||||
|
n, _ := strconv.Atoi(value)
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildVehicleListSQL(t *testing.T) {
|
||||||
|
query := url.Values{"keyword": {"粤A"}, "protocol": {"JT808"}, "limit": {"5"}, "offset": {"10"}}
|
||||||
|
built := buildVehicleListSQL(query)
|
||||||
|
for _, want := range []string{"vehicle_identity_binding", "vehicle_realtime_snapshot", "vehicle_realtime_location", "ORDER BY s.updated_at DESC"} {
|
||||||
|
if !strings.Contains(built.Text, want) {
|
||||||
|
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(built.Args) != 7 || built.Args[0] != "%粤A%" || built.Args[4] != "JT808" || built.Args[5] != 5 || built.Args[6] != 10 {
|
||||||
|
t.Fatalf("args = %#v", built.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDailyMileageSQL(t *testing.T) {
|
||||||
|
query := url.Values{"vin": {"VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||||
|
built := buildDailyMileageSQL(query)
|
||||||
|
if !strings.Contains(built.Text, "vehicle_daily_mileage") || !strings.Contains(built.Text, "vehicle_identity_binding") {
|
||||||
|
t.Fatalf("SQL = %s", built.Text)
|
||||||
|
}
|
||||||
|
if len(built.Args) != 5 || built.Args[0] != "VIN001" || built.Args[3] != 20 || built.Args[4] != 0 {
|
||||||
|
t.Fatalf("args = %#v", built.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRawFrameSQL(t *testing.T) {
|
||||||
|
built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{
|
||||||
|
Protocol: "GB32960",
|
||||||
|
VIN: "VIN001",
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if !strings.Contains(built.Text, "lingniu_vehicle_ts.raw_frames") || !strings.Contains(built.Text, "parsed_fields") {
|
||||||
|
t.Fatalf("SQL = %s", built.Text)
|
||||||
|
}
|
||||||
|
if len(built.Args) != 4 || built.Args[0] != "GB32960" || built.Args[1] != "VIN001" || built.Args[2] != 1 {
|
||||||
|
t.Fatalf("args = %#v", built.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
vehicle-data-platform/apps/api/internal/platform/service.go
Normal file
71
vehicle-data-platform/apps/api/internal/platform/service.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
DashboardSummary(context.Context) (DashboardSummary, error)
|
||||||
|
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
||||||
|
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
|
||||||
|
HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error)
|
||||||
|
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||||
|
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
|
||||||
|
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
|
||||||
|
OpsHealth(context.Context) (OpsHealth, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawFrameQuery struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
DateFrom string `json:"dateFrom"`
|
||||||
|
DateTo string `json:"dateTo"`
|
||||||
|
Fields []string `json:"fields"`
|
||||||
|
IncludeFields bool `json:"includeFields"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store Store) *Service {
|
||||||
|
return &Service{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) {
|
||||||
|
return s.store.DashboardSummary(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||||
|
return s.store.Vehicles(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||||
|
return s.store.RealtimeLocations(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||||
|
return s.store.HistoryLocations(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||||
|
if query.Limit <= 0 || query.Limit > 500 {
|
||||||
|
query.Limit = 100
|
||||||
|
}
|
||||||
|
return s.store.RawFrames(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
||||||
|
return s.store.DailyMileage(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
|
return s.store.QualityIssues(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
||||||
|
return s.store.OpsHealth(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func buildRawFrameSQL(database string, query RawFrameQuery) SQLQuery {
|
||||||
|
table := qualifyTDengine(database, "raw_frames")
|
||||||
|
where := []string{"1 = 1"}
|
||||||
|
args := []any{}
|
||||||
|
if query.Protocol != "" {
|
||||||
|
where = append(where, "protocol = ?")
|
||||||
|
args = append(args, query.Protocol)
|
||||||
|
}
|
||||||
|
if query.VIN != "" {
|
||||||
|
where = append(where, "vin = ?")
|
||||||
|
args = append(args, query.VIN)
|
||||||
|
}
|
||||||
|
if query.DateFrom != "" {
|
||||||
|
where = append(where, "ts >= ?")
|
||||||
|
args = append(args, query.DateFrom)
|
||||||
|
}
|
||||||
|
if query.DateTo != "" {
|
||||||
|
where = append(where, "ts <= ?")
|
||||||
|
args = append(args, query.DateTo)
|
||||||
|
}
|
||||||
|
limit := query.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
args = append(args, limit, query.Offset)
|
||||||
|
return SQLQuery{
|
||||||
|
Text: `SELECT ts, frame_id, event_time, received_at, raw_size_bytes, parsed_fields, parse_status, ` +
|
||||||
|
`parse_error, source_endpoint, protocol, vehicle_key, vin, phone FROM ` + table +
|
||||||
|
` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ts DESC LIMIT ? OFFSET ?`,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery {
|
||||||
|
table := qualifyTDengine(database, "vehicle_locations")
|
||||||
|
where := []string{"1 = 1"}
|
||||||
|
args := []any{}
|
||||||
|
if protocol := strings.TrimSpace(query["protocol"]); protocol != "" {
|
||||||
|
where = append(where, "protocol = ?")
|
||||||
|
args = append(args, protocol)
|
||||||
|
}
|
||||||
|
if vin := strings.TrimSpace(query["vin"]); vin != "" {
|
||||||
|
where = append(where, "vin = ?")
|
||||||
|
args = append(args, vin)
|
||||||
|
}
|
||||||
|
limit, offset := parseLimitOffset(query["limit"], query["offset"])
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
return SQLQuery{
|
||||||
|
Text: `SELECT ts, vin, protocol, longitude, latitude, speed_kmh, total_mileage_km, event_time, received_at FROM ` +
|
||||||
|
table + ` WHERE ` + strings.Join(where, " AND ") + ` ORDER BY ts DESC LIMIT ? OFFSET ?`,
|
||||||
|
Args: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qualifyTDengine(database, table string) string {
|
||||||
|
if strings.TrimSpace(database) == "" {
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
return database + "." + table
|
||||||
|
}
|
||||||
27
vehicle-data-platform/apps/api/internal/static/static.go
Normal file
27
vehicle-data-platform/apps/api/internal/static/static.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler(dir string, fallback http.Handler) http.Handler {
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
fs := http.FileServer(http.Dir(dir))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
fallback.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
|
||||||
|
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||||
|
})
|
||||||
|
}
|
||||||
12
vehicle-data-platform/apps/web/index.html
Normal file
12
vehicle-data-platform/apps/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>车辆数据管理中台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
vehicle-data-platform/apps/web/package.json
Normal file
29
vehicle-data-platform/apps/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 20301",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-icons": "^2.71.0",
|
||||||
|
"@douyinfe/semi-theme-default": "^2.71.0",
|
||||||
|
"@douyinfe/semi-ui": "^2.71.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"sass": "^1.83.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
4666
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
Normal file
4666
vehicle-data-platform/apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
vehicle-data-platform/apps/web/pnpm-workspace.yaml
Normal file
3
vehicle-data-platform/apps/web/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
esbuild: true
|
||||||
28
vehicle-data-platform/apps/web/src/App.tsx
Normal file
28
vehicle-data-platform/apps/web/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AppShell, type PageKey } from './layout/AppShell';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { History } from './pages/History';
|
||||||
|
import { Mileage } from './pages/Mileage';
|
||||||
|
import { Quality } from './pages/Quality';
|
||||||
|
import { Realtime } from './pages/Realtime';
|
||||||
|
import { VehicleDetail } from './pages/VehicleDetail';
|
||||||
|
import { Vehicles } from './pages/Vehicles';
|
||||||
|
|
||||||
|
const pages: Record<PageKey, JSX.Element> = {
|
||||||
|
dashboard: <Dashboard />,
|
||||||
|
vehicles: <Vehicles />,
|
||||||
|
realtime: <Realtime />,
|
||||||
|
detail: <VehicleDetail />,
|
||||||
|
history: <History />,
|
||||||
|
mileage: <Mileage />,
|
||||||
|
quality: <Quality />
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [activePage, setActivePage] = useState<PageKey>('dashboard');
|
||||||
|
return (
|
||||||
|
<AppShell activePage={activePage} onChange={setActivePage}>
|
||||||
|
{pages[activePage]}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
vehicle-data-platform/apps/web/src/api/client.ts
Normal file
32
vehicle-data-platform/apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
ApiEnvelope,
|
||||||
|
DailyMileageRow,
|
||||||
|
DashboardSummary,
|
||||||
|
HistoryLocationRow,
|
||||||
|
OpsHealth,
|
||||||
|
Page,
|
||||||
|
QualityIssueRow,
|
||||||
|
RawFrameRow,
|
||||||
|
RealtimeLocationRow,
|
||||||
|
VehicleRow
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(path, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`request failed ${response.status}`);
|
||||||
|
}
|
||||||
|
const envelope = (await response.json()) as ApiEnvelope<T>;
|
||||||
|
return envelope.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||||
|
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||||
|
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||||
|
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
|
||||||
|
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||||
|
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
||||||
|
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||||
|
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||||
|
};
|
||||||
103
vehicle-data-platform/apps/web/src/api/types.ts
Normal file
103
vehicle-data-platform/apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
data: T;
|
||||||
|
traceId: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtocolStat {
|
||||||
|
protocol: string;
|
||||||
|
online: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinkHealth {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
onlineVehicles: number;
|
||||||
|
activeToday: number;
|
||||||
|
frameToday: number;
|
||||||
|
issueVehicles: number;
|
||||||
|
kafkaLag: number;
|
||||||
|
protocols: ProtocolStat[];
|
||||||
|
linkHealth: LinkHealth[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleRow {
|
||||||
|
vin: string;
|
||||||
|
plate: string;
|
||||||
|
phone: string;
|
||||||
|
oem: string;
|
||||||
|
protocol: string;
|
||||||
|
online: boolean;
|
||||||
|
lastSeen: string;
|
||||||
|
locationText: string;
|
||||||
|
bindingScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealtimeLocationRow {
|
||||||
|
vin: string;
|
||||||
|
plate: string;
|
||||||
|
protocol: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
speedKmh: number;
|
||||||
|
socPercent: number;
|
||||||
|
totalMileageKm: number;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryLocationRow extends RealtimeLocationRow {
|
||||||
|
deviceTime: string;
|
||||||
|
serverTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawFrameRow {
|
||||||
|
id: string;
|
||||||
|
vin: string;
|
||||||
|
protocol: string;
|
||||||
|
frameType: string;
|
||||||
|
deviceTime: string;
|
||||||
|
serverTime: string;
|
||||||
|
rawSizeBytes: number;
|
||||||
|
parsedFields?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyMileageRow {
|
||||||
|
vin: string;
|
||||||
|
plate: string;
|
||||||
|
date: string;
|
||||||
|
startMileageKm: number;
|
||||||
|
endMileageKm: number;
|
||||||
|
dailyMileageKm: number;
|
||||||
|
source: string;
|
||||||
|
anomalySeverity?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityIssueRow {
|
||||||
|
vin: string;
|
||||||
|
plate: string;
|
||||||
|
protocol: string;
|
||||||
|
issueType: string;
|
||||||
|
severity: string;
|
||||||
|
lastSeen: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpsHealth {
|
||||||
|
linkHealth: LinkHealth[];
|
||||||
|
kafkaLag: number;
|
||||||
|
redisOnlineKeys: number;
|
||||||
|
tdengineWritable: boolean;
|
||||||
|
mysqlWritable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Empty } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export function DataEmpty({ text = '暂无数据' }: { text?: string }) {
|
||||||
|
return <Empty description={text} style={{ padding: 48 }} />;
|
||||||
|
}
|
||||||
12
vehicle-data-platform/apps/web/src/components/PageHeader.tsx
Normal file
12
vehicle-data-platform/apps/web/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export function PageHeader({ title, description }: { title: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title heading={3} style={{ margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="tertiary">{description}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
vehicle-data-platform/apps/web/src/components/StatusTag.tsx
Normal file
11
vehicle-data-platform/apps/web/src/components/StatusTag.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Tag } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
export function StatusTag({ status }: { status: 'ok' | 'warning' | 'error' | 'offline' }) {
|
||||||
|
const map = {
|
||||||
|
ok: { color: 'green' as const, text: '正常' },
|
||||||
|
warning: { color: 'orange' as const, text: '关注' },
|
||||||
|
error: { color: 'red' as const, text: '异常' },
|
||||||
|
offline: { color: 'grey' as const, text: '离线' }
|
||||||
|
};
|
||||||
|
return <Tag color={map[status].color}>{map[status].text}</Tag>;
|
||||||
|
}
|
||||||
68
vehicle-data-platform/apps/web/src/layout/AppShell.tsx
Normal file
68
vehicle-data-platform/apps/web/src/layout/AppShell.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Badge, Button, Input, Nav, Space, Tag, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconActivity,
|
||||||
|
IconBarChartHStroked,
|
||||||
|
IconHistogram,
|
||||||
|
IconHome,
|
||||||
|
IconMapPin,
|
||||||
|
IconSearch,
|
||||||
|
IconServer,
|
||||||
|
IconSetting
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ itemKey: 'dashboard', text: '总览工作台', icon: <IconHome /> },
|
||||||
|
{ itemKey: 'vehicles', text: '车辆台账', icon: <IconServer /> },
|
||||||
|
{ itemKey: 'realtime', text: '实时状态', icon: <IconActivity /> },
|
||||||
|
{ itemKey: 'detail', text: '车辆详情', icon: <IconSetting /> },
|
||||||
|
{ itemKey: 'history', text: '历史查询', icon: <IconMapPin /> },
|
||||||
|
{ itemKey: 'mileage', text: '里程分析', icon: <IconBarChartHStroked /> },
|
||||||
|
{ itemKey: 'quality', text: '数据质量', icon: <IconHistogram /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
activePage,
|
||||||
|
onChange,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
activePage: PageKey;
|
||||||
|
onChange: (page: PageKey) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="vp-app">
|
||||||
|
<div className="vp-shell">
|
||||||
|
<aside className="vp-sidebar">
|
||||||
|
<div className="vp-brand">
|
||||||
|
<span className="vp-brand-mark" />
|
||||||
|
<span>车辆数据中台</span>
|
||||||
|
</div>
|
||||||
|
<Nav
|
||||||
|
selectedKeys={[activePage]}
|
||||||
|
items={navItems}
|
||||||
|
onSelect={({ itemKey }) => onChange(itemKey as PageKey)}
|
||||||
|
style={{ maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
<main className="vp-main">
|
||||||
|
<header className="vp-topbar">
|
||||||
|
<Space spacing={12}>
|
||||||
|
<Input prefix={<IconSearch />} placeholder="搜索 VIN / 车牌 / 手机号" style={{ width: 320 }} />
|
||||||
|
<Button theme="solid" type="primary">查询车辆</Button>
|
||||||
|
</Space>
|
||||||
|
<Space spacing={12}>
|
||||||
|
<Tag color="blue">生产环境</Tag>
|
||||||
|
<Badge count={0} type="success">
|
||||||
|
<Typography.Text>链路正常</Typography.Text>
|
||||||
|
</Badge>
|
||||||
|
</Space>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
vehicle-data-platform/apps/web/src/main.tsx
Normal file
10
vehicle-data-platform/apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
124
vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
Normal file
124
vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Card, Col, Row, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { DashboardSummary, LinkHealth, ProtocolStat, RealtimeLocationRow, VehicleRow } from '../api/types';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||||
|
ok: 'green',
|
||||||
|
warning: 'orange',
|
||||||
|
error: 'red'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
|
const [vehicles, setVehicles] = useState<VehicleRow[]>([]);
|
||||||
|
const [locations, setLocations] = useState<RealtimeLocationRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.dashboardSummary(),
|
||||||
|
api.vehicles(new URLSearchParams({ limit: '5' })),
|
||||||
|
api.realtimeLocations(new URLSearchParams({ limit: '8' }))
|
||||||
|
])
|
||||||
|
.then(([nextSummary, vehiclePage, locationPage]) => {
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setVehicles(vehiclePage.items);
|
||||||
|
setLocations(locationPage.items);
|
||||||
|
})
|
||||||
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: '在线车辆', value: summary?.onlineVehicles ?? 0 },
|
||||||
|
{ label: '今日活跃', value: summary?.activeToday ?? 0 },
|
||||||
|
{ label: '今日帧数', value: summary?.frameToday.toLocaleString() ?? '0' },
|
||||||
|
{ label: '问题车辆', value: summary?.issueVehicles ?? 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="总览工作台" description="车辆在线、协议分布、数据质量和链路健康的统一入口" />
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div className="vp-kpi-grid">
|
||||||
|
{kpis.map((item) => (
|
||||||
|
<Card key={item.label} bordered>
|
||||||
|
<div className="vp-kpi-value">{item.value}</div>
|
||||||
|
<div className="vp-kpi-label">{item.label}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={14}>
|
||||||
|
<Card title="协议在线分布" bordered>
|
||||||
|
<Table
|
||||||
|
pagination={false}
|
||||||
|
dataSource={summary?.protocols ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '在线', dataIndex: 'online' },
|
||||||
|
{ title: '总数', dataIndex: 'total' },
|
||||||
|
{
|
||||||
|
title: '在线率',
|
||||||
|
render: (_: unknown, row: ProtocolStat) => `${Math.round((row.online / row.total) * 100)}%`
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Card title="链路健康" bordered>
|
||||||
|
<Table
|
||||||
|
pagination={false}
|
||||||
|
dataSource={summary?.linkHealth ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '链路', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag>
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'detail' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Card title="实时积压" bordered style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text>Kafka 当前消费积压:{summary?.kafkaLag ?? 0}</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="实时位置预览" bordered>
|
||||||
|
<div className="vp-map" style={{ height: 260 }}>
|
||||||
|
{locations.map((row, index) => (
|
||||||
|
<span
|
||||||
|
key={row.vin}
|
||||||
|
className="vp-map-dot"
|
||||||
|
title={`${row.plate} ${row.protocol}`}
|
||||||
|
style={{ left: `${18 + index * 13}%`, top: `${24 + (index % 4) * 15}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card title="最新车辆" bordered>
|
||||||
|
<Table
|
||||||
|
pagination={false}
|
||||||
|
dataSource={vehicles}
|
||||||
|
columns={[
|
||||||
|
{ title: '车牌', dataIndex: 'plate' },
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '最后时间', dataIndex: 'lastSeen' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
vehicle-data-platform/apps/web/src/pages/History.tsx
Normal file
73
vehicle-data-platform/apps/web/src/pages/History.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button, Card, Form, SideSheet, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { HistoryLocationRow, RawFrameRow } from '../api/types';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
export function History() {
|
||||||
|
const [locations, setLocations] = useState<HistoryLocationRow[]>([]);
|
||||||
|
const [rawFrames, setRawFrames] = useState<RawFrameRow[]>([]);
|
||||||
|
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
|
||||||
|
|
||||||
|
const load = (values?: Record<string, string>) => {
|
||||||
|
const params = new URLSearchParams({ limit: '20', includeFields: 'true' });
|
||||||
|
if (values?.vin) params.set('vin', values.vin);
|
||||||
|
if (values?.protocol) params.set('protocol', values.protocol);
|
||||||
|
api.historyLocations(params).then((page) => setLocations(page.items)).catch((error: Error) => Toast.error(error.message));
|
||||||
|
api.rawFrames(params).then((page) => setRawFrames(page.items)).catch((error: Error) => Toast.error(error.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => load(), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="历史查询" description="位置历史和 RAW 帧历史的分页查询工作台" />
|
||||||
|
<Card bordered>
|
||||||
|
<Form layout="horizontal" onSubmit={(values) => load(values as Record<string, string>)}>
|
||||||
|
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
|
||||||
|
<Form.Input field="protocol" label="协议" placeholder="GB32960 / JT808 / YUTONG_MQTT" style={{ width: 240 }} />
|
||||||
|
<Space>
|
||||||
|
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
|
<Tabs>
|
||||||
|
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||||
|
<Table
|
||||||
|
rowKey="deviceTime"
|
||||||
|
dataSource={locations}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '经度', dataIndex: 'longitude' },
|
||||||
|
{ title: '纬度', dataIndex: 'latitude' },
|
||||||
|
{ title: '速度', dataIndex: 'speedKmh' },
|
||||||
|
{ title: '设备时间', dataIndex: 'deviceTime' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={rawFrames}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: 'ID', dataIndex: 'id' },
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '帧类型', dataIndex: 'frameType' },
|
||||||
|
{ title: '大小', dataIndex: 'rawSizeBytes' },
|
||||||
|
{ title: '操作', render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}>字段</Button> }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
<SideSheet title="RAW 解析字段" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>
|
||||||
|
<pre className="vp-json">{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}</pre>
|
||||||
|
</SideSheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
vehicle-data-platform/apps/web/src/pages/Mileage.tsx
Normal file
44
vehicle-data-platform/apps/web/src/pages/Mileage.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Card, DatePicker, Form, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { DailyMileageRow } from '../api/types';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
export function Mileage() {
|
||||||
|
const [rows, setRows] = useState<DailyMileageRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.dailyMileage(new URLSearchParams({ limit: '20' }))
|
||||||
|
.then((page) => setRows(page.items))
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="里程分析" description="每日里程、区间里程和异常差值分析" />
|
||||||
|
<Card bordered>
|
||||||
|
<Form layout="horizontal">
|
||||||
|
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
|
||||||
|
<DatePicker type="dateRange" density="compact" />
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
|
<Table
|
||||||
|
rowKey="vin"
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '日期', dataIndex: 'date' },
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '车牌', dataIndex: 'plate' },
|
||||||
|
{ title: '起始里程', dataIndex: 'startMileageKm' },
|
||||||
|
{ title: '结束里程', dataIndex: 'endMileageKm' },
|
||||||
|
{ title: '日里程', dataIndex: 'dailyMileageKm' },
|
||||||
|
{ title: '来源', dataIndex: 'source' },
|
||||||
|
{ title: '异常', render: (_: unknown, row: DailyMileageRow) => row.anomalySeverity ? <Tag color="orange">{row.anomalySeverity}</Tag> : <Tag color="green">正常</Tag> }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
vehicle-data-platform/apps/web/src/pages/Quality.tsx
Normal file
57
vehicle-data-platform/apps/web/src/pages/Quality.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Card, Col, Row, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { OpsHealth, QualityIssueRow } from '../api/types';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
export function Quality() {
|
||||||
|
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
||||||
|
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.qualityIssues(new URLSearchParams({ limit: '20' }))
|
||||||
|
.then((page) => setIssues(page.items))
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
api.opsHealth()
|
||||||
|
.then(setHealth)
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="数据质量" description="断链、VIN 缺失、字段缺失和链路健康的排查入口" />
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}><Card bordered title="Kafka Lag">{health?.kafkaLag ?? 0}</Card></Col>
|
||||||
|
<Col span={8}><Card bordered title="Redis 在线 Key">{health?.redisOnlineKeys ?? 0}</Card></Col>
|
||||||
|
<Col span={8}><Card bordered title="存储写入">{health?.tdengineWritable && health.mysqlWritable ? '正常' : '异常'}</Card></Col>
|
||||||
|
</Row>
|
||||||
|
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
|
||||||
|
<Table
|
||||||
|
rowKey="vin"
|
||||||
|
dataSource={issues}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '车牌', dataIndex: 'plate' },
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '问题', dataIndex: 'issueType' },
|
||||||
|
{ title: '级别', render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
||||||
|
{ title: '最后时间', dataIndex: 'lastSeen' },
|
||||||
|
{ title: '说明', dataIndex: 'detail' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card bordered title="链路健康" style={{ marginTop: 16 }}>
|
||||||
|
<Table
|
||||||
|
dataSource={health?.linkHealth ?? []}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '链路', dataIndex: 'name' },
|
||||||
|
{ title: '状态', dataIndex: 'status' },
|
||||||
|
{ title: '说明', dataIndex: 'detail' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
vehicle-data-platform/apps/web/src/pages/Realtime.tsx
Normal file
61
vehicle-data-platform/apps/web/src/pages/Realtime.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Card, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { RealtimeLocationRow } from '../api/types';
|
||||||
|
import { DataEmpty } from '../components/DataEmpty';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
export function Realtime() {
|
||||||
|
const [rows, setRows] = useState<RealtimeLocationRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.realtimeLocations(new URLSearchParams({ limit: '50' }))
|
||||||
|
.then((page) => setRows(page.items))
|
||||||
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="实时状态" description="按协议和车辆查看最新实时位置、在线状态和核心数据" />
|
||||||
|
<Card bordered>
|
||||||
|
<Tabs type="line">
|
||||||
|
<Tabs.TabPane tab="表格视图" itemKey="table">
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<DataEmpty />
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
loading={loading}
|
||||||
|
rowKey="vin"
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: 'VIN', dataIndex: 'vin' },
|
||||||
|
{ title: '车牌', dataIndex: 'plate' },
|
||||||
|
{ title: '协议', dataIndex: 'protocol' },
|
||||||
|
{ title: '速度 km/h', dataIndex: 'speedKmh' },
|
||||||
|
{ title: 'SOC %', dataIndex: 'socPercent' },
|
||||||
|
{ title: '总里程 km', dataIndex: 'totalMileageKm' },
|
||||||
|
{ title: '最后时间', dataIndex: 'lastSeen' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane tab="地图视图" itemKey="map">
|
||||||
|
<div className="vp-map">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<span
|
||||||
|
key={row.vin}
|
||||||
|
className="vp-map-dot"
|
||||||
|
title={`${row.plate} ${row.protocol}`}
|
||||||
|
style={{ left: `${18 + index * 24}%`, top: `${26 + (index % 3) * 18}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
Normal file
55
vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Card, Descriptions, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { RawFrameRow, RealtimeLocationRow } from '../api/types';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
|
export function VehicleDetail() {
|
||||||
|
const [latest, setLatest] = useState<RealtimeLocationRow | null>(null);
|
||||||
|
const [raw, setRaw] = useState<RawFrameRow | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams({ vin: 'LB9A32A24R0LS1426', limit: '1' });
|
||||||
|
api.realtimeLocations(params)
|
||||||
|
.then((page) => setLatest(page.items[0] ?? null))
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
params.set('includeFields', 'true');
|
||||||
|
api.rawFrames(params)
|
||||||
|
.then((page) => setRaw(page.items[0] ?? null))
|
||||||
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="车辆详情" description="单车身份、实时、历史、RAW、里程和质量的综合视图" />
|
||||||
|
<Card bordered>
|
||||||
|
<Descriptions row data={[
|
||||||
|
{ key: 'VIN', value: latest?.vin ?? 'LB9A32A24R0LS1426' },
|
||||||
|
{ key: '车牌', value: latest?.plate ?? '-' },
|
||||||
|
{ key: '协议', value: latest?.protocol ?? '-' },
|
||||||
|
{ key: '最后时间', value: latest?.lastSeen ?? '-' }
|
||||||
|
]} />
|
||||||
|
</Card>
|
||||||
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
|
<Tabs>
|
||||||
|
<Tabs.TabPane tab="最新状态" itemKey="latest">
|
||||||
|
<Table
|
||||||
|
pagination={false}
|
||||||
|
dataSource={latest ? [latest] : []}
|
||||||
|
columns={[
|
||||||
|
{ title: '经度', dataIndex: 'longitude' },
|
||||||
|
{ title: '纬度', dataIndex: 'latitude' },
|
||||||
|
{ title: '速度', dataIndex: 'speedKmh' },
|
||||||
|
{ title: 'SOC', dataIndex: 'socPercent' },
|
||||||
|
{ title: '总里程', dataIndex: 'totalMileageKm' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
|
||||||
|
<pre className="vp-json">{JSON.stringify(raw?.parsedFields ?? {}, null, 2)}</pre>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
Normal file
72
vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Button, Card, Form, Select, SideSheet, Space, Table, TextArea, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { VehicleRow } from '../api/types';
|
||||||
|
import { DataEmpty } from '../components/DataEmpty';
|
||||||
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
import { StatusTag } from '../components/StatusTag';
|
||||||
|
|
||||||
|
export function Vehicles() {
|
||||||
|
const [rows, setRows] = useState<VehicleRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<VehicleRow | null>(null);
|
||||||
|
|
||||||
|
const load = (values?: Record<string, string>) => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ limit: '20' });
|
||||||
|
if (values?.keyword) params.set('keyword', values.keyword);
|
||||||
|
if (values?.protocol) params.set('protocol', values.protocol);
|
||||||
|
api.vehicles(params)
|
||||||
|
.then((page) => setRows(page.items))
|
||||||
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => load(), []);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||||||
|
{ title: '车牌', dataIndex: 'plate', width: 120 },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 130 },
|
||||||
|
{ title: 'OEM', dataIndex: 'oem', width: 120 },
|
||||||
|
{ title: '协议', dataIndex: 'protocol', width: 120 },
|
||||||
|
{ title: '在线', render: (_: unknown, row: VehicleRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||||||
|
{ title: '最后在线', dataIndex: 'lastSeen', width: 170 },
|
||||||
|
{ title: '位置', dataIndex: 'locationText' },
|
||||||
|
{ title: '绑定分', dataIndex: 'bindingScore', width: 90 },
|
||||||
|
{ title: '操作', render: (_: unknown, row: VehicleRow) => <Button onClick={() => setSelected(row)}>详情</Button> }
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vp-page">
|
||||||
|
<PageHeader title="车辆台账" description="车辆身份、协议绑定、车牌、手机号和 OEM 的运营台账" />
|
||||||
|
<Card bordered>
|
||||||
|
<Form layout="horizontal" onSubmit={(values) => load(values as Record<string, string>)}>
|
||||||
|
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 260 }} />
|
||||||
|
<Form.Select field="protocol" label="协议" placeholder="全部协议" style={{ width: 180 }}>
|
||||||
|
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||||
|
<Select.Option value="JT808">JT808</Select.Option>
|
||||||
|
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Space>
|
||||||
|
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||||
|
<Button onClick={() => load()}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
|
{rows.length === 0 && !loading ? (
|
||||||
|
<DataEmpty />
|
||||||
|
) : (
|
||||||
|
<Table loading={loading} rowKey="vin" dataSource={rows} columns={columns} pagination={false} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<SideSheet title="车辆详情" visible={Boolean(selected)} onCancel={() => setSelected(null)} width={520}>
|
||||||
|
<TextArea value={JSON.stringify(selected, null, 2)} autosize readOnly />
|
||||||
|
</SideSheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
vehicle-data-platform/apps/web/src/styles/global.css
Normal file
145
vehicle-data-platform/apps/web/src/styles/global.css
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
@import '@douyinfe/semi-theme-default/scss/index.scss';
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 1280px;
|
||||||
|
background: var(--vp-bg);
|
||||||
|
color: var(--vp-text);
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-app {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--vp-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--vp-shell-sidebar) minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--vp-surface);
|
||||||
|
border-right: 1px solid var(--vp-border);
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-brand {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 8px 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--vp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-brand-mark {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: linear-gradient(135deg, var(--vp-primary), var(--vp-cyan));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-topbar {
|
||||||
|
height: var(--vp-shell-header);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-bottom: 1px solid var(--vp-border);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-page {
|
||||||
|
padding: var(--vp-page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-section {
|
||||||
|
background: var(--vp-surface);
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
box-shadow: var(--vp-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-grid.two {
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-kpi-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 34px;
|
||||||
|
color: var(--vp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-kpi-label {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--vp-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map {
|
||||||
|
position: relative;
|
||||||
|
height: 360px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px),
|
||||||
|
#f8fbff;
|
||||||
|
background-size: 36px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-map-dot {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--vp-primary);
|
||||||
|
box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101828;
|
||||||
|
color: #e4e7ec;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 360px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-navigation-item-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
20
vehicle-data-platform/apps/web/src/styles/tokens.css
Normal file
20
vehicle-data-platform/apps/web/src/styles/tokens.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
:root {
|
||||||
|
--vp-bg: #f5f7fb;
|
||||||
|
--vp-surface: #ffffff;
|
||||||
|
--vp-surface-muted: #f9fafc;
|
||||||
|
--vp-border: #e6e9f0;
|
||||||
|
--vp-text: #172033;
|
||||||
|
--vp-text-muted: #667085;
|
||||||
|
--vp-text-subtle: #98a2b3;
|
||||||
|
--vp-primary: #1664ff;
|
||||||
|
--vp-primary-hover: #0f4fd6;
|
||||||
|
--vp-cyan: #00a4b8;
|
||||||
|
--vp-success: #12b76a;
|
||||||
|
--vp-warning: #f79009;
|
||||||
|
--vp-danger: #f04438;
|
||||||
|
--vp-radius: 8px;
|
||||||
|
--vp-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||||
|
--vp-shell-sidebar: 232px;
|
||||||
|
--vp-shell-header: 56px;
|
||||||
|
--vp-page-gutter: 24px;
|
||||||
|
}
|
||||||
9
vehicle-data-platform/apps/web/src/test/App.test.tsx
Normal file
9
vehicle-data-platform/apps/web/src/test/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { expect, test } from 'vitest';
|
||||||
|
import App from '../App';
|
||||||
|
|
||||||
|
test('renders vehicle platform shell', () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByText('车辆数据中台')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
85
vehicle-data-platform/apps/web/src/test/setup.ts
Normal file
85
vehicle-data-platform/apps/web/src/test/setup.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||||
|
value: () => ({
|
||||||
|
fillStyle: '',
|
||||||
|
fillRect: () => undefined,
|
||||||
|
clearRect: () => undefined,
|
||||||
|
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||||
|
putImageData: () => undefined,
|
||||||
|
createImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||||
|
setTransform: () => undefined,
|
||||||
|
drawImage: () => undefined,
|
||||||
|
save: () => undefined,
|
||||||
|
fillText: () => undefined,
|
||||||
|
restore: () => undefined,
|
||||||
|
beginPath: () => undefined,
|
||||||
|
moveTo: () => undefined,
|
||||||
|
lineTo: () => undefined,
|
||||||
|
closePath: () => undefined,
|
||||||
|
stroke: () => undefined,
|
||||||
|
translate: () => undefined,
|
||||||
|
scale: () => undefined,
|
||||||
|
rotate: () => undefined,
|
||||||
|
arc: () => undefined,
|
||||||
|
fill: () => undefined,
|
||||||
|
measureText: () => ({ width: 0 }),
|
||||||
|
transform: () => undefined,
|
||||||
|
rect: () => undefined,
|
||||||
|
clip: () => undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestResizeObserver {
|
||||||
|
observe() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
unobserve() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'ResizeObserver', { value: TestResizeObserver });
|
||||||
|
Object.defineProperty(globalThis, 'ResizeObserver', { value: TestResizeObserver });
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
dispatchEvent: () => false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
document.createRange = () => ({
|
||||||
|
setStart: () => undefined,
|
||||||
|
setEnd: () => undefined,
|
||||||
|
selectNodeContents: () => undefined,
|
||||||
|
detach: () => undefined,
|
||||||
|
commonAncestorContainer: document.body,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
bottom: 0,
|
||||||
|
height: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => undefined
|
||||||
|
}),
|
||||||
|
getClientRects: () => ({
|
||||||
|
length: 0,
|
||||||
|
item: () => null,
|
||||||
|
[Symbol.iterator]: function* iterator() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} as unknown as Range);
|
||||||
22
vehicle-data-platform/apps/web/tsconfig.json
Normal file
22
vehicle-data-platform/apps/web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
15
vehicle-data-platform/apps/web/vite.config.ts
Normal file
15
vehicle-data-platform/apps/web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:20300'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lingniu Vehicle Data Platform
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/lingniu-vehicle-platform/current
|
||||||
|
EnvironmentFile=/opt/lingniu-vehicle-platform/env/platform.env
|
||||||
|
ExecStart=/opt/lingniu-vehicle-platform/current/platform-api
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
44
vehicle-data-platform/docs/deployment.md
Normal file
44
vehicle-data-platform/docs/deployment.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Run from repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vehicle-data-platform
|
||||||
|
pnpm --dir apps/web install
|
||||||
|
pnpm run web:build
|
||||||
|
cd apps/api
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-api ./cmd/platform-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## ECS Paths
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/lingniu-vehicle-platform/current
|
||||||
|
/opt/lingniu-vehicle-platform/releases
|
||||||
|
/opt/lingniu-vehicle-platform/env/platform.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP_ADDR=:20300
|
||||||
|
STATIC_DIR=/opt/lingniu-vehicle-platform/current/web
|
||||||
|
MYSQL_DSN=lingniu_vehicle:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/lingniu_vehicle_data?parseTime=true&loc=Local
|
||||||
|
REDIS_ADDR=r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379
|
||||||
|
REDIS_USERNAME=lingniu_vehicle
|
||||||
|
REDIS_PASSWORD=***
|
||||||
|
REDIS_DB=50
|
||||||
|
TDENGINE_DSN=http://root:***@172.17.111.57:6041
|
||||||
|
TDENGINE_DATABASE=lingniu_vehicle_ts
|
||||||
|
CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
|
||||||
|
AUTH_TOKEN=***
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:20300/api/ops/health
|
||||||
|
curl -fsS 'http://127.0.0.1:20300/api/vehicles?limit=5'
|
||||||
|
```
|
||||||
24
vehicle-data-platform/docs/product-spec.md
Normal file
24
vehicle-data-platform/docs/product-spec.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Vehicle Data Platform Product Spec
|
||||||
|
|
||||||
|
## Visual Direction
|
||||||
|
|
||||||
|
The UI is a professional vehicle data operations console. It uses Semi UI as the component base, a restrained gray-white control-room palette, dense but readable tables, right-side drawers for detail, and blue-cyan accents for primary actions. It should feel closer to a cloud data console than a marketing dashboard.
|
||||||
|
|
||||||
|
## Primary Screens
|
||||||
|
|
||||||
|
1. Dashboard: KPI summary, protocol distribution, vehicle map preview, quality issues, and link health.
|
||||||
|
2. Vehicles: table-first identity and vehicle registry.
|
||||||
|
3. Realtime: table/map switch for current vehicle state.
|
||||||
|
4. Vehicle Detail: identity header and tabs for latest state, history, raw, mileage, and quality.
|
||||||
|
5. History: location and raw-frame query workspace.
|
||||||
|
6. Mileage: daily and range mileage analysis.
|
||||||
|
7. Quality: data quality and link health workspace.
|
||||||
|
|
||||||
|
## Interaction Rules
|
||||||
|
|
||||||
|
- Tables are the default data surface.
|
||||||
|
- Filters stay above the table.
|
||||||
|
- Details open in a right drawer unless a full route is needed.
|
||||||
|
- Error and empty states must be explicit.
|
||||||
|
- No decorative hero, no oversized marketing card layout, no dark large-screen style.
|
||||||
|
|
||||||
10
vehicle-data-platform/package.json
Normal file
10
vehicle-data-platform/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"web:dev": "pnpm --dir apps/web run dev",
|
||||||
|
"web:build": "pnpm --dir apps/web run build",
|
||||||
|
"web:test": "pnpm --dir apps/web run test",
|
||||||
|
"api:test": "cd apps/api && go test ./...",
|
||||||
|
"build": "pnpm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user