1751 lines
47 KiB
Markdown
1751 lines
47 KiB
Markdown
# 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
|
|
<!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>
|
|
```
|
|
|
|
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(<App />);
|
|
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(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>
|
|
);
|
|
```
|
|
|
|
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<PageKey>('dashboard');
|
|
return (
|
|
<AppShell active={page} onChange={setPage}>
|
|
{page === 'dashboard' && <Dashboard />}
|
|
{page === 'vehicles' && <Vehicles />}
|
|
{page === 'realtime' && <Realtime />}
|
|
{page === 'detail' && <VehicleDetail />}
|
|
{page === 'history' && <History />}
|
|
{page === 'mileage' && <Mileage />}
|
|
{page === 'quality' && <Quality />}
|
|
</AppShell>
|
|
);
|
|
}
|
|
```
|
|
|
|
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: <IconHome /> },
|
|
{ itemKey: 'vehicles', text: '车辆台账', icon: <IconList /> },
|
|
{ itemKey: 'realtime', text: '实时监控', icon: <IconMapPin /> },
|
|
{ itemKey: 'detail', text: '车辆详情', icon: <IconSearch /> },
|
|
{ itemKey: 'history', text: '历史数据', icon: <IconServer /> },
|
|
{ itemKey: 'mileage', text: '里程与统计', icon: <IconHistogram /> },
|
|
{ itemKey: 'quality', text: '数据质量与链路健康', icon: <IconSetting /> }
|
|
];
|
|
|
|
export function AppShell({ active, onChange, children }: { active: PageKey; onChange: (key: PageKey) => void; children: ReactNode }) {
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Layout.Sider style={{ width: 'var(--vp-shell-sidebar)', background: '#fff', borderRight: '1px solid var(--vp-border)' }}>
|
|
<div style={{ height: 56, display: 'flex', alignItems: 'center', padding: '0 20px', borderBottom: '1px solid var(--vp-border)' }}>
|
|
<Typography.Text strong>车辆数据管理中台</Typography.Text>
|
|
</div>
|
|
<Nav selectedKeys={[active]} items={items} onSelect={({ itemKey }) => onChange(itemKey as PageKey)} />
|
|
</Layout.Sider>
|
|
<Layout>
|
|
<Layout.Header style={{ height: 56, background: '#fff', borderBottom: '1px solid var(--vp-border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 24px' }}>
|
|
<Typography.Text type="tertiary">生产环境 · 自动刷新 60s</Typography.Text>
|
|
<Typography.Text type="tertiary">ECS 115.29.187.205</Typography.Text>
|
|
</Layout.Header>
|
|
<Layout.Content>{children}</Layout.Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement shared components**
|
|
|
|
Create `vehicle-data-platform/apps/web/src/components/PageHeader.tsx`:
|
|
|
|
```tsx
|
|
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>
|
|
);
|
|
}
|
|
```
|
|
|
|
Create `vehicle-data-platform/apps/web/src/components/StatusTag.tsx`:
|
|
|
|
```tsx
|
|
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>;
|
|
}
|
|
```
|
|
|
|
Create `vehicle-data-platform/apps/web/src/components/DataEmpty.tsx`:
|
|
|
|
```tsx
|
|
import { Empty } from '@douyinfe/semi-ui';
|
|
|
|
export function DataEmpty({ text = '暂无数据' }: { text?: string }) {
|
|
return <Empty description={text} style={{ padding: 48 }} />;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create first-pass page components**
|
|
|
|
Create `vehicle-data-platform/apps/web/src/pages/Dashboard.tsx`:
|
|
|
|
```tsx
|
|
import { PageHeader } from '../components/PageHeader';
|
|
|
|
export function Dashboard() {
|
|
return (
|
|
<div className="vp-page">
|
|
<PageHeader title="总览工作台" description="车辆在线、协议分布、数据质量和链路健康的统一入口" />
|
|
<div className="vp-section" style={{ padding: 24 }}>加载总览数据...</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Create the remaining page files with the exact title and description below. Each file imports `PageHeader`, exports a function named after the file, wraps content in `<div className="vp-page">`, and renders one `<div className="vp-section" style={{ padding: 24 }}>` with the listed loading text.
|
|
|
|
| File | Export | Title | Description | Loading text |
|
|
| --- | --- | --- | --- | --- |
|
|
| `Vehicles.tsx` | `Vehicles` | `车辆台账` | `车辆身份、协议绑定、车牌、手机号和 OEM 的运营台账` | `加载车辆台账...` |
|
|
| `Realtime.tsx` | `Realtime` | `实时状态` | `按协议和车辆查看最新实时位置、在线状态和核心数据` | `加载实时状态...` |
|
|
| `VehicleDetail.tsx` | `VehicleDetail` | `车辆详情` | `单车身份、实时、历史、RAW、里程和质量的综合视图` | `加载车辆详情...` |
|
|
| `History.tsx` | `History` | `历史查询` | `位置历史和 RAW 帧历史的分页查询工作台` | `加载历史查询...` |
|
|
| `Mileage.tsx` | `Mileage` | `里程分析` | `每日里程、区间里程和异常差值分析` | `加载里程分析...` |
|
|
| `Quality.tsx` | `Quality` | `数据质量` | `断链、VIN 缺失、字段缺失和链路健康的排查入口` | `加载数据质量...` |
|
|
|
|
- [ ] **Step 6: Run tests and build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/web
|
|
npm run test
|
|
npm run build
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/apps/web/src
|
|
git commit -m "feat(platform-web): add Semi UI app shell"
|
|
```
|
|
|
|
## Task 7: Frontend API Client And Dashboard
|
|
|
|
**Files:**
|
|
- Create: `vehicle-data-platform/apps/web/src/api/types.ts`
|
|
- Create: `vehicle-data-platform/apps/web/src/api/client.ts`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/Dashboard.tsx`
|
|
|
|
- [ ] **Step 1: Create API types**
|
|
|
|
Create `vehicle-data-platform/apps/web/src/api/types.ts`:
|
|
|
|
```ts
|
|
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 Page<T> {
|
|
items: T[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create API client**
|
|
|
|
Create `vehicle-data-platform/apps/web/src/api/client.ts`:
|
|
|
|
```ts
|
|
import type { ApiEnvelope, DashboardSummary, Page, VehicleRow } from './types';
|
|
|
|
async function request<T>(path: string): Promise<T> {
|
|
const response = await fetch(path);
|
|
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: URLSearchParams) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`)
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Update dashboard**
|
|
|
|
Replace `Dashboard.tsx` with a complete data-bound page. It must import `useEffect`, `useState`, Semi UI `Card`, `Col`, `Row`, `Spin`, `Table`, `Tag`, and `Toast`, plus `api`, `DashboardSummary`, and `PageHeader`. The component state and loading lifecycle must be:
|
|
|
|
```tsx
|
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
api.dashboardSummary()
|
|
.then(setSummary)
|
|
.catch(error => Toast.error(error.message))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
```
|
|
|
|
Render inside `<Spin spinning={loading}>`:
|
|
|
|
- Four KPI cards: online vehicles, active today, frames today, issue vehicles.
|
|
- A protocol table with columns `protocol`, `online`, `total`.
|
|
- A link-health table with columns `name`, `status`, `detail`; status renders `Tag` with green for `ok`, orange for `warning`, red for `error`, and grey for other values.
|
|
- A compact Kafka lag card showing `summary.kafkaLag`.
|
|
|
|
- [ ] **Step 4: Run frontend build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/web
|
|
npm run build
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/apps/web/src/api vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
|
|
git commit -m "feat(platform-web): connect dashboard summary"
|
|
```
|
|
|
|
## Task 8: Vehicles And Realtime Pages
|
|
|
|
**Files:**
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/Vehicles.tsx`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/Realtime.tsx`
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/model.go`
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/handler.go`
|
|
|
|
- [ ] **Step 1: Write backend test for realtime location route**
|
|
|
|
Add to `handler_test.go`:
|
|
|
|
```go
|
|
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())
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run backend test and see failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/api
|
|
go test ./internal/platform -run RealtimeLocations
|
|
```
|
|
|
|
Expected: fail with 404.
|
|
|
|
- [ ] **Step 3: Implement realtime location models and route**
|
|
|
|
Add `RealtimeLocationRow` to `model.go` and expose `GET /api/realtime/locations` using mock data derived from vehicles.
|
|
|
|
Expected JSON fields:
|
|
|
|
```json
|
|
{
|
|
"vin": "LB9A32A24R0LS1426",
|
|
"plate": "粤AG18312",
|
|
"protocol": "JT808",
|
|
"longitude": 113.2644,
|
|
"latitude": 23.1291,
|
|
"speedKmh": 42.5,
|
|
"socPercent": 78.4,
|
|
"totalMileageKm": 119925,
|
|
"lastSeen": "2026-07-03 20:00:00"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement Vehicles page**
|
|
|
|
Use Semi UI `Form`, `Input`, `Select`, `Button`, `Table`, `Drawer`, and `Tag`. The table must show VIN, plate, phone, OEM, protocol, online status, lastSeen, locationText, and bindingScore.
|
|
|
|
- [ ] **Step 5: Implement Realtime page**
|
|
|
|
Use two tabs:
|
|
|
|
- `表格视图` with realtime location table.
|
|
- `地图视图` with a professional schematic map panel that plots rows as simple positioned dots inside a bounded panel.
|
|
|
|
Do not use a full-screen decorative map.
|
|
|
|
- [ ] **Step 6: Verify**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/api && go test ./...
|
|
cd ../web && npm run build
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/apps/api vehicle-data-platform/apps/web/src/pages/Vehicles.tsx vehicle-data-platform/apps/web/src/pages/Realtime.tsx
|
|
git commit -m "feat(platform): add vehicles and realtime pages"
|
|
```
|
|
|
|
## Task 9: History, Mileage, Quality, And Vehicle Detail Pages
|
|
|
|
**Files:**
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/model.go`
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/mock_store.go`
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/service.go`
|
|
- Modify: `vehicle-data-platform/apps/api/internal/platform/handler.go`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/History.tsx`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/Mileage.tsx`
|
|
- Modify: `vehicle-data-platform/apps/web/src/pages/Quality.tsx`
|
|
|
|
- [ ] **Step 1: Add backend tests**
|
|
|
|
Add handler tests for:
|
|
|
|
- `GET /api/history/locations`
|
|
- `POST /api/history/raw-frames/query`
|
|
- `GET /api/mileage/daily`
|
|
- `GET /api/quality/issues`
|
|
- `GET /api/ops/health`
|
|
|
|
Each test should assert HTTP 200 and a domain-specific field, such as `rawSizeBytes`, `dailyMileageKm`, `issueType`, or `linkHealth`.
|
|
|
|
- [ ] **Step 2: Run backend tests and see failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/api
|
|
go test ./internal/platform -run 'History|Mileage|Quality|Ops'
|
|
```
|
|
|
|
Expected: fail because routes are missing.
|
|
|
|
- [ ] **Step 3: Implement mock-backed routes**
|
|
|
|
Implement mock-backed service methods and routes for all five endpoints with these concrete response models:
|
|
|
|
- `HistoryLocationRow`: `vin`, `plate`, `protocol`, `longitude`, `latitude`, `speedKmh`, `totalMileageKm`, `deviceTime`, `serverTime`.
|
|
- `RawFrameRow`: `id`, `vin`, `protocol`, `frameType`, `deviceTime`, `serverTime`, `rawSizeBytes`, `parsedFields`.
|
|
- `DailyMileageRow`: `vin`, `plate`, `date`, `startMileageKm`, `endMileageKm`, `dailyMileageKm`, `source`.
|
|
- `QualityIssueRow`: `vin`, `plate`, `protocol`, `issueType`, `severity`, `lastSeen`, `detail`.
|
|
- `OpsHealth`: `linkHealth`, `kafkaLag`, `redisOnlineKeys`, `tdengineWritable`, `mysqlWritable`.
|
|
|
|
The mock store returns at least two rows per table so frontend empty and non-empty table states can both be verified by changing query filters.
|
|
|
|
- [ ] **Step 4: Implement pages**
|
|
|
|
Use Semi UI:
|
|
|
|
- `VehicleDetail`: `Tabs`, `Descriptions`, `Table`, `CodeHighlight` style JSON block.
|
|
- `History`: filters, `Tabs`, table, raw detail drawer.
|
|
- `Mileage`: date range controls, daily mileage table, anomaly tags.
|
|
- `Quality`: issue summary cards, issue table, ops health table.
|
|
|
|
Each page must call its matching API endpoint through `src/api/client.ts`, show a loading state, show `DataEmpty` when no rows are returned, and render `Toast.error(error.message)` on request failure.
|
|
|
|
- [ ] **Step 5: Verify**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/api && go test ./...
|
|
cd ../web && npm run build
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/apps/api vehicle-data-platform/apps/web/src/pages
|
|
git commit -m "feat(platform): add history mileage quality workflows"
|
|
```
|
|
|
|
## Task 10: Replace Mock Store With Production Repositories
|
|
|
|
**Files:**
|
|
- Create: `vehicle-data-platform/apps/api/internal/platform/mysql_store.go`
|
|
- Create: `vehicle-data-platform/apps/api/internal/platform/redis_store.go`
|
|
- Create: `vehicle-data-platform/apps/api/internal/platform/tdengine_store.go`
|
|
- Modify: `vehicle-data-platform/apps/api/cmd/platform-api/main.go`
|
|
|
|
- [ ] **Step 1: Write repository query-builder tests**
|
|
|
|
Do not add a SQL mocking dependency. Create pure query-builder functions and unit test SQL strings plus argument order.
|
|
|
|
Create tests for these query builders:
|
|
|
|
- Vehicle list from `vehicle_identity_binding` plus realtime location.
|
|
- Mileage daily from `vehicle_daily_mileage`.
|
|
- Raw frame query from TDengine `raw_frames`.
|
|
|
|
- [ ] **Step 2: Implement production store**
|
|
|
|
Create a `ProductionStore` that satisfies `platform.Store`. It should:
|
|
|
|
- Use MySQL for vehicle list, realtime snapshot, realtime location, mileage, identity.
|
|
- Use TDengine for raw frames and history locations.
|
|
- Use Redis for realtime raw and online state.
|
|
- Use capacity-check executable or local metrics endpoints for ops health.
|
|
|
|
- [ ] **Step 3: Keep mock fallback**
|
|
|
|
In `main.go`, if production DSNs are empty, use `NewMockStore()`. If DSNs are set, use `NewProductionStore(...)`. This makes local UI development possible without production credentials.
|
|
|
|
- [ ] **Step 4: Verify**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform/apps/api
|
|
go test ./...
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/apps/api
|
|
git commit -m "feat(platform-api): connect production data stores"
|
|
```
|
|
|
|
## Task 11: Static Serving, Deployment, And Systemd
|
|
|
|
**Files:**
|
|
- Create: `vehicle-data-platform/deploy/systemd/lingniu-vehicle-platform.service`
|
|
- Create: `vehicle-data-platform/docs/deployment.md`
|
|
- Modify: `vehicle-data-platform/package.json`
|
|
- Modify: `vehicle-data-platform/apps/api/cmd/platform-api/main.go`
|
|
|
|
- [ ] **Step 1: Create systemd unit**
|
|
|
|
Create `vehicle-data-platform/deploy/systemd/lingniu-vehicle-platform.service`:
|
|
|
|
```ini
|
|
[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
|
|
```
|
|
|
|
- [ ] **Step 2: Write deployment doc**
|
|
|
|
Create `vehicle-data-platform/docs/deployment.md` with:
|
|
|
|
~~~markdown
|
|
# Deployment
|
|
|
|
## Build
|
|
|
|
Run from repository root:
|
|
|
|
```bash
|
|
cd vehicle-data-platform
|
|
npm install
|
|
npm --prefix apps/web install
|
|
npm 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
|
|
```
|
|
|
|
## Health
|
|
|
|
```bash
|
|
curl -fsS http://127.0.0.1:20300/api/ops/health
|
|
```
|
|
~~~
|
|
|
|
- [ ] **Step 3: Verify build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform
|
|
npm run web:build
|
|
cd apps/api
|
|
go test ./...
|
|
go build -o ../../dist/platform-api ./cmd/platform-api
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add vehicle-data-platform/deploy vehicle-data-platform/docs vehicle-data-platform/package.json vehicle-data-platform/apps/api/cmd/platform-api/main.go
|
|
git commit -m "ops(platform): add deployment unit and docs"
|
|
```
|
|
|
|
## Task 12: ECS Deploy And Browser Verification
|
|
|
|
**Files:**
|
|
- Modify only if deployment reveals a concrete defect.
|
|
|
|
- [ ] **Step 1: Build release locally**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd vehicle-data-platform
|
|
npm install
|
|
npm --prefix apps/web install
|
|
npm 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
|
|
```
|
|
|
|
- [ ] **Step 2: Upload release**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
release="platform-$(date +%Y%m%d%H%M%S)"
|
|
ssh root@115.29.187.205 "mkdir -p /opt/lingniu-vehicle-platform/releases/$release /opt/lingniu-vehicle-platform/env"
|
|
scp vehicle-data-platform/dist/platform-api root@115.29.187.205:/opt/lingniu-vehicle-platform/releases/$release/
|
|
scp -r vehicle-data-platform/apps/web/dist root@115.29.187.205:/opt/lingniu-vehicle-platform/releases/$release/web
|
|
scp vehicle-data-platform/deploy/systemd/lingniu-vehicle-platform.service root@115.29.187.205:/etc/systemd/system/
|
|
ssh root@115.29.187.205 "chmod 755 /opt/lingniu-vehicle-platform/releases/$release/platform-api && ln -sfn /opt/lingniu-vehicle-platform/releases/$release /opt/lingniu-vehicle-platform/current"
|
|
```
|
|
|
|
- [ ] **Step 3: Create environment file**
|
|
|
|
Create `/opt/lingniu-vehicle-platform/env/platform.env` on ECS with production DSNs. Do not commit secrets.
|
|
|
|
Required keys:
|
|
|
|
```text
|
|
HTTP_ADDR=:20300
|
|
STATIC_DIR=/opt/lingniu-vehicle-platform/current/web
|
|
MYSQL_DSN=...
|
|
REDIS_ADDR=...
|
|
REDIS_USERNAME=...
|
|
REDIS_PASSWORD=...
|
|
REDIS_DB=50
|
|
TDENGINE_DSN=...
|
|
TDENGINE_DATABASE=lingniu_vehicle_ts
|
|
CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
|
|
AUTH_TOKEN=...
|
|
```
|
|
|
|
- [ ] **Step 4: Start service**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
ssh root@115.29.187.205 "systemctl daemon-reload && systemctl enable --now lingniu-vehicle-platform.service && systemctl status lingniu-vehicle-platform.service --no-pager"
|
|
```
|
|
|
|
- [ ] **Step 5: Verify APIs**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
curl -fsS http://127.0.0.1:20300/api/dashboard/summary
|
|
curl -fsS 'http://127.0.0.1:20300/api/vehicles?limit=5'
|
|
curl -fsS http://127.0.0.1:20300/api/ops/health
|
|
```
|
|
|
|
Expected:
|
|
|
|
- All return JSON envelopes.
|
|
- Dashboard has `onlineVehicles`.
|
|
- Vehicles has `items`.
|
|
- Ops health has link health state.
|
|
|
|
- [ ] **Step 6: Browser verification**
|
|
|
|
Open:
|
|
|
|
```text
|
|
http://115.29.187.205:20300
|
|
```
|
|
|
|
Verify:
|
|
|
|
- Dashboard loads.
|
|
- Vehicles page table loads.
|
|
- Realtime page loads.
|
|
- History page renders query controls.
|
|
- Mileage page renders tables.
|
|
- Quality page renders issue and health sections.
|
|
- Visual style is Semi UI console style: clean, dense, professional, no marketing hero.
|
|
|
|
- [ ] **Step 7: Commit deployment fixes**
|
|
|
|
If deployment required code changes:
|
|
|
|
```bash
|
|
git add vehicle-data-platform
|
|
git commit -m "fix(platform): stabilize ecs deployment"
|
|
```
|
|
|
|
## Self Review
|
|
|
|
Spec coverage:
|
|
|
|
- New project folder: Task 2 creates `vehicle-data-platform`.
|
|
- Semi UI frontend: Tasks 1, 2, 6, 7, 8, 9.
|
|
- Go backend: Tasks 2, 3, 4, 5, 10.
|
|
- Dashboard, vehicles, realtime, detail, history, mileage, quality: Tasks 6 through 9.
|
|
- RAW query with POST and field filtering: Tasks 9 and 10.
|
|
- Production data sources: Task 10.
|
|
- ECS deployment and browser testing: Tasks 11 and 12.
|
|
|
|
Completeness scan:
|
|
|
|
- This plan contains concrete files, routes, models, verification commands, and commit points.
|
|
- Production secrets are intentionally described as environment values and must not be committed.
|
|
|
|
Type consistency:
|
|
|
|
- `DashboardSummary`, `VehicleRow`, `Page<T>`, and API envelope names match between backend and frontend tasks.
|
|
- API route names match the design spec.
|