From 22512121774376a01044720ff6ced31e5e4f12c4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 18 Aug 2026 11:54:59 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D1=84=D0=B8=D0=BA=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=D0=B5=D0=B1=D0=BF=D0=B0=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestPortalWebPart.manifest.json | 4 +- .../requestPortal/RequestPortalWebPart.ts | 32 +++--- .../components/IRequestPortalProps.ts | 1 - .../components/RequestPortal.tsx | 10 +- src/webparts/requestPortal/loc/en-us.js | 5 +- src/webparts/requestPortal/loc/mystrings.d.ts | 5 +- .../propertyPane/DebugUserField.tsx | 97 +++++++++++++++++++ .../propertyPane/PropertyPaneDebugUser.tsx | 59 +++++++++++ .../requestPortal/shared/impersonation.ts | 87 ++++++++++++++++- 9 files changed, 271 insertions(+), 29 deletions(-) create mode 100644 src/webparts/requestPortal/propertyPane/DebugUserField.tsx create mode 100644 src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 594ae8a..a17f3a1 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -22,8 +22,6 @@ "title": { "default": "request-portal" }, "description": { "default": "request-portal description" }, "officeFabricIconFontName": "Page", - "properties": { - "description": "request-portal" - } + "properties": {} }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 34789f4..bd2fc70 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -1,10 +1,7 @@ import * as React from 'react'; import * as ReactDom from 'react-dom'; import { Version } from '@microsoft/sp-core-library'; -import { - type IPropertyPaneConfiguration, - PropertyPaneTextField -} from '@microsoft/sp-property-pane'; +import { type IPropertyPaneConfiguration } from '@microsoft/sp-property-pane'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base'; @@ -13,13 +10,15 @@ import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; -import { setImpersonatedEmail } from './shared/impersonation'; +import { setImpersonatedIdentity } from './shared/impersonation'; import { ErrorOverlay } from './shared/ui'; +import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser'; export interface IRequestPortalWebPartProps { - description: string; /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ + debugUserQuery: string; debugUserEmail: string; + debugUserDisplayName: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -30,13 +29,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart(RequestPortal, { - description: this.properties.description, isDarkTheme: this._isDarkTheme, environmentMessage: this._environmentMessage, hasTeamsContext: !!this.context.sdks.microsoftTeams, @@ -122,13 +120,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { + this.properties.debugUserQuery = query; + this.properties.debugUserEmail = email; + this.properties.debugUserDisplayName = displayName; + this.render(); + } }) ] } diff --git a/src/webparts/requestPortal/components/IRequestPortalProps.ts b/src/webparts/requestPortal/components/IRequestPortalProps.ts index b18b06d..0d71bb6 100644 --- a/src/webparts/requestPortal/components/IRequestPortalProps.ts +++ b/src/webparts/requestPortal/components/IRequestPortalProps.ts @@ -1,7 +1,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; export interface IRequestPortalProps { - description: string; isDarkTheme: boolean; environmentMessage: string; hasTeamsContext: boolean; diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 19621f1..92a8006 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -20,7 +20,7 @@ import { OwnTab, } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; -import { getImpersonatedEmail } from '../shared/impersonation'; +import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; @@ -62,6 +62,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно // только вместе с новым render всей веб-части. const impersonatedEmail = getImpersonatedEmail(); + const impersonatedDisplayName = getImpersonatedDisplayName(); const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); @@ -276,8 +277,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context закрыть: она исчезает только когда свойство веб-части очищено. */} {impersonatedEmail && (
- Включена отладочная подмена: портал работает от имени {impersonatedEmail}. - Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи. + Включена отладочная подмена: портал работает от имени{' '} + + {impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail} + + . Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
)}
diff --git a/src/webparts/requestPortal/loc/en-us.js b/src/webparts/requestPortal/loc/en-us.js index 6dbeb61..6b25431 100644 --- a/src/webparts/requestPortal/loc/en-us.js +++ b/src/webparts/requestPortal/loc/en-us.js @@ -2,9 +2,8 @@ define([], function() { return { "PropertyPaneDescription": "Description", "BasicGroupName": "Group Name", - "DescriptionFieldLabel": "Description Field", - "DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника", - "DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", + "DebugUserFieldLabel": "Отладка: работать от имени сотрудника", + "DebugUserFieldDescription": "Только для отладки маршрутов. Введите фамилию и имя сотрудника (можно в обратном порядке, отчество не обязательно) — портал найдёт его e-mail сам, найденное отобразится над полем. Можно ввести e-mail напрямую. По этому e-mail планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", "AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part", "AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app", "AppLocalEnvironmentOffice": "The app is running on your local environment in office.com", diff --git a/src/webparts/requestPortal/loc/mystrings.d.ts b/src/webparts/requestPortal/loc/mystrings.d.ts index 8535e51..86c1a93 100644 --- a/src/webparts/requestPortal/loc/mystrings.d.ts +++ b/src/webparts/requestPortal/loc/mystrings.d.ts @@ -1,9 +1,8 @@ declare interface IRequestPortalWebPartStrings { PropertyPaneDescription: string; BasicGroupName: string; - DescriptionFieldLabel: string; - DebugUserEmailFieldLabel: string; - DebugUserEmailFieldDescription: string; + DebugUserFieldLabel: string; + DebugUserFieldDescription: string; AppLocalEnvironmentSharePoint: string; AppLocalEnvironmentTeams: string; AppLocalEnvironmentOffice: string; diff --git a/src/webparts/requestPortal/propertyPane/DebugUserField.tsx b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx new file mode 100644 index 0000000..018a8d9 --- /dev/null +++ b/src/webparts/requestPortal/propertyPane/DebugUserField.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { resolveDebugUser } from '../shared/impersonation'; + +export interface IDebugUserFieldProps { + label: string; + description: string; + context: WebPartContext; + initialQuery: string; + /** Зовётся при каждом изменении: query — как введено, email/displayName — итог поиска ('' если не найдено). */ + onResolved: (query: string, email: string, displayName: string) => void; +} + +type Status = 'idle' | 'loading' | 'found' | 'notfound' | 'email'; + +const DEBOUNCE_MS = 400; + +/** + * Поле свойств веб-части: ввод «Фамилия Имя [Отчество]» (в любом порядке) или + * e-mail напрямую. Сам резолвит e-mail через SharePoint people search — см. + * shared/impersonation.ts (resolveDebugUser) — и показывает найденное тут же, + * чтобы не гадать, кого именно подставили. + */ +export const DebugUserField: React.FC = ({ label, description, context, initialQuery, onResolved }) => { + const [query, setQuery] = React.useState(initialQuery || ''); + const [status, setStatus] = React.useState('idle'); + const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined); + + React.useEffect(() => { + const text = query.trim(); + + if (!text) { + setStatus('idle'); + setFound(undefined); + onResolved('', '', ''); + return; + } + + // E-mail — используем как есть, без похода в people search. + if (text.indexOf('@') >= 0) { + setStatus('email'); + setFound(undefined); + onResolved(text, text, ''); + return; + } + + let cancelled = false; + setStatus('loading'); + const timer = window.setTimeout(() => { + resolveDebugUser(context, text) + .then((hit) => { + if (cancelled) return; + if (hit) { + setStatus('found'); + setFound(hit); + onResolved(text, hit.email, hit.displayName); + } else { + setStatus('notfound'); + setFound(undefined); + onResolved(text, '', ''); + } + }) + .catch(() => { + if (cancelled) return; + setStatus('notfound'); + setFound(undefined); + onResolved(text, '', ''); + }); + }, DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]); + + return ( +
+
{label}
+ setQuery(e.target.value)} + placeholder="Иванов Иван Иванович, Иван Иванов или e-mail" + style={{ width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 14 }} + /> +
+ {status === 'loading' && 'Ищем сотрудника…'} + {status === 'found' && found && `Найдено: ${found.displayName} — ${found.email}`} + {status === 'notfound' && 'Сотрудник не найден — подмена работать не будет'} + {status === 'email' && `Используется как e-mail напрямую: ${query.trim()}`} +
+
{description}
+
+ ); +}; diff --git a/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx b/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx new file mode 100644 index 0000000..81a1c99 --- /dev/null +++ b/src/webparts/requestPortal/propertyPane/PropertyPaneDebugUser.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import * as ReactDom from 'react-dom'; +import { + IPropertyPaneField, + PropertyPaneFieldType, + IPropertyPaneCustomFieldProps +} from '@microsoft/sp-property-pane'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { DebugUserField } from './DebugUserField'; + +export interface IPropertyPaneDebugUserProps { + label: string; + description: string; + context: WebPartContext; + initialQuery: string; + onResolved: (query: string, email: string, displayName: string) => void; +} + +class PropertyPaneDebugUserBuilder + implements IPropertyPaneField { + public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom; + public targetProperty: string; + public properties: IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps; + + constructor(targetProperty: string, properties: IPropertyPaneDebugUserProps) { + this.targetProperty = targetProperty; + this.properties = { + key: targetProperty, + onRender: this.onRender.bind(this), + onDispose: this.onDispose.bind(this), + ...properties + }; + } + + private onRender(elem: HTMLElement): void { + ReactDom.render( + , + elem + ); + } + + private onDispose(elem: HTMLElement): void { + ReactDom.unmountComponentAtNode(elem); + } +} + +/** Кастомное поле property pane: ФИО/e-mail сотрудника с резолвом в e-mail на лету. */ +export function PropertyPaneDebugUser( + targetProperty: string, + properties: IPropertyPaneDebugUserProps +): IPropertyPaneField { + return new PropertyPaneDebugUserBuilder(targetProperty, properties); +} diff --git a/src/webparts/requestPortal/shared/impersonation.ts b/src/webparts/requestPortal/shared/impersonation.ts index ddc95fd..4f4a950 100644 --- a/src/webparts/requestPortal/shared/impersonation.ts +++ b/src/webparts/requestPortal/shared/impersonation.ts @@ -1,4 +1,5 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { SPHttpClient } from '@microsoft/sp-http'; /** * ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им @@ -22,10 +23,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; */ let impersonatedEmail = ''; +let impersonatedDisplayName = ''; /** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */ -export function setImpersonatedEmail(email: string | undefined): void { +export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void { impersonatedEmail = (email || '').trim(); + impersonatedDisplayName = (displayName || '').trim(); } /** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */ @@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string { return impersonatedEmail; } +/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */ +export function getImpersonatedDisplayName(): string { + return impersonatedDisplayName; +} + /** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */ export function effectiveUserEmail(context: WebPartContext): string { return impersonatedEmail || context.pageContext.user.email || ''; } + +export interface IResolvedDebugUser { + email: string; + displayName: string; +} + +interface IPeopleSearchHit { + displayName: string; + email: string; +} + +/** Сырой ответ ClientPeoplePickerSearchUser — value внутри двойного JSON. */ +interface IPeoplePickerEntity { + DisplayText?: string; + Description?: string; + EntityData?: { Email?: string }; +} + +async function searchPeopleByText(context: WebPartContext, queryText: string): Promise { + const url = `${context.pageContext.web.absoluteUrl}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser`; + const body = JSON.stringify({ + queryParams: { + AllowEmailAddresses: true, + AllowMultipleEntities: false, + MaximumEntitySuggestions: 5, + PrincipalSource: 15, // All + PrincipalType: 1, // User + QueryString: queryText + } + }); + + const response = await context.spHttpClient.post(url, SPHttpClient.configurations.v1, { + headers: { + Accept: 'application/json;odata=nometadata', + 'Content-Type': 'application/json;odata=nometadata' + }, + body + }); + + if (!response.ok) return []; + + const json: { value?: string } = await response.json(); + if (!json.value) return []; + + let entries: IPeoplePickerEntity[]; + try { + entries = JSON.parse(json.value); + } catch { + return []; + } + + return entries + .map((e) => ({ displayName: e.DisplayText || '', email: e.EntityData?.Email || e.Description || '' })) + .filter((e) => !!e.email); +} + +/** + * Ищет сотрудника по имени для отладочной подмены (см. property pane — + * PropertyPaneDebugUser/DebugUserField). Порядок «Фамилия Имя» или «Имя + * Фамилия» не важен: если прямой запрос ничего не находит, пробуем с + * переставленными первыми двумя словами. + */ +export async function resolveDebugUser(context: WebPartContext, nameQuery: string): Promise { + const text = nameQuery.trim(); + if (!text) return undefined; + + let hits = await searchPeopleByText(context, text); + if (hits.length === 0) { + const tokens = text.split(/\s+/).filter(Boolean); + if (tokens.length >= 2) { + const swapped = [tokens[1], tokens[0], ...tokens.slice(2)].join(' '); + hits = await searchPeopleByText(context, swapped); + } + } + + return hits[0]; +}