diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index daee2873..b80c1e42 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -1684,6 +1684,13 @@ 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 exportWizardEvidenceLabel = rawFieldRows.length > 0 + ? '字段证据' + : validLocations.length > 0 + ? '轨迹报告' + : (rawFrames.total ?? 0) > 0 + ? '明细证据' + : '待查询证据'; const copyDeliveryCover = () => { copyText( deliveryCoverText({ @@ -1700,6 +1707,65 @@ export function History({ '客户数据交付封面包' ); }; + const copyExportWizardPackage = () => { + const evidenceFilters = { + ...(evidenceDateFrom ? { dateFrom: evidenceDateFrom } : {}), + ...(evidenceDateTo ? { dateTo: evidenceDateTo } : {}) + }; + const fieldFilters = { + tab: 'fields', + ...evidenceFilters, + includeFields: 'true', + ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) + }; + const lines = [ + '【客户导出向导包】', + `步骤1 锁定范围:${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源证据'} / ${evidenceDateFrom || '-'} 至 ${evidenceDateTo || '-'}`, + `步骤2 选择证据:${exportWizardEvidenceLabel}`, + `步骤3 质量复核:${deliveryState}`, + '步骤4 导出交付:复制交付包', + `位置历史:${appURL(buildAppHash({ page: 'history-query', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...evidenceFilters, tab: 'location' } }))}`, + `明细证据:${appURL(buildAppHash({ page: 'history-query', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`, + `字段裁剪:${appURL(buildAppHash({ page: 'history-query', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: fieldFilters }))}`, + `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: evidenceFilters }))}`, + `统计查询:${appURL(buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol, filters: evidenceFilters }))}` + ]; + copyText(lines.join('\n'), '客户导出向导包'); + }; + const exportWizardSteps = [ + { + step: '1', + title: '锁定范围', + value: `${currentVehicleKeyword || '全部车辆'} / ${currentProtocol || '全部来源证据'}`, + detail: `${evidenceDateFrom || '-'} 至 ${evidenceDateTo || '-'},先确认客户要查的车辆和时间窗。`, + color: currentVehicleKeyword ? 'green' as const : 'orange' as const, + onClick: () => document.querySelector('input[name="keyword"]')?.focus() + }, + { + step: '2', + title: '选择证据', + value: exportWizardEvidenceLabel, + detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个字段可按客户口径导出。` : `${validLocations.length.toLocaleString()} 个有效坐标,${(rawFrames.total ?? 0).toLocaleString()} 帧明细。`, + color: rawFieldRows.length > 0 || validLocations.length > 0 ? 'blue' as const : 'orange' as const, + onClick: () => rawFieldRows.length > 0 ? openQueryTab('fields') : validLocations.length > 0 ? openQueryTab('location') : openQueryTab('raw') + }, + { + step: '3', + title: '质量复核', + value: deliveryState, + detail: anomalyTotal > 0 ? `存在 ${anomalyTotal.toLocaleString()} 项质量提示,交付时附说明。` : '当前范围可生成客户可读交付说明。', + color: deliveryStateColor, + onClick: () => copyDeliveryCover() + }, + { + step: '4', + title: '导出交付', + value: '复制交付包', + detail: '把位置、明细、字段、轨迹和统计链接打包给客户或内部复核。', + color: 'blue' as const, + onClick: () => copyExportWizardPackage() + } + ]; const copyScheduledHistoryReportPlan = () => { copyText( scheduledHistoryReportPlanText({ @@ -2295,6 +2361,40 @@ export function History({ 来源证据:{currentProtocol || '全部来源证据'} {scopeDescription} + {mode === 'query' ? ( +
+
+ + 客户导出向导 + {deliveryState} + 0 ? 'green' : 'orange'}>{exportWizardEvidenceLabel} + + + 客户导出向导 + + + 按客户交付顺序完成:先锁定车辆和时间窗,再选择轨迹、明细、字段或统计证据,最后复核质量并复制交付包。 + + +
+
+ {exportWizardSteps.map((item) => ( + + ))} +
+
+ ) : null} {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 5bba2840..7e720b66 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -11657,6 +11657,78 @@ button.vp-realtime-command-item:focus-visible { line-height: 18px; } +.vp-history-export-wizard { + margin-top: 16px; + border: 1px solid rgba(22, 100, 255, 0.18); + border-radius: var(--vp-radius); + background: #f7fbff; + display: grid; + grid-template-columns: minmax(280px, 0.42fr) minmax(0, 1fr); + overflow: hidden; + box-shadow: var(--vp-shadow-sm); +} + +.vp-history-export-wizard-copy { + min-width: 0; + padding: 18px; + border-right: 1px solid rgba(22, 100, 255, 0.12); + display: grid; + gap: 10px; + align-content: start; +} + +.vp-history-export-wizard-copy .semi-button { + width: fit-content; +} + +.vp-history-export-wizard-steps { + min-width: 0; + padding: 14px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.vp-history-export-wizard-step { + min-height: 142px; + padding: 12px; + border: 1px solid rgba(22, 100, 255, 0.14); + border-radius: 8px; + background: #ffffff; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; + display: grid; + gap: 8px; + align-content: start; + transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; +} + +.vp-history-export-wizard-step:hover, +.vp-history-export-wizard-step:focus-visible { + border-color: rgba(22, 100, 255, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; + transform: translateY(-1px); +} + +.vp-history-export-wizard-step strong { + color: var(--vp-text); + font-size: 17px; + line-height: 23px; + font-weight: 800; + word-break: break-word; +} + +.vp-history-export-wizard-step span, +.vp-history-export-wizard-step small { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; + word-break: break-word; +} + .vp-history-delivery-conclusion-strip { margin-top: 16px; border: 1px solid rgba(22, 100, 255, 0.18); @@ -15150,6 +15222,8 @@ button.vp-realtime-command-item:focus-visible { .vp-trajectory-anomaly-grid, .vp-trajectory-impact-board, .vp-trajectory-impact-grid, + .vp-history-export-wizard, + .vp-history-export-wizard-steps, .vp-history-delivery-conclusion-strip, .vp-history-delivery-conclusion-metrics, .vp-history-next-export-strip, 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 8f17c4c3..5b66c0be 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -8221,6 +8221,13 @@ test('shows parsed field history as a flattened evidence table', async () => { render(); expect(await screen.findByRole('heading', { name: '历史查询导出' })).toBeInTheDocument(); + expect(screen.getAllByText('客户导出向导').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('按客户交付顺序完成:先锁定车辆和时间窗,再选择轨迹、明细、字段或统计证据,最后复核质量并复制交付包。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出向导 1 锁定范围 VIN-FIELDS-001 / GB32960' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出向导 2 选择证据 字段证据' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出向导 3 质量复核 可交付' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出向导 4 导出交付 复制交付包' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /复制导出向导包/ })).toBeInTheDocument(); expect(screen.getByText('客户数据交付封面')).toBeInTheDocument(); expect(screen.getByText('先给客户一个可读封面:交付结论、车辆范围、时间窗、交付物、质量提示和下一步动作。')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户数据交付封面 交付结论 可交付 复制封面' })).toBeInTheDocument(); @@ -8364,6 +8371,15 @@ test('shows parsed field history as a flattened evidence table', async () => { 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('【客户导出向导包】')); + }); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('步骤1 锁定范围:VIN-FIELDS-001 / GB32960 / 2026-07-03 至 2026-07-04')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('步骤2 选择证据:字段证据')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('步骤3 质量复核:可交付')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('步骤4 导出交付:复制交付包')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('字段裁剪:http://localhost:3000/#/history-query?keyword=VIN-FIELDS-001&protocol=GB32960&tab=fields&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true&fields=gb32960.vehicle.speed_kmh')); fireEvent.click(screen.getByRole('button', { name: /复制交付清单/ })); await waitFor(() => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付物:轨迹报告 / 位置历史 / 统计查询 / 明细证据 / 字段裁剪 / 质量提示'));