feat(platform): add single vehicle time window delivery
This commit is contained in:
@@ -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} />,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user