From 442afb8aa5d2689d57fcbd59f4cd6860dc4f66f1 Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 6 Jul 2026 01:38:00 +0800 Subject: [PATCH] feat(platform): add single vehicle time window delivery --- vehicle-data-platform/apps/web/src/App.tsx | 2 +- .../apps/web/src/pages/VehicleDetail.tsx | 127 +++++++++++++++++ .../apps/web/src/styles/global.css | 130 ++++++++++++++++++ .../apps/web/src/test/App.test.tsx | 14 ++ 4 files changed, 272 insertions(+), 1 deletion(-) diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 7ce2f20e..20c3fa6b 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -604,7 +604,7 @@ export default function App() { vehicles: , map: , realtime: , - detail: , + detail: , history: , 'history-query': , mileage: , diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 8a49d0c9..b94396d6 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -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) => void; onOpenHistoryEvidence?: (filters?: Record) => void; onOpenRawEvidence?: (filters?: Record) => void; + onOpenMileageEvidence?: (filters?: Record) => void; onQueryChange?: (keyword: string, protocol?: string) => void; }) { const [query, setQuery] = useState({ keyword: vin, protocol }); const [detail, setDetail] = useState(null); + const [vehicleTimeWindow, setVehicleTimeWindow] = useState>({}); 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({ + +
+
+ + 单车时间窗 + {activeProtocol || '全部数据通道'} + {hasResolvedVIN ? '车辆已定位' : '待绑定车辆'} + + 单车时间窗交付 + + 把同一辆车和同一段时间带入轨迹回放、里程统计、历史导出和告警通知,客户复盘时不需要跨页面重复输入。 + +
+ + + + +
+
+
+ {[ + { 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) => ( + + ))} +
+
+
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 33afb693..9328197c 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -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, diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index e92d657c..7d3a1d95 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -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 () => {