diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index be2e0cae..daee2873 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -416,6 +416,61 @@ function deliveryPackageText({ ].join('\n'); } +function deliveryCoverRecommendation(fieldCount: number, validPointCount: number, rawCount: number, locationCount: number) { + if (fieldCount > 0 && validPointCount === 0) return '优先导出字段证据,并补查轨迹位置。'; + if (validPointCount > 0) return '优先交付轨迹报告和位置历史,再附带字段证据。'; + if (rawCount > 0) return '先交付明细证据,并补充客户需要的字段裁剪。'; + if (locationCount > 0) return '先交付位置历史,并补充统计查询说明。'; + return '先锁定车辆和时间窗,再查询位置或明细证据。'; +} + +function deliveryCoverText({ + filters, + dateFrom, + dateTo, + deliveryState, + locationCount, + rawCount, + fieldCount, + validPointCount, + anomalySummary +}: { + filters: HistoryFilters; + dateFrom?: string; + dateTo?: string; + deliveryState: string; + locationCount: number; + rawCount: number; + fieldCount: number; + validPointCount: number; + anomalySummary: TrajectoryAnomalySummary; +}) { + const vehicle = filters.keyword?.trim() || '全部车辆'; + const protocol = filters.protocol?.trim() || '全部来源证据'; + const rangeFrom = dateFrom?.trim() || filters.dateFrom?.trim() || '-'; + const rangeTo = dateTo?.trim() || filters.dateTo?.trim() || '-'; + const evidenceFilters = { + ...(rangeFrom !== '-' ? { dateFrom: rangeFrom } : {}), + ...(rangeTo !== '-' ? { dateTo: rangeTo } : {}) + }; + const keyword = filters.keyword?.trim() || ''; + const protocolFilter = filters.protocol?.trim() || ''; + return [ + '【客户数据交付封面包】', + `交付主题:${vehicle} / ${protocol} / ${rangeFrom} 至 ${rangeTo}`, + `交付结论:${deliveryState}`, + '交付物:轨迹报告 / 位置历史 CSV / 明细证据 CSV / 字段裁剪 CSV / 统计查询链接 / 质量提示', + `当前证据:位置 ${locationCount.toLocaleString()} 条 / 有效坐标 ${validPointCount.toLocaleString()} 点 / 明细 ${rawCount.toLocaleString()} 帧 / 字段 ${fieldCount.toLocaleString()} 个`, + `质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, + `建议动作:${deliveryCoverRecommendation(fieldCount, validPointCount, rawCount, locationCount)}`, + `轨迹报告:${appURL(buildAppHash({ page: 'history', keyword, protocol: protocolFilter, filters: evidenceFilters }))}`, + `位置历史:${appURL(buildAppHash({ page: 'history-query', keyword, protocol: protocolFilter, filters: { ...evidenceFilters, tab: 'location' } }))}`, + `明细证据:${appURL(buildAppHash({ page: 'history-query', keyword, protocol: protocolFilter, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, + `字段裁剪:${appURL(buildAppHash({ page: 'history-query', keyword, protocol: protocolFilter, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`, + `统计查询:${appURL(buildAppHash({ page: 'mileage', keyword, protocol: protocolFilter, filters: evidenceFilters }))}` + ].join('\n'); +} + function scheduledHistoryReportPlanText({ filters, locationCount, @@ -1626,6 +1681,25 @@ export function History({ '客户查询导出包' ); }; + const evidenceDateFrom = filters.dateFrom?.trim() + || dateOnly(rawFieldRows[0]?.deviceTime || rawItems[0]?.deviceTime || firstLocation?.deviceTime || firstLocation?.serverTime); + const evidenceDateTo = filters.dateTo?.trim() || (evidenceDateFrom ? nextDate(evidenceDateFrom) : ''); + const copyDeliveryCover = () => { + copyText( + deliveryCoverText({ + filters, + dateFrom: evidenceDateFrom, + dateTo: evidenceDateTo, + deliveryState, + locationCount: locations.total ?? 0, + rawCount: rawFrames.total ?? 0, + fieldCount: rawFieldRows.length, + validPointCount: validLocations.length, + anomalySummary: trajectoryAnomalies + }), + '客户数据交付封面包' + ); + }; const copyScheduledHistoryReportPlan = () => { copyText( scheduledHistoryReportPlanText({ @@ -1935,6 +2009,44 @@ export function History({ onClick: copyDeliveryPackage } ]; + const deliveryCoverItems = [ + { + label: '交付结论', + value: deliveryState, + detail: deliveryState === '可交付' ? '当前证据可形成客户交付包。' : deliveryState === '待查询数据' ? '先查询位置或明细数据。' : '可交付,但需要附带复核说明。', + color: deliveryStateColor, + action: '复制封面', + disabled: false, + onClick: copyDeliveryCover + }, + { + label: '轨迹报告', + value: `${validLocations.length.toLocaleString()} 点`, + detail: validLocations.length > 0 ? '可交付轨迹报告和位置历史。' : '当前缺少有效轨迹点,建议补查位置历史。', + color: validLocations.length > 0 ? 'green' as const : 'orange' as const, + action: validLocations.length > 0 ? '查看轨迹' : '补查轨迹', + disabled: false, + onClick: () => openQueryTab('location') + }, + { + label: '字段证据', + value: `${fieldEvidenceCount.toLocaleString()} 字段`, + detail: fieldEvidenceCount > 0 ? '字段证据已裁剪,可用于客户交付。' : '先选择客户需要字段,减少导出体积。', + color: fieldEvidenceCount > 0 ? 'green' as const : 'orange' as const, + action: rawFieldRows.length > 0 ? '导出字段' : '字段配置', + disabled: false, + onClick: rawFieldRows.length > 0 ? exportRawFields : () => openQueryTab('fields') + }, + { + label: '客户说明', + value: '可复制', + detail: deliveryCoverRecommendation(fieldEvidenceCount, validLocations.length, rawFrames.total ?? 0, locations.total ?? 0), + color: 'blue' as const, + action: '复制说明', + disabled: false, + onClick: copyDeliveryPackage + } + ]; const customerDeliveryConclusionItems = [ { label: '交付状态', @@ -2183,6 +2295,43 @@ export function History({ 来源证据:{currentProtocol || '全部来源证据'} {scopeDescription} + {mode === 'query' ? ( +
+
+ + 客户数据交付封面 + {deliveryState} + 0 ? 'green' : 'orange'}>{fieldEvidenceCount.toLocaleString()} 字段 + + + 先给客户一个可读封面:交付结论、车辆范围、时间窗、交付物、质量提示和下一步动作。 + + + 交付主题:{currentVehicleKeyword || '全部车辆'} / {currentProtocol || '全部来源证据'} / {evidenceDateFrom || '-'} 至 {evidenceDateTo || '-'}。 + + + + + +
+
+ {deliveryCoverItems.map((item) => ( + + ))} +
+
+ ) : null} {mode === 'query' ? (
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 2dbe5aac..02b0f6c3 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -8179,6 +8179,13 @@ 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('先给客户一个可读封面:交付结论、车辆范围、时间窗、交付物、质量提示和下一步动作。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付封面 交付结论 可交付 复制封面' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付封面 轨迹报告 0 点 补查轨迹' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付封面 字段证据 2 字段 导出字段' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户数据交付封面 客户说明 可复制 复制说明' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /复制交付封面包/ })).toBeInTheDocument(); expect(screen.getByText('客户数据交付结论条')).toBeInTheDocument(); expect(screen.getByText('先判断这次查询能交付什么,再选择导出位置、明细证据、字段裁剪或复制说明。')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付结论 交付状态 可交付' })).toBeInTheDocument(); @@ -8307,6 +8314,14 @@ test('shows parsed field history as a flattened evidence table', async () => { expect(screen.getByRole('button', { name: '客户交付清单 明细证据 导出证据' })).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('【客户数据交付封面包】')); + }); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付主题:VIN-FIELDS-001 / GB32960 / 2026-07-03 至 2026-07-04')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付物:轨迹报告 / 位置历史 CSV / 明细证据 CSV / 字段裁剪 CSV / 统计查询链接 / 质量提示')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前证据:位置 0 条 / 有效坐标 0 点 / 明细 1 帧 / 字段 2 个')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('建议动作:优先导出字段证据,并补查轨迹位置。')); fireEvent.click(screen.getByRole('button', { name: /复制交付清单/ })); await waitFor(() => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付物:轨迹报告 / 位置历史 / 统计查询 / 明细证据 / 字段裁剪 / 质量提示'));