diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index e3a2bfa7..624c9955 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -1924,6 +1924,88 @@ export function History({ onClick: copyDeliveryPackage } ]; + const nextExportPrimary = rawFieldRows.length > 0 + ? '字段证据' + : locations.items.length > 0 + ? '位置历史' + : rawFrames.items.length > 0 + ? '明细证据' + : '先查询'; + const runPrimaryExport = () => { + if (rawFieldRows.length > 0) { + exportRawFields(); + return; + } + if (locations.items.length > 0) { + exportLocations(); + return; + } + if (rawFrames.items.length > 0) { + exportRawFrames(); + return; + } + openQueryTab('raw'); + }; + const nextExportDecisionItems = [ + { + label: '首要交付', + value: nextExportPrimary, + detail: rawFieldRows.length > 0 ? '优先交付客户选择的字段证据。' : locations.items.length > 0 ? '优先交付位置历史和轨迹复盘。' : rawFrames.items.length > 0 ? '先交付明细证据解释来源。' : '先查询车辆和时间窗数据。', + color: rawFieldRows.length > 0 || locations.items.length > 0 ? 'green' as const : rawFrames.items.length > 0 ? 'blue' as const : 'orange' as const, + onClick: runPrimaryExport + }, + { + label: '轨迹状态', + value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()} 点` : '待补轨迹', + detail: validLocations.length > 0 ? '可以进入轨迹回放和位置导出。' : '当前时间窗没有有效坐标,建议补查位置历史。', + color: validLocations.length > 0 ? 'green' as const : 'orange' as const, + onClick: () => openQueryTab('location') + }, + { + label: '证据状态', + value: `${(rawFrames.total ?? 0).toLocaleString()} 帧 / ${fieldEvidenceCount.toLocaleString()} 字段`, + detail: fieldEvidenceCount > 0 ? '字段证据已准备,可缩小导出体积。' : '可从明细证据中选择客户需要的字段。', + color: fieldEvidenceCount > 0 ? 'green' as const : (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, + onClick: () => openQueryTab(fieldEvidenceCount > 0 ? 'fields' : 'raw') + }, + { + label: '说明状态', + value: deliveryState, + detail: anomalyTotal > 0 ? '复制时会带上质量提示和复核说明。' : '可复制客户可读的交付说明。', + color: deliveryStateColor, + onClick: copyDeliveryPackage + } + ]; + const nextExportActionItems = [ + { + label: '导出优先项', + action: '执行导出', + color: nextExportPrimary === '字段证据' || nextExportPrimary === '位置历史' ? 'green' as const : nextExportPrimary === '明细证据' ? 'blue' as const : 'orange' as const, + disabled: nextExportPrimary === '先查询', + onClick: runPrimaryExport + }, + { + label: '补查轨迹', + action: '位置历史', + color: validLocations.length > 0 ? 'green' as const : 'orange' as const, + disabled: false, + onClick: () => openQueryTab('location') + }, + { + label: '复制说明', + action: '交付包', + color: deliveryStateColor, + disabled: false, + onClick: copyDeliveryPackage + }, + { + label: '保存视图', + action: '复用', + color: 'blue' as const, + disabled: false, + onClick: copySavedReportView + } + ]; const tripReviewSummaryItems = [ { label: '起点', @@ -2077,6 +2159,57 @@ export function History({ ) : null} + {mode === 'query' ? ( +
+
+ + 导出下一步行动条 + + {nextExportPrimary} + + 0 ? 'green' : 'orange'}> + {validLocations.length > 0 ? '轨迹可用' : '待补轨迹'} + + + + 当前查询先决定交付什么、缺什么、怎么复用,避免客户拿到一堆无解释 CSV。 + + + 建议交付:{nextExportPrimary};证据:{(rawFrames.total ?? 0).toLocaleString()} 帧 / {fieldEvidenceCount.toLocaleString()} 字段;范围:{deliveryScopeText}。 + +
+
+ {nextExportDecisionItems.map((item) => ( + + ))} +
+
+ {nextExportActionItems.map((item) => ( + + ))} +
+
+ ) : null}
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 77d3f3f8..ab8e6364 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -10765,6 +10765,108 @@ button.vp-realtime-command-item:focus-visible { white-space: nowrap; } +.vp-history-next-export-strip { + margin-top: 12px; + border: 1px solid rgba(45, 136, 255, 0.18); + border-radius: var(--vp-radius); + background: #fff; + display: grid; + grid-template-columns: minmax(260px, 0.84fr) minmax(0, 1.28fr) minmax(190px, 0.58fr); + overflow: hidden; +} + +.vp-history-next-export-copy { + padding: 16px 18px; + border-right: 1px solid var(--vp-border); + background: linear-gradient(180deg, #f7fbff 0%, #fff 100%); + display: grid; + gap: 10px; + align-content: start; +} + +.vp-history-next-export-grid { + padding: 12px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.vp-history-next-export-item { + min-height: 112px; + padding: 12px; + border: 1px solid rgba(22, 100, 255, 0.12); + border-radius: 8px; + background: #fbfcff; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + display: grid; + gap: 8px; + align-content: start; +} + +.vp-history-next-export-item:hover, +.vp-history-next-export-item:focus-visible, +.vp-history-next-export-action:hover, +.vp-history-next-export-action:focus-visible { + border-color: rgba(22, 100, 255, 0.42); + background: #f5f9ff; + box-shadow: var(--vp-shadow-sm); + outline: none; +} + +.vp-history-next-export-item strong { + color: var(--vp-text); + font-size: 18px; + line-height: 24px; + word-break: break-word; +} + +.vp-history-next-export-item span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; + word-break: break-word; +} + +.vp-history-next-export-actions { + padding: 12px; + border-left: 1px solid var(--vp-border); + background: #fafcff; + display: grid; + gap: 8px; +} + +.vp-history-next-export-action { + min-height: 44px; + padding: 8px 10px; + border: 1px solid rgba(22, 100, 255, 0.13); + border-radius: 8px; + background: #fff; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.vp-history-next-export-action:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.vp-history-next-export-action span { + color: var(--vp-primary); + font-size: 12px; + font-weight: 700; + line-height: 18px; + white-space: nowrap; +} + .vp-history-evidence-chain { margin-top: 16px; border: 1px solid rgba(22, 100, 255, 0.18); @@ -13948,6 +14050,8 @@ button.vp-realtime-command-item:focus-visible { .vp-trajectory-impact-grid, .vp-history-delivery-conclusion-strip, .vp-history-delivery-conclusion-metrics, + .vp-history-next-export-strip, + .vp-history-next-export-grid, .vp-history-evidence-chain, .vp-history-evidence-chain-grid, .vp-trip-review-summary-grid, @@ -14075,7 +14179,9 @@ button.vp-realtime-command-item:focus-visible { } .vp-history-delivery-conclusion-copy, - .vp-history-delivery-conclusion-actions { + .vp-history-delivery-conclusion-actions, + .vp-history-next-export-copy, + .vp-history-next-export-actions { border-left: 0; border-right: 0; border-bottom: 1px solid var(--vp-border); 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 d385dac8..a6f331c8 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7649,6 +7649,16 @@ test('shows parsed field history as a flattened evidence table', async () => { expect(screen.getByRole('button', { name: '客户数据交付动作 导出明细 明细CSV' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付动作 字段裁剪 字段CSV' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付动作 复制说明 交付包' })).toBeInTheDocument(); + expect(screen.getByText('导出下一步行动条')).toBeInTheDocument(); + expect(screen.getByText('当前查询先决定交付什么、缺什么、怎么复用,避免客户拿到一堆无解释 CSV。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出下一步行动 首要交付 字段证据' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出下一步行动 轨迹状态 待补轨迹' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出下一步行动 证据状态 1 帧 / 2 字段' })).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: '导出下一步动作 保存视图 复用' })).toBeInTheDocument(); expect(screen.getByText('车辆历史服务台')).toBeInTheDocument(); expect(screen.getByText('客户证据包交付总览')).toBeInTheDocument(); expect(screen.getByText('先给客户一个结论:当前筛选能交付哪些证据、还缺哪一类证据、下一步应该导出还是补查。')).toBeInTheDocument();