From a19444eb2891a8d97ada1c8dcce485bffd695554 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 21 Aug 2026 15:00:33 +0500 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=82=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D1=87=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 40 +++++- .../features/myRequests/myRequests.service.ts | 128 ++++++++++++------ 2 files changed, 117 insertions(+), 51 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 5630086..a8a139c 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -353,18 +353,26 @@ export const RequestsTableView: React.FC = ({ const q = debouncedQuery.trim(); - const fetchRows = async (): Promise => { + // onPartial — Search и REST-довесок по свежести («Мои заявки»/«Доработка», + // см. getOwnRequests/getOwnRequestsByStatus в myRequests.service.ts) теперь + // идут параллельно, и заранее не угадать, какой из двух ответит первым — + // сеть шумит то в одну, то в другую сторону. onPartial зовётся с тем, что + // пришло раньше, чтобы не держать список пустым ради заведомо более + // медленного источника. Остальные ветки (approvals/execution/поиск) его + // просто не принимают — там нет пары «Search + довесок» с независимо + // валидным промежуточным результатом. + const fetchRows = async (onPartial?: (p: IPage) => void): Promise => { // «Доработка» — тот же список, что «Инициированные», просто со статусом, // зашитым в раздел, а не выбранным в фильтре. if (view === 'rework' || (view === 'own:created' && statusFilter)) { const status = view === 'rework' ? REQUEST_STATUS.returned : statusFilter; - return getOwnRequestsByStatus(context, types, status, page, debouncedQuery); + return getOwnRequestsByStatus(context, types, status, page, debouncedQuery, onPartial); } if (view === 'own:created') { return q.length > 0 ? searchOwnRequests(context, types, debouncedQuery, page) - : getOwnRequests(context, types, page); + : getOwnRequests(context, types, page, onPartial); } const queue = QUEUE_BY_VIEW[view]; @@ -387,9 +395,11 @@ export const RequestsTableView: React.FC = ({ // Комментарий к решению дочитываем ОДНИМ местом на все ветки: «Доработка» и // «Отклонённые мной» приходят разными путями (по автору и по строкам // маршрута соответственно), а колонка у них одинаковая по смыслу. И только - // для видимой страницы — как и автор в hydratePendingAuthors. - const fetchPage = async (): Promise => { - const result = await fetchRows(); + // для видимой страницы — как и автор в hydratePendingAuthors. Промежуточный + // показ (onPartial) комментарий ещё не несёт — он дочитывается только для + // ИТОГОВОГО результата, полный показ его донесёт следом. + const fetchPage = async (onPartial?: (p: IPage) => void): Promise => { + const result = await fetchRows(onPartial); const decisionStatus = decisionStatusFor(view, statusFilter); if (decisionStatus) { await hydrateDecisionComments(context, types, result.rows, decisionStatus); @@ -399,7 +409,23 @@ export const RequestsTableView: React.FC = ({ const load = async (forceRender = false): Promise => { try { - const fresh = await perfScope(`таблица ${view} стр.${page}`, fetchPage); + // Промежуточный показ — ТОЛЬКО когда для этой страницы прямо СЕЙЧАС + // ничего не закешировано (первый заход, экран пуст). Проверяем на + // каждый вызов load(), а не один раз на весь эффект: тот же load() + // переиспользуется автообновлением по таймеру и событием + // rp-requests-changed все 10 минут жизни вкладки, и ко второму вызову + // кеш почти наверняка уже есть — без повторной проверки список бы на + // мгновение "усыхал" при КАЖДОМ фоновом обновлении, а не только при + // самой первой загрузке. + const onPartial = readPage(cacheKey) + ? undefined + : (partial: IPage): void => { + if (cancelled) return; + setRows(partial.rows); + setTotalRows(partial.totalRows); + }; + + const fresh = await perfScope(`таблица ${view} стр.${page}`, () => fetchPage(onPartial)); if (cancelled) return; // Перерисовываем, только если данные реально изменились — иначе при diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 1c36bcd..889eb2d 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1252,37 +1252,85 @@ async function getRecentlyChangedOwnRowsForType(context: WebPartContext, type: I } } +// Итог слияния Search-страницы с REST-довеском по свежести — общая логика для +// getOwnRequests и ownRequestsByStatus. Довесок ДОПОЛНЯЕТ Search, а не заменяет: +// то, что Search уже нашёл, в приоритете (там могло появиться более точное +// совпадение по совместному запросу — статус/текст), довесок добавляет только +// то, чего в найденном ещё нет. +function mergeSearchAndFresh(searchPage: IOwnRequestsPage, freshRows: IMyRequest[]): IOwnRequestsPage { + const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r))); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); + const merged = onlyFresh + .concat(searchPage.rows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length }; +} + +/** + * Гонит Search-страницу и REST-довесок по свежести ПАРАЛЛЕЛЬНО — то один + * отвечает быстрее, то другой, заранее не угадать, и ждать заведомо более + * медленный, держа экран пустым, незачем. + * + * Если задан onPartial — зовёт его РАЗ, как только придёт ПЕРВЫЙ из двух + * источников (та половина, что уже есть, без дедупа со вторым — его мы ещё не + * видели). Возвращает функция в любом случае ИТОГОВЫЙ слитый результат; + * onPartial — только для промежуточного показа, на итог не влияет. + * + * Без onPartial ведёт себя как раньше: ждёт оба источника и сразу отдаёт слитый + * результат — второй показ (полный) экономить незачем, если промежуточный + * всё равно никто не увидит. + */ +async function raceThenMerge( + searchPromise: Promise, + freshPromise: Promise, + onPartial: ((page: IOwnRequestsPage) => void) | undefined +): Promise { + if (onPartial) { + let searchDone = false; + let freshDone = false; + // Ошибку здесь не обрабатываем — её поймает Promise.all ниже; этот + // отдельный .then только подсматривает за тем, что пришло раньше. + searchPromise.then((r) => { + searchDone = true; + if (!freshDone) onPartial(r); + }, () => undefined); + freshPromise.then((r) => { + freshDone = true; + if (!searchDone) onPartial({ rows: r, totalRows: r.length }); + }, () => undefined); + } + + const [searchPage, freshRows] = await Promise.all([searchPromise, freshPromise]); + return mergeSearchAndFresh(searchPage, freshRows); +} + // «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией // (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по // «последним N минутам» добирает то, что Search мог не успеть проиндексировать // (см. RECENT_MS). Довесок применяется только на первой странице — свежее по // дате там и оказалось бы, будь оно проиндексировано. +// +// Search и довесок идут ПАРАЛЛЕЛЬНО (см. raceThenMerge) — раньше довесок +// стартовал только ПОСЛЕ ответа Search, хотя они независимы друг от друга и +// читают разные источники; тем самым просто складывалось время обоих вместо +// времени более медленного из двух. export async function getOwnRequests( context: WebPartContext, types: IRequestType[], - page: number + page: number, + onPartial?: (page: IOwnRequestsPage) => void ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const searchPage = await searchOwnRequests(context, types, '', page); + const searchPromise = searchOwnRequests(context, types, '', page); + if (page !== 1) return searchPromise; - if (page !== 1) return searchPage; + const freshPromise = Promise.all(types.map((t) => getFreshOwnRowsForType(context, t))).then((r) => + r.reduce((all, x) => all.concat(x), [] as IMyRequest[]) + ); - const freshRows = ( - await Promise.all( - types.map((t) => getFreshOwnRowsForType(context, t)) - ) - ).reduce((all, r) => all.concat(r), [] as IMyRequest[]); - - const searchKeys = new Set(searchPage.rows.map((r) => rowKey(r))); - const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); - - const merged = onlyFresh - .concat(searchPage.rows) - .sort((a, b) => b.created.localeCompare(a.created)) - .slice(0, PAGE_SIZE); - - return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length }; + return raceThenMerge(searchPromise, freshPromise, onPartial); } // «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть: @@ -1339,9 +1387,10 @@ export async function getOwnRequestsByStatus( types: IRequestType[], status: string, page: number, - query: string = '' + query: string = '', + onPartial?: (page: IOwnRequestsPage) => void ): Promise { - return ownRequestsByStatus(context, types, status, page, query); + return ownRequestsByStatus(context, types, status, page, query, onPartial); } /** @@ -1370,7 +1419,8 @@ async function ownRequestsByStatus( types: IRequestType[], status: string, page: number, - query: string + query: string, + onPartial?: (page: IOwnRequestsPage) => void ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; @@ -1385,7 +1435,7 @@ async function ownRequestsByStatus( if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await traced('Search: свои заявки', () => + const searchPromise: Promise = traced('Search: свои заявки', () => sp.search({ Querytext: kql, RowLimit: PAGE_SIZE, @@ -1395,34 +1445,24 @@ async function ownRequestsByStatus( // Свежие сверху — как в searchOwnRequests. SortList: [{ Property: 'Created', Direction: 1 }], }) - ); - - const totalRows = results.TotalRows || 0; - const entries = hitsToEntries(results, types); - const searchRows = entries.map(toMyRequestFromSearch); - - if (page !== 1 || query.trim()) return { rows: searchRows, totalRows }; + ).then((results) => ({ + rows: hitsToEntries(results, types).map(toMyRequestFromSearch), + totalRows: results.TotalRows || 0, + })); // Довесок по недавним изменениям — только на первой странице и только без // текстового поиска (тот же принцип, что в getOwnRequests): заявку могли // вернуть на доработку только что, индекс мог не успеть её подхватить. - const freshRows = ( - await Promise.all( - types.map((t) => getRecentlyChangedOwnRowsForType(context, t)) - ) - ) - .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) - .filter((r) => r.status === status); + // Запускаем СРАЗУ, не дожидаясь Search: условие включения известно из + // параметров вызова, ждать ради него ответ Search незачем — раньше довесок + // стартовал только ПОСЛЕ Search, хотя независим от его результата. + if (page !== 1 || query.trim()) return searchPromise; - const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id))); - const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r))); + const freshPromise = Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t))).then((r) => + r.reduce((acc, x) => acc.concat(x), [] as IMyRequest[]).filter((x) => x.status === status) + ); - const merged = onlyFresh - .concat(searchRows) - .sort((a, b) => b.created.localeCompare(a.created)) - .slice(0, PAGE_SIZE); - - return { rows: merged, totalRows: totalRows + onlyFresh.length }; + return raceThenMerge(searchPromise, freshPromise, onPartial); } // Сколько строк маршрута максимум забираем за раз при дочитывании комментариев: