From 473c9e1251c1c83a67b02d6e222984ecbb9dc61e Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 08:13:36 +0800 Subject: [PATCH] fix(history): respect export permission boundary --- .../apps/web/src/v2/pages/HistoryPage.test.tsx | 18 ++++++++++++++++++ .../apps/web/src/v2/pages/HistoryPage.tsx | 8 ++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) 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 8de4ad89..69b5925a 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 @@ -13,11 +13,14 @@ const mocks = vi.hoisted(() => ({ historyExports: vi.fn(), createHistoryExport: vi.fn() })); +const auth = vi.hoisted(() => ({ role: 'admin' })); vi.mock('../../api/client', () => ({ api: mocks })); +vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: auth.role } }) })); afterEach(() => { cleanup(); + auth.role = 'admin'; Object.values(mocks).forEach((mock) => mock.mockReset()); }); @@ -121,3 +124,18 @@ test('opens a working column visibility panel and preserves non-hideable identit fireEvent.click(screen.getByRole('button', { name: '关闭列显示设置' })); expect(screen.queryByRole('dialog', { name: '列显示设置' })).not.toBeInTheDocument(); }); + +test('keeps history readable without mounting operator-only export requests for a viewer', async () => { + auth.role = 'viewer'; + 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')); + renderPage(); + + expect((await screen.findAllByText('旧车牌')).length).toBeGreaterThan(0); + expect(screen.getByText('只读 · 导出需操作员权限')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /创建导出/ })).not.toBeInTheDocument(); + expect(screen.queryByText('导出任务')).not.toBeInTheDocument(); + expect(mocks.historyExports).not.toHaveBeenCalled(); + expect(mocks.createHistoryExport).not.toHaveBeenCalled(); +}); 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 5f91332e..ac3e2a8f 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -7,6 +7,8 @@ import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, History import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; import { InlineError } from '../shared/AsyncState'; import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; +import { usePlatformSession } from '../auth/AuthGate'; +import { canOperate } from '../auth/session'; function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) { const panels = useMemo(() => buildHistorySeriesPanels(response), [response]); @@ -80,6 +82,8 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe } export default function HistoryPage() { + const { session } = usePlatformSession(); + const exportAllowed = canOperate(session); 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') || '' }; @@ -161,7 +165,7 @@ export default function HistoryPage() { - + {exportAllowed ? : 只读 · 导出需操作员权限}
指标字段{allMetrics.map((metric) => )}{!allMetrics.length ? 查询后加载可用指标 : null}
{dataQuery.isError ? dataQuery.refetch()} /> : null} @@ -171,7 +175,7 @@ export default function HistoryPage() {
数据明细
{columnSettingsOpen ? setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}
{visibleMetrics.map((metric) => )}{result?.rows.map((row) => {visibleMetrics.map((metric) => )})}
设备时间服务时间车牌VIN协议{metric.label}{metric.unit ? ` (${metric.unit})` : ''}质量操作
setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} />{row.deviceTime}{row.serverTime}{row.plate || '—'}{row.vin}{row.protocol}{formatHistoryValue(row.values[metric.key], metric)}{row.quality === 'normal' ? '正常' : row.quality}
{dataQuery.isPending && keywords.length ?
正在加载当前筛选范围的历史数据…
: !result?.rows.length ?
{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}
: null}
第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条
- + ; }