diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 624c9955..444e8bc7 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -1131,6 +1131,77 @@ export function History({ } ]; const fieldEvidenceCount = rawFieldRows.length > 0 ? rawFieldRows.length : selectedFieldCount; + const exportAcceptanceItems = [ + { + label: '范围锁定', + value: deliveryScopeText, + detail: `${filters.dateFrom || '-'} 至 ${filters.dateTo || '-'},用于限定客户交付边界。`, + color: currentVehicleKeyword ? 'green' as const : 'orange' as const, + onClick: () => openQueryTab(activeTab) + }, + { + label: '轨迹证据', + value: validLocations.length > 0 ? `${validLocations.length.toLocaleString()} 点` : '0 点', + detail: validLocations.length > 0 ? '轨迹点可用于回放和位置 CSV。' : '缺少轨迹点时先补查位置历史。', + color: validLocations.length > 0 ? 'green' as const : 'orange' as const, + onClick: () => openQueryTab('location') + }, + { + label: '明细证据', + value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, + detail: '保留原始接入明细,解释位置、里程和字段来源。', + color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, + onClick: () => openQueryTab('raw') + }, + { + label: '字段裁剪', + value: fieldEvidenceCount > 0 ? `${fieldEvidenceCount.toLocaleString()} 字段` : '待配置', + detail: fieldEvidenceCount > 0 ? '字段证据已裁剪,可减少客户导出体积。' : '选择客户需要字段后再交付。', + color: fieldEvidenceCount > 0 ? 'green' as const : 'orange' as const, + onClick: () => openQueryTab('fields') + }, + { + label: '异常说明', + value: anomalyTotal > 0 ? `${anomalyTotal.toLocaleString()} 项` : deliveryState, + detail: anomalyTotal > 0 ? '复制交付说明时带上复核提示。' : '可直接生成客户可读交付说明。', + color: anomalyTotal > 0 ? 'orange' as const : deliveryStateColor, + onClick: () => copyDeliveryPackage() + } + ]; + const exportAcceptanceActions = [ + { + label: '复制验收', + action: '交付说明', + detail: '把范围、证据和异常说明复制给客户或内部复核。', + color: deliveryStateColor, + disabled: false, + onClick: () => copyDeliveryPackage() + }, + { + label: '导出位置', + action: '位置CSV', + detail: '导出当前页位置点,作为轨迹回放明细。', + color: locations.items.length > 0 ? 'green' as const : 'grey' as const, + disabled: locations.items.length === 0, + onClick: () => exportLocations() + }, + { + label: '导出字段', + action: '字段CSV', + detail: '导出客户指定字段,减少无关原始数据暴露。', + color: rawFieldRows.length > 0 ? 'green' as const : 'orange' as const, + disabled: rawFieldRows.length === 0, + onClick: () => exportRawFields() + }, + { + label: '查看明细', + action: '明细证据', + detail: '回到原始明细,确认字段和报文来源。', + color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const, + disabled: false, + onClick: () => openQueryTab('raw') + } + ]; const customerExportCenterItems = [ { title: '行程历史', @@ -2350,6 +2421,51 @@ export function History({ ) : null} + {mode === 'query' ? ( +
+
+ + 客户导出验收清单 + {deliveryState} + {currentVehicleKeyword || '全部车辆'} + + + 客户交付前只看五个门禁:范围、轨迹、明细、字段、异常说明;任何一项不满足都能直接进入补查或导出。 + +
+
+ {exportAcceptanceItems.map((item) => ( + + ))} +
+
+ {exportAcceptanceActions.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 4e5b93b3..ec4689b7 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -11674,6 +11674,97 @@ button.vp-realtime-command-item:focus-visible { font-weight: 700; } +.vp-history-export-acceptance { + margin-top: 16px; + border: 1px solid rgba(24, 144, 255, 0.18); + border-radius: var(--vp-radius); + background: #fff; + display: grid; + grid-template-columns: minmax(260px, 0.56fr) minmax(0, 1.1fr) minmax(220px, 0.48fr); + overflow: hidden; + box-shadow: var(--vp-shadow-sm); +} + +.vp-history-export-acceptance-copy { + padding: 16px; + border-right: 1px solid var(--vp-border); + background: #f7fbff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-history-export-acceptance-grid { + padding: 14px; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.vp-history-export-acceptance-item, +.vp-history-export-acceptance-action { + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: 8px; + background: #fbfcff; + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; + display: grid; + align-content: start; + gap: 8px; +} + +.vp-history-export-acceptance-item { + min-height: 136px; +} + +.vp-history-export-acceptance-action { + min-height: 108px; +} + +.vp-history-export-acceptance-item:hover, +.vp-history-export-acceptance-item:focus-visible, +.vp-history-export-acceptance-action:hover, +.vp-history-export-acceptance-action:focus-visible { + border-color: rgba(24, 144, 255, 0.45); + background: #f5faff; + outline: none; + box-shadow: var(--vp-shadow-sm); +} + +.vp-history-export-acceptance-action:disabled { + cursor: not-allowed; + opacity: 0.62; + box-shadow: none; +} + +.vp-history-export-acceptance-item strong, +.vp-history-export-acceptance-action strong { + color: var(--vp-text); + font-size: 16px; + line-height: 22px; + font-weight: 700; + word-break: break-word; +} + +.vp-history-export-acceptance-item span, +.vp-history-export-acceptance-action span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; + word-break: break-word; +} + +.vp-history-export-acceptance-actions { + padding: 14px; + border-left: 1px solid var(--vp-border); + background: #f8fbff; + display: grid; + gap: 10px; +} + .vp-history-export-center { margin-top: 16px; border: 1px solid rgba(22, 100, 255, 0.16); @@ -14476,6 +14567,8 @@ button.vp-realtime-command-item:focus-visible { .vp-history-evidence-package-grid, .vp-history-delivery-readiness, .vp-history-delivery-readiness-grid, + .vp-history-export-acceptance, + .vp-history-export-acceptance-grid, .vp-history-service-desk, .vp-history-service-desk-grid, .vp-history-time-window-board, @@ -14602,6 +14695,8 @@ button.vp-realtime-command-item:focus-visible { .vp-history-delivery-conclusion-actions, .vp-history-next-export-copy, .vp-history-next-export-actions, + .vp-history-export-acceptance-copy, + .vp-history-export-acceptance-actions, .vp-time-next-action-copy, .vp-time-next-action-actions, .vp-map-layer-control-copy, 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 4457c7e8..af8960d6 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7689,6 +7689,17 @@ test('shows parsed field history as a flattened evidence table', async () => { expect(screen.getByRole('button', { name: '客户交付准备度 轨迹待补 0 点 查询轨迹' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户交付准备度 字段已裁剪 2 字段 字段导出' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户交付准备度 说明可复制 可交付 复制说明' })).toBeInTheDocument(); + expect(screen.getByText('客户导出验收清单')).toBeInTheDocument(); + expect(screen.getByText('客户交付前只看五个门禁:范围、轨迹、明细、字段、异常说明;任何一项不满足都能直接进入补查或导出。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出验收 范围锁定 VIN-FIELDS-001 / GB32960' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出验收 轨迹证据 0 点' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出验收 明细证据 1 帧' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出验收 字段裁剪 2 字段' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '客户导出验收 异常说明 可交付' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出验收动作 复制验收 交付说明' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出验收动作 导出位置 位置CSV' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出验收动作 导出字段 字段CSV' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '导出验收动作 查看明细 明细证据' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户证据包交付总览 交付范围 VIN-FIELDS-001 / GB32960 查看范围' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户证据包交付总览 轨迹证据 0 点 查询轨迹' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '客户证据包交付总览 明细字段 2 字段 字段导出' })).toBeInTheDocument();