feat(platform): summarize vehicle service status
This commit is contained in:
@@ -88,6 +88,31 @@ func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerVehicleServiceIncludesVehicleLevelStatus(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312", nil)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data VehicleDetail `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Data.ServiceStatus == nil {
|
||||||
|
t.Fatalf("vehicle service should include serviceStatus: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Data.ServiceStatus.Status != "degraded" || body.Data.ServiceStatus.Title != "部分来源离线" {
|
||||||
|
t.Fatalf("vehicle service should summarize partial source health, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Data.ServiceStatus.OnlineSourceCount != 1 || body.Data.ServiceStatus.SourceCount != 2 {
|
||||||
|
t.Fatalf("vehicle service should expose source counts, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) {
|
func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type VehicleDetail struct {
|
|||||||
Resolution *VehicleIdentityResolution `json:"resolution,omitempty"`
|
Resolution *VehicleIdentityResolution `json:"resolution,omitempty"`
|
||||||
Identity *VehicleRow `json:"identity,omitempty"`
|
Identity *VehicleRow `json:"identity,omitempty"`
|
||||||
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
||||||
|
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||||
Sources []string `json:"sources"`
|
Sources []string `json:"sources"`
|
||||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||||
@@ -82,6 +83,15 @@ type VehicleDetail struct {
|
|||||||
Quality Page[QualityIssueRow] `json:"quality"`
|
Quality Page[QualityIssueRow] `json:"quality"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VehicleServiceStatus struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
SourceCount int `json:"sourceCount"`
|
||||||
|
OnlineSourceCount int `json:"onlineSourceCount"`
|
||||||
|
}
|
||||||
|
|
||||||
type VehicleSourceStatus struct {
|
type VehicleSourceStatus struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
LookupKey: keyword,
|
LookupKey: keyword,
|
||||||
LookupResolved: false,
|
LookupResolved: false,
|
||||||
Resolution: &resolution,
|
Resolution: &resolution,
|
||||||
|
ServiceStatus: buildVehicleServiceStatus(false, nil),
|
||||||
Sources: []string{},
|
Sources: []string{},
|
||||||
SourceStatus: []VehicleSourceStatus{},
|
SourceStatus: []VehicleSourceStatus{},
|
||||||
Realtime: []RealtimeLocationRow{},
|
Realtime: []RealtimeLocationRow{},
|
||||||
@@ -176,6 +178,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
Resolution: &resolution,
|
Resolution: &resolution,
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
RealtimeSummary: summary,
|
RealtimeSummary: summary,
|
||||||
|
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
|
||||||
Sources: sourceNames(sourceStatus),
|
Sources: sourceNames(sourceStatus),
|
||||||
SourceStatus: sourceStatus,
|
SourceStatus: sourceStatus,
|
||||||
Realtime: realtime.Items,
|
Realtime: realtime.Items,
|
||||||
@@ -419,6 +422,66 @@ func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow,
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *VehicleServiceStatus {
|
||||||
|
if !resolved {
|
||||||
|
return &VehicleServiceStatus{
|
||||||
|
Status: "identity_required",
|
||||||
|
Severity: "warning",
|
||||||
|
Title: "身份未绑定",
|
||||||
|
Detail: "车辆关键词暂未解析到 VIN,需先维护身份绑定后才能形成完整车辆服务。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceCount := len(statuses)
|
||||||
|
onlineSourceCount := 0
|
||||||
|
for _, status := range statuses {
|
||||||
|
if status.Online {
|
||||||
|
onlineSourceCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sourceCount == 0 {
|
||||||
|
return &VehicleServiceStatus{
|
||||||
|
Status: "no_data",
|
||||||
|
Severity: "warning",
|
||||||
|
Title: "暂无数据来源",
|
||||||
|
Detail: "车辆已解析,但暂未查询到 32960、808 或 MQTT 数据来源。",
|
||||||
|
SourceCount: sourceCount,
|
||||||
|
OnlineSourceCount: onlineSourceCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onlineSourceCount == 0 {
|
||||||
|
return &VehicleServiceStatus{
|
||||||
|
Status: "offline",
|
||||||
|
Severity: "error",
|
||||||
|
Title: "车辆离线",
|
||||||
|
Detail: "所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。",
|
||||||
|
SourceCount: sourceCount,
|
||||||
|
OnlineSourceCount: onlineSourceCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onlineSourceCount < sourceCount {
|
||||||
|
return &VehicleServiceStatus{
|
||||||
|
Status: "degraded",
|
||||||
|
Severity: "warning",
|
||||||
|
Title: "部分来源离线",
|
||||||
|
Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "车辆服务可用但需要关注离线来源。"),
|
||||||
|
SourceCount: sourceCount,
|
||||||
|
OnlineSourceCount: onlineSourceCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &VehicleServiceStatus{
|
||||||
|
Status: "healthy",
|
||||||
|
Severity: "ok",
|
||||||
|
Title: "服务正常",
|
||||||
|
Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "全部已知来源在线。"),
|
||||||
|
SourceCount: sourceCount,
|
||||||
|
OnlineSourceCount: onlineSourceCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string {
|
||||||
|
return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix
|
||||||
|
}
|
||||||
|
|
||||||
func latestString(left string, right string) string {
|
func latestString(left string, right string) string {
|
||||||
left = strings.TrimSpace(left)
|
left = strings.TrimSpace(left)
|
||||||
right = strings.TrimSpace(right)
|
right = strings.TrimSpace(right)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface VehicleDetail {
|
|||||||
resolution?: VehicleIdentityResolution;
|
resolution?: VehicleIdentityResolution;
|
||||||
identity?: VehicleRow;
|
identity?: VehicleRow;
|
||||||
realtimeSummary?: VehicleRealtimeRow;
|
realtimeSummary?: VehicleRealtimeRow;
|
||||||
|
serviceStatus?: VehicleServiceStatus;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
sourceStatus: VehicleSourceStatus[];
|
sourceStatus: VehicleSourceStatus[];
|
||||||
realtime: RealtimeLocationRow[];
|
realtime: RealtimeLocationRow[];
|
||||||
@@ -79,6 +80,15 @@ export interface VehicleDetail {
|
|||||||
quality: Page<QualityIssueRow>;
|
quality: Page<QualityIssueRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleServiceStatus {
|
||||||
|
status: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
sourceCount: number;
|
||||||
|
onlineSourceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VehicleSourceStatus {
|
export interface VehicleSourceStatus {
|
||||||
protocol: string;
|
protocol: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function VehicleDetail({
|
|||||||
const qualityCount = detail?.quality?.total ?? 0;
|
const qualityCount = detail?.quality?.total ?? 0;
|
||||||
const online = resolution?.online || summary?.online || identity?.online || false;
|
const online = resolution?.online || summary?.online || identity?.online || false;
|
||||||
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-';
|
||||||
|
const serviceStatus = detail?.serviceStatus;
|
||||||
const activeProtocol = query.protocol?.trim() ?? '';
|
const activeProtocol = query.protocol?.trim() ?? '';
|
||||||
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
|
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
|
||||||
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
|
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
|
||||||
@@ -145,6 +146,16 @@ export function VehicleDetail({
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{serviceStatus ? (
|
||||||
|
<div className={`vp-service-status vp-service-status-${serviceStatus.severity}`}>
|
||||||
|
<span className="vp-scope-label">车辆服务状态</span>
|
||||||
|
<Tag color={serviceStatus.severity === 'ok' ? 'green' : serviceStatus.severity === 'error' ? 'red' : 'orange'}>
|
||||||
|
{serviceStatus.title}
|
||||||
|
</Tag>
|
||||||
|
<Typography.Text type="tertiary">{serviceStatus.detail}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card bordered style={{ marginTop: 16 }}>
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
<div className="vp-vehicle-summary">
|
<div className="vp-vehicle-summary">
|
||||||
<Descriptions
|
<Descriptions
|
||||||
|
|||||||
@@ -115,6 +115,33 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-service-status {
|
||||||
|
min-height: 40px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: var(--vp-surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-service-status-ok {
|
||||||
|
border-color: rgba(18, 183, 106, 0.35);
|
||||||
|
background: rgba(18, 183, 106, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-service-status-warning {
|
||||||
|
border-color: rgba(247, 144, 9, 0.35);
|
||||||
|
background: rgba(247, 144, 9, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-service-status-error {
|
||||||
|
border-color: rgba(240, 68, 56, 0.35);
|
||||||
|
background: rgba(240, 68, 56, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
.vp-section {
|
.vp-section {
|
||||||
background: var(--vp-surface);
|
background: var(--vp-surface);
|
||||||
border: 1px solid var(--vp-border);
|
border: 1px solid var(--vp-border);
|
||||||
|
|||||||
@@ -489,6 +489,14 @@ test('shows selected source scope on vehicle detail', async () => {
|
|||||||
{ protocol: 'GB32960', online: false, lastSeen: '2026-07-03 20:11:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true },
|
{ protocol: 'GB32960', online: false, lastSeen: '2026-07-03 20:11:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true },
|
||||||
{ protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }
|
{ protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }
|
||||||
],
|
],
|
||||||
|
serviceStatus: {
|
||||||
|
status: 'degraded',
|
||||||
|
severity: 'warning',
|
||||||
|
title: '部分来源离线',
|
||||||
|
detail: '1/2 个来源在线,车辆服务可用但需要关注离线来源。',
|
||||||
|
sourceCount: 2,
|
||||||
|
onlineSourceCount: 1
|
||||||
|
},
|
||||||
realtime: [],
|
realtime: [],
|
||||||
history: { items: [], total: 0, limit: 20, offset: 0 },
|
history: { items: [], total: 0, limit: 20, offset: 0 },
|
||||||
raw: { items: [], total: 0, limit: 10, offset: 0 },
|
raw: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
@@ -516,5 +524,7 @@ test('shows selected source scope on vehicle detail', async () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('当前查看范围')).toBeInTheDocument();
|
expect(await screen.findByText('当前查看范围')).toBeInTheDocument();
|
||||||
expect(screen.getByText('单一来源:JT808')).toBeInTheDocument();
|
expect(screen.getByText('单一来源:JT808')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('车辆服务状态')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('部分来源离线')).toBeInTheDocument();
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user