perf(web): make mileage exports cancellable
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { createMileageWorkbook } from './mileageExport';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { createMileageWorkbook, downloadMileageWorkbook } from './mileageExport';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => {
|
||||
const workbook = await createMileageWorkbook({
|
||||
@@ -36,3 +38,42 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
test('does not build or download a workbook after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(downloadMileageWorkbook({
|
||||
dateFrom: '2026-07-13',
|
||||
dateTo: '2026-07-14',
|
||||
dates: ['2026-07-13', '2026-07-14'],
|
||||
vehicles: [],
|
||||
mileageRows: [],
|
||||
sources: []
|
||||
}, controller.signal)).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('terminates the workbook worker when an active export is cancelled', async () => {
|
||||
const terminate = vi.fn();
|
||||
const postMessage = vi.fn();
|
||||
class WorkerMock {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
postMessage = postMessage;
|
||||
terminate = terminate;
|
||||
}
|
||||
vi.stubGlobal('Worker', WorkerMock);
|
||||
const controller = new AbortController();
|
||||
const exportPromise = downloadMileageWorkbook({
|
||||
dateFrom: '2026-07-13',
|
||||
dateTo: '2026-07-14',
|
||||
dates: ['2026-07-13', '2026-07-14'],
|
||||
vehicles: [],
|
||||
mileageRows: [],
|
||||
sources: []
|
||||
}, controller.signal);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
controller.abort();
|
||||
await expect(exportPromise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(terminate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,16 @@ export type MileageExportInput = {
|
||||
exportedAt?: Date;
|
||||
};
|
||||
|
||||
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
|
||||
|
||||
function abortError() {
|
||||
return new DOMException('里程导出已取消', 'AbortError');
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
}
|
||||
|
||||
function excelColumn(index: number) {
|
||||
let value = index;
|
||||
let result = '';
|
||||
@@ -146,9 +156,46 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
return workbook;
|
||||
}
|
||||
|
||||
export async function downloadMileageWorkbook(input: MileageExportInput) {
|
||||
const workbook = await createMileageWorkbook(input);
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
async function createMileageWorkbookBuffer(input: MileageExportInput, signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
if (typeof Worker === 'undefined') {
|
||||
const workbook = await createMileageWorkbook(input);
|
||||
throwIfAborted(signal);
|
||||
const output = await workbook.xlsx.writeBuffer();
|
||||
throwIfAborted(signal);
|
||||
return new Uint8Array(output as ArrayBuffer).slice().buffer;
|
||||
}
|
||||
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./mileageExport.worker.ts', import.meta.url), { type: 'module' });
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
worker.terminate();
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
const cancel = () => finish(() => reject(abortError()));
|
||||
worker.onmessage = (event: MessageEvent<MileageWorkerResponse>) => {
|
||||
finish(() => event.data.ok ? resolve(event.data.buffer) : reject(new Error(event.data.message)));
|
||||
};
|
||||
worker.onerror = (event) => finish(() => reject(new Error(event.message || 'Excel 工作线程执行失败')));
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
if (signal?.aborted) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
worker.postMessage(input);
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
|
||||
const buffer = await createMileageWorkbookBuffer(input, signal);
|
||||
throwIfAborted(signal);
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createMileageWorkbook, type MileageExportInput } from './mileageExport';
|
||||
|
||||
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
|
||||
type WorkerScope = {
|
||||
onmessage: ((event: MessageEvent<MileageExportInput>) => void) | null;
|
||||
postMessage: (message: MileageWorkerResponse, transfer?: Transferable[]) => void;
|
||||
};
|
||||
|
||||
const workerScope = self as unknown as WorkerScope;
|
||||
|
||||
workerScope.onmessage = async (event) => {
|
||||
try {
|
||||
const workbook = await createMileageWorkbook(event.data);
|
||||
const output = await workbook.xlsx.writeBuffer();
|
||||
const buffer = new Uint8Array(output as ArrayBuffer).slice().buffer;
|
||||
workerScope.postMessage({ ok: true, buffer }, [buffer]);
|
||||
} catch (error) {
|
||||
workerScope.postMessage({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : 'Excel 工作簿生成失败'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -126,5 +126,54 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
|
||||
expect(exportMileageCall.get('vins')).toBeNull();
|
||||
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
|
||||
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1);
|
||||
expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][1]).toBeInstanceOf(AbortSignal);
|
||||
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows export progress and lets the user cancel the active request', async () => {
|
||||
prepareData();
|
||||
let exportSignal: AbortSignal | undefined;
|
||||
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
|
||||
if (params.get('limit') !== String(2_000)) {
|
||||
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
|
||||
}
|
||||
exportSignal = signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true });
|
||||
});
|
||||
});
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '导出 Excel' })).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
|
||||
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '取消导出' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消导出' }));
|
||||
|
||||
await waitFor(() => expect(exportSignal?.aborted).toBe(true));
|
||||
expect(await screen.findByText(/已取消导出/)).toBeInTheDocument();
|
||||
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('aborts an active export when the mileage route unmounts', async () => {
|
||||
prepareData();
|
||||
let exportSignal: AbortSignal | undefined;
|
||||
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams, signal?: AbortSignal) => {
|
||||
if (params.get('limit') !== String(2_000)) {
|
||||
return Promise.resolve({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
|
||||
}
|
||||
exportSignal = signal;
|
||||
return new Promise(() => undefined);
|
||||
});
|
||||
const view = renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '导出 Excel' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
|
||||
expect(await screen.findByRole('progressbar', { name: '正在读取车辆档案' })).toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(exportSignal?.aborted).toBe(true);
|
||||
expect(exportMocks.downloadMileageWorkbook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
|
||||
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
||||
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
||||
type ExportProgress = { label: string; completed?: number; total?: number };
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
|
||||
const DEFAULT_SOURCES: MileageSourceOption[] = [
|
||||
@@ -233,6 +234,16 @@ export default function StatisticsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportFeedback, setExportFeedback] = useState('');
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress>();
|
||||
const exportControllerRef = useRef<AbortController | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
exportControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
const hasVehicles = criteria.vehicles.length > 0;
|
||||
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
|
||||
const fleetVehicles = useQuery({
|
||||
@@ -265,9 +276,17 @@ export default function StatisticsPage() {
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
|
||||
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
|
||||
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
||||
const exportPercent = exportProgress?.total
|
||||
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
||||
: undefined;
|
||||
|
||||
const exportExcel = async () => {
|
||||
if (isExporting || !totalVehicles) return;
|
||||
if (exportControllerRef.current || !totalVehicles) return;
|
||||
const controller = new AbortController();
|
||||
exportControllerRef.current = controller;
|
||||
const reportProgress = (progress: ExportProgress) => {
|
||||
if (mountedRef.current && !controller.signal.aborted) setExportProgress(progress);
|
||||
};
|
||||
setIsExporting(true);
|
||||
setExportFeedback('');
|
||||
try {
|
||||
@@ -275,9 +294,11 @@ export default function StatisticsPage() {
|
||||
if (!vehicles.length) {
|
||||
vehicles = [];
|
||||
let offset = 0;
|
||||
reportProgress({ label: '正在读取车辆档案', completed: 0, total: totalVehicles });
|
||||
while (offset < totalVehicles) {
|
||||
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }));
|
||||
const result = await api.vehicleCoverage(new URLSearchParams({ limit: String(EXPORT_VEHICLE_PAGE_SIZE), offset: String(offset), bindingStatus: 'bound' }), controller.signal);
|
||||
vehicles.push(...result.items.map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })));
|
||||
reportProgress({ label: '正在读取车辆档案', completed: vehicles.length, total: result.total });
|
||||
if (!result.items.length) break;
|
||||
offset += result.items.length;
|
||||
if (offset >= result.total) break;
|
||||
@@ -287,18 +308,24 @@ export default function StatisticsPage() {
|
||||
const vehicleBatches = criteria.vehicles.length
|
||||
? Array.from({ length: Math.ceil(vehicles.length / EXPORT_VIN_BATCH_SIZE) }, (_, index) => vehicles.slice(index * EXPORT_VIN_BATCH_SIZE, (index + 1) * EXPORT_VIN_BATCH_SIZE))
|
||||
: [[] as VehicleOption[]];
|
||||
for (const vehicleBatch of vehicleBatches) {
|
||||
for (const [batchIndex, vehicleBatch] of vehicleBatches.entries()) {
|
||||
let offset = 0;
|
||||
const mileageLabel = vehicleBatches.length > 1
|
||||
? `正在读取里程数据(${batchIndex + 1}/${vehicleBatches.length} 批)`
|
||||
: '正在读取全部车辆里程';
|
||||
reportProgress({ label: mileageLabel });
|
||||
while (true) {
|
||||
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
|
||||
const result = await api.dailyMileage(params);
|
||||
const result = await api.dailyMileage(params, controller.signal);
|
||||
mileageRows.push(...result.items);
|
||||
offset += result.items.length;
|
||||
reportProgress({ label: mileageLabel, completed: offset, total: result.total });
|
||||
if (!result.items.length || offset >= result.total) break;
|
||||
}
|
||||
}
|
||||
const plateByVin = new Map(mileageRows.filter((row) => row.plate).map((row) => [row.vin, row.plate]));
|
||||
vehicles = vehicles.map((vehicle) => ({ ...vehicle, plate: vehicle.plate || plateByVin.get(vehicle.vin) || '' }));
|
||||
reportProgress({ label: '正在后台生成 Excel 文件' });
|
||||
await downloadMileageWorkbook({
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
@@ -307,15 +334,29 @@ export default function StatisticsPage() {
|
||||
mileageRows,
|
||||
sources: criteria.sources.filter((source) => source.enabled),
|
||||
exportedAt: new Date()
|
||||
});
|
||||
setExportFeedback(`已导出 ${vehicles.length} 辆车`);
|
||||
}, controller.signal);
|
||||
if (mountedRef.current) setExportFeedback(`已导出 ${vehicles.length} 辆车`);
|
||||
} catch (error) {
|
||||
setExportFeedback(error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
|
||||
if (mountedRef.current) {
|
||||
setExportFeedback(controller.signal.aborted
|
||||
? '已取消导出'
|
||||
: error instanceof Error ? `导出失败:${error.message}` : '导出失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
if (exportControllerRef.current === controller) exportControllerRef.current = null;
|
||||
if (mountedRef.current) {
|
||||
setIsExporting(false);
|
||||
setExportProgress(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelExport = () => {
|
||||
if (!exportControllerRef.current || exportControllerRef.current.signal.aborted) return;
|
||||
setExportProgress({ label: '正在取消导出' });
|
||||
exportControllerRef.current.abort();
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -331,7 +372,11 @@ export default function StatisticsPage() {
|
||||
</section>
|
||||
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
<section className="v2-mileage-results">
|
||||
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button type="button" onClick={exportExcel} disabled={isExporting || !totalVehicles}><IconDownload />{isExporting ? '正在导出…' : '导出 Excel'}</button></div></header>
|
||||
<header><div><strong>车辆每日里程</strong><span>{criteria.dateFrom} 至 {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles} 辆`} · {dates.length} 个自然日</em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
|
||||
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
|
||||
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
|
||||
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>
|
||||
</div> : null}
|
||||
<MileageTable rows={matrixRows} dates={dates} />
|
||||
{!fleetVehicles.isLoading && !displayVehicles.length ? <div className="v2-mileage-empty">当前没有可展示的车辆</div> : null}
|
||||
<footer><span>{hasVehicles ? `已选择 ${totalVehicles} 辆车辆` : `第 ${page} / ${totalPages} 页 · 共 ${totalVehicles} 辆 · 每页 ${PAGE_SIZE} 辆`}{exportFeedback ? ` · ${exportFeedback}` : ''}</span>{!hasVehicles && totalVehicles ? <div><button type="button" disabled={page <= 1 || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button><button type="button" disabled={page >= totalPages || fleetVehicles.isFetching} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>下一页</button></div> : null}</footer>
|
||||
|
||||
@@ -1018,6 +1018,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-mileage-result-actions > button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 11px; color: #315f9f; cursor: pointer; font-size: 10px; font-weight: 650; box-shadow: 0 1px 2px rgba(15,23,42,.04); transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; }
|
||||
.v2-mileage-result-actions > button:hover { border-color: #99b6dd; background: #f5f9ff; box-shadow: 0 2px 7px rgba(37,99,235,.08); }
|
||||
.v2-mileage-result-actions > button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
|
||||
.v2-mileage-export-progress { display: flex; flex: 0 0 auto; align-items: center; gap: 12px; border-bottom: 1px solid #e4e9f0; background: #f7faff; padding: 9px 18px; }
|
||||
.v2-mileage-export-progress > span { display: flex; min-width: 190px; align-items: center; justify-content: space-between; gap: 12px; color: #315f9f; font-size: 10px; }
|
||||
.v2-mileage-export-progress > span strong { font-weight: 650; }.v2-mileage-export-progress > span small { color: #6f8198; font-variant-numeric: tabular-nums; }
|
||||
.v2-mileage-export-progress > i { position: relative; height: 5px; flex: 1; overflow: hidden; border-radius: 999px; background: #dce8f8; }
|
||||
.v2-mileage-export-progress > i b { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--v2-blue); transition: width .18s ease; }
|
||||
.v2-mileage-export-progress > i.is-indeterminate b { width: 38%; animation: v2-export-progress 1.1s ease-in-out infinite; }
|
||||
@keyframes v2-export-progress { 0% { transform: translateX(-110%); } 100% { transform: translateX(290%); } }
|
||||
.v2-mileage-table-wrap { min-height: 0; flex: 1; overflow: auto; }
|
||||
.v2-mileage-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||
.v2-mileage-table th { position: sticky; z-index: 2; top: 0; height: 42px; border-bottom: 1px solid #dfe6ef; background: #f8fafc; padding: 0 14px; color: #59677b; font-size: 11px; font-weight: 650; text-align: left; white-space: nowrap; }
|
||||
@@ -1257,6 +1264,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-mileage-results > header div { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||
.v2-mileage-results > header .v2-mileage-result-actions { width: 100%; align-items: center; justify-content: space-between; flex-direction: row; gap: 8px; }
|
||||
.v2-mileage-result-actions > button { height: 34px; flex: 0 0 auto; }
|
||||
.v2-mileage-export-progress { align-items: stretch; flex-direction: column; gap: 7px; padding: 9px 12px; }
|
||||
.v2-mileage-export-progress > span { min-width: 0; }
|
||||
.v2-mileage-table-wrap { display: none; }
|
||||
.v2-mileage-mobile-list { display: flex; flex-direction: column; }
|
||||
.v2-mileage-mobile-list article { border-bottom: 1px solid #e9eef5; padding: 14px 12px; }
|
||||
|
||||
Reference in New Issue
Block a user