2026-10-28 22:26:30 +04:00

267 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Хук серверной постраничной загрузки свода 2: поколения запросов, окно страниц, догрузка при прокрутке.
import axios from 'axios';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { SummaryApi } from '../../api/summary';
import {
PAGE_SIZE,
buildSvod2PageParams,
buildSvod2QueryParams,
buildWindowRows,
createSheetPagingState,
facetsToMaps,
getMissingPages,
getPageCount,
getVirtualizerPadding,
getVisiblePage,
getWindowPages,
getWindowRange,
svod2PagingReducer,
} from './svod2Paging';
const SHEET_KEYS = ['GO', 'RF'];
const LOAD_ERROR_FALLBACK = 'Не удалось загрузить свод. Попробуйте ещё раз.';
const SCROLL_ERROR_MESSAGE = 'Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.';
const NO_PADDING = { paddingStart: 0, paddingEnd: 0 };
const createInitialState = () => Object.fromEntries(SHEET_KEYS.map((key) => [key, createSheetPagingState()]));
const rootReducer = (state, action) => ({ ...state, [action.sheet]: svod2PagingReducer(state[action.sheet], action) });
const getRawPage = (scrollTop, rowHeight) => {
const page = Math.floor(scrollTop / rowHeight / PAGE_SIZE);
return Number.isFinite(page) && page > 0 ? page : 0;
};
const isCanceled = (error, ctx) => axios.isCancel(error) || error?.code === 'ERR_CANCELED' || ctx.controller.signal.aborted;
export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop }) {
// Состояние хранится в ref синхронно (редьюсер вызывается в dispatch), чтобы логика догрузки видела актуальные страницы.
const stateRef = useRef(null);
if (stateRef.current === null) stateRef.current = createInitialState();
const [state, setState] = useState(stateRef.current);
const [scrollPage, setScrollPage] = useState(0);
const generationRef = useRef(Object.fromEntries(SHEET_KEYS.map((key) => [key, 0])));
const ctxRef = useRef({});
const rafRef = useRef(0);
const mountedRef = useRef(true);
const propsRef = useRef(null);
propsRef.current = { sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop };
const dispatch = useCallback((action) => {
const next = rootReducer(stateRef.current, action);
if (next === stateRef.current) return;
stateRef.current = next;
if (mountedRef.current) setState(next);
}, []);
const fetchPage = useCallback(async (ctx, pageIndex) => {
ctx.inflight.add(pageIndex);
try {
const response = await SummaryApi.getSvod2Page(
ctx.sheet,
ctx.year,
buildSvod2PageParams({
orgIds: ctx.orgIds,
search: ctx.search,
columnFilters: ctx.columnFilters,
offset: pageIndex * PAGE_SIZE,
}),
{ signal: ctx.controller.signal },
);
const rows = response.result || [];
return { rows, total: response.total ?? rows.length };
} finally {
ctx.inflight.delete(pageIndex);
}
}, []);
const getWindowFor = useCallback((total) => {
const { rowHeight: height, getScrollTop: readScrollTop } = propsRef.current;
const visible = getVisiblePage({ scrollTop: readScrollTop(), rowHeight: height, total });
return getWindowPages(visible, total);
}, []);
const start = useCallback(
(mode) => {
const current = propsRef.current;
const sheetKey = current.sheet;
ctxRef.current[sheetKey]?.controller.abort();
const generation = generationRef.current[sheetKey] + 1;
generationRef.current[sheetKey] = generation;
const ctx = {
sheet: sheetKey,
year: current.year,
orgIds: current.orgIds,
search: current.search,
columnFilters: current.columnFilters,
generation,
controller: new AbortController(),
inflight: new Set(),
ready: false,
};
ctxRef.current[sheetKey] = ctx;
const candidate = getRawPage(current.getScrollTop(), current.rowHeight);
setScrollPage(candidate);
dispatch({ type: 'begin', sheet: sheetKey, generation, queryKey: current.queryKey, mode: mode === 'refine' ? 'refine' : 'load' });
let failed = false;
const fail = (error) => {
if (isCanceled(error, ctx) || failed) return;
failed = true;
dispatch({
type: 'fail',
sheet: sheetKey,
generation,
mode: mode === 'refine' ? 'refine' : 'load',
message: error?.response?.data?.detail || LOAD_ERROR_FALLBACK,
});
ctx.controller.abort();
};
const loadFacets = async () => {
try {
const response = await SummaryApi.getSvod2Facets(ctx.sheet, ctx.year, buildSvod2QueryParams(ctx), {
signal: ctx.controller.signal,
});
if (ctx.controller.signal.aborted) return;
dispatch({ type: 'facets', sheet: sheetKey, generation, facets: response.result });
} catch (error) {
fail(error);
}
};
const loadPages = async () => {
try {
const first = await fetchPage(ctx, candidate);
if (ctx.controller.signal.aborted) return;
const { total } = first;
const pages = getWindowFor(total);
const pageCount = getPageCount(total);
let cache = new Map();
if (total === 0 || candidate < pageCount) {
const pageIndex = total === 0 ? 0 : candidate;
dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages });
cache = stateRef.current[sheetKey].pages;
}
ctx.ready = true;
const missing = getMissingPages(pages, cache, ctx.inflight);
await Promise.all(
missing.map(async (pageIndex) => {
try {
const result = await fetchPage(ctx, pageIndex);
if (ctx.controller.signal.aborted) return;
dispatch({
type: 'page',
sheet: sheetKey,
generation,
pageIndex,
rows: result.rows,
total: result.total,
keepPages: getWindowFor(result.total),
});
} catch (error) {
fail(error);
}
}),
);
} catch (error) {
fail(error);
}
};
(async () => {
try {
await Promise.all([loadFacets(), loadPages()]);
} finally {
dispatch({ type: 'settled', sheet: sheetKey, generation });
}
})();
},
[dispatch, fetchPage, getWindowFor],
);
const load = useCallback(() => start('load'), [start]);
// Догрузка недостающих страниц окна текущей вкладки в режиме scroll.
const syncWindow = useCallback(() => {
const current = propsRef.current;
setScrollPage(getRawPage(current.getScrollTop(), current.rowHeight));
const ctx = ctxRef.current[current.sheet];
const sheetState = stateRef.current[current.sheet];
if (!ctx || !ctx.ready || ctx.controller.signal.aborted) return;
if (sheetState.generation !== ctx.generation || sheetState.loadedQueryKey !== current.queryKey) return;
const pages = getWindowFor(sheetState.total);
for (const pageIndex of getMissingPages(pages, sheetState.pages, ctx.inflight)) {
fetchPage(ctx, pageIndex)
.then((result) => {
if (ctx.controller.signal.aborted) return;
dispatch({
type: 'page',
sheet: ctx.sheet,
generation: ctx.generation,
pageIndex,
rows: result.rows,
total: result.total,
keepPages: getWindowFor(result.total),
});
})
.catch((error) => {
if (isCanceled(error, ctx)) return;
toast.error(SCROLL_ERROR_MESSAGE, { toastId: 'svod2-scroll-error' });
});
}
}, [dispatch, fetchPage, getWindowFor]);
const onScroll = useCallback(() => {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = 0;
if (mountedRef.current) syncWindow();
});
}, [syncWindow]);
// Смена поиска или колоночных фильтров на загруженных данных — уточнение (refine) теми же запросами.
const sheetState = state[sheet];
const hasCurrentData = sheetState.loadedQueryKey === queryKey;
const refineKey = JSON.stringify([search, buildSvod2QueryParams({ columnFilters })]);
const previousRef = useRef({ sheet, refineKey });
// biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену поиска/фильтров и вкладки
useEffect(() => {
const previous = previousRef.current;
previousRef.current = { sheet, refineKey };
if (previous.sheet !== sheet) {
syncWindow();
return;
}
if (previous.refineKey !== refineKey && hasCurrentData) start('refine');
}, [sheet, refineKey]);
useEffect(() => {
mountedRef.current = true;
const contexts = ctxRef.current;
return () => {
mountedRef.current = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
for (const ctx of Object.values(contexts)) ctx.controller.abort();
};
}, []);
const visiblePage = getVisiblePage({ scrollTop: scrollPage * PAGE_SIZE * rowHeight, rowHeight, total: sheetState.total });
const windowPages = useMemo(() => getWindowPages(visiblePage, sheetState.total), [visiblePage, sheetState.total]);
const windowRows = useMemo(
() => buildWindowRows(windowPages, sheetState.pages, sheetState.total),
[windowPages, sheetState.pages, sheetState.total],
);
const padding = useMemo(() => {
if (!hasCurrentData) return NO_PADDING;
const { windowStart, windowEnd } = getWindowRange(windowPages, sheetState.total);
return getVirtualizerPadding({ windowStart, windowEnd, total: sheetState.total, rowHeight });
}, [hasCurrentData, windowPages, sheetState.total, rowHeight]);
const facetsMap = useMemo(() => facetsToMaps(sheetState.facets), [sheetState.facets]);
return { state: sheetState, windowRows, padding, facetsMap, load, onScroll };
}