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

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