feat(platform): make mobile workflows task focused
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="v2-shell">
|
||||
<Sidebar />
|
||||
{mobileLayout ? <MobileNavigation /> : <Sidebar />}
|
||||
<div className="v2-main">
|
||||
<header className="v2-topbar">
|
||||
<h1>{pageNames[section] ?? '车辆数据中台'}</h1>
|
||||
@@ -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]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
|
||||
return <>
|
||||
{moreOpen ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong>更多功能</strong><span>数据分析与系统管理</span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{mobileMoreNavigation.map(link)}</nav></section></div> : null}
|
||||
<nav className="v2-mobile-navigation" aria-label="主导航">
|
||||
{mobilePrimaryNavigation.map(link)}
|
||||
<button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span>更多</span></button>
|
||||
</nav>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
|
||||
@@ -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<AccessThresholdUpdate>(); 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 <div className="v2-access-page v2-access-page-v3">
|
||||
<header className="v2-access-heading"><div><h2>车辆接入管理</h2><p>以主车辆为对象,对照应接协议、实际接入和各协议最新上报时间</p></div><div><span>数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</span><button type="button" onClick={() => void refresh()}><IconRefresh />刷新</button></div></header>
|
||||
<form className="v2-access-filter-v3" onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">有接入差异</option><option value="healthy">三协议正常</option><option value="incomplete">接入不完整</option><option value="degraded">协议离线</option><option value="offline">全部离线</option><option value="not_connected">尚未接入</option></select></label><label><span>关注协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>筛选条件</b><small>{Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button>
|
||||
<form className={`v2-access-filter-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label className="is-search"><span>车辆</span><div><IconSearch /><input aria-label="车辆" value={draft.keyword} onChange={(event) => setDraft({ ...draft, keyword: event.target.value })} placeholder="车牌 / VIN" /></div></label><label><span>接入状态</span><select aria-label="接入状态" value={draft.connectionState} onChange={(event) => setDraft({ ...draft, connectionState: event.target.value })}><option value="">全部状态</option><option value="attention">有接入差异</option><option value="healthy">三协议正常</option><option value="incomplete">接入不完整</option><option value="degraded">协议离线</option><option value="offline">全部离线</option><option value="not_connected">尚未接入</option></select></label><label><span>关注协议</span><select aria-label="关注协议" value={draft.protocol} onChange={(event) => setDraft({ ...draft, protocol: event.target.value })}><option value="">全部协议</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>车辆品牌</span><select aria-label="车辆品牌" value={draft.oem} onChange={(event) => setDraft({ ...draft, oem: event.target.value })}><option value="">全部品牌</option>{summary?.oems.filter((item) => item.name !== '未维护').map((item) => <option key={item.name}>{item.name}</option>)}</select></label><button className="v2-primary-button" type="submit">查询</button><button className="v2-secondary-button" type="button" onClick={() => apply(EMPTY_FILTERS)}>重置</button></form>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<section className="v2-access-kpis-v3">{[
|
||||
['主车辆', 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]) => <button key={String(label)} className={`is-${tone}`} type="button" onClick={() => apply({ ...criteria, connectionState: String(connectionState) })}><small>{label}</small><strong>{Number(value).toLocaleString('zh-CN')}</strong>{label === '主车辆' ? <em>车辆主档</em> : null}</button>)}</section>
|
||||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆协议接入差异</strong><span>优先展示应接与实接差异;时间为各协议最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3"><table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>应接协议</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} 项</b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
<div className={`v2-access-workspace-v3 ${selected ? 'is-inspector-open' : ''}`}><section className="v2-access-table-v3"><header><div className="v2-access-table-title"><strong>车辆协议接入差异</strong><span>优先展示应接与实接差异;时间为各协议最后接收时间</span></div><div className="v2-access-table-actions"><ProtocolCoverage summary={summary} /><button type="button" onClick={() => downloadRows(rows)} disabled={!rows.length}><IconDownload />导出当前页</button></div></header><div className="v2-access-table-scroll-v3">{mobileLayout ? <div className="v2-access-mobile-list">{rows.map((row) => <button type="button" key={row.vin} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><ConnectionState row={row} /></header><p>{row.oem || '品牌未维护'} · {row.model || row.company || '车型未维护'}</p><div>{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} status={statusByProtocol(row, protocol)} />)}</div></button>)}</div> : <table><thead><tr><th>车辆</th><th>品牌 / 车型</th><th>应接协议</th>{PROTOCOLS.map((item) => <th key={item}>{item}</th>)}<th>综合状态</th></tr></thead><tbody>{rows.map((row) => <tr key={row.vin} data-testid={`access-row-${row.vin}`} tabIndex={0} className={selected?.vin === row.vin ? 'is-selected' : ''} onClick={() => setSelectedVIN(row.vin)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedVIN(row.vin); }}><td><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></td><td><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></td><td><b>{row.expectedProtocols.length} 项</b><span>{row.expectedProtocols.join(' / ')}</span></td>{PROTOCOLS.map((protocol) => <td key={protocol}><ProtocolState status={statusByProtocol(row, protocol)} /></td>)}<td><ConnectionState row={row} /></td></tr>)}</tbody></table>}{vehiclesQuery.isFetching ? <div className="v2-access-loading"><i />正在更新车辆接入状态…</div> : null}{!vehiclesQuery.isFetching && !rows.length ? <div className="v2-access-empty">当前筛选条件没有车辆</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select aria-label="每页数量" value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 辆/页</option><option value="50">50 辆/页</option><option value="100">100 辆/页</option></select></div></footer></section>{selected ? <VehicleInspector row={selected} onClose={() => setSelectedVIN('')} /> : null}</div>
|
||||
{editable && unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
{editable ? <IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} /> : null}
|
||||
{editable && thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
|
||||
@@ -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<Page<AlertEvent>>({ queryKey: ['alert-events-v2', eventScope, limit, offset], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: retainPreviousPageWithinScope<Page<AlertEvent>>(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 <><form className="v2-alert-filter" onSubmit={submit}><label><span>关键词</span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value="">全部</option><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label><label><span>状态</span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value="">全部</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span>规则</span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value="">全部</option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span>协议</span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value="">全部</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>起始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button">查询</button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}>重置</button></form>
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
return <><button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>筛选条件</b><small>{activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'}</small></span><em>{filtersCollapsed ? '展开' : '收起'}</em></button><form className={`v2-alert-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}><label><span>关键词</span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span>严重程度</span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value="">全部</option><option value="critical">紧急</option><option value="major">重要</option><option value="minor">一般</option></select></label><label><span>状态</span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value="">全部</option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span>规则</span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value="">全部</option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span>协议</span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value="">全部</option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span>起始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button">查询</button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}>重置</button></form>
|
||||
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
|
||||
<section className="v2-alert-kpis">{[['活跃告警', 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]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
|
||||
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => 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 }); }} /></div></>;
|
||||
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong>告警事件</strong><div><span>共 {(events.data?.total ?? 0).toLocaleString('zh-CN')} 条</span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), ...(selectedID ? [detail.refetch()] : [])])}><IconRefresh />刷新</button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th>严重程度</th><th>车牌 / VIN</th><th>规则</th><th>协议</th><th>触发时间</th><th>恢复时间</th><th>状态</th><th>触发值</th><th>阈值</th><th>位置</th><th>处理人</th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={(id) => setSelection({ scope: eventScope, id })} /></tbody></table><div className="v2-alert-mobile-list">{rows.map((event) => <button type="button" key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => setSelection({ scope: eventScope, id: event.id })}><header><strong>{event.plate || '未绑定车牌'}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></header><p>{event.ruleName}</p><dl><div><dt>触发时间</dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt>数据来源</dt><dd>{event.protocol || '—'}</dd></div><div><dt>触发值</dt><dd>{alertValue(event)}</dd></div><div><dt>阈值</dt><dd>{thresholdText(event)}</dd></div></dl></button>)}</div>{events.isFetching ? <div className="v2-alert-loading"><i />正在更新事件…</div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty">当前筛选条件没有告警事件</div> : null}</div><footer><span>第 {page} / {totalPages} 页</span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => 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 }); }} /></div></>;
|
||||
}
|
||||
|
||||
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 }; }
|
||||
|
||||
@@ -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 <div className="v2-history-page">
|
||||
<form className="v2-history-toolbar" onSubmit={submit}>
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>查询条件</b><small>{keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
|
||||
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<label className="v2-history-vehicles"><span>车辆(最多 5 台)</span><div><IconSearch /><input value={draft.keywords} onChange={(event) => setDraft((value) => ({ ...value, keywords: event.target.value }))} placeholder="车牌 / VIN,多台用逗号分隔" /></div></label>
|
||||
<label><span>开始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
|
||||
@@ -175,7 +178,7 @@ export default function HistoryPage() {
|
||||
<div className="v2-history-main">
|
||||
<div className="v2-history-summary"><div><small>结果行数</small><strong>{result?.total.toLocaleString('zh-CN') ?? '—'}</strong></div><div><small>车辆数</small><strong>{result?.summary.vehicleCount ?? '—'}</strong></div><div><small>数据源</small><strong>{result?.summary.sources.join('、') || '—'}</strong></div><div><small>查询耗时</small><strong>{result ? `${result.summary.queryDurationMs} ms` : '—'}</strong></div></div>
|
||||
<HistoryTrend response={seriesQuery.data} category={criteria.category} loading={seriesQuery.isFetching} error={seriesQuery.isError ? (seriesQuery.error instanceof Error ? seriesQuery.error.message : '未知错误') : undefined} />
|
||||
<section className={`v2-history-table-card is-${density}`}><header><strong>数据明细</strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting />列设置</button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact">紧凑</option><option value="comfortable">舒适</option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th>设备时间</th><th>服务时间</th><th>车牌</th><th>VIN</th><th>协议</th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th>质量</th><th>操作</th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}>查看证据</button></td></tr>)}</tbody></table>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i />正在加载当前筛选范围的历史数据…</div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option><option value="100">100 条/页</option></select></div></footer></section>
|
||||
<section className={`v2-history-table-card is-${density}`}><header><strong>数据明细</strong><div><button type="button" aria-label="列设置" aria-haspopup="dialog" aria-expanded={columnSettingsOpen} onClick={() => setColumnSettingsOpen((value) => !value)}><IconSetting />列设置</button><select value={density} onChange={(event) => setDensity(event.target.value as typeof density)}><option value="compact">紧凑</option><option value="comfortable">舒适</option></select><button type="button" onClick={() => dataQuery.refetch()} aria-label="刷新历史数据"><IconRefresh /></button></div></header>{columnSettingsOpen ? <ColumnVisibilityPanel metrics={allMetrics} visibleKeys={visibleKeys} onToggle={toggleMetric} onShowAll={() => setVisibleMetrics(allMetrics.map((metric) => metric.key))} onReset={() => setVisibleMetrics(allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key))} onClose={() => setColumnSettingsOpen(false)} /> : null}<div className="v2-history-table-scroll"><table><thead><tr><th aria-label="选择行" /><th>设备时间</th><th>服务时间</th><th>车牌</th><th>VIN</th><th>协议</th>{visibleMetrics.map((metric) => <th key={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</th>)}<th>质量</th><th>操作</th></tr></thead><tbody>{result?.rows.map((row) => <tr className={selectedRow?.id === row.id ? 'is-selected' : ''} key={row.id}><td><input type="checkbox" checked={selectedRow?.id === row.id} onChange={() => setSelectedRowRef(selectedRow?.id === row.id ? undefined : { scope: dataScope, id: row.id })} aria-label={`选择 ${row.plate || row.vin} ${row.deviceTime}`} /></td><td>{row.deviceTime}</td><td>{row.serverTime}</td><td>{row.plate || '—'}</td><td title={row.vin}>{row.vin}</td><td>{row.protocol}</td>{visibleMetrics.map((metric) => <td key={metric.key}>{formatHistoryValue(row.values[metric.key], metric)}</td>)}<td><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></td><td><button type="button" onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}>查看证据</button></td></tr>)}</tbody></table><div className="v2-history-mobile-list">{result?.rows.map((row) => <button type="button" key={row.id} className={selectedRow?.id === row.id ? 'is-selected' : ''} onClick={() => setSelectedRowRef({ scope: dataScope, id: row.id })}><header><div><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div><span className={`v2-quality is-${row.quality}`}><i />{row.quality === 'normal' ? '正常' : row.quality}</span></header><p><time>{row.deviceTime}</time><b>{row.protocol}</b></p><dl>{visibleMetrics.slice(0, 4).map((metric) => <div key={metric.key}><dt>{metric.label}</dt><dd>{formatHistoryValue(row.values[metric.key], metric)}</dd></div>)}</dl><em>查看完整证据</em></button>)}</div>{dataQuery.isPending && keywords.length ? <div className="v2-history-loading" role="status"><i />正在加载当前筛选范围的历史数据…</div> : !result?.rows.length ? <div className="v2-history-empty">{keywords.length ? '当前条件没有历史记录' : '输入车辆并查询历史数据'}</div> : null}</div><footer><span>第 {page} / {totalPages} 页,共 {result?.total ?? 0} 条</span><div><button type="button" disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button type="button" disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}>下一页</button><select value={limit} onChange={(event) => { setLimit(Number(event.target.value)); setOffset(0); }}><option value="20">20 条/页</option><option value="50">50 条/页</option><option value="100">100 条/页</option></select></div></footer></section>
|
||||
</div>
|
||||
<aside className="v2-history-side"><EvidencePanel row={selectedRow} metrics={allMetrics} onClose={() => setSelectedRowRef(undefined)} />{exportAllowed ? <ExportJobsPanel /> : null}</aside>
|
||||
</div>
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function StatisticsPage() {
|
||||
const [exportFeedback, setExportFeedback] = useState('');
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress>();
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const exportControllerRef = useRef<AbortController | null>(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 <div className="v2-mileage-page">
|
||||
<header className="v2-mileage-heading"><div><h2>里程查询</h2><p>未选择车牌时分页展示全部已绑定主车辆;选择车牌后查询指定车辆。</p></div><button type="button" onClick={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} disabled={refreshing}><IconRefresh />{refreshing ? '更新中' : '刷新数据'}</button></header>
|
||||
<section className="v2-mileage-query-panel">
|
||||
<form className="v2-mileage-filter" onSubmit={submit}>
|
||||
<button type="button" className="v2-mobile-filter-toggle" aria-expanded={!filtersCollapsed} onClick={() => setFiltersCollapsed((value) => !value)}><span><b>查询条件</b><small>{criteria.vehicles.length ? `已选 ${criteria.vehicles.length} 辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天` : `全部车辆 · ${inclusiveDays(criteria.dateFrom, criteria.dateTo)} 天`}</small></span><em>{filtersCollapsed ? '修改' : '收起'}</em></button>
|
||||
<form className={`v2-mileage-filter${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<VehicleMultiSelect value={draft.vehicles} onChange={(vehicles) => setDraft((current) => ({ ...current, vehicles }))} />
|
||||
<label><span>开始日期</span><input type="date" value={draft.dateFrom} max={draft.dateTo} onChange={(event) => setDraft((current) => ({ ...current, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束日期</span><input type="date" value={draft.dateTo} min={draft.dateFrom} onChange={(event) => setDraft((current) => ({ ...current, dateTo: event.target.value }))} /></label>
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function TrackPage() {
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [showStops, setShowStops] = useState(true);
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
|
||||
const [railCollapsed, setRailCollapsed] = useState(false);
|
||||
const [railCollapsed, setRailCollapsed] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 700px)').matches);
|
||||
const animationRef = useRef<number>();
|
||||
const lastFrameRef = useRef(0);
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user