Добавлена поддержка оффлайн-режима с улучшенной логикой инициализации и синхронизации. Обновлены компоненты для работы с состоянием сети, включая использование localStorage для восстановления аутентификации в оффлайн-режиме. Оптимизированы настройки кэширования и обработка ошибок при запросах к API. Улучшена производительность и адаптивность интерфейса.
This commit is contained in:
parent
3c2b23c699
commit
2ec93b8cc2
@ -34,22 +34,31 @@ axiosClient.interceptors.response.use(
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
// Список URL, где 401 означает неправильный пароль, а не истечение сессии
|
||||
// или где не нужно делать редирект (например, проверка статуса)
|
||||
const passwordProtectedUrls = [
|
||||
"/login", // Страница входа
|
||||
"/register", // Страница регистрации
|
||||
"/notes/archived/all", // Удаление всех архивных заметок
|
||||
"/user/delete-account", // Удаление аккаунта
|
||||
"/auth/status", // Проверка статуса (не нужно делать редирект при ошибке)
|
||||
];
|
||||
|
||||
// Проверяем, является ли это запросом с проверкой пароля
|
||||
// Проверяем, является ли это запросом с проверкой пароля или статуса
|
||||
const isPasswordProtected = passwordProtectedUrls.some((url) =>
|
||||
error.config?.url?.includes(url)
|
||||
);
|
||||
|
||||
// Разлогиниваем только если это НЕ запрос с проверкой пароля
|
||||
if (!isPasswordProtected) {
|
||||
// Разлогиниваем только если это НЕ запрос с проверкой пароля/статуса
|
||||
// и только если есть ответ от сервера (не offline режим)
|
||||
if (!isPasswordProtected && error.response) {
|
||||
localStorage.removeItem("isAuthenticated");
|
||||
window.location.href = "/";
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("username");
|
||||
// Не делаем редирект, если нет интернета (error.response будет undefined)
|
||||
// ProtectedRoute сам обработает это
|
||||
if (navigator.onLine) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { useAppSelector, useAppDispatch } from "../store/hooks";
|
||||
import { setAuth, clearAuth } from "../store/slices/authSlice";
|
||||
import { authApi } from "../api/authApi";
|
||||
import { LoadingOverlay } from "./common/LoadingOverlay";
|
||||
import { checkNetworkStatus } from "../utils/offlineManager";
|
||||
|
||||
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
@ -15,26 +16,84 @@ export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const authStatus = await authApi.checkStatus();
|
||||
if (authStatus.authenticated) {
|
||||
// Проверяем, есть ли интернет
|
||||
const isOnline = await checkNetworkStatus();
|
||||
|
||||
if (!isOnline) {
|
||||
// Offline режим - используем данные из localStorage
|
||||
const savedAuth = localStorage.getItem("isAuthenticated") === "true";
|
||||
const savedUserId = localStorage.getItem("userId");
|
||||
const savedUsername = localStorage.getItem("username");
|
||||
|
||||
if (savedAuth && savedUserId && savedUsername) {
|
||||
// Восстанавливаем авторизацию из localStorage
|
||||
dispatch(
|
||||
setAuth({
|
||||
userId: parseInt(savedUserId, 10),
|
||||
username: savedUsername,
|
||||
})
|
||||
);
|
||||
console.log('[ProtectedRoute] Offline mode: restored auth from localStorage');
|
||||
} else {
|
||||
// Нет сохраненных данных - не авторизован
|
||||
dispatch(clearAuth());
|
||||
}
|
||||
} else {
|
||||
// Online режим - проверяем через API
|
||||
try {
|
||||
const authStatus = await authApi.checkStatus();
|
||||
if (authStatus.authenticated) {
|
||||
dispatch(
|
||||
setAuth({
|
||||
userId: authStatus.userId!,
|
||||
username: authStatus.username!,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(clearAuth());
|
||||
}
|
||||
} catch (apiError) {
|
||||
// Если API запрос не удался, но есть данные в localStorage
|
||||
// Используем их для offline работы
|
||||
const savedAuth = localStorage.getItem("isAuthenticated") === "true";
|
||||
const savedUserId = localStorage.getItem("userId");
|
||||
const savedUsername = localStorage.getItem("username");
|
||||
|
||||
if (savedAuth && savedUserId && savedUsername) {
|
||||
console.log('[ProtectedRoute] API failed, using cached auth');
|
||||
dispatch(
|
||||
setAuth({
|
||||
userId: parseInt(savedUserId, 10),
|
||||
username: savedUsername,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(clearAuth());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProtectedRoute] Auth check error:', error);
|
||||
// В случае ошибки проверяем localStorage
|
||||
const savedAuth = localStorage.getItem("isAuthenticated") === "true";
|
||||
const savedUserId = localStorage.getItem("userId");
|
||||
const savedUsername = localStorage.getItem("username");
|
||||
|
||||
if (savedAuth && savedUserId && savedUsername) {
|
||||
dispatch(
|
||||
setAuth({
|
||||
userId: authStatus.userId!,
|
||||
username: authStatus.username!,
|
||||
userId: parseInt(savedUserId, 10),
|
||||
username: savedUsername,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(clearAuth());
|
||||
}
|
||||
} catch {
|
||||
dispatch(clearAuth());
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Всегда проверяем статус аутентификации при монтировании,
|
||||
// независимо от начального состояния Redux (localStorage может быть устаревшим)
|
||||
checkAuth();
|
||||
}, [dispatch]);
|
||||
|
||||
|
||||
97
src/main.tsx
97
src/main.tsx
@ -14,7 +14,7 @@ import { addNotification } from "./store/slices/uiSlice";
|
||||
|
||||
// Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto")
|
||||
|
||||
// Инициализация offline функционала
|
||||
// Инициализация offline функционала (неблокирующая)
|
||||
async function initOfflineMode() {
|
||||
try {
|
||||
console.log('[Init] Initializing offline mode...');
|
||||
@ -23,28 +23,46 @@ async function initOfflineMode() {
|
||||
await dbManager.init();
|
||||
console.log('[Init] IndexedDB initialized');
|
||||
|
||||
// Проверка состояния сети
|
||||
const isOnline = await checkNetworkStatus();
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
console.log(`[Init] Network status: ${isOnline ? 'online' : 'offline'}`);
|
||||
// Устанавливаем начальное состояние на основе navigator.onLine (мгновенно, без блокировки)
|
||||
const initialOnlineState = navigator.onLine;
|
||||
store.dispatch(setOfflineMode(!initialOnlineState));
|
||||
console.log(`[Init] Initial network status (navigator.onLine): ${initialOnlineState ? 'online' : 'offline'}`);
|
||||
|
||||
// Проверка состояния сети в фоне (не блокирует загрузку приложения)
|
||||
checkNetworkStatus().then((isOnline) => {
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
console.log(`[Init] Network status (after check): ${isOnline ? 'online' : 'offline'}`);
|
||||
}).catch((error) => {
|
||||
console.warn('[Init] Network check failed, using navigator.onLine:', error);
|
||||
// В случае ошибки используем navigator.onLine
|
||||
store.dispatch(setOfflineMode(!navigator.onLine));
|
||||
});
|
||||
|
||||
// Установка listeners для событий сети
|
||||
networkListener.onOnline(async () => {
|
||||
console.log('[Network] Online event detected');
|
||||
const isOnline = await checkNetworkStatus();
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
|
||||
if (isOnline) {
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Подключение восстановлено, начинаем синхронизацию...',
|
||||
type: 'info',
|
||||
})
|
||||
);
|
||||
|
||||
// Запуск синхронизации
|
||||
await syncService.startSync();
|
||||
}
|
||||
// Небольшая задержка перед проверкой для стабилизации соединения
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const isOnline = await checkNetworkStatus();
|
||||
store.dispatch(setOfflineMode(!isOnline));
|
||||
|
||||
if (isOnline) {
|
||||
store.dispatch(
|
||||
addNotification({
|
||||
message: 'Подключение восстановлено, начинаем синхронизацию...',
|
||||
type: 'info',
|
||||
})
|
||||
);
|
||||
|
||||
// Запуск синхронизации
|
||||
await syncService.startSync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Network] Error checking network status:', error);
|
||||
store.dispatch(setOfflineMode(!navigator.onLine));
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
networkListener.onOffline(() => {
|
||||
@ -59,29 +77,40 @@ async function initOfflineMode() {
|
||||
});
|
||||
|
||||
// Обновление счетчика ожидающих синхронизацию
|
||||
const pendingCount = await dbManager.getPendingSyncCount();
|
||||
store.dispatch(setPendingSyncCount(pendingCount));
|
||||
|
||||
if (pendingCount > 0) {
|
||||
console.log(`[Init] Found ${pendingCount} pending sync items`);
|
||||
}
|
||||
dbManager.getPendingSyncCount().then((pendingCount) => {
|
||||
store.dispatch(setPendingSyncCount(pendingCount));
|
||||
|
||||
if (pendingCount > 0) {
|
||||
console.log(`[Init] Found ${pendingCount} pending sync items`);
|
||||
}
|
||||
|
||||
// Автоматическая синхронизация при старте если есть что синхронизировать
|
||||
if (isOnline && pendingCount > 0) {
|
||||
console.log('[Init] Starting initial sync...');
|
||||
// Небольшая задержка для инициализации UI
|
||||
setTimeout(() => {
|
||||
syncService.startSync();
|
||||
}, 2000);
|
||||
}
|
||||
// Автоматическая синхронизация при старте если есть что синхронизировать
|
||||
// Проверяем статус сети перед синхронизацией
|
||||
checkNetworkStatus().then((isOnline) => {
|
||||
if (isOnline && pendingCount > 0) {
|
||||
console.log('[Init] Starting initial sync...');
|
||||
// Небольшая задержка для инициализации UI
|
||||
setTimeout(() => {
|
||||
syncService.startSync();
|
||||
}, 2000);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Если проверка сети не удалась, не запускаем синхронизацию
|
||||
console.log('[Init] Skipping initial sync due to network check failure');
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('[Init] Error getting pending sync count:', error);
|
||||
});
|
||||
|
||||
console.log('[Init] Offline mode initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[Init] Error initializing offline mode:', error);
|
||||
// Не блокируем запуск приложения даже при ошибке
|
||||
store.dispatch(setOfflineMode(!navigator.onLine));
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск инициализации
|
||||
// Запуск инициализации (не блокирует рендеринг React)
|
||||
initOfflineMode();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@ -9,7 +9,7 @@ interface AuthState {
|
||||
|
||||
const initialState: AuthState = {
|
||||
isAuthenticated: localStorage.getItem("isAuthenticated") === "true",
|
||||
userId: null,
|
||||
userId: localStorage.getItem("userId") ? parseInt(localStorage.getItem("userId")!, 10) : null,
|
||||
username: localStorage.getItem("username") || null,
|
||||
loading: false,
|
||||
};
|
||||
@ -26,6 +26,7 @@ const authSlice = createSlice({
|
||||
state.userId = action.payload.userId;
|
||||
state.username = action.payload.username;
|
||||
localStorage.setItem("isAuthenticated", "true");
|
||||
localStorage.setItem("userId", action.payload.userId.toString());
|
||||
localStorage.setItem("username", action.payload.username);
|
||||
},
|
||||
clearAuth: (state) => {
|
||||
@ -33,6 +34,7 @@ const authSlice = createSlice({
|
||||
state.userId = null;
|
||||
state.username = null;
|
||||
localStorage.removeItem("isAuthenticated");
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("username");
|
||||
},
|
||||
},
|
||||
|
||||
@ -39,6 +39,8 @@ export function waitForIndexedDB(): Promise<IDBDatabase> {
|
||||
|
||||
/**
|
||||
* Проверка состояния сети (более надежный метод)
|
||||
* Не блокирует выполнение, всегда возвращает результат
|
||||
* Различает "нет интернета" (offline) и "не авторизован" (401)
|
||||
*/
|
||||
export async function checkNetworkStatus(): Promise<boolean> {
|
||||
// Простая проверка navigator.onLine
|
||||
@ -49,7 +51,8 @@ export async function checkNetworkStatus(): Promise<boolean> {
|
||||
// Дополнительная проверка через fetch с коротким таймаутом
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
// Уменьшаем таймаут для более быстрого ответа
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||
|
||||
// Используем /auth/status так как он всегда доступен при наличии сети
|
||||
const response = await fetch('/api/auth/status', {
|
||||
@ -60,10 +63,27 @@ export async function checkNetworkStatus(): Promise<boolean> {
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
|
||||
// Если получили ответ (даже 401), значит интернет есть
|
||||
// 401 означает "не авторизован", но НЕ "нет интернета"
|
||||
// Любой ответ (даже ошибка) означает, что сеть работает
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Если запрос не удался, но navigator.onLine = true, считаем что онлайн
|
||||
// (возможно, просто таймаут или другая проблема)
|
||||
// Если запрос не удался из-за таймаута или сетевой ошибки
|
||||
// (AbortError, NetworkError, TypeError и т.д.)
|
||||
// Это означает, что интернета действительно нет
|
||||
if (error instanceof Error) {
|
||||
// AbortError означает таймаут - нет интернета
|
||||
if (error.name === 'AbortError') {
|
||||
return false;
|
||||
}
|
||||
// TypeError обычно означает, что запрос не может быть выполнен
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// В остальных случаях считаем, что интернет есть
|
||||
// (возможно, просто ошибка авторизации или другая проблема)
|
||||
return navigator.onLine;
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,10 @@ export default defineConfig({
|
||||
"icon.svg",
|
||||
"icons/icon-192x192.png",
|
||||
"icons/icon-512x512.png",
|
||||
"manifest.json",
|
||||
],
|
||||
// Включаем стратегию для offline работы
|
||||
strategies: "generateSW",
|
||||
manifest: {
|
||||
name: "NoteJS - Система заметок",
|
||||
short_name: "NoteJS",
|
||||
@ -88,8 +91,41 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot}"],
|
||||
// Кэшируем все статические ресурсы для offline работы
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot,json}"],
|
||||
// Стратегия для главной страницы - CacheFirst для offline работы
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/_/],
|
||||
// Кэширование для offline работы приложения
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Стратегия для корневого пути и index.html
|
||||
urlPattern: ({ request }) =>
|
||||
request.destination === 'document' ||
|
||||
request.url.endsWith('/') ||
|
||||
request.url.endsWith('/index.html'),
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "html-cache",
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
networkTimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Статические ресурсы (JS, CSS) - кэшируем для offline
|
||||
urlPattern: /\.(?:js|css|woff|woff2|ttf|eot)$/,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "static-resources-cache",
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/api\./,
|
||||
handler: "NetworkFirst",
|
||||
@ -99,6 +135,7 @@ export default defineConfig({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60, // 1 hour
|
||||
},
|
||||
networkTimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -110,7 +147,7 @@ export default defineConfig({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
networkTimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user