From 07663026e4fadce509313b0d8c0ff9f592d5b125 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 5 Jul 2026 02:48:02 +0800 Subject: [PATCH] feat(platform): add customer delivery package --- .../apps/web/src/pages/History.tsx | 155 ++++++++++++++++++ .../apps/web/src/styles/global.css | 74 +++++++++ .../apps/web/src/test/App.test.tsx | 5 + 3 files changed, 234 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 3a2cb47e..df76bbfe 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -335,6 +335,55 @@ function historyEvidencePackageText({ ].join('\n'); } +function deliveryPackageText({ + filters, + locationCount, + rawCount, + fieldCount, + validPointCount, + coverageRate, + mileageDelta, + anomalySummary +}: { + filters: HistoryFilters; + locationCount: number; + rawCount: number; + fieldCount: number; + validPointCount: number; + coverageRate?: number; + mileageDelta?: number; + anomalySummary: TrajectoryAnomalySummary; +}) { + const vehicle = filters.keyword?.trim() || ''; + const protocol = filters.protocol?.trim() || ''; + const evidenceFilters = { + ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), + ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}) + }; + const anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount; + const deliveryState = locationCount > 0 || rawCount > 0 + ? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可交付,建议附带复核说明' : '可交付' + : '待查询数据'; + return [ + '【客户数据交付包】', + `交付状态:${deliveryState}`, + `车辆范围:${vehicle || '全部车辆'}`, + `数据来源:${protocol || '全部来源'}`, + `时间范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, + `位置历史:${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},覆盖率 ${formatPercent(coverageRate)}`, + `原始证据:${rawCount.toLocaleString()} 帧`, + `字段明细:${fieldCount.toLocaleString()} 个字段`, + `区间里程:${formatNumber(mileageDelta, ' km')}`, + `质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, + `位置导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, + `RAW导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, + `字段导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, + `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, + `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, + `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` + ].join('\n'); +} + function trajectoryReviewPackageText({ filters, locationCount, @@ -700,6 +749,12 @@ export function History({ isIncludeFieldsEnabled(filters.includeFields) ? '返回字段明细' : '', selectedFieldCount > 0 ? `字段裁剪:${selectedFieldCount} 个` : '' ].filter(Boolean); + const anomalyTotal = trajectoryAnomalies.gapCount + trajectoryAnomalies.mileageRollbackCount + trajectoryAnomalies.overspeedCount; + const deliveryState = locations.total > 0 || (rawFrames.total ?? 0) > 0 + ? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可交付,需复核说明' : '可交付' + : '待查询数据'; + const deliveryStateColor = deliveryState === '可交付' ? 'green' as const : deliveryState === '待查询数据' ? 'grey' as const : 'orange' as const; + const deliveryScopeText = `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源'}`; const clearRangeFilters = () => { applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {}); }; @@ -758,6 +813,21 @@ export function History({ '数据复核包' ); }; + const copyDeliveryPackage = () => { + copyText( + deliveryPackageText({ + filters, + locationCount: locations.total ?? 0, + rawCount: rawFrames.total ?? 0, + fieldCount: rawFieldRows.length, + validPointCount: validLocations.length, + coverageRate: trajectoryCoverageRate, + mileageDelta, + anomalySummary: trajectoryAnomalies + }), + '客户数据交付包' + ); + }; const copyTrajectoryReviewPackage = () => { copyText( trajectoryReviewPackageText({ @@ -1000,6 +1070,91 @@ export function History({ ) : null} + {mode === 'query' ? ( + +
+
+ + {deliveryState} + {currentProtocol || '全部来源'} + + {deliveryScopeText} + + 面向客户交付时,先确认车辆、时间、交付物和质量提示,再导出位置、RAW 或字段明细,避免把内部排查过程直接暴露给客户。 + + + + + + +
+
+ {[ + { + label: '位置历史', + value: `${locations.total.toLocaleString()} 条`, + detail: `${validLocations.length.toLocaleString()} 个有效坐标,覆盖率 ${formatPercent(trajectoryCoverageRate)}。`, + color: locations.total > 0 ? 'green' as const : 'grey' as const, + action: '导出位置', + disabled: locations.items.length === 0, + onClick: exportLocations + }, + { + label: '原始证据', + value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, + detail: '用于证明数据来自接入协议解析后的真实记录。', + color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, + action: '导出RAW', + disabled: rawFrames.items.length === 0, + onClick: exportRawFrames + }, + { + label: '字段明细', + value: `${rawFieldRows.length.toLocaleString()} 个`, + detail: selectedFieldCount > 0 ? `按 ${selectedFieldCount.toLocaleString()} 个字段裁剪。` : '需要字段级交付时切换字段明细。', + color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const, + action: '字段明细', + disabled: false, + onClick: () => openQueryTab('fields') + }, + { + label: '质量提示', + value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项` : '无明显异常', + detail: anomalyTotal > 0 + ? `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}。` + : '当前页未发现断点、里程回退或超速。', + color: anomalyTotal > 0 ? 'orange' as const : 'green' as const, + action: '复制说明', + disabled: false, + onClick: copyDeliveryPackage + } + ].map((item) => ( + + ))} +
+
+
+ ) : null}
submit(values)}> diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 8235deef..8db0a2a8 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -2595,6 +2595,78 @@ button.vp-realtime-command-item:focus-visible { word-break: break-word; } +.vp-history-package-board { + display: grid; + grid-template-columns: minmax(280px, 0.62fr) minmax(0, 1.38fr); + gap: 16px; +} + +.vp-history-package-summary { + min-height: 190px; + padding: 14px; + border: 1px solid rgba(34, 197, 94, 0.22); + border-radius: var(--vp-radius); + background: #f7fdf9; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-history-package-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-history-package-item { + min-width: 0; + min-height: 190px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + color: inherit; + text-align: left; + display: grid; + gap: 10px; + align-content: start; + cursor: pointer; +} + +.vp-history-package-item:hover, +.vp-history-package-item:focus-visible { + border-color: rgba(22, 100, 255, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; +} + +.vp-history-package-item:disabled { + cursor: not-allowed; + opacity: 0.62; + box-shadow: none; +} + +.vp-history-package-item span { + min-width: 0; + display: grid; + gap: 8px; +} + +.vp-history-package-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + +.vp-history-package-item em { + color: var(--vp-primary); + font-size: 12px; + font-style: normal; + font-weight: 700; +} + .vp-history-delivery-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -3643,6 +3715,8 @@ button.vp-realtime-command-item:focus-visible { .vp-trajectory-impact-grid, .vp-history-query-workbench, .vp-history-query-grid, + .vp-history-package-board, + .vp-history-package-grid, .vp-history-delivery-grid, .vp-source-fusion-grid, .vp-source-fusion-facts, 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 48bee18f..edb7cadf 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -6360,6 +6360,11 @@ test('shows parsed field history as a flattened evidence table', async () => { render(); expect(await screen.findByRole('heading', { name: '数据导出' })).toBeInTheDocument(); + expect(screen.getByText('客户数据交付包')).toBeInTheDocument(); + expect(screen.getByText('面向客户交付时,先确认车辆、时间、交付物和质量提示,再导出位置、RAW 或字段明细,避免把内部排查过程直接暴露给客户。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付 原始证据 导出RAW' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付 字段明细 字段明细' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付 质量提示 复制说明' })).toBeInTheDocument(); expect(await screen.findByRole('tab', { name: '字段明细' })).toHaveAttribute('aria-selected', 'true'); expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({ method: 'POST',