From eef358c56ecfdbb2750b2e9d35480e49d08f556e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 13:18:13 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(svod2):=20=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D1=8B=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D1=85-=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=D1=85=20(Skeleton,?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20=D1=83=20MRT=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=C2=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ячейки строк с data-svod2-placeholder рисуют MUI Skeleton wave высотой 20 с детерминированной шириной. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/Svod2Page.jsx | 16 +++++++++++++--- web/src/pages/Svod2Page/svod2Paging.js | 15 +++++++++++++++ web/src/pages/Svod2Page/svod2Paging.test.js | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 16db1f3..5a942cd 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -1,5 +1,5 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; -import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; +import { Alert, Box, Button, Paper, Skeleton, Tab, Tabs, Typography } from '@mui/material'; import { MRT_TableHead, MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MRT_Localization_RU } from 'material-react-table/locales/ru'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -13,7 +13,7 @@ import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; -import { EMPTY_FACET_MAP, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; +import { EMPTY_FACET_MAP, getSkeletonWidth, getSvod2RowId, isPlaceholderRow, shouldShowFound } from './svod2Paging'; import { SVOD2_LAYOUT_OPTIONS, SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; import { useSvod2Pages } from './useSvod2Pages'; @@ -402,7 +402,17 @@ export default function Svod2Page() { }, muiTableHeadCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), muiTableHeadRowProps: { sx: { boxShadow: 'none' } }, - muiTableBodyCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), + muiTableBodyCellProps: ({ column, row }) => ({ + className: columnClassNames.get(column.id), + // Ячейки строк-заглушек: полосы загрузки того же вида, что MRT рисует при «Обновить» (Skeleton wave, высота 20). + ...(isPlaceholderRow(row.original) + ? { + children: ( + + ), + } + : {}), + }), localization: { ...MRT_Localization_RU, noRecordsToDisplay: 'Нет данных для выбранных параметров', diff --git a/web/src/pages/Svod2Page/svod2Paging.js b/web/src/pages/Svod2Page/svod2Paging.js index 3f763b8..6e76155 100644 --- a/web/src/pages/Svod2Page/svod2Paging.js +++ b/web/src/pages/Svod2Page/svod2Paging.js @@ -111,6 +111,21 @@ export function shouldRefine({ hasCurrentData, loading, pendingQueryKey, queryKe return Boolean(loading) && pendingQueryKey === queryKey; } +// Ширина полосы загрузки в ячейке строки-заглушки: детерминированная (без Math.random, чтобы полосы не «прыгали» +// при перерисовке окна), от трети до полной ширины колонки, как у штатных полос MRT. +export function getSkeletonWidth(columnSize, rowIndex, columnIndex) { + const hash = ((rowIndex + 1) * 2654435761 + (columnIndex + 1) * 40503) >>> 0; + const min = Math.round(columnSize / 3); + return (min + ((hash % 1000) / 1000) * (columnSize - min)) | 0; +} + +// При смене поиска/фильтров на глубине первая запрошенная страница (по прокрутке) может оказаться за концом нового, +// короткого списка: сервер ответит только total. Тогда страницу 0 и общее число берём из запасного запроса offset=0, +// не дожидаясь (и не ожидая) ответа на страницу за пределами списка. +export function shouldFetchFirstPageFallback({ candidate, total }) { + return candidate > 0 && candidate >= getPageCount(total); +} + export function isPlaceholderRow(row) { return Boolean(row?.__placeholder); } diff --git a/web/src/pages/Svod2Page/svod2Paging.test.js b/web/src/pages/Svod2Page/svod2Paging.test.js index d14a7cd..1e44844 100644 --- a/web/src/pages/Svod2Page/svod2Paging.test.js +++ b/web/src/pages/Svod2Page/svod2Paging.test.js @@ -10,12 +10,14 @@ import { facetsToMaps, getMissingPages, getPageCount, + getSkeletonWidth, getSvod2RowId, getVirtualizerPadding, getVisiblePage, getWindowPages, getWindowRange, isPlaceholderRow, + shouldFetchFirstPageFallback, shouldRefine, shouldShowFound, svod2PagingReducer, @@ -307,3 +309,22 @@ test('shouldRefine: первое монтаж и смена вкладки бе assert.equal(shouldRefine({ ...base, dataRefineKey: undefined }), false); assert.equal(shouldRefine({ ...base, hasCurrentData: true, dataRefineKey: 'a' }), false); }); + +test('полосы загрузки: ширина детерминирована и в пределах колонки', () => { + for (const size of [60, 150, 400]) { + for (let i = 0; i < 20; i++) { + const w = getSkeletonWidth(size, i, 3); + assert.equal(w, getSkeletonWidth(size, i, 3)); + assert.ok(w >= Math.round(size / 3) && w <= size, `${size}:${i}:${w}`); + } + } + assert.notEqual(getSkeletonWidth(400, 1, 0), getSkeletonWidth(400, 2, 0)); +}); + +test('первая страница при смене условий: запасной offset=0, если запрошенная страница за концом нового списка', () => { + assert.equal(shouldFetchFirstPageFallback({ candidate: 0, total: 43 }), false); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 43 }), true); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 0 }), true); + assert.equal(shouldFetchFirstPageFallback({ candidate: 37, total: 22419 }), false); + assert.equal(shouldFetchFirstPageFallback({ candidate: 75, total: 22419 }), true); +}); From ea6915c52fe77f488ea0ad9d544488f4b72820fc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 13:18:13 +0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(svod2):=20=D0=BF=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=D0=B5/=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B5=20=D0=BD=D0=B0=20=D0=B3=D0=BB=D1=83=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=20total=20=D0=B1=D0=B5=D1=80=D1=91=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20offset=3D0,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=B7=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=86=D0=BE=D0=BC=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/Svod2Page/useSvod2Pages.js | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Svod2Page/useSvod2Pages.js b/web/src/pages/Svod2Page/useSvod2Pages.js index af0a00e..2c02353 100644 --- a/web/src/pages/Svod2Page/useSvod2Pages.js +++ b/web/src/pages/Svod2Page/useSvod2Pages.js @@ -17,6 +17,7 @@ import { getVisiblePage, getWindowPages, getWindowRange, + shouldFetchFirstPageFallback, shouldRefine, svod2PagingReducer, } from './svod2Paging'; @@ -59,7 +60,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil if (mountedRef.current) setState(next); }, []); - const fetchPage = useCallback(async (ctx, pageIndex) => { + const fetchPage = useCallback(async (ctx, pageIndex, signal = ctx.controller.signal) => { ctx.inflight.add(pageIndex); try { const response = await SummaryApi.getSvod2Page( @@ -71,7 +72,7 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil columnFilters: ctx.columnFilters, offset: pageIndex * PAGE_SIZE, }), - { signal: ctx.controller.signal }, + { signal }, ); const rows = response.result || []; return { rows, total: response.total ?? rows.length }; @@ -138,14 +139,43 @@ export function useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFil const loadPages = async () => { try { - const first = await fetchPage(ctx, candidate); + // Страница по прокрутке и (на глубине) запасная offset=0 летят параллельно: если страница по прокрутке + // за концом нового списка, сервер отвечает на неё долго и без строк, а страница 0 уже даёт total. + const candidateController = new AbortController(); + const zeroController = new AbortController(); + const abortRequests = () => { + candidateController.abort(); + zeroController.abort(); + }; + ctx.controller.signal.addEventListener('abort', abortRequests); + const settle = (request, index) => + request.then( + (result) => ({ result, index }), + (error) => ({ error, index }), + ); + const candidateRequest = settle(fetchPage(ctx, candidate, candidateController.signal), candidate); + const zeroRequest = candidate > 0 ? settle(fetchPage(ctx, 0, zeroController.signal), 0) : null; + let answer = zeroRequest ? await Promise.race([candidateRequest, zeroRequest]) : await candidateRequest; + if ( + answer.index === 0 && + candidate > 0 && + !answer.error && + shouldFetchFirstPageFallback({ candidate, total: answer.result.total }) + ) { + candidateController.abort(); + } else { + zeroController.abort(); + answer = await candidateRequest; + if (answer.error) throw answer.error; + } if (ctx.controller.signal.aborted) return; + const first = answer.result; 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; + if (total === 0 || answer.index < pageCount) { + const pageIndex = total === 0 ? 0 : answer.index; dispatch({ type: 'page', sheet: sheetKey, generation, pageIndex, rows: first.rows, total, keepPages: pages }); cache = stateRef.current[sheetKey].pages; }