diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 597f32a7..3b9eeee8 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -455,6 +455,44 @@ function scheduledHistoryReportPlanText({ ].join('\n'); } +function savedHistoryReportViewText({ + filters, + fieldCount, + fieldPaths, + locationCount, + rawCount, + validPointCount, + anomalySummary +}: { + filters: HistoryFilters; + fieldCount: number; + fieldPaths: string[]; + locationCount: number; + rawCount: number; + validPointCount: number; + anomalySummary: TrajectoryAnomalySummary; +}) { + const vehicle = filters.keyword?.trim() || ''; + const protocol = filters.protocol?.trim() || ''; + const fieldTemplate = fieldPaths.length > 0 ? fieldPaths.join(', ') : filters.fields?.trim() || '未配置字段裁剪'; + const evidenceFilters = { + tab: 'fields', + ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}), + ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {}), + includeFields: 'true', + ...(fieldPaths.length > 0 ? { fields: fieldPaths.join(',') } : filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) + }; + return [ + '【保存报表视图】', + `视图范围:${vehicle || '全部车辆'} / ${protocol || '全部数据通道'} / ${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, + `字段模板:${fieldTemplate}`, + '交付节奏:日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。', + `当前证据:位置 ${locationCount.toLocaleString()} 条,有效定位 ${validPointCount.toLocaleString()},明细 ${rawCount.toLocaleString()} 帧,字段 ${fieldCount.toLocaleString()} 个。`, + `质量提示:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, + `复用链接:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: evidenceFilters }))}` + ].join('\n'); +} + function trajectoryReviewPackageText({ filters, locationCount, @@ -1075,7 +1113,7 @@ export function History({ }, { title: '字段裁剪包', - value: selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 字段` : `${rawFieldRows.length.toLocaleString()} 字段`, + value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`, detail: selectedFieldCount > 0 ? '按客户指定字段裁剪,减少导出体积。' : '先填写字段裁剪或切换字段裁剪页,再生成字段级交付。', color: rawFieldRows.length > 0 || selectedFieldCount > 0 ? 'green' as const : 'orange' as const, action: '字段配置', @@ -1169,6 +1207,44 @@ export function History({ onClick: () => openQueryTab('fields') } ]; + const savedReportViewItems = [ + { + title: '当前筛选', + value: currentVehicleKeyword || '全部车辆', + detail: `${currentProtocol || '全部数据通道'} / ${filters.dateFrom || '-'} 至 ${filters.dateTo || '-'}`, + action: '保存视图', + color: currentVehicleKeyword ? 'green' as const : 'blue' as const, + disabled: false, + onClick: () => copySavedReportView() + }, + { + title: '字段模板', + value: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 字段` : `${selectedFieldCount.toLocaleString()} 字段`, + detail: filters.fields?.trim() || '未配置字段裁剪,默认使用当前字段明细。', + action: '复用字段', + color: selectedFieldCount > 0 || rawFieldRows.length > 0 ? 'green' as const : 'orange' as const, + disabled: false, + onClick: () => openQueryTab('fields') + }, + { + title: '交付节奏', + value: '日报/周报', + detail: '日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。', + action: '复制计划', + color: 'blue' as const, + disabled: false, + onClick: () => copyScheduledHistoryReportPlan() + }, + { + title: '快速分享', + value: '可复制', + detail: '复制当前视图链接和字段模板,交给客户或内部运营复用。', + action: '复制链接', + color: 'blue' as const, + disabled: false, + onClick: () => copySavedReportView() + } + ]; const customerHistoryQuestions = [ { question: '这辆车这段时间在哪里?', @@ -1293,6 +1369,29 @@ export function History({ '历史数据周期交付计划' ); }; + const copySavedReportView = () => { + const fieldPaths = Array.from(new Set(rawFieldRows.map((row) => row.fieldPath).filter(Boolean))); + const fallbackDateFrom = dateOnly(rawFieldRows[0]?.deviceTime); + const fallbackDateTo = fallbackDateFrom ? nextDate(fallbackDateFrom) : ''; + const viewFilters = { + ...filters, + ...(filters.dateFrom?.trim() ? {} : fallbackDateFrom ? { dateFrom: fallbackDateFrom } : {}), + ...(filters.dateTo?.trim() ? {} : fallbackDateTo ? { dateTo: fallbackDateTo } : {}), + ...(fieldPaths.length > 0 ? { fields: fieldPaths.join(',') } : {}) + }; + copyText( + savedHistoryReportViewText({ + filters: viewFilters, + locationCount: locations.total ?? 0, + rawCount: rawFrames.total ?? 0, + fieldCount: rawFieldRows.length, + fieldPaths, + validPointCount: validLocations.length, + anomalySummary: trajectoryAnomalies + }), + '保存报表视图' + ); + }; const copyTrajectoryReviewPackage = () => { copyText( trajectoryReviewPackageText({ @@ -1755,6 +1854,42 @@ export function History({ ) : null} + {mode === 'query' ? ( + +
+
+ + 可复用视图 + {currentVehicleKeyword ? '单车范围' : '全部车辆'} + 0 || rawFieldRows.length > 0 ? 'green' : 'orange'}> + {selectedFieldCount > 0 ? `${selectedFieldCount.toLocaleString()} 个字段` : `${rawFieldRows.length.toLocaleString()} 个字段`} + + + 把当前车辆、时间窗、数据通道、字段裁剪和交付节奏保存成客户可复用视图,后续不用重新拼查询条件。 + + 保存视图是前端可复制的报表配置:后续可升级为账号级报表模板、定时邮件或客户分组权限。 + +
+
+ {savedReportViewItems.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 822f74cf..198edd11 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -6819,6 +6819,76 @@ button.vp-realtime-command-item:focus-visible { font-weight: 700; } +.vp-history-saved-report { + display: grid; + grid-template-columns: minmax(300px, 0.7fr) minmax(0, 1.3fr); + gap: 14px; +} + +.vp-history-saved-report-summary { + padding: 14px; + border: 1px solid rgba(14, 173, 105, 0.2); + border-radius: var(--vp-radius); + background: #f5fcf8; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-history-saved-report-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-history-saved-report-item { + min-height: 158px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfefc; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + display: grid; + gap: 8px; + align-content: start; +} + +.vp-history-saved-report-item:hover, +.vp-history-saved-report-item:focus-visible { + border-color: rgba(14, 173, 105, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; +} + +.vp-history-saved-report-item:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.vp-history-saved-report-item strong { + color: var(--vp-text); + font-size: 18px; + line-height: 24px; + font-weight: 700; + word-break: break-word; +} + +.vp-history-saved-report-item span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-history-saved-report-item em { + color: #0a8f56; + font-size: 12px; + font-style: normal; + font-weight: 700; +} + .vp-history-question-board { display: grid; grid-template-columns: minmax(300px, 0.7fr) minmax(0, 1.3fr); @@ -8773,6 +8843,8 @@ button.vp-realtime-command-item:focus-visible { .vp-history-report-template-grid, .vp-history-report-cadence, .vp-history-report-cadence-grid, + .vp-history-saved-report, + .vp-history-saved-report-grid, .vp-history-question-board, .vp-history-question-grid, .vp-history-replay-nav, 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 328c74cb..338b1451 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -6664,6 +6664,12 @@ 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(); + expect(screen.getByText('保存报表视图')).toBeInTheDocument(); + expect(screen.getByText('把当前车辆、时间窗、数据通道、字段裁剪和交付节奏保存成客户可复用视图,后续不用重新拼查询条件。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '保存报表视图 当前筛选 VIN-FIELDS-001 保存视图' })).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.getAllByRole('button', { name: /复制决策说明/ }).length).toBeGreaterThanOrEqual(1); expect(screen.getByRole('button', { name: '客户轨迹决策 明细复核 明细证据' })).toBeInTheDocument(); @@ -6689,6 +6695,13 @@ test('shows parsed field history as a flattened evidence table', async () => { }); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('推荐频率:日报用于运营复盘,周报用于客户对账,临时导出用于问题解释。')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('交付内容:轨迹报告 / 位置历史 CSV / 明细证据 CSV / 字段裁剪 CSV / 统计查询链接 / 质量提示。')); + fireEvent.click(screen.getByRole('button', { name: '保存报表视图 当前筛选 VIN-FIELDS-001 保存视图' })); + 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('字段模板:gb32960.vehicle.speed_kmh, gb32960.vehicle.soc_percent')); + 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%2Cgb32960.vehicle.soc_percent')); expect(await screen.findByRole('tab', { name: '字段明细' })).toHaveAttribute('aria-selected', 'true'); expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({ method: 'POST',