вроде переделал на прямой апи
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user