From 069b324565953d56ff6b5777bdf08efc433a5b42 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 12:05:01 +0800 Subject: [PATCH] feat(platform): make mobile workflows task focused --- .../apps/web/src/v2/hooks/useMobileLayout.ts | 15 ++ .../apps/web/src/v2/layout/AppShell.tsx | 37 ++++- .../apps/web/src/v2/pages/AccessPage.tsx | 10 +- .../apps/web/src/v2/pages/AlertsPage.tsx | 12 +- .../apps/web/src/v2/pages/HistoryPage.tsx | 7 +- .../apps/web/src/v2/pages/StatisticsPage.tsx | 6 +- .../apps/web/src/v2/pages/TrackPage.tsx | 2 +- .../apps/web/src/v2/styles/v2.css | 151 ++++++++++++++++++ 8 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/hooks/useMobileLayout.ts diff --git a/vehicle-data-platform/apps/web/src/v2/hooks/useMobileLayout.ts b/vehicle-data-platform/apps/web/src/v2/hooks/useMobileLayout.ts new file mode 100644 index 00000000..562dce0d --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/hooks/useMobileLayout.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export function useMobileLayout(maxWidth = 680) { + const query = `(max-width: ${maxWidth}px)`; + const [mobile, setMobile] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(query).matches); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia(query); + const update = () => setMobile(media.matches); + media.addEventListener('change', update); + update(); + return () => media.removeEventListener('change', update); + }, [query]); + return mobile; +} diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index 6f55cae5..c3f91e48 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -6,6 +6,7 @@ import { IconHelpCircle, IconHome, IconMapPin, + IconMore, IconSearch, IconSetting, IconUser, @@ -71,12 +72,20 @@ export function AppShell() { const activeRoutePath = `/${section}`; const { session, logout } = usePlatformSession(); const [helpOpen, setHelpOpen] = useState(false); + const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches); const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role]; useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); + useEffect(() => { + const media = window.matchMedia('(max-width: 680px)'); + const update = () => setMobileLayout(media.matches); + media.addEventListener('change', update); + update(); + return () => media.removeEventListener('change', update); + }, []); return (
- + {mobileLayout ? : }

{pageNames[section] ?? '车辆数据中台'}

@@ -93,6 +102,32 @@ export function AppShell() { ); } +const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; +const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], { to: '/operations', label: '运维质量', icon: IconSetting }]; + +function MobileNavigation() { + const location = useLocation(); + const [moreOpen, setMoreOpen] = useState(false); + const moreActive = mobileMoreNavigation.some((item) => location.pathname.startsWith(item.to)); + const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; + useEffect(() => setMoreOpen(false), [location.pathname]); + useEffect(() => { + if (!moreOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMoreOpen(false); }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [moreOpen]); + + const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}>{label}; + return <> + {moreOpen ?
{ if (event.target === event.currentTarget) setMoreOpen(false); }}>
更多功能数据分析与系统管理
: null} + + ; +} + function Sidebar() { const [collapsed, setCollapsed] = useState(false); const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index ec05786c..c373fd2c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -10,6 +10,7 @@ import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; import { downloadBlob } from '../domain/download'; +import { useMobileLayout } from '../hooks/useMobileLayout'; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); @@ -98,6 +99,8 @@ export default function AccessPage() { const [searchParams, setSearchParams] = useSearchParams(); const initial = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters; const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial); + const [filtersCollapsed, setFiltersCollapsed] = useState(true); + const mobileLayout = useMobileLayout(); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState(''); const [thresholdDraft, setThresholdDraft] = useState(); const queryClient = useQueryClient(); const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]); @@ -111,19 +114,20 @@ export default function AccessPage() { const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN); const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); }; const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); }; - const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); }; + const submit = (event: FormEvent) => { event.preventDefault(); apply(draft); setFiltersCollapsed(true); }; const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data; const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]); return

车辆接入管理

以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间

数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}
-
+ +
{summaryQuery.isError ? summaryQuery.refetch()} /> : null}
{[ ['主车辆', summary?.totalVehicles ?? 0, 'all', ''], ['有接入差异', Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0)), 'attention', 'attention'], ['全部离线', summary?.offlineVehicles ?? 0, 'offline', 'offline'], ['尚未接入', summary?.neverReported ?? 0, 'never', 'not_connected'] ].map(([label, value, tone, connectionState]) => )}
{vehiclesQuery.isError ? vehiclesQuery.refetch()} /> : null} -
车辆协议接入差异优先展示应接与实接差异;时间为各协议最后接收时间
{PROTOCOLS.map((item) => )}{rows.map((row) => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}>{PROTOCOLS.map((protocol) => )})}
车辆品牌 / 车型应接协议{item}综合状态
{row.plate || '未绑定车牌'}{row.vin}{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}{row.expectedProtocols.length} 项{row.expectedProtocols.join(' / ')}
{vehiclesQuery.isFetching ?
正在更新车辆接入状态…
: null}{!vehiclesQuery.isFetching && !rows.length ?
当前筛选条件没有车辆
: null}
第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆
{selected ? setSelectedVIN('')} /> : null}
+
车辆协议接入差异优先展示应接与实接差异;时间为各协议最后接收时间
{mobileLayout ?
{rows.map((row) => )}
: {PROTOCOLS.map((item) => )}{rows.map((row) => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}>{PROTOCOLS.map((protocol) => )})}
车辆品牌 / 车型应接协议{item}综合状态
{row.plate || '未绑定车牌'}{row.vin}{row.oem || '品牌未维护'}{row.model || row.company || '车型未维护'}{row.expectedProtocols.length} 项{row.expectedProtocols.join(' / ')}
}{vehiclesQuery.isFetching ?
正在更新车辆接入状态…
: null}{!vehiclesQuery.isFetching && !rows.length ?
当前筛选条件没有车辆
: null}
第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆
{selected ? setSelectedVIN('')} /> : null}
{editable && unresolvedQuery.isError ? unresolvedQuery.refetch()} /> : null} {editable ? : null} {editable && thresholdQuery.isError ? thresholdQuery.refetch()} /> : null} diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index 5201b7fd..22ebe4e4 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -41,6 +41,9 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) { const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selection, setSelection] = useState<{ scope: string; id: string }>(); const [note, setNote] = useState(''); const queryClient = useQueryClient(); + const [filtersCollapsed, setFiltersCollapsed] = useState(true); + const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches); + useEffect(() => { const media = window.matchMedia('(max-width: 680px)'); const update = () => setMobileLayout(media.matches); media.addEventListener('change', update); update(); return () => media.removeEventListener('change', update); }, []); const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]); const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]); const eventScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]); @@ -48,17 +51,18 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e const events = useQuery>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope>(eventScope), staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime }); const rows = events.data?.items ?? []; const selectedID = selection?.scope === eventScope ? selection.id : ''; - useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, rows, selectedID]); + useEffect(() => { if (!mobileLayout && rows.length && !rows.some((item) => item.id === selectedID)) setSelection({ scope: eventScope, id: rows[0].id }); }, [eventScope, mobileLayout, rows, selectedID]); const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime }); const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } }); - const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); }; + const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); setFiltersCollapsed(true); }; const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); }; const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data; - return <>
+ const activeFilterCount = Object.values(filters).filter(Boolean).length; + return <>
{summary.isError ? summary.refetch()} /> : null}
{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => )}
{events.isError ? events.refetch()} /> : null} -
告警事件
共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条
setSelection({ scope: eventScope, id })} />
严重程度车牌 / VIN规则协议触发时间恢复时间状态触发值阈值位置处理人
{events.isFetching ?
正在更新事件…
: null}{!events.isFetching && !rows.length ?
当前筛选条件没有告警事件
: null}
第 {page} / {totalPages} 页
row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} />
; +
告警事件
共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条
setSelection({ scope: eventScope, id })} />
严重程度车牌 / VIN规则协议触发时间恢复时间状态触发值阈值位置处理人
{rows.map((event) => )}
{events.isFetching ?
正在更新事件…
: null}{!events.isFetching && !rows.length ?
当前筛选条件没有告警事件
: null}
第 {page} / {totalPages} 页
row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} />
; } function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; } 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 36757268..33b67b09 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -97,6 +97,7 @@ export default function HistoryPage() { const [selectedRowRef, setSelectedRowRef] = useState<{ scope: string; id: string }>(); const [density, setDensity] = useState<'compact' | 'comfortable'>('compact'); const [columnSettingsOpen, setColumnSettingsOpen] = useState(false); + const [filtersCollapsed, setFiltersCollapsed] = useState(true); const keywords = useMemo(() => parseHistoryKeywords(criteria.keywords), [criteria.keywords]); const params = useMemo(() => { const next = new URLSearchParams({ keywords: keywords.join(','), category: criteria.category, limit: String(limit), offset: String(offset) }); @@ -144,6 +145,7 @@ export default function HistoryPage() { if (!parsed.length) return; const next = { ...draft, keywords: parsed.join(',') }; setCriteria(next); setOffset(0); + setFiltersCollapsed(true); const url = new URLSearchParams({ keywords: next.keywords, category: next.category }); if (next.dateFrom) url.set('dateFrom', next.dateFrom); if (next.dateTo) url.set('dateTo', next.dateTo); @@ -161,7 +163,8 @@ export default function HistoryPage() { const page = Math.floor(offset / limit) + 1; return
-
+ + @@ -175,7 +178,7 @@ export default function HistoryPage() {
结果行数{result?.total.toLocaleString('zh-CN') ?? '—'}
车辆数{result?.summary.vehicleCount ?? '—'}
数据源{result?.summary.sources.join('、') || '—'}
查询耗时{result ? `${result.summary.queryDurationMs} ms` : '—'}
-
数据明细
{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} 条
+
数据明细
{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}
{result?.rows.map((row) => )}
{dataQuery.isPending && keywords.length ?
正在加载当前筛选范围的历史数据…
: !result?.rows.length ?
{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}
: null}
第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx index 84321dac..4ce2baa3 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/StatisticsPage.tsx @@ -270,6 +270,7 @@ export default function StatisticsPage() { const [exportFeedback, setExportFeedback] = useState(''); const [exportProgress, setExportProgress] = useState(); const [validationError, setValidationError] = useState(''); + const [filtersCollapsed, setFiltersCollapsed] = useState(true); const exportControllerRef = useRef(null); const mountedRef = useRef(true); useEffect(() => { @@ -329,7 +330,7 @@ export default function StatisticsPage() { const error = mileageDateRangeError(draft); setValidationError(error); if (error) return; - setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); + setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); setFiltersCollapsed(true); }; const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); }; const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching; @@ -420,7 +421,8 @@ export default function StatisticsPage() { return

里程查询

未选择车牌时分页展示全部已绑定主车辆;选择车牌后查询指定车辆。

- + + setDraft((current) => ({ ...current, vehicles }))} /> diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 7acaaa90..79844f92 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -206,7 +206,7 @@ export default function TrackPage() { const [follow, setFollow] = useState(true); const [showStops, setShowStops] = useState(true); const [panelTab, setPanelTab] = useState('stops'); - const [railCollapsed, setRailCollapsed] = useState(false); + const [railCollapsed, setRailCollapsed] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 700px)').matches); const animationRef = useRef(); const lastFrameRef = useRef(0); diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 1bb325ba..a2c62d04 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -1409,6 +1409,132 @@ button, a { -webkit-tap-highlight-color: transparent; } *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } +/* Mobile task-first interaction layer. Desktop keeps the full workspace. */ +.v2-mobile-navigation, +.v2-mobile-more-backdrop, +.v2-mobile-filter-toggle, +.v2-alert-mobile-list, +.v2-access-mobile-list, +.v2-history-mobile-list { display: none; } + +@media (max-width: 680px) { + .v2-mobile-navigation { position: fixed; z-index: 90; right: 0; bottom: 0; left: 0; display: grid; height: calc(66px + env(safe-area-inset-bottom)); grid-template-columns: repeat(5, minmax(0, 1fr)); border-top: 1px solid #dce4ee; background: rgba(255,255,255,.97); padding: 5px 6px calc(5px + env(safe-area-inset-bottom)); box-shadow: 0 -10px 30px rgba(20,35,55,.10); backdrop-filter: blur(16px); } + .v2-mobile-nav-item { position: relative; display: flex; min-width: 0; min-height: 54px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border: 0; border-radius: 10px; background: transparent; color: #718096; text-decoration: none; cursor: pointer; } + .v2-mobile-nav-item > svg { font-size: 20px; } + .v2-mobile-nav-item > span { max-width: 100%; overflow: hidden; font-size: 10px; font-weight: 700; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } + .v2-mobile-nav-item.is-active { background: #edf4ff; color: var(--v2-blue); } + .v2-mobile-nav-item.is-active::after { position: absolute; right: 22%; bottom: 1px; left: 22%; height: 2px; border-radius: 2px; background: var(--v2-blue); content: ""; } + .v2-mobile-more-backdrop { position: fixed; z-index: 89; inset: 0 0 calc(65px + env(safe-area-inset-bottom)); display: flex; align-items: flex-end; background: rgba(15,23,42,.34); backdrop-filter: blur(2px); } + .v2-mobile-more-sheet { width: 100%; border-radius: 18px 18px 0 0; background: #fff; padding: 12px 14px 18px; box-shadow: 0 -22px 60px rgba(15,23,42,.2); animation: v2-mobile-sheet-enter .18s ease-out; } + .v2-mobile-more-sheet > header { display: flex; align-items: center; justify-content: space-between; padding: 2px 2px 12px; } + .v2-mobile-more-sheet > header div { display: flex; flex-direction: column; gap: 3px; } + .v2-mobile-more-sheet > header strong { color: #17253a; font-size: 17px; } + .v2-mobile-more-sheet > header span { color: #8290a3; font-size: 11px; } + .v2-mobile-more-sheet > header button { display: grid; width: 40px; height: 40px; place-items: center; border: 0; border-radius: 20px; background: #f1f5f9; color: #64748b; font-size: 23px; } + .v2-mobile-more-sheet > nav { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 8px; } + .v2-mobile-more-sheet .v2-mobile-nav-item { min-height: 76px; border: 1px solid #e5eaf1; background: #f8fafc; } + .v2-main { padding-bottom: calc(66px + env(safe-area-inset-bottom)); } + + .v2-mobile-filter-toggle { display: flex; width: 100%; min-height: 54px; flex: 0 0 auto; align-items: center; justify-content: space-between; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; padding: 8px 12px; color: #26364b; box-shadow: 0 3px 12px rgba(25,39,59,.04); text-align: left; } + .v2-mobile-filter-toggle > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; } + .v2-mobile-filter-toggle b { font-size: 13px; } + .v2-mobile-filter-toggle small { overflow: hidden; color: #7a889b; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + .v2-mobile-filter-toggle em { flex: 0 0 auto; border-radius: 14px; background: #edf4ff; padding: 5px 10px; color: var(--v2-blue); font-size: 11px; font-style: normal; font-weight: 700; } + :is(.v2-alert-filter,.v2-access-filter-v3,.v2-history-toolbar,.v2-mileage-filter).is-mobile-collapsed { display: none !important; } + + .v2-alert-heading > div, + .v2-access-heading > div:first-child, + .v2-mileage-heading > div { display: none; } + .v2-alert-heading { display: none; } + .v2-access-heading { justify-content: flex-end; min-height: 40px; } + .v2-access-heading > div:last-child { width: 100%; } + .v2-mileage-heading { justify-content: flex-end; min-height: 40px; } + + .v2-kpis { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); overflow: hidden; } + .v2-kpi { width: auto; min-width: 0; min-height: 64px; padding: 9px 11px; } + .v2-kpi:nth-child(3), .v2-kpi:nth-child(4), .v2-kpi:nth-child(5), .v2-kpi:nth-child(7) { display: none; } + .v2-kpi + .v2-kpi::before { display: block; } + .v2-monitor-workspace, + .v2-monitor-workspace.is-detail-open, + .v2-monitor-workspace.is-detail-collapsed { min-height: max(430px, calc(100dvh - 330px)); grid-template-columns: 1fr; grid-template-rows: minmax(430px, 1fr); } + .v2-monitor-workspace > .v2-vehicle-rail { display: none; } + .v2-monitor-workspace > .v2-fleet-map { grid-row: 1; } + .v2-vehicle-detail { height: min(58dvh, 520px); } + + .v2-alert-page { gap: 9px; } + .v2-alert-kpis { display: flex; min-height: 70px; overflow-x: auto; scroll-snap-type: x proximity; } + .v2-alert-kpis button { min-width: 116px; flex: 0 0 116px; scroll-snap-align: start; } + .v2-alert-workspace { display: flex; min-height: 0; flex-direction: column; } + .v2-alert-table-card { height: auto; min-height: 420px; } + .v2-alert-table-scroll { overflow: visible; } + .v2-alert-table-scroll table { display: none; } + .v2-alert-mobile-list { display: flex; flex-direction: column; background: #f5f7fa; padding: 8px; gap: 8px; } + .v2-alert-mobile-list > button { width: 100%; border: 1px solid #e0e7f0; border-radius: 10px; background: #fff; padding: 12px; color: #334155; text-align: left; box-shadow: 0 2px 8px rgba(20,35,55,.04); } + .v2-alert-mobile-list > button.is-selected { border-color: #8eb8f8; box-shadow: 0 0 0 2px rgba(18,104,243,.08); } + .v2-alert-mobile-list header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .v2-alert-mobile-list header > strong { font-size: 15px; } + .v2-alert-mobile-list header > span { display: flex; gap: 5px; } + .v2-alert-mobile-list p { margin: 9px 0; color: #26364b; font-size: 13px; font-weight: 700; } + .v2-alert-mobile-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 12px; margin: 0; border-top: 1px solid #edf1f5; padding-top: 10px; } + .v2-alert-mobile-list dt { color: #8a97a9; font-size: 10px; } + .v2-alert-mobile-list dd { margin: 3px 0 0; color: #46566c; font-size: 11px; font-weight: 650; } + .v2-alert-inspector { max-height: none; margin-top: 10px; } + + .v2-access-kpis-v3 { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); overflow: hidden; } + .v2-access-kpis-v3 button { min-width: 0; min-height: 74px; flex: none; } + .v2-access-workspace-v3 { min-height: 480px; flex-basis: auto; } + .v2-access-table-v3 > header { min-height: 0; } + .v2-access-table-title > span, .v2-access-protocol-coverage { display: none; } + .v2-access-table-actions { justify-content: flex-end; } + .v2-access-table-scroll-v3 { overflow: visible; } + .v2-access-table-scroll-v3 table { display: none; } + .v2-access-mobile-list { display: flex; flex-direction: column; gap: 8px; background: #f5f7fa; padding: 8px; } + .v2-access-mobile-list > button { width: 100%; border: 1px solid #e0e7ef; border-radius: 10px; background: #fff; padding: 12px; color: #334155; text-align: left; } + .v2-access-mobile-list > button.is-selected { border-color: #8eb8f8; box-shadow: 0 0 0 2px rgba(18,104,243,.08); } + .v2-access-mobile-list > button > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } + .v2-access-mobile-list > button > header > div:first-child { min-width: 0; } + .v2-access-mobile-list > button > header > div:first-child strong, + .v2-access-mobile-list > button > header > div:first-child span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .v2-access-mobile-list > button > header > div:first-child strong { font-size: 15px; } + .v2-access-mobile-list > button > header > div:first-child span { margin-top: 3px; color: #8491a3; font-size: 10px; } + .v2-access-mobile-list > button > p { margin: 9px 0; color: #68768a; font-size: 11px; } + .v2-access-mobile-list > button > div { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 5px; border-top: 1px solid #edf1f5; padding-top: 9px; } + .v2-access-mobile-list .v2-access-protocol-cell { min-width: 0; border-radius: 7px; background: #f7f9fc; padding: 7px; } + .v2-access-mobile-list .v2-access-protocol-cell strong { font-size: 9px; } + .v2-access-mobile-list .v2-access-protocol-cell small { display: none; } + + .v2-history-page { gap: 9px; } + .v2-history-metrics { max-height: 48px; overflow-x: auto; flex-wrap: nowrap; } + .v2-history-main { display: flex; height: auto; min-height: 0; flex-direction: column; } + .v2-history-summary { min-height: 92px; } + .v2-history-table-card { order: 2; height: auto; min-height: 380px; } + .v2-history-trend { order: 3; min-height: 220px; margin-top: 10px; } + .v2-history-table-scroll { overflow: visible; } + .v2-history-table-scroll table { display: none; } + .v2-history-mobile-list { display: flex; flex-direction: column; gap: 8px; background: #f5f7fa; padding: 8px; } + .v2-history-mobile-list > button { width: 100%; border: 1px solid #e0e7ef; border-radius: 10px; background: #fff; padding: 12px; color: #334155; text-align: left; } + .v2-history-mobile-list > button.is-selected { border-color: #8eb8f8; } + .v2-history-mobile-list header, .v2-history-mobile-list p { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .v2-history-mobile-list header > div { min-width: 0; } + .v2-history-mobile-list header strong, .v2-history-mobile-list header > div > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .v2-history-mobile-list header strong { font-size: 14px; } + .v2-history-mobile-list header > div > span { margin-top: 3px; color: #8794a5; font-size: 10px; } + .v2-history-mobile-list p { margin: 9px 0; color: #718096; font-size: 10px; } + .v2-history-mobile-list p b { color: var(--v2-blue); } + .v2-history-mobile-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 0; border-top: 1px solid #edf1f5; padding-top: 9px; } + .v2-history-mobile-list dt { color: #8a97a9; font-size: 9px; } + .v2-history-mobile-list dd { margin: 3px 0 0; font-size: 11px; font-weight: 700; } + .v2-history-mobile-list > button > em { display: block; margin-top: 10px; color: var(--v2-blue); font-size: 11px; font-style: normal; font-weight: 700; text-align: right; } + .v2-history-side { margin-top: 10px; } + + .v2-mileage-query-panel { padding: 8px; } + .v2-mileage-query-panel > .v2-mobile-filter-toggle { margin-bottom: 8px; } + .v2-mileage-filter.is-mobile-collapsed + .v2-mileage-validation { margin-top: 4px; } + + .v2-track-page.is-rail-collapsed { display: grid; grid-template-columns: minmax(0,1fr); } + .v2-track-page.is-rail-collapsed .v2-track-stage { min-height: calc(100dvh - 118px - env(safe-area-inset-bottom)); } +} + /* Track replay command center — map-first layout based on the G7 reference. */ .v2-track-page { display: grid; width: 100%; height: 100%; min-height: 0; grid-template-columns: 320px minmax(0, 1fr); gap: 0; overflow: hidden; padding: 0; background: #e9eef4; } .v2-track-page.is-rail-collapsed { grid-template-columns: 0 minmax(0, 1fr); } @@ -1747,3 +1873,28 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-alert-tabs { overflow-x: auto; } .v2-alert-tabs button { min-width: max-content; } } + +/* Keep mobile task layouts authoritative after the desktop typography layer. */ +@media (max-width: 680px) { + .v2-alert-heading { display: none; } + .v2-alert-workspace { display: flex; min-height: 0; grid-template-columns: none; flex-direction: column; } + .v2-alert-table-card { height: auto; min-height: 420px; } + .v2-alert-table-scroll table { display: none; } + .v2-alert-mobile-list { display: flex; } + .v2-access-heading > div:first-child, + .v2-mileage-heading > div { display: none; } + .v2-access-kpis-v3 { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); overflow: hidden; } + .v2-access-kpis-v3 button { min-width: 0; flex: none; } + .v2-access-kpis-v3 small { flex: 0 0 auto; white-space: nowrap; } + .v2-access-kpis-v3 em { display: none; } + .v2-access-workspace-v3 { min-height: 480px; flex-basis: auto; } + .v2-access-table-scroll-v3 table { display: none; } + .v2-access-mobile-list { display: flex; } + .v2-history-main { display: flex; height: auto; min-height: 0; flex-direction: column; } + .v2-history-table-card { height: auto; min-height: 380px; order: 2; } + .v2-history-trend { order: 3; } + .v2-history-table-scroll table { display: none; } + .v2-history-mobile-list { display: flex; } + :is(.v2-alert-filter,.v2-access-filter-v3,.v2-history-toolbar,.v2-mileage-filter).is-mobile-collapsed { display: none !important; } + .v2-track-page.is-rail-collapsed { display: grid; grid-template-columns: minmax(0,1fr); } +}