вроде переделал на прямой апи

This commit is contained in:
gorbenko.pavel
2026-08-04 16:07:59 +05:00
parent e989c26c30
commit a387fec557
2 changed files with 105 additions and 97 deletions
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service'; import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { navigate, routeUrl } from '../../shared/routing'; import { navigate, routeUrl } from '../../shared/routing';
@@ -66,12 +66,12 @@ const RequestRow: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({
{r.title || `#${r.id}`} {r.title || `#${r.id}`}
</a> </a>
</td> </td>
<td className={styles.cellDate}>{formatDate(r.created)}</td>
{variant === 'approval' && ( {variant === 'approval' && (
<td className={styles.cellAuthor}> <td className={styles.cellAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td> </td>
)} )}
<td className={styles.cellDate}>{formatDate(r.created)}</td>
{variant === 'own' && ( {variant === 'own' && (
<td className={styles.cellStatus}> <td className={styles.cellStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span> <span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
@@ -86,8 +86,8 @@ const RequestsTable: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval'
<tr> <tr>
<th className={styles.cellType}>Тип</th> <th className={styles.cellType}>Тип</th>
<th className={styles.cellTitle}>Название</th> <th className={styles.cellTitle}>Название</th>
{variant === 'approval' && <th className={styles.cellAuthor}>Автор</th>}
<th className={styles.cellDate}>Дата</th> <th className={styles.cellDate}>Дата</th>
{variant === 'approval' && <th className={styles.cellAuthor}>Автор</th>}
{variant === 'own' && <th className={styles.cellStatus}>Статус</th>} {variant === 'own' && <th className={styles.cellStatus}>Статус</th>}
</tr> </tr>
</thead> </thead>
@@ -185,9 +185,10 @@ const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => {
); );
}; };
// «Созданы вами»: один общий постраничный поиск по всем типам сразу через // «Созданы вами»: один общий постраничный список по всем типам сразу через
// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) — // обычный REST (см. myRequests.service.getOwnRequests) — единая пагинация,
// единая пагинация, без разбивки списка по типам заявок. // без разбивки по типам, данные всегда актуальные (не из поискового индекса),
// поэтому секция перечитывается и по событию rp-requests-changed.
const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({
context, context,
types, types,
@@ -206,7 +207,9 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que
return; return;
} }
let cancelled = false; let cancelled = false;
searchOwnRequests(context, types, query, page)
const load = (): void => {
getOwnRequests(context, types, query, page)
.then((result) => { .then((result) => {
if (cancelled) return; if (cancelled) return;
setRows(result.rows); setRows(result.rows);
@@ -216,8 +219,13 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que
.catch((e) => { .catch((e) => {
if (!cancelled) setError((e as Error).message); if (!cancelled) setError((e as Error).message);
}); });
};
load();
const unsubscribe = onRequestsChanged(load);
return () => { return () => {
cancelled = true; cancelled = true;
unsubscribe();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, types, query, page]); }, [context, types, query, page]);
@@ -230,7 +238,11 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que
return <div className={styles.empty}>Загрузка…</div>; return <div className={styles.empty}>Загрузка…</div>;
} }
if (totalRows === 0) { if (totalRows === 0) {
return <div className={styles.empty}>Вы ещё не создавали заявок.</div>; return (
<div className={styles.empty}>
{query ? `По запросу «${query}» ничего не найдено.` : 'Вы ещё не создавали заявок.'}
</div>
);
} }
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
@@ -250,18 +262,14 @@ const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; que
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки. // Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять. // Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
// //
// «Ждёт вашего решения» перечитывается по событию rp-requests-changed // Обе секции перечитываются по событию rp-requests-changed
// (shared/requestEvents) — оно шлётся после сохранения формы и после решения // (shared/requestEvents) — оно шлётся после сохранения формы и после решения
// согласующего. «Созданы вами» идёт через SharePoint Search (см. // согласующего, так что список обновляется сам, без перезагрузки страницы.
// myRequests.service) постранично — намеренно НЕ обновляется по этому
// событию: только что созданная заявка появится там, когда её проиндексирует
// поиск (задержка приемлема).
// //
// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже // Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже
// загружены целиком), «Созданы вами» — реальный запрос к Search REST API // загружены целиком), «Созданы вами» — серверным OData-фильтром по Title.
// (может слегка отставать от только что созданных/переименованных заявок).
// //
// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в UI не выводится — // Диагностика (listId/siteUrl/rowCount/error) в UI не выводится —
// см. console.log/console.table в devtools при загрузке панели. // см. console.log/console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context, collapsed, onToggleCollapsed }) => { const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context, collapsed, onToggleCollapsed }) => {
const [linkedTypes, setLinkedTypes] = React.useState<IRequestType[] | undefined>(undefined); const [linkedTypes, setLinkedTypes] = React.useState<IRequestType[] | undefined>(undefined);
@@ -3,7 +3,6 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists'; import '@pnp/sp/lists';
import '@pnp/sp/items'; import '@pnp/sp/items';
import '@pnp/sp/site-users/web'; import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest'; import { IMyRequest } from './models/IMyRequest';
@@ -172,116 +171,117 @@ function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// «Созданы вами» — по каждому типу отдельно, через SharePoint Search REST API // «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда
// (search/query, KQL), с постраничной выборкой (RowLimit/StartRow). Заявок // актуальны, только что созданная заявка видна сразу (у поискового индекса
// автора может накопиться много со временем, поэтому весь список целиком не // была бы задержка переиндексации).
// грузим — только текущую страницу. Расплата за это — результаты идут из
// поискового индекса, который переиндексируется с задержкой (только что
// созданная/переименованная заявка может не найтись сразу).
// //
// Search отдаёт только Title/Path/Id — Status и прочие поля не запрашиваем // Пагинация «сшивает» несколько списков в один нумерованный список: сначала по
// через Search (для кастомных колонок нет гарантии, что они вообще // каждому типу берём только лёгкие поля (Id + Created) всех заявок автора,
// проиндексированы как managed property), а дочитываем обычным REST-запросом // сортируем объединённый набор по дате создания и лишь для нужной страницы
// по найденным Id из самого списка данных — так надёжнее. // дочитываем полные поля. Так на экран не тянется всё содержимое заявок,
export interface IOwnSearchPage { // но общее число страниц известно точно.
export interface IOwnRequestsPage {
rows: IMyRequest[]; rows: IMyRequest[];
totalRows: number; totalRows: number;
} }
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы
// посчитать общее число страниц. Хватает с большим запасом; если у кого-то
// заявок окажется больше — в консоль уйдёт предупреждение.
const INDEX_LIMIT = 5000;
function siteOrigin(siteUrl: string): string { // В OData-строке одинарная кавычка экранируется удвоением.
const match = /^https?:\/\/[^/]+/.exec(siteUrl); function escapeOData(text: string): string {
return match ? match[0] : siteUrl; return text.replace(/'/g, "''");
} }
// KQL не терпит спецсимволов в значении — оставляем буквы (лат./кир.), цифры и пробелы. interface IOwnIndexEntry {
// (без \p{L} — целевой ES-таргет проекта его не поддерживает) id: number;
function sanitizeForKql(text: string): string { created: string;
return text type: IRequestType;
.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
} }
// Один запрос на ВСЕ типы сразу (не по одному на тип) — так у результата единая export async function getOwnRequests(
// пагинация (одна StartRow/RowLimit/TotalRows), без разбивки по типам заявок.
// Path одного типа ищем через OR внутри запроса; после поиска по Path каждого
// найденного элемента определяем, какому типу он принадлежит, и дочитываем
// поля из ЕГО списка данных (см. комментарий выше — Search отдаёт не все поля).
export async function searchOwnRequests(
context: WebPartContext, context: WebPartContext,
types: IRequestType[], types: IRequestType[],
query: string, query: string,
page: number page: number
): Promise<IOwnSearchPage> { ): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 }; if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); const q = query.trim();
const tokenEmail = currentUserEmail(context);
const claimsLogin = `i:0#.f|membership|${tokenEmail}`;
const typeInfo = types.map((t) => ({ type: t, listAbsUrl: `${siteOrigin(t.siteUrl as string)}${t.listId}` })); // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора.
const pathClause = typeInfo.map((t) => `Path:"${t.listAbsUrl}*"`).join(' OR '); const perType = await Promise.all(
types.map(async (type): Promise<IOwnIndexEntry[]> => {
try {
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const me = await sp.web.currentUser.select('Id')();
const filters = [`AuthorId eq ${me.Id}`];
if (q) filters.push(`substringof('${escapeOData(q)}',Title)`);
const clauses = [`(${pathClause})`, `AuthorOWSUSER:"${claimsLogin}"`]; const rows = (await sp.web
const q = sanitizeForKql(query); .getList(type.listId as string)
if (q) clauses.push(`Title:${q}*`); .items.filter(filters.join(' and '))
const kql = clauses.join(' AND '); .select('Id', 'Created')
.orderBy('Created', false)
.top(INDEX_LIMIT)()) as Array<Record<string, unknown>>;
const results = await sp.search({ if (rows.length === INDEX_LIMIT) {
Querytext: kql, console.warn(
RowLimit: PAGE_SIZE, `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна`
StartRow: (page - 1) * PAGE_SIZE, );
SelectProperties: ['ListItemID', 'Title', 'Path'], }
TrimDuplicates: false, return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type }));
}); } catch (e) {
console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message);
return [];
}
})
);
console.log(`[Текущие заявки] Search «Созданы вами»: KQL = ${kql}, всего найдено: ${results.TotalRows}`); // 2) Общий список всех типов, свежие сверху.
console.table(results.PrimarySearchResults); const index = perType
.reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[])
.sort((a, b) => b.created.localeCompare(a.created));
const hits = results.PrimarySearchResults.map((r) => { const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const rec = r as Record<string, unknown>; if (pageEntries.length === 0) {
return { id: Number(rec.ListItemID), path: String(rec.Path || '') }; return { rows: [], totalRows: index.length };
}).filter((h) => !isNaN(h.id) && h.id > 0);
if (hits.length === 0) {
return { rows: [], totalRows: results.TotalRows || 0 };
} }
// Каждый найденный элемент → его тип (по префиксу Path), чтобы дочитать поля // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип.
// из правильного списка данных.
const idsByType = new Map<IRequestType, number[]>(); const idsByType = new Map<IRequestType, number[]>();
hits.forEach((h) => { pageEntries.forEach((e) => {
const matched = typeInfo.find((t) => h.path.toLowerCase().indexOf(t.listAbsUrl.toLowerCase()) === 0); const ids = idsByType.get(e.type) || [];
if (!matched) return; ids.push(e.id);
const ids = idsByType.get(matched.type) || []; idsByType.set(e.type, ids);
ids.push(h.id);
idsByType.set(matched.type, ids);
}); });
const byId = new Map<number, IMyRequest>(); const byKey = new Map<string, IMyRequest>();
await Promise.all( await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => { Array.from(idsByType.entries()).map(async ([type, ids]) => {
const typeSp = spfi(type.siteUrl as string).using(SPFx(context)); const sp = spfi(type.siteUrl as string).using(SPFx(context));
const list = typeSp.web.getList(type.listId as string); const rows = (await sp.web
const filter = ids.map((id) => `Id eq ${id}`).join(' or '); .getList(type.listId as string)
const rows = (await list.items .items.filter(ids.map((id) => `Id eq ${id}`).join(' or '))
.filter(filter)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author') .expand('Author')
.top(PAGE_SIZE)()) as Array<Record<string, unknown>>; .top(PAGE_SIZE)()) as Array<Record<string, unknown>>;
rows.forEach((r) => { rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined; const author = r.Author as { EMail?: string; Title?: string } | undefined;
byId.set(r.Id as number, toMyRequest(r, author, type, 'own')); byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, 'own'));
}); });
}) })
); );
// Сохраняем порядок релевантности из поиска, а не порядок ответов REST. // Порядок страницы задаёт индекс (по дате), а не порядок ответов REST.
const ordered = hits.map((h) => byId.get(h.id)).filter((r): r is IMyRequest => !!r); const rows = pageEntries
.map((e) => byKey.get(`${e.type.id}-${e.id}`))
.filter((r): r is IMyRequest => !!r);
return { rows: ordered, totalRows: results.TotalRows || 0 }; return { rows, totalRows: index.length };
} }
function toMyRequest( function toMyRequest(