diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index 4ef8bf7e..c6faffa6 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -283,8 +283,11 @@ test('renders only selectable Semi history cards on mobile', async () => { fireEvent.click(screen.getByRole('menuitem', { name: /导出任务/ })); expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument(); expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-task-sidesheet'); - expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(76dvh, 680px)' }); + expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(82dvh, 720px)' }); await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1)); + expect(screen.getByRole('list', { name: '历史数据导出任务摘要' })).toHaveTextContent('生成中0排队与执行可下载0已完成任务需处理0失败任务'); + expect(screen.getByRole('button', { name: '刷新导出任务' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '完成' })).toBeInTheDocument(); expect(screen.getByText('暂无导出任务')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭历史数据导出任务' })); expect(toolsButton).toHaveAttribute('aria-expanded', 'false'); @@ -388,9 +391,12 @@ test('shows only the authenticated customer export workspace with owner and scop fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' })); expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument(); expect(screen.getByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument(); - expect(screen.getByRole('list', { name: '导出任务状态概览' })).toHaveTextContent('生成中0排队与执行可下载1已完成任务需处理0失败任务'); + expect(screen.getByRole('list', { name: '历史数据导出任务摘要' })).toHaveTextContent('生成中0排队与执行可下载1已完成任务需处理0失败任务'); + expect(screen.getByText('最近更新')).toBeInTheDocument(); + expect(screen.getByText('2026-07-16 13:00:01')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '刷新导出任务' })); + await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(2)); expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument(); - expect(mocks.historyExports).toHaveBeenCalled(); }); test('uses selected chartable fields for trends and omits empty RAW evidence UI', async () => { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index f3de719b..d65f0c94 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -1,10 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons'; import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; -import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; +import type { HistoryDataResponse, HistoryDataRow, HistoryExportJob, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; import { downloadBlob } from '../domain/download'; import { formatShanghaiDateTime } from '../domain/formatters'; @@ -27,7 +27,6 @@ import { usePlatformSession } from '../auth/AuthGate'; import { canOperate } from '../auth/session'; import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext'; import { useMobileLayout } from '../hooks/useMobileLayout'; -import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; const DESKTOP_HISTORY_PAGE_SIZE = 50; const MOBILE_HISTORY_PAGE_SIZE = 20; @@ -133,37 +132,81 @@ function ExportDownloadButton({ id }: { id: string }) { return ; } -function ExportJobsPanel({ showHeader = true }: { showHeader?: boolean }) { - const query = useQuery({ +function useHistoryExportJobs(enabled = true) { + return useQuery({ queryKey: ['history-exports'], queryFn: ({ signal }) => api.historyExports(signal), + enabled, refetchInterval: (current) => historyExportPollInterval(current.state.data), ...LIVE_QUERY_POLICY, gcTime: QUERY_MEMORY.highVolumeGcTime }); +} + +function ExportJobsPanel({ query, showHeader = true }: { query: UseQueryResult; showHeader?: boolean }) { const jobs = query.data ?? []; const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败'; const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey'; - const activeJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running').length; - const completedJobs = jobs.filter((job) => job.status === 'completed').length; - const failedJobs = jobs.filter((job) => job.status === 'failed').length; return {showHeader ? : null} - {query.isPending || query.isError || !jobs.length ? null :
- 生成中{activeJobs}排队与执行 - 可下载{completedJobs}已完成任务 - 需处理{failedJobs}失败任务 -
}
{query.isPending ?
: query.isError ? : !jobs.length ? : {jobs.slice(0, 6).map((job) => {job.name}} headerExtraContent={{statusLabel(job.status)}} headerLine bodyStyle={{ padding: 0 }}>
{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'} {job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} 辆{job.downloadUrl ? : null} - {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')} 至 ${job.dateTo.replace('T', ' ')}` : job.createdAt} + {job.dateFrom && job.dateTo ? `${formatHistoryDateTime(job.dateFrom)} 至 ${formatHistoryDateTime(job.dateTo)}` : <>创建于 }
{job.status === 'running' ? : null}
)}
}
; } +function ExportJobsWorkspace({ visible, mobileLayout, onClose }: { visible: boolean; mobileLayout: boolean; onClose: () => void }) { + const query = useHistoryExportJobs(visible); + const jobs = query.data ?? []; + const activeJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running').length; + const completedJobs = jobs.filter((job) => job.status === 'completed').length; + const failedJobs = jobs.filter((job) => job.status === 'failed').length; + const latestUpdatedAt = jobs.reduce((latest, job) => { + if (!latest) return job.updatedAt; + return Date.parse(job.updatedAt) > Date.parse(latest) ? job.updatedAt : latest; + }, undefined); + const badge = query.isFetching ? '同步中' : `${jobs.length.toLocaleString('zh-CN')} 个任务`; + const badgeColor = failedJobs ? 'red' : activeJobs ? 'blue' : completedJobs ? 'green' : 'grey'; + + return } + badge={badge} + badgeColor={badgeColor} + summaryItems={[ + { label: '生成中', value: activeJobs, detail: '排队与执行', tone: activeJobs ? 'primary' : 'neutral' }, + { label: '可下载', value: completedJobs, detail: '已完成任务', tone: 'success' }, + { label: '需处理', value: failedJobs, detail: '失败任务', tone: failedJobs ? 'danger' : 'neutral' } + ]} + footerNote={query.isError ? '任务状态读取失败,请手动刷新' : latestUpdatedAt ? <>最近更新 : '打开后自动同步任务状态'} + secondaryActions={[{ + label: '刷新任务', + ariaLabel: '刷新导出任务', + icon: , + loading: query.isFetching, + onClick: () => { void query.refetch(); } + }]} + primaryAction={{ label: '完成', onClick: onClose }} + onCancel={onClose} + > + {visible ? : null} + ; +} + function EvidencePanel({ row, metrics, onClose, showHeader = true }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void; showHeader?: boolean }) { return {showHeader ? } onClick={onClose} aria-label="关闭数据详情" /> : null} /> : null} {row ? <>
@@ -368,7 +411,6 @@ export default function HistoryPage() { setSelectedRowRef({ scope: dataScope, id: '' }); }, [dataScope]); const mobileFiltersOpen = mobileLayout && !filtersCollapsed; - useSideSheetA11y(exportAllowed && exportJobsOpen, '.v2-history-export-sidesheet', 'v2-history-export-jobs', '历史数据导出任务', '关闭历史数据导出任务'); const applyDraft = () => { const parsed = parseHistoryKeywords(draft.keywords); @@ -575,21 +617,10 @@ export default function HistoryPage() { > {mobileLayout && selectedRow ? : null} - setExportJobsOpen(false)} - > - {exportJobsOpen ? : null} - + {exportAllowed ? setExportJobsOpen(false)} + /> : null}
; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 5ae4f9fa..a5424440 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -149,6 +149,9 @@ describe('V2 production entry', () => { expect(corePageSources.HistoryPage).toContain(' void; + ariaLabel?: string; + icon?: ReactNode; disabled?: boolean; loading?: boolean; theme?: 'solid' | 'light' | 'borderless'; @@ -102,6 +104,8 @@ export function WorkspaceSideSheet({ key={action.label} theme={action.theme ?? 'light'} type={action.type} + aria-label={action.ariaLabel} + icon={action.icon} disabled={action.disabled} loading={action.loading} onClick={action.onClick} @@ -109,6 +113,8 @@ export function WorkspaceSideSheet({ {primaryAction ?