feat(platform): filter vehicle coverage
This commit is contained in:
@@ -36,7 +36,7 @@ func TestHandlerVehicles(t *testing.T) {
|
||||
func TestHandlerVehicleCoverage(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&coverage=multi&online=online&bindingStatus=bound", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
@@ -87,7 +87,9 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
||||
for _, row := range byVIN {
|
||||
items = append(items, *row)
|
||||
if keepCoverageRow(*row, query) {
|
||||
items = append(items, *row)
|
||||
}
|
||||
}
|
||||
sortVehicleCoverage(items)
|
||||
return page(items, query), nil
|
||||
@@ -237,6 +239,37 @@ func sortVehicleCoverage(rows []VehicleCoverageRow) {
|
||||
})
|
||||
}
|
||||
|
||||
func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool {
|
||||
switch strings.TrimSpace(query.Get("coverage")) {
|
||||
case "single":
|
||||
if row.SourceCount != 1 {
|
||||
return false
|
||||
}
|
||||
case "multi":
|
||||
if row.SourceCount <= 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
if !row.Online {
|
||||
return false
|
||||
}
|
||||
case "offline":
|
||||
if row.Online {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("bindingStatus")) {
|
||||
case "bound":
|
||||
return row.BindingStatus == "bound"
|
||||
case "unbound":
|
||||
return row.BindingStatus == "unbound"
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositive(raw string, fallback int) int {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
|
||||
@@ -45,6 +45,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
args := []any{}
|
||||
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
@@ -54,21 +55,44 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
where = append(where, "s.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("coverage")) {
|
||||
case "single":
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
|
||||
case "multi":
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
||||
case "offline":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("bindingStatus")) {
|
||||
case "bound":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||
case "unbound":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
havingSQL := ""
|
||||
if len(having) > 0 {
|
||||
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT s.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||
`SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) AS online_source_count, ` +
|
||||
`CASE WHEN SUM(CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
||||
`CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
|
||||
`FROM vehicle_realtime_snapshot s ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` +
|
||||
havingSQL +
|
||||
`ORDER BY MAX(s.updated_at) DESC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@ func TestBuildVehicleListSQL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "limit": {"8"}, "offset": {"16"}}
|
||||
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
for _, want := range []string{"GROUP BY s.vin", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} {
|
||||
for _, want := range []string{"GROUP BY s.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "1ORDER BY") || strings.Contains(built.Text, "0ORDER BY") {
|
||||
t.Fatalf("SQL should keep whitespace before ORDER BY: %s", built.Text)
|
||||
}
|
||||
if len(built.Args) != 9 || built.Args[0] != "%粤A%" || built.Args[6] != "GB32960" || built.Args[7] != 8 || built.Args[8] != 16 {
|
||||
t.Fatalf("args = %#v", built.Args)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Col, Row, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Col, Form, Row, Select, Space, 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, VehicleCoverageRow, VehicleRow } from '../api/types';
|
||||
@@ -17,6 +17,20 @@ export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => v
|
||||
const [coverage, setCoverage] = useState<VehicleCoverageRow[]>([]);
|
||||
const [locations, setLocations] = useState<RealtimeLocationRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||
|
||||
const loadCoverage = (values?: Record<string, string>) => {
|
||||
setCoverageLoading(true);
|
||||
const params = new URLSearchParams({ limit: '8' });
|
||||
if (values?.keyword) params.set('keyword', values.keyword);
|
||||
if (values?.coverage) params.set('coverage', values.coverage);
|
||||
if (values?.online) params.set('online', values.online);
|
||||
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
||||
api.vehicleCoverage(params)
|
||||
.then((page) => setCoverage(page.items))
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
.finally(() => setCoverageLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -93,7 +107,27 @@ export function Dashboard({ onOpenVehicle }: { onOpenVehicle: (vin: string) => v
|
||||
<Typography.Text>Kafka 当前消费积压:{summary?.kafkaLag ?? 0}</Typography.Text>
|
||||
</Card>
|
||||
<Card title="车辆服务覆盖" bordered style={{ marginTop: 16 }}>
|
||||
<Form layout="horizontal" onSubmit={(values) => loadCoverage(values as Record<string, string>)} style={{ marginBottom: 12 }}>
|
||||
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 240 }} />
|
||||
<Form.Select field="coverage" label="来源覆盖" placeholder="全部" style={{ width: 130 }}>
|
||||
<Select.Option value="single">单源</Select.Option>
|
||||
<Select.Option value="multi">多源</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||||
<Select.Option value="online">在线</Select.Option>
|
||||
<Select.Option value="offline">离线</Select.Option>
|
||||
</Form.Select>
|
||||
<Form.Select field="bindingStatus" label="绑定" placeholder="全部" style={{ width: 130 }}>
|
||||
<Select.Option value="bound">已绑定</Select.Option>
|
||||
<Select.Option value="unbound">未绑定</Select.Option>
|
||||
</Form.Select>
|
||||
<Space>
|
||||
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||||
<Button onClick={() => loadCoverage()}>重置</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<Table
|
||||
loading={coverageLoading}
|
||||
pagination={false}
|
||||
rowKey="vin"
|
||||
dataSource={coverage}
|
||||
|
||||
Reference in New Issue
Block a user