From 0d8916df47c94f675156750c3b257c7838177741 Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 20:25:09 +0800 Subject: [PATCH] docs: add vehicle data platform implementation plan --- ...03-vehicle-data-platform-implementation.md | 1750 +++++++++++++++++ 1 file changed, 1750 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-vehicle-data-platform-implementation.md diff --git a/docs/superpowers/plans/2026-07-03-vehicle-data-platform-implementation.md b/docs/superpowers/plans/2026-07-03-vehicle-data-platform-implementation.md new file mode 100644 index 00000000..09a7708c --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-vehicle-data-platform-implementation.md @@ -0,0 +1,1750 @@ +# Vehicle Data Platform Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and deploy a new `vehicle-data-platform` project with a Go BFF backend and a React + TypeScript + Semi UI frontend for vehicle data management. + +**Architecture:** The platform is a new project folder under the existing repository. The Go API acts as a BFF that reads MySQL, Redis, TDengine, and local ops endpoints, then serves unified `/api/*` responses plus the built frontend. The frontend is a Semi UI console with dashboard, vehicles, realtime, vehicle detail, history, mileage, and quality/ops pages. + +**Tech Stack:** Go, React, TypeScript, Vite, Semi UI, ECharts, MySQL, Redis, TDengine, systemd. + +--- + +## File Structure + +Create this structure: + +```text +vehicle-data-platform/ + apps/ + api/ + go.mod + cmd/platform-api/main.go + internal/app/server.go + internal/app/routes.go + internal/config/config.go + internal/httpx/response.go + internal/httpx/response_test.go + internal/platform/model.go + internal/platform/mock_store.go + internal/platform/service.go + internal/platform/service_test.go + internal/platform/handler.go + internal/platform/handler_test.go + internal/static/static.go + web/ + package.json + index.html + tsconfig.json + vite.config.ts + src/main.tsx + src/App.tsx + src/styles/tokens.css + src/styles/global.css + src/api/client.ts + src/api/types.ts + src/layout/AppShell.tsx + src/components/StatusTag.tsx + src/components/PageHeader.tsx + src/components/DataEmpty.tsx + src/pages/Dashboard.tsx + src/pages/Vehicles.tsx + src/pages/Realtime.tsx + src/pages/VehicleDetail.tsx + src/pages/History.tsx + src/pages/Mileage.tsx + src/pages/Quality.tsx + src/test/App.test.tsx + src/test/setup.ts + deploy/ + systemd/lingniu-vehicle-platform.service + docs/ + product-spec.md + api-contract.md + deployment.md + package.json +``` + +Do not modify the existing Go gateway services unless a task explicitly says so. + +## Task 1: Visual Concept And Frontend Design System + +**Files:** +- Create: `vehicle-data-platform/docs/product-spec.md` +- Create: `vehicle-data-platform/apps/web/src/styles/tokens.css` +- Create: `vehicle-data-platform/apps/web/src/styles/global.css` + +- [ ] **Step 1: Write the product UI brief** + +Create `vehicle-data-platform/docs/product-spec.md` with: + +```markdown +# 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. +``` + +- [ ] **Step 2: Create design tokens** + +Create `vehicle-data-platform/apps/web/src/styles/tokens.css`: + +```css +: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; +} +``` + +- [ ] **Step 3: Create global styling** + +Create `vehicle-data-platform/apps/web/src/styles/global.css`: + +```css +@import './tokens.css'; + +* { + 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-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); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add vehicle-data-platform/docs/product-spec.md vehicle-data-platform/apps/web/src/styles/tokens.css vehicle-data-platform/apps/web/src/styles/global.css +git commit -m "docs(platform): define product UI direction" +``` + +## Task 2: Scaffold Monorepo And Tooling + +**Files:** +- Create: `vehicle-data-platform/package.json` +- Create: `vehicle-data-platform/apps/web/package.json` +- Create: `vehicle-data-platform/apps/web/index.html` +- Create: `vehicle-data-platform/apps/web/tsconfig.json` +- Create: `vehicle-data-platform/apps/web/vite.config.ts` +- Create: `vehicle-data-platform/apps/web/src/test/setup.ts` +- Create: `vehicle-data-platform/apps/api/go.mod` + +- [ ] **Step 1: Create root package file** + +Create `vehicle-data-platform/package.json`: + +```json +{ + "private": true, + "scripts": { + "web:dev": "npm --prefix apps/web run dev", + "web:build": "npm --prefix apps/web run build", + "web:test": "npm --prefix apps/web run test", + "api:test": "cd apps/api && go test ./...", + "build": "npm run web:build && cd apps/api && go build -o ../../dist/platform-api ./cmd/platform-api" + } +} +``` + +- [ ] **Step 2: Create web package** + +Create `vehicle-data-platform/apps/web/package.json`: + +```json +{ + "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-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", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} +``` + +- [ ] **Step 3: Create Vite entry files** + +Create `vehicle-data-platform/apps/web/index.html`: + +```html + + + + + + 车辆数据管理中台 + + +
+ + + +``` + +Create `vehicle-data-platform/apps/web/tsconfig.json`: + +```json +{ + "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" + }, + "include": ["src"] +} +``` + +Create `vehicle-data-platform/apps/web/vite.config.ts`: + +```ts +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' + } +}); +``` + +Create `vehicle-data-platform/apps/web/src/test/setup.ts`: + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +- [ ] **Step 4: Create API module** + +Create `vehicle-data-platform/apps/api/go.mod`: + +```go +module lingniu-vehicle-platform/api + +go 1.23 + +require ( + github.com/go-sql-driver/mysql v1.8.1 + github.com/redis/go-redis/v9 v9.7.0 + github.com/taosdata/driver-go/v3 v3.6.0 +) +``` + +- [ ] **Step 5: Verify scaffolding** + +Run: + +```bash +cd vehicle-data-platform/apps/api && go test ./... +cd ../../apps/web && npm install && npm run test +``` + +Expected: + +- Go reports no packages or passing packages. +- Web dependencies install and Vitest reports no tests or passing tests. + +- [ ] **Step 6: Commit** + +```bash +git add vehicle-data-platform/package.json vehicle-data-platform/apps/web vehicle-data-platform/apps/api/go.mod +git commit -m "chore(platform): scaffold vehicle data platform" +``` + +## Task 3: Backend Response Envelope And Config + +**Files:** +- Create: `vehicle-data-platform/apps/api/internal/httpx/response.go` +- Create: `vehicle-data-platform/apps/api/internal/httpx/response_test.go` +- Create: `vehicle-data-platform/apps/api/internal/config/config.go` + +- [ ] **Step 1: Write response tests** + +Create `vehicle-data-platform/apps/api/internal/httpx/response_test.go`: + +```go +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 +} +``` + +- [ ] **Step 2: Run response tests and see failure** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./internal/httpx +``` + +Expected: fail because `WriteOK` and `WriteError` are undefined. + +- [ ] **Step 3: Implement response helpers** + +Create `vehicle-data-platform/apps/api/internal/httpx/response.go`: + +```go +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 string, message string, detail string, 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, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +``` + +- [ ] **Step 4: Implement config** + +Create `vehicle-data-platform/apps/api/internal/config/config.go`: + +```go +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + HTTPAddr string + MySQLDSN string + RedisAddr string + RedisUsername string + RedisPassword string + RedisDB int + TDengineDSN string + TDengineDatabase string + CapacityCheckBin string + AuthToken string + RequestTimeout time.Duration +} + +func Load() Config { + return Config{ + HTTPAddr: env("HTTP_ADDR", ":20300"), + MySQLDSN: env("MYSQL_DSN", ""), + RedisAddr: env("REDIS_ADDR", "127.0.0.1:6379"), + RedisUsername: env("REDIS_USERNAME", ""), + RedisPassword: env("REDIS_PASSWORD", ""), + RedisDB: envInt("REDIS_DB", 0), + TDengineDSN: env("TDENGINE_DSN", ""), + TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"), + CapacityCheckBin: env("CAPACITY_CHECK_BIN", "/opt/lingniu-go-native/current/capacity-check"), + AuthToken: env("AUTH_TOKEN", ""), + RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, + } +} + +func env(key string, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./... +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add vehicle-data-platform/apps/api/internal/httpx vehicle-data-platform/apps/api/internal/config +git commit -m "feat(platform-api): add response envelope and config" +``` + +## Task 4: Backend Platform Service With Mock Store + +**Files:** +- Create: `vehicle-data-platform/apps/api/internal/platform/model.go` +- Create: `vehicle-data-platform/apps/api/internal/platform/mock_store.go` +- Create: `vehicle-data-platform/apps/api/internal/platform/service.go` +- Create: `vehicle-data-platform/apps/api/internal/platform/service_test.go` + +- [ ] **Step 1: Write service tests** + +Create `vehicle-data-platform/apps/api/internal/platform/service_test.go`: + +```go +package platform + +import ( + "context" + "testing" +) + +func TestServiceDashboardSummary(t *testing.T) { + service := NewService(NewMockStore()) + summary, err := service.DashboardSummary(context.Background()) + if err != nil { + t.Fatal(err) + } + if summary.OnlineVehicles == 0 { + t.Fatalf("online vehicles should be positive: %#v", summary) + } + if len(summary.Protocols) == 0 { + t.Fatalf("protocols missing: %#v", summary) + } +} + +func TestServiceVehicleListPaginates(t *testing.T) { + service := NewService(NewMockStore()) + page, err := service.ListVehicles(context.Background(), VehicleQuery{Limit: 1, Offset: 0}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Total < 1 { + t.Fatalf("page = %#v", page) + } +} +``` + +- [ ] **Step 2: Run service tests and see failure** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./internal/platform +``` + +Expected: fail because package types are undefined. + +- [ ] **Step 3: Implement models** + +Create `vehicle-data-platform/apps/api/internal/platform/model.go`: + +```go +package platform + +type Page[T any] struct { + Items []T `json:"items"` + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ProtocolStat struct { + Protocol string `json:"protocol"` + Online int64 `json:"online"` + Total int64 `json:"total"` +} + +type DashboardSummary struct { + OnlineVehicles int64 `json:"onlineVehicles"` + ActiveToday int64 `json:"activeToday"` + FrameToday int64 `json:"frameToday"` + IssueVehicles int64 `json:"issueVehicles"` + KafkaLag int64 `json:"kafkaLag"` + Protocols []ProtocolStat `json:"protocols"` + LinkHealth []LinkHealth `json:"linkHealth"` +} + +type LinkHealth struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +type VehicleQuery struct { + Keyword string + Protocol string + Online string + Limit int + Offset int +} + +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"` +} +``` + +- [ ] **Step 4: Implement mock store** + +Create `vehicle-data-platform/apps/api/internal/platform/mock_store.go`: + +```go +package platform + +import "context" + +type Store interface { + DashboardSummary(context.Context) (DashboardSummary, error) + ListVehicles(context.Context, VehicleQuery) (Page[VehicleRow], error) +} + +type MockStore struct { + vehicles []VehicleRow +} + +func NewMockStore() *MockStore { + return &MockStore{vehicles: []VehicleRow{ + {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Phone: "13307795425", OEM: "G7s", Protocol: "JT808", Online: true, LastSeen: "2026-07-03 20:00:00", LocationText: "广东 广州", BindingScore: 100}, + {VIN: "LNXNEGRR7SR318212", Plate: "川A00001", Phone: "", OEM: "Hyundai", Protocol: "GB32960", Online: true, LastSeen: "2026-07-03 20:00:03", LocationText: "四川 成都", BindingScore: 80}, + }} +} + +func (s *MockStore) DashboardSummary(context.Context) (DashboardSummary, error) { + return DashboardSummary{ + OnlineVehicles: 2, + ActiveToday: 2, + FrameToday: 138240, + IssueVehicles: 1, + KafkaLag: 0, + Protocols: []ProtocolStat{ + {Protocol: "GB32960", Online: 1, Total: 1}, + {Protocol: "JT808", Online: 1, Total: 1}, + {Protocol: "YUTONG_MQTT", Online: 0, Total: 0}, + }, + LinkHealth: []LinkHealth{ + {Name: "Gateway", Status: "ok"}, + {Name: "NATS", Status: "ok"}, + {Name: "Kafka", Status: "ok"}, + {Name: "TDengine", Status: "ok"}, + {Name: "Redis", Status: "ok"}, + {Name: "MySQL", Status: "ok"}, + }, + }, nil +} + +func (s *MockStore) ListVehicles(_ context.Context, query VehicleQuery) (Page[VehicleRow], error) { + limit := query.Limit + if limit <= 0 { + limit = 20 + } + offset := query.Offset + if offset < 0 { + offset = 0 + } + end := offset + limit + if end > len(s.vehicles) { + end = len(s.vehicles) + } + items := []VehicleRow{} + if offset < len(s.vehicles) { + items = s.vehicles[offset:end] + } + return Page[VehicleRow]{Items: items, Total: int64(len(s.vehicles)), Limit: limit, Offset: offset}, nil +} +``` + +- [ ] **Step 5: Implement service** + +Create `vehicle-data-platform/apps/api/internal/platform/service.go`: + +```go +package platform + +import "context" + +type Service struct { + store Store +} + +func NewService(store Store) *Service { + if store == nil { + store = NewMockStore() + } + return &Service{store: store} +} + +func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) { + return s.store.DashboardSummary(ctx) +} + +func (s *Service) ListVehicles(ctx context.Context, query VehicleQuery) (Page[VehicleRow], error) { + if query.Limit <= 0 { + query.Limit = 20 + } + if query.Limit > 200 { + query.Limit = 200 + } + if query.Offset < 0 { + query.Offset = 0 + } + return s.store.ListVehicles(ctx, query) +} +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./internal/platform +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add vehicle-data-platform/apps/api/internal/platform +git commit -m "feat(platform-api): add platform service model" +``` + +## Task 5: Backend HTTP Routes And Static Serving + +**Files:** +- Create: `vehicle-data-platform/apps/api/internal/platform/handler.go` +- Create: `vehicle-data-platform/apps/api/internal/platform/handler_test.go` +- Create: `vehicle-data-platform/apps/api/internal/app/server.go` +- Create: `vehicle-data-platform/apps/api/internal/app/routes.go` +- Create: `vehicle-data-platform/apps/api/cmd/platform-api/main.go` + +- [ ] **Step 1: Write handler tests** + +Create `vehicle-data-platform/apps/api/internal/platform/handler_test.go`: + +```go +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()) + } + for _, want := range []string{"onlineVehicles", "protocols", "linkHealth"} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("response missing %s: %s", want, rec.Body.String()) + } + } +} + +func TestHandlerVehicleList(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles?limit=1", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"LB9A32A24R0LS1426", "items", "total"} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("response missing %s: %s", want, rec.Body.String()) + } + } +} +``` + +- [ ] **Step 2: Run handler tests and see failure** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./internal/platform -run Handler +``` + +Expected: fail because `NewHandler` is undefined. + +- [ ] **Step 3: Implement handler** + +Create `vehicle-data-platform/apps/api/internal/platform/handler.go`: + +```go +package platform + +import ( + "net/http" + "strconv" + "strings" + + "lingniu-vehicle-platform/api/internal/httpx" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + if service == nil { + service = NewService(NewMockStore()) + } + return &Handler{service: service} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + traceID := r.Header.Get("X-Trace-Id") + if traceID == "" { + traceID = "local" + } + path := strings.Trim(r.URL.Path, "/") + switch { + case r.Method == http.MethodGet && path == "api/dashboard/summary": + data, err := h.service.DashboardSummary(r.Context()) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "QUERY_FAILED", "查询失败", err.Error(), traceID) + return + } + httpx.WriteOK(w, traceID, data) + case r.Method == http.MethodGet && path == "api/vehicles": + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + data, err := h.service.ListVehicles(r.Context(), VehicleQuery{ + Keyword: r.URL.Query().Get("keyword"), + Protocol: r.URL.Query().Get("protocol"), + Online: r.URL.Query().Get("online"), + Limit: limit, + Offset: offset, + }) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "QUERY_FAILED", "查询失败", err.Error(), traceID) + return + } + httpx.WriteOK(w, traceID, data) + default: + httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "接口不存在", path, traceID) + } +} +``` + +- [ ] **Step 4: Implement app routes** + +Create `vehicle-data-platform/apps/api/internal/app/routes.go`: + +```go +package app + +import ( + "net/http" + + "lingniu-vehicle-platform/api/internal/platform" +) + +func NewMux(service *platform.Service, static http.Handler) *http.ServeMux { + mux := http.NewServeMux() + api := platform.NewHandler(service) + mux.Handle("/api/", api) + if static != nil { + mux.Handle("/", static) + } + return mux +} +``` + +Create `vehicle-data-platform/apps/api/internal/app/server.go`: + +```go +package app + +import ( + "net/http" + "time" +) + +func NewServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } +} +``` + +- [ ] **Step 5: Implement main** + +Create `vehicle-data-platform/apps/api/cmd/platform-api/main.go`: + +```go +package main + +import ( + "log" + "net/http" + "os" + + "lingniu-vehicle-platform/api/internal/app" + "lingniu-vehicle-platform/api/internal/config" + "lingniu-vehicle-platform/api/internal/platform" +) + +func main() { + cfg := config.Load() + service := platform.NewService(platform.NewMockStore()) + static := http.FileServer(http.Dir(os.Getenv("STATIC_DIR"))) + server := app.NewServer(cfg.HTTPAddr, app.NewMux(service, static)) + log.Printf("vehicle platform api listening on %s", cfg.HTTPAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} +``` + +- [ ] **Step 6: Run tests and build** + +Run: + +```bash +cd vehicle-data-platform/apps/api +go test ./... +go build ./cmd/platform-api +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add vehicle-data-platform/apps/api +git commit -m "feat(platform-api): expose dashboard and vehicle APIs" +``` + +## Task 6: Frontend App Shell With Semi UI + +**Files:** +- Create: `vehicle-data-platform/apps/web/src/main.tsx` +- Create: `vehicle-data-platform/apps/web/src/App.tsx` +- Create: `vehicle-data-platform/apps/web/src/layout/AppShell.tsx` +- Create: `vehicle-data-platform/apps/web/src/components/PageHeader.tsx` +- Create: `vehicle-data-platform/apps/web/src/components/StatusTag.tsx` +- Create: `vehicle-data-platform/apps/web/src/components/DataEmpty.tsx` +- Create: `vehicle-data-platform/apps/web/src/test/App.test.tsx` + +- [ ] **Step 1: Write app shell test** + +Create `vehicle-data-platform/apps/web/src/test/App.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react'; +import App from '../App'; + +test('renders vehicle data platform navigation', () => { + render(); + expect(screen.getByText('车辆数据管理中台')).toBeInTheDocument(); + expect(screen.getByText('总览工作台')).toBeInTheDocument(); + expect(screen.getByText('车辆台账')).toBeInTheDocument(); + expect(screen.getByText('实时监控')).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run test and see failure** + +Run: + +```bash +cd vehicle-data-platform/apps/web +npm run test +``` + +Expected: fail because `App` does not exist. + +- [ ] **Step 3: Implement entry and shell** + +Create `vehicle-data-platform/apps/web/src/main.tsx`: + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import '@douyinfe/semi-ui/dist/css/semi.min.css'; +import './styles/global.css'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +); +``` + +Create `vehicle-data-platform/apps/web/src/App.tsx`: + +```tsx +import { useState } from 'react'; +import { Dashboard } from './pages/Dashboard'; +import { Vehicles } from './pages/Vehicles'; +import { Realtime } from './pages/Realtime'; +import { History } from './pages/History'; +import { Mileage } from './pages/Mileage'; +import { Quality } from './pages/Quality'; +import { VehicleDetail } from './pages/VehicleDetail'; +import { AppShell, PageKey } from './layout/AppShell'; + +export default function App() { + const [page, setPage] = useState('dashboard'); + return ( + + {page === 'dashboard' && } + {page === 'vehicles' && } + {page === 'realtime' && } + {page === 'detail' && } + {page === 'history' && } + {page === 'mileage' && } + {page === 'quality' && } + + ); +} +``` + +Create `vehicle-data-platform/apps/web/src/layout/AppShell.tsx`: + +```tsx +import { Layout, Nav, Typography } from '@douyinfe/semi-ui'; +import { IconHome, IconMapPin, IconServer, IconSetting, IconHistogram, IconSearch, IconList } from '@douyinfe/semi-icons'; +import type { ReactNode } from 'react'; + +export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality'; + +const items = [ + { itemKey: 'dashboard', text: '总览工作台', icon: }, + { itemKey: 'vehicles', text: '车辆台账', icon: }, + { itemKey: 'realtime', text: '实时监控', icon: }, + { itemKey: 'detail', text: '车辆详情', icon: }, + { itemKey: 'history', text: '历史数据', icon: }, + { itemKey: 'mileage', text: '里程与统计', icon: }, + { itemKey: 'quality', text: '数据质量与链路健康', icon: } +]; + +export function AppShell({ active, onChange, children }: { active: PageKey; onChange: (key: PageKey) => void; children: ReactNode }) { + return ( + + +
+ 车辆数据管理中台 +
+