feat(history): scope export tasks to owners
This commit is contained in:
@@ -11,7 +11,8 @@ const mocks = vi.hoisted(() => ({
|
||||
historyData: vi.fn(),
|
||||
historySeries: vi.fn(),
|
||||
historyExports: vi.fn(),
|
||||
createHistoryExport: vi.fn()
|
||||
createHistoryExport: vi.fn(),
|
||||
downloadHistoryExport: vi.fn()
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' }));
|
||||
|
||||
@@ -144,6 +145,26 @@ test('keeps history readable without mounting operator-only export requests for
|
||||
expect(mocks.createHistoryExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows only the authenticated customer export workspace with owner and scope metadata', async () => {
|
||||
auth.role = 'customer';
|
||||
mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] });
|
||||
mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of'));
|
||||
mocks.historyExports.mockResolvedValue([{
|
||||
id: 'customer-export', name: '历史数据_20260716_customer-a', status: 'completed', progress: 100, format: 'csv', category: 'location',
|
||||
keywords: ['OLDVIN'], vehicleVins: ['OLDVIN'], dateFrom: '2026-07-16T00:00', dateTo: '2026-07-16T05:00',
|
||||
ownerName: '客户甲', ownerUsername: 'customer-a', ownerRole: 'customer', ownerUserType: 'customer',
|
||||
rowCount: 10, totalRows: 10, processedRows: 10, fileSizeBytes: 1024, downloadUrl: '/api/v2/exports/customer-export/download',
|
||||
createdAt: '2026-07-16T05:00:00Z', updatedAt: '2026-07-16T05:00:01Z', completedAt: '2026-07-16T05:00:01Z', evidence: 'owner scoped'
|
||||
}]);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument();
|
||||
expect(screen.getByText('导出任务')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument();
|
||||
expect(mocks.historyExports).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses selected chartable fields for trends and omits empty RAW evidence UI', async () => {
|
||||
const totalMileageMetric = { key: 'totalMileageKm', label: '总里程', unit: 'km', category: 'location', valueType: 'number', defaultVisible: true };
|
||||
const data = historyData('OLDVIN', '旧车牌', 'old-as-of');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types';
|
||||
import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
@@ -53,6 +54,14 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
|
||||
return <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
|
||||
}
|
||||
|
||||
function ExportDownloadButton({ id }: { id: string }) {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.downloadHistoryExport(id),
|
||||
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
|
||||
});
|
||||
return <button type="button" className="v2-export-download" disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}><IconDownload />{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</button>;
|
||||
}
|
||||
|
||||
function ExportJobsPanel() {
|
||||
const query = useQuery({
|
||||
queryKey: ['history-exports'],
|
||||
@@ -62,7 +71,7 @@ function ExportJobsPanel() {
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const jobs = query.data ?? [];
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload />下载</a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">尚未创建导出任务</div> : null}</div></section>;
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small><small className="v2-export-owner">{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} 辆 · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')} 至 ${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">当前账号尚未创建导出任务</div> : null}</div></section>;
|
||||
}
|
||||
|
||||
function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) {
|
||||
@@ -101,7 +110,7 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe
|
||||
|
||||
export default function HistoryPage() {
|
||||
const { session } = usePlatformSession();
|
||||
const exportAllowed = canOperate(session);
|
||||
const exportAllowed = canOperate(session) || session.role === 'customer';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const today = useMemo(currentHistoryWindow, []);
|
||||
const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' };
|
||||
|
||||
Reference in New Issue
Block a user