feat(monitor): retain authorized vehicles without locations
This commit is contained in:
@@ -60,6 +60,8 @@ export interface CustomerUserInput {
|
||||
|
||||
export interface MonitorSummary {
|
||||
totalVehicles: number;
|
||||
locationVehicles?: number;
|
||||
noLocationVehicles?: number;
|
||||
onlineVehicles: number;
|
||||
offlineVehicles: number;
|
||||
drivingVehicles: number;
|
||||
@@ -593,11 +595,17 @@ export interface VehicleRealtimeRow {
|
||||
locationSource?: string;
|
||||
locationConflict?: boolean;
|
||||
conflictDistanceM?: number;
|
||||
locationAvailable?: boolean;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
speedAvailable?: boolean;
|
||||
speedKmh: number;
|
||||
socAvailable?: boolean;
|
||||
socPercent: number;
|
||||
mileageAvailable?: boolean;
|
||||
totalMileageKm: number;
|
||||
todayMileageAvailable?: boolean;
|
||||
todayMileageKm?: number;
|
||||
lastSeen: string;
|
||||
reportIntervalMs?: number;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const vehicles = [{
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
|
||||
longitude: 113.26, latitude: 23.13, speedKmh: 42, socPercent: 80, totalMileageKm: 1234,
|
||||
locationAvailable: true, longitude: 113.26, latitude: 23.13, speedAvailable: true, speedKmh: 42,
|
||||
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 1234, todayMileageAvailable: true, todayMileageKm: 18.6,
|
||||
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
|
||||
}, {
|
||||
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
|
||||
longitude: 113.28, latitude: 23.15, speedKmh: 0, socPercent: 72, totalMileageKm: 2234,
|
||||
locationAvailable: true, longitude: 113.28, latitude: 23.15, speedAvailable: true, speedKmh: 0,
|
||||
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 2234, todayMileageAvailable: false, todayMileageKm: 0,
|
||||
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
|
||||
}] as VehicleRealtimeRow[];
|
||||
const monitorMap = { clusters: [], points: [], total: 2 };
|
||||
@@ -58,7 +60,7 @@ vi.mock('../hooks/useMonitorData', () => ({
|
||||
useMonitorData: (...args: unknown[]) => {
|
||||
monitorDataArgsSpy(...args);
|
||||
return {
|
||||
summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
||||
summary: { data: { totalVehicles: 2, locationVehicles: 2, noLocationVehicles: 0, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
|
||||
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
|
||||
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
|
||||
selectedVehicle: { data: { items: [] } }
|
||||
@@ -95,7 +97,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(firstVehicle).not.toHaveClass('is-selected');
|
||||
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||
expect(screen.getByText('有实时位置')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆总数')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
|
||||
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
|
||||
@@ -106,6 +109,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
|
||||
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
|
||||
expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
|
||||
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
|
||||
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
|
||||
|
||||
@@ -140,15 +144,17 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
|
||||
expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
|
||||
expect(screen.getAllByText('42')).toHaveLength(1);
|
||||
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
|
||||
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
|
||||
expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
|
||||
expect(reverseGeocode).toHaveBeenCalledTimes(1);
|
||||
expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(1);
|
||||
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
|
||||
@@ -160,6 +166,41 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test('keeps authorized vehicles without realtime coordinates in the list without geocoding them', async () => {
|
||||
const row = {
|
||||
...vehicles[0],
|
||||
vin: 'LNOLOCATION0000001',
|
||||
plate: '粤A无定位',
|
||||
protocols: [],
|
||||
primaryProtocol: '',
|
||||
locationAvailable: false,
|
||||
longitude: 0,
|
||||
latitude: 0,
|
||||
speedAvailable: false,
|
||||
speedKmh: 0,
|
||||
mileageAvailable: false,
|
||||
totalMileageKm: 0,
|
||||
todayMileageAvailable: false,
|
||||
todayMileageKm: 0,
|
||||
lastSeen: '',
|
||||
online: false,
|
||||
sourceCount: 0,
|
||||
onlineSourceCount: 0
|
||||
} satisfies VehicleRealtimeRow;
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
|
||||
const reverseGeocode = vi.spyOn(api, 'reverseGeocode');
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
|
||||
expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
|
||||
expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
|
||||
expect(screen.getByText('从未上报')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => {
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQ
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
|
||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
|
||||
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
|
||||
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
|
||||
|
||||
@@ -49,8 +49,27 @@ function useMobileMonitorLayout() {
|
||||
|
||||
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
||||
|
||||
function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
|
||||
if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
|
||||
return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
|
||||
&& Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
|
||||
&& (vehicle.longitude !== 0 || vehicle.latitude !== 0);
|
||||
}
|
||||
|
||||
function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
|
||||
}
|
||||
|
||||
function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
|
||||
}
|
||||
|
||||
function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
|
||||
return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
|
||||
}
|
||||
|
||||
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
|
||||
if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|
||||
if (!hasRealtimeLocation(vehicle) || !Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|
||||
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|
||||
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
|
||||
const longitude = Number(vehicle.longitude.toFixed(4));
|
||||
@@ -75,29 +94,31 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
|
||||
});
|
||||
const moved = Boolean(current && requested && current.key !== requested.key);
|
||||
|
||||
if (!current) return <span className="v2-monitor-address-empty">无有效坐标</span>;
|
||||
if (!current) return <span className="v2-monitor-address-empty">暂无实时位置</span>;
|
||||
if (!requested) return <button type="button" className="v2-monitor-address-action" aria-label={`解析${vehicle.plate || vehicle.vin}位置`} title="按需调用高德逆地理编码,不随实时数据刷新重复请求" onClick={() => setRequested(current)}><IconMapPin />解析位置</button>;
|
||||
if (addressQuery.isFetching && !addressQuery.data) return <span className="v2-monitor-address-loading"><i className="v2-spinner" />解析中</span>;
|
||||
if (addressQuery.isError) return <button type="button" className="v2-monitor-address-action is-error" onClick={() => void addressQuery.refetch()}>解析失败,重试</button>;
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</button> : null}</div>;
|
||||
return <div className="v2-monitor-address-result"><span title={addressQuery.data?.formattedAddress}>{addressQuery.data?.formattedAddress || '暂无地址'}</span><small>{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}</small>{moved ? <button type="button" title="车辆已移动,按需更新地址" onClick={() => setRequested(current)}>位置已移动 · 更新</button> : null}</div>;
|
||||
});
|
||||
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
const mobile = useMobileMonitorLayout();
|
||||
return <section className="v2-monitor-table-panel">
|
||||
<header><div><strong>车辆实时列表</strong><span>速度、里程和坐标实时刷新,文字地址按需解析</span></div><b>{total.toLocaleString('zh-CN')} 辆车辆</b></header>
|
||||
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th>车辆</th><th>速度</th><th>总里程</th><th>经纬度</th><th>地理位置</th></tr></thead>
|
||||
<header><div><strong>车辆实时列表</strong><span>覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析</span></div><b>{total.toLocaleString('zh-CN')} 辆车辆</b></header>
|
||||
{!mobile ? <div className="v2-monitor-table-scroll"><table><thead><tr><th>车辆</th><th>推荐来源</th><th>速度</th><th>里程</th><th>经纬度</th><th>地理位置</th><th>最新上报</th></tr></thead>
|
||||
<tbody>{rows.map((row) => <tr key={row.vin}>
|
||||
<td className="v2-monitor-table-vehicle"><button type="button" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></button></td>
|
||||
<td><strong className="v2-monitor-live-value">{formatNumber(row.speedKmh, 1)}</strong><small className="v2-monitor-live-unit">km/h</small></td>
|
||||
<td><strong className="v2-monitor-live-value">{formatNumber(row.totalMileageKm, 1)}</strong><small className="v2-monitor-live-unit">km</small></td>
|
||||
<td><code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code></td>
|
||||
<td><span className="v2-monitor-source-badge">{row.primaryProtocol || '—'}</span></td>
|
||||
<td><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</td>
|
||||
<td><div className="v2-monitor-mileage"><span><small>总</small><strong>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</strong></span><span><small>今日</small><strong>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</strong></span></div></td>
|
||||
<td>{hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span>}</td>
|
||||
<td><MonitorAddressCell vehicle={row} /></td>
|
||||
<td><div className="v2-monitor-last-seen"><strong>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</strong><span>{row.lastSeen || '—'}</span></div></td>
|
||||
</tr>)}</tbody></table>{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <article key={row.vin}>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><b>{formatNumber(row.speedKmh, 1)}<small>km/h</small></b></header>
|
||||
<dl><div><dt>总里程</dt><dd>{formatNumber(row.totalMileageKm, 1)} km</dd></div><div><dt>经纬度</dt><dd><code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code></dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />地图定位</button></footer>
|
||||
<header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin} · {row.primaryProtocol || '暂无来源'}</span></div><b>{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? <small>km/h</small> : null}</b></header>
|
||||
<dl><div><dt>总里程</dt><dd>{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}</dd></div><div><dt>今日里程</dt><dd>{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}</dd></div><div><dt>经纬度</dt><dd>{hasRealtimeLocation(row) ? <code>{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}</code> : '—'}</dd></div><div><dt>最新上报</dt><dd>{row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}</dd></div><div className="is-address"><dt>地理位置</dt><dd><MonitorAddressCell vehicle={row} /></dd></div></dl>
|
||||
<footer><button type="button" onClick={() => onSelect(row.vin)}><IconMapPin />{hasRealtimeLocation(row) ? '地图定位' : '查看车辆'}</button></footer>
|
||||
</article>)}{loading ? <div className="v2-monitor-table-loading"><i className="v2-spinner" />正在更新车辆实时数据…</div> : null}{!loading && !rows.length ? <EmptyState /> : null}</div> : null}
|
||||
<footer><span>第 {page} / {totalPages} 页</span><div><button type="button" disabled={page <= 1} onClick={() => onPage(page - 1)}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => onPage(page + 1)}>下一页</button><select aria-label="每页车辆数" value={limit} onChange={(event) => onLimit(Number(event.target.value))}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer>
|
||||
</section>;
|
||||
@@ -199,9 +220,9 @@ function VehicleDetailCard({
|
||||
<section>
|
||||
<h3>实时状态</h3>
|
||||
<div className="v2-metric-grid">
|
||||
<div><small>速度</small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
|
||||
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
|
||||
<div><small>总里程</small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
|
||||
<div><small>速度</small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
|
||||
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
|
||||
<div><small>总里程</small><strong>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></strong></div>
|
||||
<div><small>今日里程</small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
|
||||
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
||||
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
||||
@@ -212,8 +233,8 @@ function VehicleDetailCard({
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
||||
<div><dt>新鲜度</dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
|
||||
<div><dt>坐标</dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
|
||||
<div><dt>位置</dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
|
||||
<div><dt>坐标</dt><dd>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</dd></div>
|
||||
<div><dt>位置</dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
|
||||
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -328,7 +349,7 @@ export default function MonitorPage() {
|
||||
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={(event) => { setStatus(event.target.value); setListOffset(0); }} aria-label="在线状态">
|
||||
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
|
||||
{statuses.map((item) => <option key={item} value={item}>{item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态'}</option>)}
|
||||
</select>
|
||||
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); setListOffset(0); }}><IconRefresh />清空</button>
|
||||
<span className="v2-filter-live-status" role="status" title="搜索和筛选条件修改后自动生效"><IconFilter />实时筛选</span>
|
||||
@@ -339,7 +360,8 @@ export default function MonitorPage() {
|
||||
|
||||
<section className="v2-kpis" aria-label="车辆整体统计">
|
||||
{[
|
||||
['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||||
['车辆总数', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
|
||||
['无实时位置', formatNumber(summary.data?.noLocationVehicles ?? 0), 'missing'],
|
||||
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
|
||||
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
|
||||
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
|
||||
|
||||
@@ -123,7 +123,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-monitor-mode button.is-active { background: #fff; color: var(--v2-blue); box-shadow: 0 1px 4px rgba(21,32,51,.12); font-weight: 700; }
|
||||
.v2-monitor-mobile-entry { height: 36px; border: 1px solid #dce4ef; background: #fff; }
|
||||
|
||||
.v2-kpis { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-kpis { display: grid; grid-template-columns: repeat(8, minmax(86px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-kpi { position: relative; min-width: 0; padding: 10px 14px; }
|
||||
.v2-kpi + .v2-kpi::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ""; }
|
||||
.v2-kpi small { display: block; color: var(--v2-muted); font-size: 11px; }
|
||||
@@ -131,7 +131,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-kpi.is-online strong, .v2-kpi.is-idle strong { color: var(--v2-green); }
|
||||
.v2-kpi.is-driving strong, .v2-kpi.is-today strong { color: var(--v2-blue); }
|
||||
.v2-kpi.is-alert strong { color: var(--v2-red); }
|
||||
.v2-kpi.is-offline strong { color: #7b8798; }
|
||||
.v2-kpi.is-offline strong, .v2-kpi.is-missing strong { color: #7b8798; }
|
||||
|
||||
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
|
||||
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(300px, 17vw, 360px); }
|
||||
@@ -166,9 +166,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-monitor-table-panel > header strong { font-size: 14px; }
|
||||
.v2-monitor-table-panel > header span, .v2-monitor-table-panel > header > b { color: var(--v2-muted); font-size: 11px; font-weight: 500; }
|
||||
.v2-monitor-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
|
||||
.v2-monitor-table-scroll table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
|
||||
.v2-monitor-table-scroll table { width: 100%; min-width: 1080px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
|
||||
.v2-monitor-table-scroll th { position: sticky; top: 0; z-index: 3; height: 42px; border-bottom: 1px solid #dce4ee; background: #f7f9fc; padding: 0 14px; color: #607086; font-size: 11px; font-weight: 650; text-align: left; }
|
||||
.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 92px; }.v2-monitor-table-scroll th:nth-child(3) { width: 128px; }.v2-monitor-table-scroll th:nth-child(4) { width: 164px; }.v2-monitor-table-scroll th:nth-child(5) { width: auto; }
|
||||
.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 116px; }.v2-monitor-table-scroll th:nth-child(3) { width: 92px; }.v2-monitor-table-scroll th:nth-child(4) { width: 154px; }.v2-monitor-table-scroll th:nth-child(5) { width: 150px; }.v2-monitor-table-scroll th:nth-child(6) { width: auto; }.v2-monitor-table-scroll th:nth-child(7) { width: 148px; }
|
||||
.v2-monitor-table-scroll td { height: 62px; border-bottom: 1px solid #edf1f6; padding: 7px 14px; vertical-align: middle; }
|
||||
.v2-monitor-table-scroll tbody tr { transition: background .14s ease; }
|
||||
.v2-monitor-table-scroll tbody tr:hover { background: #f8fbff; }
|
||||
@@ -178,11 +178,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-monitor-live-value { display: inline !important; color: #213044; font-size: 14px !important; font-variant-numeric: tabular-nums; }
|
||||
.v2-monitor-live-unit { margin-left: 4px; color: #8b98aa; font-size: 9px; }
|
||||
.v2-monitor-coordinate { color: #52637a; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.55; }
|
||||
.v2-monitor-source-badge { display: inline-flex !important; max-width: 100%; align-items: center; overflow: hidden; border: 1px solid #dbe5f1; border-radius: 999px; background: #f7faff; padding: 4px 8px; color: #42648f; font-size: 9px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-monitor-mileage, .v2-monitor-last-seen { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.v2-monitor-mileage > span { display: grid; min-width: 0; grid-template-columns: 26px minmax(0,1fr); align-items: center; gap: 4px; }
|
||||
.v2-monitor-mileage small { color: #8996a8; font-size: 8px; }.v2-monitor-mileage strong { overflow: hidden; color: #34445a; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-monitor-last-seen strong { color: #46566c; font-size: 10px; }.v2-monitor-last-seen span { overflow: hidden; color: #8a97a9; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.v2-monitor-unavailable { color: #a2acb9; }
|
||||
.v2-monitor-address-action { display: inline-flex; height: 28px; align-items: center; gap: 5px; border: 1px solid #d5e0ee; border-radius: 6px; background: #fff; padding: 0 9px; color: #3568a9; cursor: pointer; font-size: 10px; font-weight: 650; transition: border-color .14s ease, background .14s ease; }
|
||||
.v2-monitor-address-action:hover { border-color: #9bb8dd; background: #f4f8fe; }.v2-monitor-address-action.is-error { border-color: #f0c9c9; color: #b74b4b; }
|
||||
.v2-monitor-address-empty, .v2-monitor-address-loading { display: inline-flex; align-items: center; gap: 6px; color: #929dac; font-size: 10px; }
|
||||
.v2-monitor-address-result { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.v2-monitor-address-result > span { display: block; width: 100%; overflow: hidden; color: #47576c; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-monitor-address-result > small { color: #98a3b2; font-size: 8px; }
|
||||
.v2-monitor-address-result > button { border: 0; background: transparent; padding: 0; color: #c27a13; cursor: pointer; font-size: 9px; }
|
||||
.v2-monitor-source { min-width: 0; border-left: 2px solid #d6dee9; padding-left: 8px; }
|
||||
.v2-monitor-source.is-online { border-color: #1fb573; }.v2-monitor-source.is-offline, .v2-monitor-source.is-pending, .v2-monitor-source.is-unknown { border-color: #f59e0b; }.v2-monitor-source.is-not-required { opacity: .62; }
|
||||
|
||||
Reference in New Issue
Block a user