feat(platform): persist realtime source filters
This commit is contained in:
@@ -23,6 +23,9 @@ export default function App() {
|
|||||||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(
|
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'vehicles' ? vehicleFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'vehicles' ? vehicleFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
|
const [realtimeFilters, setRealtimeFilters] = useState<Record<string, string>>(
|
||||||
|
initialRoute.page === 'realtime' ? realtimeFiltersFromRoute(initialRoute) : {}
|
||||||
|
);
|
||||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||||
);
|
);
|
||||||
@@ -98,6 +101,9 @@ export default function App() {
|
|||||||
if (route.page === 'vehicles') {
|
if (route.page === 'vehicles') {
|
||||||
setVehicleFilters(vehicleFiltersFromRoute(route));
|
setVehicleFilters(vehicleFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
|
if (route.page === 'realtime') {
|
||||||
|
setRealtimeFilters(realtimeFiltersFromRoute(route));
|
||||||
|
}
|
||||||
if (route.page === 'quality') {
|
if (route.page === 'quality') {
|
||||||
setQualityFilters(qualityFiltersFromRoute(route));
|
setQualityFilters(qualityFiltersFromRoute(route));
|
||||||
}
|
}
|
||||||
@@ -133,6 +139,10 @@ export default function App() {
|
|||||||
replaceVehicleHash(vehicleFilters);
|
replaceVehicleHash(vehicleFilters);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (page === 'realtime') {
|
||||||
|
replaceRealtimeHash(realtimeFilters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
replaceHash(page);
|
replaceHash(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +162,16 @@ export default function App() {
|
|||||||
replaceHash('vehicles', keyword, protocol, restFilters);
|
replaceHash('vehicles', keyword, protocol, restFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateRealtimeFilters = (filters: Record<string, string> = {}) => {
|
||||||
|
setRealtimeFilters(filters);
|
||||||
|
replaceRealtimeHash(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceRealtimeHash = (filters: Record<string, string> = {}) => {
|
||||||
|
const { keyword, protocol, ...restFilters } = filters;
|
||||||
|
replaceHash('realtime', keyword, protocol, restFilters);
|
||||||
|
};
|
||||||
|
|
||||||
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
||||||
const keyword = filters.keyword;
|
const keyword = filters.keyword;
|
||||||
const protocol = filters.protocol;
|
const protocol = filters.protocol;
|
||||||
@@ -241,7 +261,7 @@ export default function App() {
|
|||||||
const pages: Record<PageKey, JSX.Element> = {
|
const pages: Record<PageKey, JSX.Element> = {
|
||||||
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
dashboard: <Dashboard onOpenVehicle={openVehicle} onOpenQuality={() => navigatePage('quality')} onOpenVehicles={openVehicles} />,
|
||||||
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
vehicles: <Vehicles onOpenVehicle={openVehicle} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
|
||||||
realtime: <Realtime onOpenVehicle={openVehicle} />,
|
realtime: <Realtime onOpenVehicle={openVehicle} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
|
||||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onQueryChange={updateVehicleDetailQuery} />,
|
||||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} onOpenVehicle={openVehicle} />,
|
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} onOpenVehicle={openVehicle} />,
|
||||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||||
@@ -271,6 +291,15 @@ function vehicleFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function realtimeFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...(route.keyword ? { keyword: route.keyword } : {}),
|
||||||
|
...(route.protocol ? { protocol: route.protocol } : {}),
|
||||||
|
...(route.filters?.online ? { online: route.filters.online } : {}),
|
||||||
|
...(route.filters?.serviceStatus ? { serviceStatus: route.filters.serviceStatus } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
||||||
const sourceCount = overview.sourceCount;
|
const sourceCount = overview.sourceCount;
|
||||||
const onlineSourceCount = overview.onlineSourceCount;
|
const onlineSourceCount = overview.onlineSourceCount;
|
||||||
|
|||||||
@@ -31,10 +31,18 @@ function sourceEvidenceText(row: VehicleRealtimeRow) {
|
|||||||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, protocol?: string) => void }) {
|
export function Realtime({
|
||||||
|
onOpenVehicle,
|
||||||
|
onFiltersChange,
|
||||||
|
initialFilters = {}
|
||||||
|
}: {
|
||||||
|
onOpenVehicle: (vin: string, protocol?: string) => void;
|
||||||
|
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||||
|
initialFilters?: Record<string, string>;
|
||||||
|
}) {
|
||||||
const [rows, setRows] = useState<VehicleRealtimeRow[]>([]);
|
const [rows, setRows] = useState<VehicleRealtimeRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
||||||
|
|
||||||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
@@ -54,17 +62,23 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load({}, 1, pagination.pageSize);
|
setFilters(initialFilters);
|
||||||
}, []);
|
load(initialFilters, 1, pagination.pageSize);
|
||||||
|
}, [JSON.stringify(initialFilters)]);
|
||||||
|
|
||||||
|
const applyFilters = (nextFilters: Record<string, string>) => {
|
||||||
|
setFilters(nextFilters);
|
||||||
|
onFiltersChange?.(nextFilters);
|
||||||
|
load(nextFilters, 1, pagination.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
<PageHeader title="车辆服务实时态" description="以车辆为主对象查看最新位置、在线来源和核心实时数据,协议只作为来源证据" />
|
<PageHeader title="车辆服务实时态" description="以车辆为主对象查看最新位置、在线来源和核心实时数据,协议只作为来源证据" />
|
||||||
<Card bordered>
|
<Card bordered>
|
||||||
<Form layout="horizontal" onSubmit={(values) => {
|
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||||
const nextFilters = values as Record<string, string>;
|
const nextFilters = values as Record<string, string>;
|
||||||
setFilters(nextFilters);
|
applyFilters(nextFilters);
|
||||||
load(nextFilters, 1, pagination.pageSize);
|
|
||||||
}} style={{ marginBottom: 12 }}>
|
}} style={{ marginBottom: 12 }}>
|
||||||
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
|
||||||
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
|
||||||
@@ -85,8 +99,7 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto
|
|||||||
<Space>
|
<Space>
|
||||||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
setFilters({});
|
applyFilters({});
|
||||||
load({}, 1, pagination.pageSize);
|
|
||||||
}}>重置</Button>
|
}}>重置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -2393,6 +2393,96 @@ test('frames realtime page as one vehicle service with source evidence', async (
|
|||||||
expect(screen.getByText('2/2 来源在线')).toBeInTheDocument();
|
expect(screen.getByText('2/2 来源在线')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loads realtime vehicles from shareable source filter hash', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/realtime?keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded');
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/realtime/vehicles')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [{
|
||||||
|
vin: 'VIN-RT-FILTERED',
|
||||||
|
plate: '粤ART002',
|
||||||
|
phone: '',
|
||||||
|
oem: 'G7s',
|
||||||
|
protocols: ['JT808'],
|
||||||
|
sourceCount: 1,
|
||||||
|
onlineSourceCount: 1,
|
||||||
|
online: true,
|
||||||
|
primaryProtocol: 'JT808',
|
||||||
|
longitude: 113.2,
|
||||||
|
latitude: 23.1,
|
||||||
|
speedKmh: 30,
|
||||||
|
socPercent: 78,
|
||||||
|
totalMileageKm: 119925,
|
||||||
|
lastSeen: '2026-07-03 20:12:10',
|
||||||
|
bindingStatus: 'bound'
|
||||||
|
}],
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('VIN-RT-FILTERED')).toBeInTheDocument();
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates realtime hash when source filters are submitted', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/realtime');
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/realtime/vehicles')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 50, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: '车辆服务实时态' });
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤ART002' } });
|
||||||
|
fireEvent.click(screen.getByText('全部来源'));
|
||||||
|
fireEvent.click(await screen.findByText('JT808'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe('#/realtime?keyword=%E7%B2%A4ART002&protocol=JT808');
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows vehicle service status in vehicle list', async () => {
|
test('shows vehicle service status in vehicle list', async () => {
|
||||||
window.history.replaceState(null, '', '/#/vehicles');
|
window.history.replaceState(null, '', '/#/vehicles');
|
||||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user