diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index df76bbfe..30d740d7 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -367,6 +367,7 @@ function deliveryPackageText({ return [ '【客户数据交付包】', `交付状态:${deliveryState}`, + '交付物:位置历史 / 轨迹回放 / 里程复核 / RAW证据 / 字段明细 / 质量提示', `车辆范围:${vehicle || '全部车辆'}`, `数据来源:${protocol || '全部来源'}`, `时间范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, @@ -755,6 +756,62 @@ export function History({ : '待查询数据'; const deliveryStateColor = deliveryState === '可交付' ? 'green' as const : deliveryState === '待查询数据' ? 'grey' as const : 'orange' as const; const deliveryScopeText = `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源'}`; + const deliveryChecklistItems = [ + { + title: '位置历史', + value: `${locations.total.toLocaleString()} 条`, + detail: `${validLocations.length.toLocaleString()} 个有效坐标,交付车辆定位明细。`, + color: locations.total > 0 ? 'green' as const : 'grey' as const, + action: '导出位置', + disabled: locations.items.length === 0, + onClick: () => exportLocations() + }, + { + title: '轨迹回放', + value: validLocations.length > 0 ? '可回放' : '待查询', + detail: '同一时间窗回放路线、速度、里程断点。', + color: validLocations.length > 0 ? 'blue' as const : 'orange' as const, + action: '查看轨迹', + disabled: validLocations.length === 0, + onClick: () => openQueryTab('location') + }, + { + title: '里程复核', + value: formatNumber(mileageDelta, ' km'), + detail: '进入里程统计核对区间里程和日统计闭合。', + color: isFiniteNumber(mileageDelta) ? 'green' as const : 'grey' as const, + action: '里程复核', + disabled: !onOpenMileage, + onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) }) + }, + { + title: 'RAW证据', + 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() + }, + { + title: '字段明细', + 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') + }, + { + title: '质量提示', + value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项` : '无明显异常', + detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()} / 里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} / 超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}。`, + color: anomalyTotal > 0 ? 'orange' as const : 'green' as const, + action: '复制清单', + disabled: false, + onClick: () => copyDeliveryPackage() + } + ]; const clearRangeFilters = () => { applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {}); }; @@ -1155,6 +1212,31 @@ export function History({ ) : null} + {mode === 'query' ? ( + 客户交付物清单} onClick={copyDeliveryPackage}>复制交付清单} + style={{ marginTop: 16 }} + > + + {deliveryChecklistItems.map((item) => ( + + {item.title} + {item.value} + {item.detail} + {item.action} + + ))} + + + ) : 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 9ad687cc..484ad27f 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -2786,6 +2786,61 @@ button.vp-realtime-command-item:focus-visible { font-weight: 700; } +.vp-history-checklist-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.vp-history-checklist-item { + min-height: 178px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + color: inherit; + display: grid; + gap: 10px; + align-content: start; + text-align: left; + font: inherit; + cursor: pointer; +} + +.vp-history-checklist-item:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.vp-history-checklist-item:not(:disabled):hover, +.vp-history-checklist-item:not(:disabled):focus-visible { + border-color: rgba(0, 164, 184, 0.42); + background: #f5fbff; + outline: none; +} + +.vp-history-checklist-item strong { + color: var(--vp-text); + font-size: 19px; + line-height: 25px; + font-weight: 700; + word-break: break-word; +} + +.vp-history-checklist-item span { + color: var(--vp-text-muted); + font-size: 13px; + line-height: 20px; +} + +.vp-history-checklist-item em { + color: var(--vp-cyan); + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 18px; +} + .vp-history-delivery-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -3922,6 +3977,7 @@ button.vp-realtime-command-item:focus-visible { .vp-history-query-grid, .vp-history-package-board, .vp-history-package-grid, + .vp-history-checklist-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 813b7810..a47a8d11 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -6309,6 +6309,11 @@ test('shows and clears current history filters while keeping vehicle scope', asy test('shows parsed field history as a flattened evidence table', async () => { window.history.replaceState(null, '', '/#/history-query?keyword=VIN-FIELDS-001&protocol=GB32960&tab=fields&fields=gb32960.vehicle.speed_kmh'); + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const path = String(input); if (path.includes('/api/ops/health')) { @@ -6377,6 +6382,17 @@ test('shows parsed field history as a flattened evidence table', async () => { expect(screen.getByRole('button', { name: '客户数据交付 原始证据 导出RAW' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付 字段明细 字段明细' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付 质量提示 复制说明' })).toBeInTheDocument(); + expect(screen.getByText('客户交付物清单')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 位置历史 导出位置' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 轨迹回放 查看轨迹' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 里程复核 里程复核' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 RAW证据 导出RAW' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 字段明细 字段明细' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户交付清单 质量提示 复制清单' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制交付清单/ })); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付物:位置历史 / 轨迹回放 / 里程复核 / RAW证据 / 字段明细 / 质量提示')); + }); expect(await screen.findByRole('tab', { name: '字段明细' })).toHaveAttribute('aria-selected', 'true'); expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({ method: 'POST',