Параллельные шаги (#3)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-06 12:36:22 +00:00
parent 40f9ac6c03
commit df9d12622d
24 changed files with 967 additions and 175 deletions
@@ -0,0 +1,85 @@
@import '../../../../../styles/design-tokens';
// Поверх всего, включая SPFx-хром вебпарта — иначе падение внутри вложенного
// блока схлопывает только его, а не видно, что вообще произошло.
.overlay {
position: fixed;
inset: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-6;
background: rgba($navy-950, .78);
font-family: $font-sans;
}
.card {
box-sizing: border-box;
width: 100%;
max-width: 780px;
max-height: 80vh;
display: flex;
flex-direction: column;
padding: $sp-6;
border-radius: $r-lg;
background: $white;
box-shadow: $sh-modal;
border-top: 4px solid $red-500;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-950;
}
.source {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
.stack {
box-sizing: border-box;
margin: $sp-4 0 0;
padding: $sp-3;
overflow: auto;
background: $gray-50;
border: 1px solid $gray-200;
border-radius: $r-sm;
font-family: 'Consolas', 'Menlo', monospace;
font-size: $fs-caption;
color: $gray-900;
white-space: pre-wrap;
word-break: break-word;
}
.actions {
display: flex;
gap: $sp-2;
margin-top: $sp-4;
}
.copyBtn {
@include btn-accent;
padding: 9px 16px;
border-radius: $r-sm;
font-size: $fs-sm;
cursor: pointer;
}
.reloadBtn {
padding: 9px 16px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-size: $fs-sm;
cursor: pointer;
&:hover {
border-color: $gold-400;
}
}
@@ -0,0 +1,82 @@
import * as React from 'react';
import styles from './ErrorOverlay.module.scss';
interface ICaughtError {
message: string;
stack: string;
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
}
interface IErrorOverlayState {
error: ICaughtError | undefined;
}
function formatError(err: unknown, source: string): ICaughtError {
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
return { message: e.message, stack: e.stack || '(нет стека)', source };
}
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
// самого копирования не должна маскировать исходную — просто игнорируем.
function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => undefined);
}
/**
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
* 1) React-рендер (componentDidCatch);
* 2) синхронные ошибки вне React (window.onerror);
* 3) необработанные rejected promise (unhandledrejection).
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
* «Скопировать», чтобы можно было переслать стек трейс.
*/
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
public state: IErrorOverlayState = { error: undefined };
private onWindowError = (event: ErrorEvent): void => {
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
};
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
};
public componentDidMount(): void {
window.addEventListener('error', this.onWindowError);
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
}
public componentWillUnmount(): void {
window.removeEventListener('error', this.onWindowError);
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
}
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
return { error: formatError(err, 'React render') };
}
public render(): React.ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
return (
<div className={styles.overlay}>
<div className={styles.card}>
<div className={styles.title}>Необработанное исключение</div>
<div className={styles.source}>{error.source}</div>
<pre className={styles.stack}>{fullText}</pre>
<div className={styles.actions}>
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
Скопировать
</button>
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
Перезагрузить страницу
</button>
</div>
</div>
</div>
);
}
}
@@ -19,6 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber';
export { PrintButton } from './PrintButton/PrintButton';