feat(platform): support vehicle keyword detail lookup
This commit is contained in:
@@ -57,12 +57,13 @@ func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
vin := strings.TrimSpace(r.URL.Query().Get("vin"))
|
keyword := firstNonEmpty(r.URL.Query().Get("keyword"), r.URL.Query().Get("vin"))
|
||||||
if vin == "" {
|
keyword = strings.TrimSpace(keyword)
|
||||||
httpx.WriteError(w, http.StatusBadRequest, "VIN_REQUIRED", "VIN 不能为空", "", traceID(r))
|
if keyword == "" {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "VEHICLE_KEY_REQUIRED", "车辆关键词不能为空", "", traceID(r))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := h.service.VehicleDetail(r.Context(), vin, r.URL.Query().Get("protocol"))
|
data, err := h.service.VehicleDetail(r.Context(), keyword, r.URL.Query().Get("protocol"))
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerVehicleDetailAcceptsKeyword(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/detail?keyword=粤AG18312", 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(), `"vin":"LB9A32A24R0LS1426"`) {
|
||||||
|
t.Fatalf("response should resolve keyword to VIN: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerRealtimeLocations(t *testing.T) {
|
func TestHandlerRealtimeLocations(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { QualityIssueRow, 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';
|
||||||
|
|
||||||
type VehicleQuery = {
|
type VehicleQuery = {
|
||||||
vin: string;
|
keyword: string;
|
||||||
protocol?: string;
|
protocol?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,29 +27,44 @@ export function VehicleDetail({
|
|||||||
onOpenHistory: (vin: string) => void;
|
onOpenHistory: (vin: string) => void;
|
||||||
onOpenMileage: (vin: string) => void;
|
onOpenMileage: (vin: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState<VehicleQuery>({ vin });
|
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin });
|
||||||
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
|
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
const load = (nextQuery = query) => {
|
const load = (nextQuery = query) => {
|
||||||
const vin = nextQuery.vin.trim();
|
const keyword = nextQuery.keyword.trim();
|
||||||
if (!vin) {
|
if (!keyword) {
|
||||||
Toast.warning('请输入 VIN');
|
Toast.warning('请输入 VIN / 车牌 / 手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = new URLSearchParams({ vin });
|
const params = new URLSearchParams({ keyword });
|
||||||
if (nextQuery.protocol?.trim()) {
|
if (nextQuery.protocol?.trim()) {
|
||||||
params.set('protocol', nextQuery.protocol.trim());
|
params.set('protocol', nextQuery.protocol.trim());
|
||||||
}
|
}
|
||||||
|
const seq = requestSeq.current + 1;
|
||||||
|
requestSeq.current = seq;
|
||||||
api.vehicleDetail(params)
|
api.vehicleDetail(params)
|
||||||
.then(setDetail)
|
.then((nextDetail) => {
|
||||||
.catch((error: Error) => Toast.error(error.message))
|
if (requestSeq.current === seq) {
|
||||||
.finally(() => setLoading(false));
|
setDetail(nextDetail);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
if (requestSeq.current === seq) {
|
||||||
|
Toast.error(error.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestSeq.current === seq) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextQuery = { vin };
|
const nextQuery = { keyword: vin };
|
||||||
setQuery(nextQuery);
|
setQuery(nextQuery);
|
||||||
load(nextQuery);
|
load(nextQuery);
|
||||||
}, [vin]);
|
}, [vin]);
|
||||||
@@ -57,6 +72,7 @@ export function VehicleDetail({
|
|||||||
const identity = detail?.identity;
|
const identity = detail?.identity;
|
||||||
const summary = detail?.realtimeSummary;
|
const summary = detail?.realtimeSummary;
|
||||||
const latest = detail?.realtime[0];
|
const latest = detail?.realtime[0];
|
||||||
|
const resolvedVIN = detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword;
|
||||||
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.total ?? 0;
|
const qualityCount = detail?.quality.total ?? 0;
|
||||||
@@ -68,11 +84,11 @@ export function VehicleDetail({
|
|||||||
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
|
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
|
||||||
<Card bordered>
|
<Card bordered>
|
||||||
<Form initValues={query} layout="horizontal" onSubmit={(values) => {
|
<Form initValues={query} layout="horizontal" onSubmit={(values) => {
|
||||||
const nextQuery = { vin: String(values.vin ?? ''), protocol: String(values.protocol ?? '') };
|
const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') };
|
||||||
setQuery(nextQuery);
|
setQuery(nextQuery);
|
||||||
load(nextQuery);
|
load(nextQuery);
|
||||||
}}>
|
}}>
|
||||||
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 280 }} />
|
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 280 }} />
|
||||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||||||
<Select.Option value="GB32960">GB32960</Select.Option>
|
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||||
<Select.Option value="JT808">JT808</Select.Option>
|
<Select.Option value="JT808">JT808</Select.Option>
|
||||||
@@ -81,8 +97,8 @@ export function VehicleDetail({
|
|||||||
<Space>
|
<Space>
|
||||||
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary">查询车辆</Button>
|
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary">查询车辆</Button>
|
||||||
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}>刷新</Button>
|
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}>刷新</Button>
|
||||||
<Button onClick={() => onOpenHistory(query.vin)}>查看历史</Button>
|
<Button onClick={() => onOpenHistory(resolvedVIN)}>查看历史</Button>
|
||||||
<Button onClick={() => onOpenMileage(query.vin)}>查看里程</Button>
|
<Button onClick={() => onOpenMileage(resolvedVIN)}>查看里程</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -92,7 +108,7 @@ export function VehicleDetail({
|
|||||||
<Descriptions
|
<Descriptions
|
||||||
row
|
row
|
||||||
data={[
|
data={[
|
||||||
{ key: 'VIN', value: summary?.vin ?? identity?.vin ?? latest?.vin ?? query.vin },
|
{ key: 'VIN', value: resolvedVIN },
|
||||||
{ key: '车牌', value: summary?.plate || identity?.plate || latest?.plate || '-' },
|
{ key: '车牌', value: summary?.plate || identity?.plate || latest?.plate || '-' },
|
||||||
{ key: '手机号', value: summary?.phone || identity?.phone || '-' },
|
{ key: '手机号', value: summary?.phone || identity?.phone || '-' },
|
||||||
{ key: 'OEM', value: summary?.oem || identity?.oem || '-' },
|
{ key: 'OEM', value: summary?.oem || identity?.oem || '-' },
|
||||||
|
|||||||
Reference in New Issue
Block a user