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