feat(platform): surface vehicle quality issues
This commit is contained in:
@@ -56,7 +56,7 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources", "sourceStatus"} {
|
for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus", "VIN_MISSING"} {
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,9 +163,15 @@ func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[Dail
|
|||||||
|
|
||||||
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
rows := []QualityIssueRow{
|
rows := []QualityIssueRow{
|
||||||
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 未命中 binding 表"},
|
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 曾未命中 binding 表,已通过手机号关联 VIN"},
|
||||||
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
|
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
|
||||||
}
|
}
|
||||||
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
|
rows = keep(rows, func(row QualityIssueRow) bool { return row.VIN == vin })
|
||||||
|
}
|
||||||
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||||
|
rows = keep(rows, func(row QualityIssueRow) bool { return row.Protocol == protocol })
|
||||||
|
}
|
||||||
return page(rows, query), nil
|
return page(rows, query), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type VehicleDetail struct {
|
|||||||
History Page[HistoryLocationRow] `json:"history"`
|
History Page[HistoryLocationRow] `json:"history"`
|
||||||
Raw Page[RawFrameRow] `json:"raw"`
|
Raw Page[RawFrameRow] `json:"raw"`
|
||||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
Mileage Page[DailyMileageRow] `json:"mileage"`
|
||||||
|
Quality Page[QualityIssueRow] `json:"quality"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VehicleSourceStatus struct {
|
type VehicleSourceStatus struct {
|
||||||
|
|||||||
@@ -273,19 +273,38 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
|||||||
|
|
||||||
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
limit, offset := buildLimitOffset(query)
|
limit, offset := buildLimitOffset(query)
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT phone, COALESCE(source_endpoint, ''), COALESCE(DATE_FORMAT(latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') FROM jt808_registration WHERE vin = '' OR vin = 'unknown' OR vin IS NULL ORDER BY latest_seen_at DESC LIMIT ? OFFSET ?`, limit, offset)
|
where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"}
|
||||||
|
args := []any{}
|
||||||
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
|
where = append(where, "(r.vin = ? OR b.vin = ?)")
|
||||||
|
args = append(args, vin, vin)
|
||||||
|
}
|
||||||
|
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||||
|
where = append(where, "? = 'JT808'")
|
||||||
|
args = append(args, protocol)
|
||||||
|
}
|
||||||
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||||
|
where = append(where, "(r.phone LIKE ? OR COALESCE(r.source_endpoint, '') LIKE ? OR COALESCE(b.plate, '') LIKE ?)")
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
args = append(args, like, like, like)
|
||||||
|
}
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT COALESCE(b.vin, 'unknown') AS vin, COALESCE(b.plate, '') AS plate, r.phone, COALESCE(r.source_endpoint, ''), COALESCE(DATE_FORMAT(r.latest_seen_at, '%Y-%m-%d %H:%i:%s'), '') `+
|
||||||
|
`FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone `+
|
||||||
|
`WHERE `+strings.Join(where, " AND ")+` ORDER BY r.latest_seen_at DESC LIMIT ? OFFSET ?`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Page[QualityIssueRow]{}, nil
|
return Page[QualityIssueRow]{}, nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
items := make([]QualityIssueRow, 0)
|
items := make([]QualityIssueRow, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var phone, source, lastSeen string
|
var vin, plate, phone, source, lastSeen string
|
||||||
if err := rows.Scan(&phone, &source, &lastSeen); err != nil {
|
if err := rows.Scan(&vin, &plate, &phone, &source, &lastSeen); err != nil {
|
||||||
return Page[QualityIssueRow]{}, err
|
return Page[QualityIssueRow]{}, err
|
||||||
}
|
}
|
||||||
items = append(items, QualityIssueRow{
|
items = append(items, QualityIssueRow{
|
||||||
VIN: "unknown",
|
VIN: vin,
|
||||||
|
Plate: plate,
|
||||||
Protocol: "JT808",
|
Protocol: "JT808",
|
||||||
IssueType: "VIN_MISSING",
|
IssueType: "VIN_MISSING",
|
||||||
Severity: "warning",
|
Severity: "warning",
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
historyQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
|
historyQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
|
||||||
rawQuery := RawFrameQuery{VIN: resolvedVIN, IncludeFields: true, Limit: 10}
|
rawQuery := RawFrameQuery{VIN: resolvedVIN, IncludeFields: true, Limit: 10}
|
||||||
mileageQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
|
mileageQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
|
||||||
|
qualityQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
|
||||||
if protocol != "" {
|
if protocol != "" {
|
||||||
realtimeQuery.Set("protocol", protocol)
|
realtimeQuery.Set("protocol", protocol)
|
||||||
historyQuery.Set("protocol", protocol)
|
historyQuery.Set("protocol", protocol)
|
||||||
rawQuery.Protocol = protocol
|
rawQuery.Protocol = protocol
|
||||||
|
qualityQuery.Set("protocol", protocol)
|
||||||
}
|
}
|
||||||
realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery)
|
realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,6 +92,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return VehicleDetail{}, err
|
return VehicleDetail{}, err
|
||||||
}
|
}
|
||||||
|
quality, err := s.store.QualityIssues(ctx, qualityQuery)
|
||||||
|
if err != nil {
|
||||||
|
return VehicleDetail{}, err
|
||||||
|
}
|
||||||
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
||||||
sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus)
|
sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus)
|
||||||
return VehicleDetail{
|
return VehicleDetail{
|
||||||
@@ -101,6 +107,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
History: history,
|
History: history,
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
Mileage: mileage,
|
Mileage: mileage,
|
||||||
|
Quality: quality,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface VehicleDetail {
|
|||||||
history: Page<HistoryLocationRow>;
|
history: Page<HistoryLocationRow>;
|
||||||
raw: Page<RawFrameRow>;
|
raw: Page<RawFrameRow>;
|
||||||
mileage: Page<DailyMileageRow>;
|
mileage: Page<DailyMileageRow>;
|
||||||
|
quality: Page<QualityIssueRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleSourceStatus {
|
export interface VehicleSourceStatus {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card, Col, Row, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { OpsHealth, QualityIssueRow } from '../api/types';
|
import type { OpsHealth, QualityIssueRow } from '../api/types';
|
||||||
@@ -8,10 +8,17 @@ export function Quality() {
|
|||||||
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
||||||
const [health, setHealth] = useState<OpsHealth | null>(null);
|
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadIssues = (values?: Record<string, string>) => {
|
||||||
api.qualityIssues(new URLSearchParams({ limit: '20' }))
|
const params = new URLSearchParams({ limit: '20' });
|
||||||
|
if (values?.keyword) params.set('keyword', values.keyword);
|
||||||
|
if (values?.protocol) params.set('protocol', values.protocol);
|
||||||
|
api.qualityIssues(params)
|
||||||
.then((page) => setIssues(page.items))
|
.then((page) => setIssues(page.items))
|
||||||
.catch((error: Error) => Toast.error(error.message));
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIssues();
|
||||||
api.opsHealth()
|
api.opsHealth()
|
||||||
.then(setHealth)
|
.then(setHealth)
|
||||||
.catch((error: Error) => Toast.error(error.message));
|
.catch((error: Error) => Toast.error(error.message));
|
||||||
@@ -26,6 +33,16 @@ export function Quality() {
|
|||||||
<Col span={8}><Card bordered title="存储写入">{health?.tdengineWritable && health.mysqlWritable ? '正常' : '异常'}</Card></Col>
|
<Col span={8}><Card bordered title="存储写入">{health?.tdengineWritable && health.mysqlWritable ? '正常' : '异常'}</Card></Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
|
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
|
||||||
|
<Form layout="horizontal" onSubmit={(values) => loadIssues(values as Record<string, string>)} style={{ marginBottom: 12 }}>
|
||||||
|
<Form.Input field="keyword" label="关键词" placeholder="手机号 / 来源地址 / 车牌" style={{ width: 240 }} />
|
||||||
|
<Form.Select field="protocol" label="协议" placeholder="全部协议" style={{ width: 160 }}>
|
||||||
|
<Select.Option value="JT808">JT808</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
<Space>
|
||||||
|
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||||||
|
<Button onClick={() => loadIssues()}>重置</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
<Table
|
<Table
|
||||||
rowKey="vin"
|
rowKey="vin"
|
||||||
dataSource={issues}
|
dataSource={issues}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toas
|
|||||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
|
import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export function VehicleDetail({ vin }: { vin: string }) {
|
|||||||
const latest = detail?.realtime[0];
|
const latest = detail?.realtime[0];
|
||||||
const protocols = useMemo(() => detail?.sources ?? [], [detail?.sources]);
|
const protocols = useMemo(() => detail?.sources ?? [], [detail?.sources]);
|
||||||
const latestRaw = detail?.raw.items[0];
|
const latestRaw = detail?.raw.items[0];
|
||||||
|
const qualityCount = detail?.quality.items.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
@@ -78,7 +79,8 @@ export function VehicleDetail({ vin }: { vin: string }) {
|
|||||||
{ key: '在线', value: <StatusTag status={identity?.online || latest ? 'ok' : 'offline'} /> },
|
{ key: '在线', value: <StatusTag status={identity?.online || latest ? 'ok' : 'offline'} /> },
|
||||||
{ key: '来源协议', value: protocols.length > 0 ? <Space>{protocols.map((item) => <Tag key={item} color="blue">{item}</Tag>)}</Space> : '-' },
|
{ key: '来源协议', value: protocols.length > 0 ? <Space>{protocols.map((item) => <Tag key={item} color="blue">{item}</Tag>)}</Space> : '-' },
|
||||||
{ key: '最后位置时间', value: latest?.lastSeen ?? '-' },
|
{ key: '最后位置时间', value: latest?.lastSeen ?? '-' },
|
||||||
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' }
|
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' },
|
||||||
|
{ key: '质量风险', value: <Tag color={qualityCount > 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount} 项` : '无'}</Tag> }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,6 +172,21 @@ export function VehicleDetail({ vin }: { vin: string }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane tab="质量风险" itemKey="quality">
|
||||||
|
<Table
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(row?: QualityIssueRow) => `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`}
|
||||||
|
dataSource={detail?.quality.items ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||||
|
{ title: '问题', dataIndex: 'issueType', width: 150 },
|
||||||
|
{ title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
|
||||||
|
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
|
||||||
|
{ title: '说明', dataIndex: 'detail' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user