feat(history): scope export tasks to owners

This commit is contained in:
lingniu
2026-07-16 17:00:33 +08:00
parent a2722e4afd
commit 38b056f5f1
18 changed files with 528 additions and 100 deletions

View File

@@ -21,6 +21,25 @@ test('authenticated requests use the session-only bearer token', async () => {
expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull();
});
test('history export downloads use bearer authentication and retain the server filename', async () => {
setAccessToken('customer-export-token');
const payload = new Blob(['export'], { type: 'text/csv' });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
headers: new Headers({ 'X-Export-Name': encodeURIComponent('历史数据_customer-a.csv'), 'Content-Type': 'text/csv' }),
blob: async () => payload
} as Response);
const result = await api.downloadHistoryExport('exp customer');
expect(result.filename).toBe('历史数据_customer-a.csv');
expect(result.blob.size).toBe(6);
expect(result.blob.type).toBe('text/csv');
const [path, init] = fetchMock.mock.calls[0];
expect(path).toBe('/api/v2/exports/exp%20customer/download');
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer customer-export-token');
});
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
const unauthorized = vi.fn();

View File

@@ -141,6 +141,29 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
}
async function requestBlob(path: string, signal?: AbortSignal) {
const token = getAccessToken();
const init: RequestInit = token ? { signal, headers: withAuthorization(undefined, token) } : { signal };
const response = await fetch(path, init);
if (!response.ok) {
if (response.status === 401 && token) notifyUnauthorizedSession(token);
throw new Error(await responseErrorMessage(response));
}
const encodedFilename = response.headers.get('X-Export-Name')?.trim();
let filename = 'history-export.csv';
if (encodedFilename) {
try {
filename = decodeURIComponent(encodedFilename);
} catch {
filename = encodedFilename;
}
}
return {
blob: await response.blob(),
filename
};
}
function withAuthorization(headers: HeadersInit | undefined, token: string) {
const authorized = new Headers(headers);
authorized.set('Authorization', `Bearer ${token}`);
@@ -201,6 +224,7 @@ export const api = {
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}),
downloadHistoryExport: (id: string, signal?: AbortSignal) => requestBlob(`/api/v2/exports/${encodeURIComponent(id)}/download`, signal),
accessSummary: (query: AccessQuery, signal?: AbortSignal) => request<AccessSummary>('/api/v2/access/summary', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)),

View File

@@ -251,7 +251,7 @@ export interface HistorySeriesSummary { rawPointCount: number; bucketCount: numb
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; }
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }

View File

@@ -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');

View File

@@ -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') || '' };

View File

@@ -713,7 +713,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-history-evidence > footer b { overflow: hidden; color: var(--v2-blue); text-overflow: ellipsis; white-space: nowrap; }
.v2-history-side-empty { display: flex; min-height: 92px; align-items: center; justify-content: center; padding: 14px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; }
.v2-export-jobs > div { padding: 4px 11px 8px; }
.v2-export-jobs article { display: grid; min-height: 42px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; }
.v2-export-jobs article { display: grid; min-height: 54px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; }
.v2-export-jobs article > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
.v2-export-jobs article > i.is-running { background: var(--v2-blue); }
.v2-export-jobs article > i.is-completed { background: var(--v2-green); }
@@ -721,7 +721,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-export-jobs article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.v2-export-jobs article strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-export-jobs article small { overflow: hidden; color: #8290a3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.v2-export-jobs article a { display: inline-flex; align-items: center; gap: 4px; color: var(--v2-blue); text-decoration: none; font-size: 7px; }
.v2-export-jobs article small.v2-export-owner { color: #64748b; }
.v2-export-jobs article .v2-export-download { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent; padding: 0; color: var(--v2-blue); cursor: pointer; font-size: 7px; }
.v2-export-jobs article .v2-export-download:disabled { opacity: .55; cursor: wait; }
.v2-export-jobs article em { color: #7f8c9f; font-size: 7px; font-style: normal; }
.v2-access-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 9px; overflow: hidden; padding: 12px 14px 14px; }