From 06d80f81470b778c29b35939e553b8dd97a9957f Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 5 Jul 2026 03:49:18 +0800 Subject: [PATCH] feat(platform): add customer trajectory decision panel --- .../apps/web/src/pages/History.tsx | 190 +++++++++++++++++- .../apps/web/src/styles/global.css | 73 +++++++ .../apps/web/src/test/App.test.tsx | 16 ++ 3 files changed, 278 insertions(+), 1 deletion(-) diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 96c4c999..2564f250 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -542,6 +542,79 @@ function trajectoryImpactPackageText({ ].join('\n'); } +function trajectoryCustomerDecisionText({ + filters, + locationCount, + rawCount, + fieldCount, + validPointCount, + coverageRate, + playbackSpanMinutes, + playbackIntervalMinutes, + mileageDelta, + maxSpeed, + firstLocation, + lastLocation, + anomalySummary, + amapConfigured, + deliveryState +}: { + filters: HistoryFilters; + locationCount: number; + rawCount: number; + fieldCount: number; + validPointCount: number; + coverageRate?: number; + playbackSpanMinutes?: number; + playbackIntervalMinutes?: number; + mileageDelta?: number; + maxSpeed?: number; + firstLocation?: HistoryLocationRow; + lastLocation?: HistoryLocationRow; + anomalySummary: TrajectoryAnomalySummary; + amapConfigured: boolean; + deliveryState: string; +}) { + 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 decision = locationCount > 0 || rawCount > 0 + ? anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '可复盘,需附异常说明' : '可直接复盘' + : '待查询数据'; + return [ + '【客户轨迹决策说明】', + `决策结论:${decision}`, + `交付状态:${deliveryState}`, + `车辆范围:${vehicle || '全部车辆'}`, + `数据来源:${protocol || '全部来源'}`, + `时间范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, + `轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / 原始记录 ${rawCount.toLocaleString()} / 字段明细 ${fieldCount.toLocaleString()}`, + `定位覆盖:${formatPercent(coverageRate)}`, + `回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, + `区间里程:${formatNumber(mileageDelta, ' km')},最高速度:${formatNumber(maxSpeed, ' km/h')}`, + `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`, + `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`, + `异常提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, + `地图能力:${amapConfigured ? '高德地图可用' : '坐标预览'}`, + '', + '客户复盘路径:', + '1. 先看轨迹覆盖:确认是否有足够有效坐标。', + '2. 再看时间断点:断点会影响定位连续性和客户解释。', + '3. 再看里程速度:区间里程、速度和轨迹方向需要能互相解释。', + '4. 最后回到证据:异常点必须打开 RAW、字段明细和里程统计复核。', + `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`, + `数据导出:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, + `原始记录:${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: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`, + `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}` + ].join('\n'); +} + function amapLocationName(row: HistoryLocationRow, fallback: string) { return encodeURIComponent(row.plate || row.vin || fallback); } @@ -786,7 +859,58 @@ export function History({ ? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可交付,需复核说明' : '可交付' : '待查询数据'; const deliveryStateColor = deliveryState === '可交付' ? 'green' as const : deliveryState === '待查询数据' ? 'grey' as const : 'orange' as const; + const trajectoryDecisionState = locations.total > 0 || (rawFrames.total ?? 0) > 0 + ? anomalyTotal > 0 || (trajectoryCoverageRate ?? 100) < 80 ? '可复盘,需说明' : '可直接复盘' + : '待查询数据'; + const trajectoryDecisionColor = trajectoryDecisionState === '可直接复盘' ? 'green' as const : trajectoryDecisionState === '待查询数据' ? 'grey' as const : 'orange' as const; const deliveryScopeText = `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源'}`; + const trajectoryDecisionItems = [ + { + label: '复盘结论', + value: trajectoryDecisionState, + detail: trajectoryDecisionState === '可直接复盘' ? '轨迹连续性和证据覆盖可用于客户复盘。' : trajectoryDecisionState === '待查询数据' ? '先输入车辆和时间范围查询轨迹。' : '轨迹可用,但需要附带断点或异常说明。', + color: trajectoryDecisionColor, + action: '复制决策', + disabled: false, + onClick: () => copyTrajectoryDecision() + }, + { + label: '定位覆盖', + value: formatPercent(trajectoryCoverageRate), + detail: `${validLocations.length.toLocaleString()} / ${locations.total.toLocaleString()} 个有效坐标。`, + color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : locations.total > 0 ? 'orange' as const : 'grey' as const, + action: '播放轨迹', + disabled: validLocations.length === 0, + onClick: () => openQueryTab('location') + }, + { + label: '时间断点', + value: `${trajectoryAnomalies.gapCount.toLocaleString()} 项`, + detail: trajectoryAnomalies.maxGapMinutes ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}。` : '当前页未发现超过 30 分钟断点。', + color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const, + action: '复盘包', + disabled: false, + onClick: () => copyTrajectoryReviewPackage() + }, + { + label: '里程速度', + value: formatNumber(mileageDelta, ' km'), + detail: `最高速度 ${formatNumber(maxSpeed, ' km/h')},用于和里程统计互相解释。`, + color: isFiniteNumber(mileageDelta) ? 'blue' as const : 'grey' as const, + action: '里程复核', + disabled: !onOpenMileage, + onClick: () => onOpenMileage?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, ...(filters.dateFrom ? { dateFrom: filters.dateFrom } : {}), ...(filters.dateTo ? { dateTo: filters.dateTo } : {}) }) + }, + { + label: '证据交付', + value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, + detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个字段明细可导出。` : '异常点需要回到 RAW 和字段明细。', + color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, + action: 'RAW证据', + disabled: false, + onClick: () => openQueryTab('raw') + } + ]; const deliveryChecklistItems = [ { title: '位置历史', @@ -959,6 +1083,28 @@ export function History({ '轨迹运营影响' ); }; + const copyTrajectoryDecision = () => { + copyText( + trajectoryCustomerDecisionText({ + filters, + locationCount: locations.total ?? 0, + rawCount: rawFrames.total ?? 0, + fieldCount: rawFieldRows.length, + validPointCount: validLocations.length, + coverageRate: trajectoryCoverageRate, + playbackSpanMinutes, + playbackIntervalMinutes, + mileageDelta, + maxSpeed, + firstLocation, + lastLocation, + anomalySummary: trajectoryAnomalies, + amapConfigured, + deliveryState + }), + '客户轨迹决策说明' + ); + }; const openAmapTrajectory = () => { const url = amapTrajectoryURL(validLocations); if (!url) { @@ -1092,8 +1238,50 @@ export function History({ 当前来源:{currentProtocol || '全部来源'} {scopeDescription} + 客户轨迹决策台} + style={{ marginTop: 16 }} + > +
+
+ + {trajectoryDecisionState} + {deliveryState} + {amapConfigured ? '高德地图可用' : '坐标预览'} + + 先判断轨迹能否复盘,再进入 RAW、字段和里程证据 + + 客户问某辆车某段时间发生了什么时,先看定位覆盖、时间断点、里程速度和原始证据,避免把协议排查过程当成客户主流程。 + + + + + + + +
+
+ {trajectoryDecisionItems.map((item) => ( + + ))} +
+
+
{mode === 'query' ? ( - +
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 70cc5824..2dd4eb10 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -3105,6 +3105,77 @@ button.vp-realtime-command-item:focus-visible { word-break: break-word; } +.vp-trajectory-decision-board { + display: grid; + grid-template-columns: minmax(300px, 0.72fr) minmax(0, 1.28fr); + gap: 16px; +} + +.vp-trajectory-decision-summary { + min-height: 206px; + padding: 14px; + border: 1px solid rgba(11, 159, 119, 0.22); + border-radius: var(--vp-radius); + background: #f5fbf9; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-trajectory-decision-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-trajectory-decision-item { + min-width: 0; + min-height: 206px; + 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-trajectory-decision-item:hover, +.vp-trajectory-decision-item:focus-visible { + border-color: rgba(11, 159, 119, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; +} + +.vp-trajectory-decision-item:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.vp-trajectory-decision-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + +.vp-trajectory-decision-item span { + color: var(--vp-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-trajectory-decision-item em { + color: var(--vp-primary); + font-size: 12px; + font-style: normal; + font-weight: 700; +} + .vp-history-package-board { display: grid; grid-template-columns: minmax(280px, 0.62fr) minmax(0, 1.38fr); @@ -4373,6 +4444,8 @@ button.vp-realtime-command-item:focus-visible { .vp-bi-publish-grid, .vp-stat-audit-board, .vp-stat-audit-grid, + .vp-trajectory-decision-board, + .vp-trajectory-decision-grid, .vp-trajectory-anomaly-grid, .vp-trajectory-impact-board, .vp-trajectory-impact-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 37b2bca4..aea2465b 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -5389,6 +5389,19 @@ test('copies trajectory playback summary from history page', async () => { render(); expect((await screen.findAllByText('VIN-HISTORY-SUMMARY')).length).toBeGreaterThan(0); + expect(screen.getByText('客户轨迹决策台')).toBeInTheDocument(); + expect(screen.getByText('先判断轨迹能否复盘,再进入 RAW、字段和里程证据')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户轨迹决策 复盘结论 复制决策' })).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证据' })).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: /复制决策说明/ })[0]); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户轨迹决策说明】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('决策结论:可复盘,需附异常说明')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('客户复盘路径:')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('4. 最后回到证据:异常点必须打开 RAW、字段明细和里程统计复核。')); + fireEvent.click(screen.getByRole('button', { name: /复制轨迹摘要/ })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【轨迹回放摘要】')); @@ -6411,6 +6424,9 @@ 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.getAllByRole('button', { name: /复制决策说明/ }).length).toBeGreaterThanOrEqual(1); + expect(screen.getByRole('button', { name: '客户轨迹决策 证据交付 RAW证据' })).toBeInTheDocument(); expect(screen.getByText('客户数据交付包')).toBeInTheDocument(); expect(screen.getByText('面向客户交付时,先确认车辆、时间、交付物和质量提示,再导出位置、RAW 或字段明细,避免把内部排查过程直接暴露给客户。')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付 原始证据 导出RAW' })).toBeInTheDocument();