feat: add vehicle mileage statistics
This commit is contained in:
@@ -9,6 +9,7 @@ const MonitorPage = lazy(() => import('./pages/MonitorPage'));
|
||||
const VehiclePage = lazy(() => import('./pages/VehiclePage'));
|
||||
const TrackPage = lazy(() => import('./pages/TrackPage'));
|
||||
const HistoryPage = lazy(() => import('./pages/HistoryPage'));
|
||||
const StatisticsPage = lazy(() => import('./pages/StatisticsPage'));
|
||||
const AccessPage = lazy(() => import('./pages/AccessPage'));
|
||||
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
|
||||
const OperationsPage = lazy(() => import('./pages/OperationsPage'));
|
||||
@@ -35,6 +36,7 @@ export function AppV2() {
|
||||
<Route path="/vehicles/:vin?" element={<Suspense fallback={<PageLoading />}><VehiclePage /></Suspense>} />
|
||||
<Route path="/tracks" element={<Suspense fallback={<PageLoading />}><TrackPage /></Suspense>} />
|
||||
<Route path="/history" element={<Suspense fallback={<PageLoading />}><HistoryPage /></Suspense>} />
|
||||
<Route path="/statistics" element={<Suspense fallback={<PageLoading />}><StatisticsPage /></Suspense>} />
|
||||
<Route path="/access" element={<Suspense fallback={<PageLoading />}><AccessPage /></Suspense>} />
|
||||
<Route path="/alerts/*" element={<Suspense fallback={<PageLoading />}><AlertsPage /></Suspense>} />
|
||||
<Route path="/operations" element={<Suspense fallback={<PageLoading />}><OperationsPage /></Suspense>} />
|
||||
|
||||
@@ -20,6 +20,7 @@ const navigation = [
|
||||
{ to: '/vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin },
|
||||
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked },
|
||||
{ to: '/statistics', label: '车辆统计', icon: IconBarChartHStroked },
|
||||
{ to: '/alerts', label: '告警中心', icon: IconAlarm },
|
||||
{ to: '/access', label: '接入管理', icon: IconBox }
|
||||
];
|
||||
@@ -29,6 +30,7 @@ const pageNames: Record<string, string> = {
|
||||
vehicles: '车辆查询',
|
||||
tracks: '轨迹回放',
|
||||
history: '历史数据',
|
||||
statistics: '车辆统计',
|
||||
alerts: '告警中心',
|
||||
access: '接入管理',
|
||||
operations: '运维质量'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage from './StatisticsPage';
|
||||
|
||||
const mileageStatistics = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../api/client', () => ({ api: { mileageStatistics } }));
|
||||
|
||||
afterEach(() => { cleanup(); mileageStatistics.mockReset(); });
|
||||
|
||||
test('shows distinct period and latest fleet mileage metrics with exact daily evidence', async () => {
|
||||
mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 2, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, fleetLatestMileageKm: 168723.9, averageMileagePerVin: 96.65, averageDailyMileageKm: 96.65,
|
||||
trend: [{ date: '2026-07-14', mileageKm: 193.3, vehicles: 2 }],
|
||||
ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 104.6, latestMileageKm: 119925, activeDays: 1 }],
|
||||
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('168,723.9 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('按车辆与自然日去重')).toBeInTheDocument();
|
||||
expect(screen.getByText('粤A12345')).toHaveAttribute('href', '/vehicles/LTEST000000000001');
|
||||
expect(screen.getByRole('img', { name: '每日行驶里程趋势图' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(mileageStatistics).toHaveBeenCalledTimes(1));
|
||||
expect(mileageStatistics.mock.calls[0][0].get('dateFrom')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { MileageStatistics, MileageTrendPoint } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function localDate(value = new Date()) {
|
||||
const offset = value.getTimezoneOffset() * 60_000;
|
||||
return new Date(value.getTime() - offset).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function defaultWindow(days = 30) {
|
||||
const end = new Date();
|
||||
return { dateFrom: localDate(new Date(end.getTime() - (days - 1) * DAY)), dateTo: localDate(end) };
|
||||
}
|
||||
|
||||
function formatKm(value?: number, compact = false) {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return new Intl.NumberFormat('zh-CN', compact ? { notation: 'compact', maximumFractionDigits: 1 } : { maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
function MileageChart({ points }: { points: MileageTrendPoint[] }) {
|
||||
if (!points.length) return <div className="v2-stat-empty">当前筛选范围没有日里程数据</div>;
|
||||
const width = 860; const height = 238; const left = 58; const right = 18; const top = 16; const bottom = 34;
|
||||
const max = Math.max(...points.map((point) => point.mileageKm), 1);
|
||||
const x = (index: number) => left + (width - left - right) * (points.length === 1 ? .5 : index / (points.length - 1));
|
||||
const y = (value: number) => top + (height - top - bottom) * (1 - value / max);
|
||||
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(index).toFixed(1)},${y(point.mileageKm).toFixed(1)}`).join(' ');
|
||||
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), points.length - 1]));
|
||||
return <div className="v2-stat-chart-wrap"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="每日行驶里程趋势图">
|
||||
<g className="v2-stat-grid">{[0, .5, 1].map((ratio) => <line key={ratio} x1={left} x2={width - right} y1={y(max * ratio)} y2={y(max * ratio)} />)}</g>
|
||||
<g className="v2-stat-axis"><text x={left - 8} y={y(max) + 4} textAnchor="end">{formatKm(max, true)}</text><text x={left - 8} y={y(max / 2) + 4} textAnchor="end">{formatKm(max / 2, true)}</text><text x={left - 8} y={y(0) + 4} textAnchor="end">0</text>{labelIndexes.map((index) => <text key={index} x={x(index)} y={height - 8} textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}>{points[index].date.slice(5)}</text>)}</g>
|
||||
<path className="v2-stat-area" d={`${path} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`} />
|
||||
<path className="v2-stat-line" d={path} />
|
||||
{points.map((point, index) => <circle key={point.date} className="v2-stat-point" cx={x(index)} cy={y(point.mileageKm)} r="3"><title>{point.date}:{formatKm(point.mileageKm)} km,{point.vehicles} 辆</title></circle>)}
|
||||
</svg></div>;
|
||||
}
|
||||
|
||||
function Kpis({ data }: { data?: MileageStatistics }) {
|
||||
const items = [
|
||||
['统计期行驶里程', `${formatKm(data?.periodMileageKm)} km`, '按车辆与自然日去重'],
|
||||
['最新里程表总和', `${formatKm(data?.fleetLatestMileageKm)} km`, '每车取最新一次上报'],
|
||||
['有里程车辆', formatKm(data?.vehicleCount), `${data?.sourceCount ?? 0} 个数据来源`],
|
||||
['车均行驶里程', `${formatKm(data?.averageMileagePerVin)} km`, '统计期累计 / 车辆'],
|
||||
['车日均里程', `${formatKm(data?.averageDailyMileageKm)} km`, '有效车辆日平均']
|
||||
];
|
||||
return <section className="v2-stat-kpis">{items.map(([label, value, note], index) => <article key={label} className={index < 2 ? 'is-primary' : ''}><small>{label}</small><strong>{value}</strong><span>{note}</span></article>)}</section>;
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const defaults = useMemo(() => defaultWindow(30), []);
|
||||
const initial = { vin: searchParams.get('vin') ?? '', protocol: searchParams.get('protocol') ?? '', dateFrom: searchParams.get('dateFrom') ?? defaults.dateFrom, dateTo: searchParams.get('dateTo') ?? defaults.dateTo };
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const params = useMemo(() => { const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo }); if (criteria.vin) next.set('vin', criteria.vin); if (criteria.protocol) next.set('protocol', criteria.protocol); return next; }, [criteria]);
|
||||
const query = useQuery({ queryKey: ['mileage-statistics', params.toString()], queryFn: () => api.mileageStatistics(params), staleTime: 60_000, refetchInterval: 5 * 60_000, placeholderData: (previous) => previous });
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setCriteria(draft); setSearchParams(paramsFrom(draft), { replace: true }); };
|
||||
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setDraft(next); setCriteria(next); setSearchParams(paramsFrom(next), { replace: true }); };
|
||||
const data = query.data; const maximumRank = Math.max(...(data?.ranking.map((item) => item.mileageKm) ?? [1]), 1);
|
||||
|
||||
return <div className="v2-stat-page">
|
||||
<header className="v2-stat-heading"><div><h2>车辆里程统计</h2><p>日里程按车辆和自然日归并;最新总里程来自每辆车最后一次有效上报。</p></div><button type="button" onClick={() => query.refetch()} disabled={query.isFetching}><IconRefresh />{query.isFetching ? '更新中' : '刷新数据'}</button></header>
|
||||
<form className="v2-stat-filter" onSubmit={submit}>
|
||||
<label className="v2-stat-search"><span>车辆</span><div><IconSearch /><input value={draft.vin} onChange={(event) => setDraft((current) => ({ ...current, vin: event.target.value }))} placeholder="车牌 / VIN(留空统计全车队)" /></div></label>
|
||||
<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>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => setDraft((current) => ({ ...current, protocol: event.target.value }))}><option value="">全部来源</option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
|
||||
<button className="v2-primary-button" type="submit">查询</button>
|
||||
<div className="v2-stat-ranges"><button type="button" onClick={() => setDays(7)}>近 7 天</button><button type="button" onClick={() => setDays(30)}>近 30 天</button><button type="button" onClick={() => setDays(90)}>近 90 天</button></div>
|
||||
</form>
|
||||
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '统计数据加载失败'} onRetry={() => query.refetch()} /> : null}
|
||||
<Kpis data={data} />
|
||||
<div className="v2-stat-grid-layout">
|
||||
<section className="v2-stat-card v2-stat-trend"><header><div><strong>每日行驶里程</strong><span>{data?.dateFrom || criteria.dateFrom} 至 {data?.dateTo || criteria.dateTo}</span></div><em>{data?.trend.length ?? 0} 个有效自然日</em></header><MileageChart points={data?.trend ?? []} /><div className="v2-stat-daily-list" aria-label="每日里程精确数据">{[...(data?.trend ?? [])].reverse().slice(0, 10).map((point) => <div key={point.date}><time>{point.date}</time><strong>{formatKm(point.mileageKm)} km</strong><span>{point.vehicles} 辆</span></div>)}</div></section>
|
||||
<section className="v2-stat-card v2-stat-ranking"><header><div><strong>车辆里程排名</strong><span>按统计期累计里程排序,最多 20 辆</span></div></header><div>{data?.ranking.map((item, index) => <article key={item.vin}><b>{index + 1}</b><div><header><Link to={`/vehicles/${encodeURIComponent(item.vin)}`}>{item.plate || item.vin}</Link><strong>{formatKm(item.mileageKm)} km</strong></header><span><i style={{ width: `${Math.max(2, item.mileageKm / maximumRank * 100)}%` }} /></span><footer><small>{item.plate ? item.vin : '未绑定车牌'}</small><em>{item.activeDays} 个有效日 · 最新 {formatKm(item.latestMileageKm)} km</em></footer></div></article>)}{!query.isLoading && !data?.ranking.length ? <div className="v2-stat-empty">当前范围没有车辆里程记录</div> : null}</div></section>
|
||||
</div>
|
||||
<footer className="v2-stat-evidence"><span>数据更新时间:{data?.asOf || '—'}</span><span>{data?.evidence || '正在读取生产统计证据'}</span><span>页面每 5 分钟自动更新</span></footer>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function paramsFrom(criteria: { vin: string; protocol: string; dateFrom: string; dateTo: string }) {
|
||||
const next = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
if (criteria.vin.trim()) next.set('vin', criteria.vin.trim());
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}
|
||||
@@ -811,6 +811,67 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
|
||||
}
|
||||
|
||||
.v2-stat-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 16px 18px 20px; }
|
||||
.v2-stat-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||
.v2-stat-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.03em; }
|
||||
.v2-stat-heading p { margin: 5px 0 0; color: var(--v2-muted); font-size: 11px; }
|
||||
.v2-stat-heading > button { display: flex; height: 34px; align-items: center; gap: 6px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 12px; color: #59677c; cursor: pointer; font-size: 11px; font-weight: 700; }
|
||||
.v2-stat-filter { display: grid; grid-template-columns: minmax(250px, 1.3fr) minmax(130px, .65fr) minmax(130px, .65fr) minmax(130px, .65fr) auto; align-items: end; gap: 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; padding: 12px; box-shadow: var(--v2-shadow); }
|
||||
.v2-stat-filter label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #68768a; font-size: 10px; font-weight: 600; }
|
||||
.v2-stat-filter input, .v2-stat-filter select { width: 100%; height: 36px; border: 1px solid #dce4ef; border-radius: 7px; background: #fff; padding: 0 10px; color: var(--v2-text); outline: 0; font-size: 12px; }
|
||||
.v2-stat-search > div { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid #dce4ef; border-radius: 7px; padding: 0 10px; color: #8a98aa; }
|
||||
.v2-stat-search > div:focus-within, .v2-stat-filter input:focus, .v2-stat-filter select:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
|
||||
.v2-stat-search input { height: auto; border: 0; padding: 0; box-shadow: none !important; }
|
||||
.v2-stat-ranges { display: flex; grid-column: 1 / -1; gap: 6px; }
|
||||
.v2-stat-ranges button { height: 26px; border: 1px solid #dce4ef; border-radius: 13px; background: #fff; padding: 0 11px; color: #64748b; cursor: pointer; font-size: 9px; }
|
||||
.v2-stat-ranges button:hover { border-color: #a9c8f8; color: var(--v2-blue); }
|
||||
.v2-stat-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-stat-kpis article { position: relative; min-width: 0; padding: 14px 16px; }
|
||||
.v2-stat-kpis article + article::before { position: absolute; inset: 14px auto 14px 0; width: 1px; background: var(--v2-border); content: ""; }
|
||||
.v2-stat-kpis small, .v2-stat-kpis span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-stat-kpis strong { display: block; margin: 7px 0 5px; overflow: hidden; font-size: clamp(17px, 1.55vw, 24px); font-variant-numeric: tabular-nums; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-stat-kpis .is-primary strong { color: var(--v2-blue); }
|
||||
.v2-stat-grid-layout { display: grid; min-height: 490px; flex: 1; grid-template-columns: minmax(0, 1.65fr) minmax(320px, .85fr); gap: 12px; }
|
||||
.v2-stat-card { min-width: 0; overflow: hidden; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
|
||||
.v2-stat-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--v2-border); padding: 8px 14px; }
|
||||
.v2-stat-card > header div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.v2-stat-card > header strong { font-size: 12px; }
|
||||
.v2-stat-card > header span, .v2-stat-card > header em { color: var(--v2-muted); font-size: 9px; font-style: normal; }
|
||||
.v2-stat-chart-wrap { padding: 14px 12px 2px; }
|
||||
.v2-stat-chart-wrap svg { display: block; width: 100%; max-height: 260px; overflow: visible; }
|
||||
.v2-stat-grid line { stroke: #e9eef5; stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.v2-stat-axis text { fill: #8290a3; font-size: 10px; }
|
||||
.v2-stat-area { fill: url(#none); fill: rgba(18,104,243,.07); stroke: none; }
|
||||
.v2-stat-line { fill: none; stroke: var(--v2-blue); stroke-linecap: round; stroke-linejoin: round; stroke-width: 2.2; vector-effect: non-scaling-stroke; }
|
||||
.v2-stat-point { fill: #fff; stroke: var(--v2-blue); stroke-width: 2; vector-effect: non-scaling-stroke; }
|
||||
.v2-stat-daily-list { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); margin: 0 14px 14px; border: 1px solid #edf1f6; border-radius: 8px; overflow: hidden; }
|
||||
.v2-stat-daily-list > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; border-right: 1px solid #edf1f6; border-bottom: 1px solid #edf1f6; padding: 8px 9px; }
|
||||
.v2-stat-daily-list time, .v2-stat-daily-list span { color: var(--v2-muted); font-size: 8px; }
|
||||
.v2-stat-daily-list strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-stat-ranking > div { max-height: 440px; overflow: auto; }
|
||||
.v2-stat-ranking article { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 8px; border-bottom: 1px solid #eef2f7; padding: 10px 13px; }
|
||||
.v2-stat-ranking article > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 6px; background: #f2f5f9; color: #728096; font-size: 9px; }
|
||||
.v2-stat-ranking article:nth-child(-n+3) > b { background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
.v2-stat-ranking article header, .v2-stat-ranking article footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.v2-stat-ranking article header a { overflow: hidden; color: var(--v2-text); font-size: 11px; font-weight: 700; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-stat-ranking article header strong { flex: 0 0 auto; color: var(--v2-blue); font-size: 10px; }
|
||||
.v2-stat-ranking article > div > span { display: block; height: 5px; margin: 7px 0; overflow: hidden; border-radius: 3px; background: #eef2f7; }
|
||||
.v2-stat-ranking article > div > span i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4f93fa, #1268f3); }
|
||||
.v2-stat-ranking article footer small, .v2-stat-ranking article footer em { overflow: hidden; color: var(--v2-muted); font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-stat-ranking article footer small { max-width: 46%; }
|
||||
.v2-stat-empty { display: grid; min-height: 180px; place-items: center; padding: 20px; color: var(--v2-muted); text-align: center; font-size: 11px; }
|
||||
.v2-stat-evidence { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; color: var(--v2-muted); font-size: 9px; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.v2-stat-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.v2-stat-search { grid-column: 1 / -1; }
|
||||
.v2-stat-filter > .v2-primary-button { width: 100%; }
|
||||
.v2-stat-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.v2-stat-kpis article:nth-child(4)::before { display: none; }
|
||||
.v2-stat-grid-layout { grid-template-columns: 1fr; }
|
||||
.v2-stat-ranking > div { max-height: 400px; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
html, body, #root { min-width: 320px; min-height: 100%; }
|
||||
body { overscroll-behavior-y: none; }
|
||||
@@ -820,7 +881,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-auth-card button { height: 46px; }
|
||||
.v2-sidebar { inset: auto 0 0 0; width: auto; height: calc(64px + env(safe-area-inset-bottom)); flex-direction: row; border: 0; border-top: 1px solid var(--v2-border); padding-bottom: env(safe-area-inset-bottom); box-shadow: 0 -8px 24px rgba(21,32,51,.07); }
|
||||
.v2-brand, .v2-collapse, .v2-nav-operations { display: none; }
|
||||
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
|
||||
.v2-navigation { display: grid; width: 100%; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0; padding: 4px 3px; }
|
||||
.v2-nav-item { height: 56px; justify-content: center; flex-direction: column; gap: 2px; padding: 3px 0 2px; border-radius: 8px; font-weight: 600; }
|
||||
.v2-nav-item > svg { flex: 0 0 auto; font-size: 18px; }
|
||||
.v2-nav-label, .v2-sidebar.is-collapsed .v2-nav-label { display: block; max-width: 100%; overflow: hidden; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; }
|
||||
@@ -962,6 +1023,29 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
|
||||
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
|
||||
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
|
||||
.v2-stat-page { gap: 9px; padding: 10px 8px 16px; }
|
||||
.v2-stat-heading { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.v2-stat-heading h2 { font-size: 18px; }
|
||||
.v2-stat-heading p { line-height: 1.6; }
|
||||
.v2-stat-heading > button { width: 100%; height: 40px; justify-content: center; }
|
||||
.v2-stat-filter { grid-template-columns: 1fr; gap: 9px; padding: 10px; }
|
||||
.v2-stat-search, .v2-stat-ranges { grid-column: auto; }
|
||||
.v2-stat-filter input, .v2-stat-filter select, .v2-stat-search > div { height: 44px; font-size: 16px; }
|
||||
.v2-stat-filter > .v2-primary-button { height: 44px; }
|
||||
.v2-stat-ranges { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.v2-stat-ranges button { height: 34px; border-radius: 7px; }
|
||||
.v2-stat-kpis { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; }
|
||||
.v2-stat-kpis article { width: 150px; min-width: 150px; flex: 0 0 150px; padding: 12px 14px; scroll-snap-align: start; }
|
||||
.v2-stat-kpis article + article::before { display: block; }
|
||||
.v2-stat-kpis strong { font-size: 19px; }
|
||||
.v2-stat-grid-layout { min-height: 0; grid-template-columns: 1fr; }
|
||||
.v2-stat-card > header { align-items: flex-start; flex-direction: column; }
|
||||
.v2-stat-chart-wrap { overflow-x: auto; padding: 10px 4px 0; }
|
||||
.v2-stat-chart-wrap svg { width: 680px; max-width: none; }
|
||||
.v2-stat-daily-list { grid-template-columns: repeat(2, minmax(0,1fr)); margin: 0 9px 10px; }
|
||||
.v2-stat-ranking > div { max-height: none; }
|
||||
.v2-stat-ranking article { padding: 11px 10px; }
|
||||
.v2-stat-evidence { flex-direction: column; line-height: 1.5; }
|
||||
}
|
||||
|
||||
@keyframes v2-mobile-sheet-enter {
|
||||
|
||||
Reference in New Issue
Block a user