Маршрут /admin (вкладки «Пользователи» и «Матрица прав») и пункт «Система → Пользователи и роли» в меню; без USERS_ADMIN в /me — экран «Нет доступа», таблицы не монтируются и запросов к /admin/* нет, пункт меню скрыт. Почему без Redux: данные экрана нужны только ему, после каждой правки перечитываются с сервера — хватает локального state и маленького хука useAsyncData (отбрасывает устаревшие ответы и ответы после ухода со страницы). Клиент один — api/client.ts (Bearer из sessionStorage, ошибки сервера -> ApiError с кодом и текстом); ошибки показываются через App.useApp().message (корень обёрнут в antd App). Экран показывает только серверное состояние: при отказе (409 LAST_ADMIN и др.) — уведомление и перечитывание, матрица сохраняет роли по очереди. РАИСА появится в auth/: токен даст SSO вместо dev-входа; этот экран и клиент не меняются. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
import { App } from 'antd';
|
||
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
|
||
import { ApiError } from '../../api/client';
|
||
|
||
/**
|
||
* Показ ошибки запроса уведомлением antd. `ApiError` уже несёт текст с сервера (например, «Нельзя снять последнего
|
||
* администратора» для 409 `LAST_ADMIN`); прочие исключения — общий текст. `message` берём из `App.useApp()`, а не
|
||
* статический `message.error`: так уведомление получает тему из `ConfigProvider` (статический вызов её не видит).
|
||
*/
|
||
export function useNotifyError(): (e: unknown) => void {
|
||
const { message } = App.useApp();
|
||
return useCallback(
|
||
(e: unknown) => {
|
||
// отмена запроса (AbortController) — не ошибка для пользователя
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
return;
|
||
}
|
||
message.error(e instanceof ApiError ? e.message : 'Не удалось выполнить запрос');
|
||
},
|
||
[message],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Флаг «компонент ещё на экране». Проверяется после `await` в обработчиках кнопок, чтобы не менять состояние
|
||
* компонента, который пользователь уже закрыл (ушёл со страницы, пока шёл запрос).
|
||
*/
|
||
export function useMountedRef(): { readonly current: boolean } {
|
||
const mounted = useRef(false);
|
||
useEffect(() => {
|
||
mounted.current = true;
|
||
return () => {
|
||
mounted.current = false;
|
||
};
|
||
}, []);
|
||
return mounted;
|
||
}
|
||
|
||
type AsyncData<T> = {
|
||
/** Последние данные с сервера; `null` — ещё не загружены или загрузка не удалась. */
|
||
data: T | null;
|
||
/** Замена данных без запроса (например, строка из ответа `PUT`). */
|
||
setData: Dispatch<SetStateAction<T | null>>;
|
||
loading: boolean;
|
||
/** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */
|
||
reload: () => Promise<void>;
|
||
};
|
||
|
||
/**
|
||
* Загрузка данных при появлении компонента и по `reload()`.
|
||
*
|
||
* `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке.
|
||
* Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ,
|
||
* пришедший после более нового `reload()` или после ухода со страницы, отбрасывается.
|
||
*/
|
||
export function useAsyncData<T>(loader: () => Promise<T>): AsyncData<T> {
|
||
const notifyError = useNotifyError();
|
||
const [data, setData] = useState<T | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const latestRun = useRef(0);
|
||
|
||
const reload = useCallback(async () => {
|
||
latestRun.current += 1;
|
||
const run = latestRun.current;
|
||
setLoading(true);
|
||
try {
|
||
const result = await loader();
|
||
if (run === latestRun.current) {
|
||
setData(result);
|
||
}
|
||
} catch (e: unknown) {
|
||
if (run === latestRun.current) {
|
||
notifyError(e);
|
||
}
|
||
} finally {
|
||
if (run === latestRun.current) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [loader, notifyError]);
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
return () => {
|
||
// уход со страницы: ответы запросов «в полёте» станут устаревшими и будут отброшены
|
||
latestRun.current += 1;
|
||
};
|
||
}, [reload]);
|
||
|
||
return { data, setData, loading, reload };
|
||
}
|