feat(platform): add single vehicle time window delivery

This commit is contained in:
lingniu
2026-07-06 01:38:00 +08:00
parent af95f69388
commit 442afb8aa5
4 changed files with 272 additions and 1 deletions

View File

@@ -604,7 +604,7 @@ export default function App() {
vehicles: <Vehicles onOpenVehicle={openVehicle} onOpenQuality={openQuality} onOpenMap={openMap} onOpenRealtime={openRealtime} onOpenHistory={openHistoryWithFilters} onOpenMileage={openMileageWithFilters} onFiltersChange={updateVehicleFilters} initialFilters={vehicleFilters} />,
map: <Realtime mode="map" title="车辆实时地图" description="以车辆为中心查看位置分布、在线状态、关注车辆和轨迹入口" onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateMapFilters} initialFilters={realtimeFilters} />,
realtime: <Realtime onOpenVehicle={openVehicle} onOpenHistory={openHistoryForVehicle} onOpenQuality={openQuality} onFiltersChange={updateRealtimeFilters} initialFilters={realtimeFilters} />,
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} platformRelease={platformRelease} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onQueryChange={updateVehicleDetailQuery} />,
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} platformRelease={platformRelease} onOpenRealtime={openRealtimeForVehicle} onOpenHistory={openHistoryForVehicle} onOpenRaw={openRawForVehicle} onOpenMileage={openMileageForVehicle} onOpenVehicles={openVehicles} onOpenQuality={openQuality} onOpenHistoryEvidence={openHistoryWithFilters} onOpenRawEvidence={openRawWithFilters} onOpenMileageEvidence={openMileageWithFilters} onQueryChange={updateVehicleDetailQuery} />,
history: <History mode="trajectory" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
'history-query': <History mode="query" initialVin={analysisVin} initialProtocol={activeProtocol} initialTab={historyTab} initialFilters={historyFilters} onFiltersChange={updateHistoryFilters} onOpenVehicle={openVehicle} onOpenMileage={openMileageWithFilters} onOpenRaw={openRawWithFilters} />,
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} initialFilters={mileageFilters} onFiltersChange={updateMileageFilters} onOpenVehicle={openVehicle} onOpenHistory={openHistoryWithFilters} onOpenRaw={openRawWithFilters} />,

View File

@@ -159,6 +159,11 @@ function nextDate(value: string) {
return date.toISOString().slice(0, 10);
}
function scopedRangeText(dateFrom?: string, dateTo?: string) {
if (!dateFrom && !dateTo) return '未限定时间';
return `${dateFrom || '未指定'}${dateTo || '未指定'}`;
}
function qualityIssueVehicleLabel(row: QualityIssueRow, fallbackVIN: string) {
const vin = row.vin?.trim() || fallbackVIN;
return [row.plate?.trim(), vin].filter(Boolean).join(' / ') || row.phone || row.sourceEndpoint || '-';
@@ -185,6 +190,7 @@ export function VehicleDetail({
onOpenQuality,
onOpenHistoryEvidence,
onOpenRawEvidence,
onOpenMileageEvidence,
onQueryChange
}: {
vin: string;
@@ -198,10 +204,12 @@ export function VehicleDetail({
onOpenQuality?: (filters?: Record<string, string>) => void;
onOpenHistoryEvidence?: (filters?: Record<string, string>) => void;
onOpenRawEvidence?: (filters?: Record<string, string>) => void;
onOpenMileageEvidence?: (filters?: Record<string, string>) => void;
onQueryChange?: (keyword: string, protocol?: string) => void;
}) {
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
const [detail, setDetail] = useState<VehicleDetailData | null>(null);
const [vehicleTimeWindow, setVehicleTimeWindow] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
@@ -402,6 +410,67 @@ export function VehicleDetail({
protocol: activeProtocol,
filters
});
const vehicleTimeWindowScope = () => ({
keyword: resolvedVIN,
...(activeProtocol ? { protocol: activeProtocol } : {}),
...(vehicleTimeWindow.dateFrom?.trim() ? { dateFrom: vehicleTimeWindow.dateFrom.trim() } : {}),
...(vehicleTimeWindow.dateTo?.trim() ? { dateTo: vehicleTimeWindow.dateTo.trim() } : {})
});
const vehicleTimeWindowText = scopedRangeText(vehicleTimeWindow.dateFrom?.trim(), vehicleTimeWindow.dateTo?.trim());
const openVehicleWindowHistory = () => {
const filters = vehicleTimeWindowScope();
if (onOpenHistoryEvidence) {
onOpenHistoryEvidence(filters);
return;
}
onOpenHistory(filters.keyword, filters.protocol);
};
const openVehicleWindowMileage = () => {
const filters = vehicleTimeWindowScope();
if (onOpenMileageEvidence) {
onOpenMileageEvidence(filters);
return;
}
onOpenMileage(filters.keyword, filters.protocol);
};
const openVehicleWindowRaw = () => {
const filters = { ...vehicleTimeWindowScope(), tab: 'raw', includeFields: 'true' };
if (onOpenRawEvidence) {
onOpenRawEvidence(filters);
return;
}
onOpenRaw(filters.keyword, filters.protocol);
};
const openVehicleWindowAlerts = () => {
const filters = vehicleTimeWindowScope();
onOpenQuality?.(filters);
};
const copyVehicleTimeWindowPackage = () => {
if (!detail) {
Toast.warning('当前没有可复制的单车时间窗交付包');
return;
}
const filters = vehicleTimeWindowScope();
const rawFilters = { ...filters, tab: 'raw', includeFields: 'true' };
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【单车时间窗交付包】',
`车辆:${vehicleName}`,
`时间范围:${vehicleTimeWindowText}`,
`数据通道:${activeProtocol || '全部数据通道'}`,
`服务状态:${customerStatusTitle(serviceStatus?.title) || serviceConclusion.status}`,
`最新上报:${lastSeen}`,
`轨迹证据:${overview?.historyCount ?? detail.history.total ?? 0}`,
`历史数据:${overview?.rawCount ?? detail.raw.total ?? 0}`,
`统计查询:${overview?.mileageCount ?? detail.mileage.total ?? 0}`,
`告警事件:${qualityCount.toLocaleString()}`,
`轨迹回放:${vehicleServiceURL(buildAppHash({ page: 'history', keyword: filters.keyword, protocol: filters.protocol, filters }))}`,
`统计查询:${vehicleServiceURL(buildAppHash({ page: 'mileage', keyword: filters.keyword, protocol: filters.protocol, filters }))}`,
`历史导出:${vehicleServiceURL(buildAppHash({ page: 'history-query', keyword: rawFilters.keyword, protocol: rawFilters.protocol, filters: rawFilters }))}`,
`告警通知:${vehicleServiceURL(buildAppHash({ page: 'alert-events', keyword: filters.keyword, protocol: filters.protocol, filters }))}`
];
copyText(lines.join('\n'), '单车时间窗交付包');
};
const copyVehicleServiceURL = () => copyText(vehicleServiceURL(window.location.hash), '服务链接');
const copyVehicleDiagnosticSummary = () => {
if (!detail) {
@@ -1308,6 +1377,64 @@ export function VehicleDetail({
</div>
</Card>
<Card bordered className="vp-single-vehicle-time-window" title="单车时间窗交付">
<div className="vp-single-vehicle-time-window-main">
<div className="vp-single-vehicle-time-window-copy">
<Space wrap>
<Tag color="blue"></Tag>
<Tag color={activeProtocol ? 'blue' : 'green'}>{activeProtocol || '全部数据通道'}</Tag>
<Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '车辆已定位' : '待绑定车辆'}</Tag>
</Space>
<Typography.Title heading={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
<div className="vp-single-vehicle-time-window-controls">
<label>
<span></span>
<input
placeholder="单车开始日期"
value={vehicleTimeWindow.dateFrom ?? ''}
onChange={(event) => setVehicleTimeWindow((previous) => ({ ...previous, dateFrom: event.currentTarget.value }))}
/>
</label>
<label>
<span></span>
<input
placeholder="单车结束日期"
value={vehicleTimeWindow.dateTo ?? ''}
onChange={(event) => setVehicleTimeWindow((previous) => ({ ...previous, dateTo: event.currentTarget.value }))}
/>
</label>
<Button size="small" theme="light" onClick={() => setVehicleTimeWindow({})}></Button>
<Button size="small" theme="light" icon={<IconCopy />} disabled={!detail} onClick={copyVehicleTimeWindowPackage}></Button>
</div>
</div>
<div className="vp-single-vehicle-time-window-actions">
{[
{ title: '轨迹回放', value: vehicleTimeWindowText, detail: '按同一时间窗回放位置、速度和里程断点。', action: '轨迹回放', color: 'blue' as const, onClick: openVehicleWindowHistory },
{ title: '里程统计', value: vehicleTimeWindowText, detail: '核对区间里程、日统计和总里程差值。', action: '统计查询', color: 'green' as const, onClick: openVehicleWindowMileage },
{ title: '历史数据导出', value: vehicleTimeWindowText, detail: '导出 RAW 与解析字段证据,缩小复盘范围。', action: '历史导出', color: 'blue' as const, onClick: openVehicleWindowRaw },
{ title: '告警通知', value: vehicleTimeWindowText, detail: '查看这辆车在同一范围内的风险事件。', action: '告警通知', color: qualityCount > 0 ? 'orange' as const : 'green' as const, onClick: openVehicleWindowAlerts }
].map((item) => (
<button
key={item.title}
type="button"
className="vp-single-vehicle-time-window-action"
disabled={!hasResolvedVIN || (item.title === '告警通知' && !onOpenQuality)}
aria-label={`单车时间窗交付 ${item.title} ${item.value}`}
onClick={item.onClick}
>
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
<span>{item.detail}</span>
<em>{item.action}</em>
</button>
))}
</div>
</div>
</Card>
<Card bordered className="vp-vehicle-archive-summary" title="客户车辆档案摘要">
<div className="vp-vehicle-archive-summary-main">
<div className="vp-vehicle-archive-summary-copy">

View File

@@ -11806,6 +11806,128 @@ button.vp-realtime-command-item:focus-visible {
line-height: 20px;
}
.vp-single-vehicle-time-window {
margin-top: 16px;
overflow: hidden;
}
.vp-single-vehicle-time-window .semi-card-body {
padding: 0;
}
.vp-single-vehicle-time-window-main {
display: grid;
grid-template-columns: minmax(300px, 0.72fr) minmax(0, 1.28fr);
min-height: 236px;
}
.vp-single-vehicle-time-window-copy {
padding: 16px;
border-right: 1px solid var(--vp-border);
background: #f7fbff;
display: grid;
gap: 12px;
align-content: start;
}
.vp-single-vehicle-time-window-copy .semi-typography {
line-height: 21px;
}
.vp-single-vehicle-time-window-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.vp-single-vehicle-time-window-controls label {
min-height: 66px;
padding: 9px 11px;
border: 1px solid var(--vp-border);
border-radius: 8px;
background: #fff;
display: grid;
gap: 4px;
}
.vp-single-vehicle-time-window-controls label span {
color: var(--vp-text-muted);
font-size: 12px;
line-height: 16px;
}
.vp-single-vehicle-time-window-controls input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--vp-text);
font: inherit;
font-size: 14px;
line-height: 20px;
}
.vp-single-vehicle-time-window-controls input:focus {
color: #1664ff;
}
.vp-single-vehicle-time-window-actions {
padding: 16px;
background: #fff;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.vp-single-vehicle-time-window-action {
min-height: 178px;
padding: 14px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 10px;
align-content: start;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
}
.vp-single-vehicle-time-window-action:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.vp-single-vehicle-time-window-action:not(:disabled):hover,
.vp-single-vehicle-time-window-action:not(:disabled):focus-visible {
border-color: rgba(22, 100, 255, 0.42);
background: #f6fbff;
box-shadow: var(--vp-shadow-sm);
outline: none;
}
.vp-single-vehicle-time-window-action strong {
color: var(--vp-text);
font-size: 18px;
line-height: 24px;
word-break: break-word;
}
.vp-single-vehicle-time-window-action span {
color: var(--vp-text-muted);
font-size: 13px;
line-height: 20px;
}
.vp-single-vehicle-time-window-action em {
color: #1664ff;
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 20px;
}
.vp-vehicle-task-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -12608,6 +12730,11 @@ button.vp-realtime-command-item:focus-visible {
width: min(100%, 132px);
}
.vp-single-vehicle-time-window-copy {
border-right: 0;
border-bottom: 1px solid var(--vp-border);
}
.vp-evidence-grid,
.vp-action-grid,
.vp-fleet-monitor .semi-card-body,
@@ -12619,6 +12746,9 @@ button.vp-realtime-command-item:focus-visible {
.vp-vehicle-live-view .semi-card-body,
.vp-vehicle-live-main,
.vp-single-vehicle-action-grid,
.vp-single-vehicle-time-window-main,
.vp-single-vehicle-time-window-controls,
.vp-single-vehicle-time-window-actions,
.vp-vehicle-live-facts,
.vp-vehicle-archive-summary-main,
.vp-vehicle-archive-summary-grid,

View File

@@ -13320,6 +13320,8 @@ test('copies vehicle service diagnostic summary', async () => {
expect(screen.getByRole('button', { name: '客户服务交付 历史数据查询 查询历史' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '客户服务交付 告警通知 告警闭环' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '客户服务交付 统计查询 统计复核' })).toBeInTheDocument();
expect(screen.getAllByText('单车时间窗交付').length).toBeGreaterThan(0);
expect(screen.getByText('把同一辆车和同一段时间带入轨迹回放、里程统计、历史导出和告警通知,客户复盘时不需要跨页面重复输入。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /复制归一摘要/ }));
await waitFor(() => {
expect(writeText.mock.calls.some((call) => String(call[0]).includes('【统一车辆服务摘要】'))).toBe(true);
@@ -13423,6 +13425,18 @@ test('copies vehicle service diagnostic summary', async () => {
expect(runbookCopied).toContain('验收:车辆在线状态、最新时间、有效坐标均可解释。');
expect(runbookCopied).toContain('告警事件http://localhost:3000/#/alert-events?keyword=VIN-SUMMARY-001&protocol=JT808');
expect(runbookCopied).toContain('统计查询http://localhost:3000/#/mileage?keyword=VIN-SUMMARY-001&protocol=JT808');
fireEvent.change(screen.getByPlaceholderText('单车开始日期'), { target: { value: '2026-07-01' } });
fireEvent.change(screen.getByPlaceholderText('单车结束日期'), { target: { value: '2026-07-03' } });
fireEvent.click(screen.getByRole('button', { name: '单车时间窗交付 历史数据导出 2026-07-01 至 2026-07-03' }));
expect(window.location.hash.startsWith('#/history-query?')).toBe(true);
const windowParams = new URLSearchParams(window.location.hash.split('?')[1] ?? '');
expect(windowParams.get('keyword')).toBe('VIN-SUMMARY-001');
expect(windowParams.get('protocol')).toBe('JT808');
expect(windowParams.get('dateFrom')).toBe('2026-07-01');
expect(windowParams.get('dateTo')).toBe('2026-07-03');
expect(windowParams.get('tab')).toBe('raw');
expect(windowParams.get('includeFields')).toBe('true');
});
test('exports vehicle detail service dossier as csv', async () => {