Рефакторинг: добавлен DataManager и API обертки для устранения дублирования запросов
НОВОЕ: - static/js/managers/dataManager.js - централизованное кеширование и дедупликация - static/js/api/calendarApi.js - обертка над calendar API с кешем (TTL 30s) - static/js/api/departmentsApi.js - обертка над departments API с кешем (TTL 60s) ИЗМЕНЕНО: - calendarWidget.js теперь использует getCalendarEvents() и getMyDepartments() - Убрано 3x дублирование запроса /api/v1/departments/my-departments/ - Убрано 2-3x дублирование запросов календаря РЕЗУЛЬТАТ: - Вместо 10+ API запросов теперь будет 3-4 (с кешем) - Автоматическая дедупликация одновременных запросов - Логирование Cache HIT/MISS для отладки
This commit is contained in:
parent
b37941e4fd
commit
38c4d3aeab
155
backend/static/js/api/calendarApi.js
Normal file
155
backend/static/js/api/calendarApi.js
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @fileoverview Calendar API - обертка над API календаря с кешированием
|
||||
* @module api/calendarApi
|
||||
*/
|
||||
|
||||
import { dataManager } from '../managers/dataManager.js';
|
||||
|
||||
/**
|
||||
* Получить токен авторизации
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
function getAccessToken() {
|
||||
const meta = document.querySelector('meta[name="api-access"]');
|
||||
const token = meta?.getAttribute('content')?.trim();
|
||||
if (token) return token;
|
||||
|
||||
try {
|
||||
return localStorage.getItem('api.access') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать заголовки с авторизацией
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
function authHeaders() {
|
||||
const token = getAccessToken();
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить события календаря
|
||||
* @param {Object} params - Параметры запроса
|
||||
* @param {string} params.start - Дата начала (YYYY-MM-DD)
|
||||
* @param {string} params.end - Дата конца (YYYY-MM-DD)
|
||||
* @param {number} [params.department_id] - ID отдела (опционально)
|
||||
* @param {number} [ttl=30000] - Time to live кеша в мс
|
||||
* @returns {Promise<Array>} Массив событий
|
||||
*/
|
||||
export async function getCalendarEvents(params, ttl = 30000) {
|
||||
// Создаем уникальный ключ для кеша
|
||||
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
|
||||
acc[key] = params[key];
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
|
||||
|
||||
return dataManager.fetch(
|
||||
key,
|
||||
async () => {
|
||||
const url = new URL('/api/v1/calendar/events/', window.location.origin);
|
||||
Object.keys(params).forEach(k => {
|
||||
if (params[k] != null) {
|
||||
url.searchParams.set(k, String(params[k]));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn('[CalendarAPI] 401 Unauthorized');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Поддержка разных форматов ответа
|
||||
return Array.isArray(data)
|
||||
? data
|
||||
: data.results || data.items || data.events || [];
|
||||
},
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить событие по ID
|
||||
* @param {number|string} eventId - ID события
|
||||
* @param {number} [ttl=60000] - Time to live кеша в мс
|
||||
* @returns {Promise<Object>} Событие
|
||||
*/
|
||||
export async function getCalendarEvent(eventId, ttl = 60000) {
|
||||
const key = `calendar:event:${eventId}`;
|
||||
|
||||
return dataManager.fetch(
|
||||
key,
|
||||
async () => {
|
||||
const url = `/api/v1/calendar/events/${eventId}/`;
|
||||
const response = await fetch(url, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кеш событий календаря
|
||||
* @param {Object} [params] - Параметры для инвалидации (если не указаны, инвалидируется весь кеш)
|
||||
*/
|
||||
export function invalidateCalendarEvents(params = null) {
|
||||
if (params) {
|
||||
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
|
||||
acc[key] = params[key];
|
||||
return acc;
|
||||
}, {});
|
||||
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
|
||||
dataManager.invalidate(key);
|
||||
} else {
|
||||
// Инвалидируем все события календаря
|
||||
dataManager.invalidatePattern(/^calendar:events:/);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кеш конкретного события
|
||||
* @param {number|string} eventId - ID события
|
||||
*/
|
||||
export function invalidateCalendarEvent(eventId) {
|
||||
dataManager.invalidate(`calendar:event:${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пре-загрузить события в кеш (например, из SSR данных)
|
||||
* @param {Object} params - Параметры запроса
|
||||
* @param {Array} events - Данные событий
|
||||
*/
|
||||
export function preloadCalendarEvents(params, events) {
|
||||
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
|
||||
acc[key] = params[key];
|
||||
return acc;
|
||||
}, {});
|
||||
const key = `calendar:events:${JSON.stringify(sortedParams)}`;
|
||||
dataManager.preload(key, events);
|
||||
}
|
||||
150
backend/static/js/api/departmentsApi.js
Normal file
150
backend/static/js/api/departmentsApi.js
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @fileoverview Departments API - обертка над API отделов с кешированием
|
||||
* @module api/departmentsApi
|
||||
*/
|
||||
|
||||
import { dataManager } from '../managers/dataManager.js';
|
||||
|
||||
/**
|
||||
* Получить токен авторизации
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
function getAccessToken() {
|
||||
const meta = document.querySelector('meta[name="api-access"]');
|
||||
const token = meta?.getAttribute('content')?.trim();
|
||||
if (token) return token;
|
||||
|
||||
try {
|
||||
return localStorage.getItem('api.access') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать заголовки с авторизацией
|
||||
* @returns {Object}
|
||||
* @private
|
||||
*/
|
||||
function authHeaders() {
|
||||
const token = getAccessToken();
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить отделы текущего пользователя
|
||||
* @param {number} [ttl=60000] - Time to live кеша в мс (60 секунд по умолчанию)
|
||||
* @returns {Promise<Array>} Массив отделов
|
||||
*/
|
||||
export async function getMyDepartments(ttl = 60000) {
|
||||
const key = 'departments:my';
|
||||
|
||||
return dataManager.fetch(
|
||||
key,
|
||||
async () => {
|
||||
const url = '/api/v1/departments/my-departments/';
|
||||
const response = await fetch(url, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn('[DepartmentsAPI] 401 Unauthorized');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Поддержка разных форматов ответа
|
||||
return Array.isArray(data)
|
||||
? data
|
||||
: data.results || data.items || data.departments || [];
|
||||
},
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все отделы
|
||||
* @param {number} [ttl=60000] - Time to live кеша в мс
|
||||
* @returns {Promise<Array>} Массив отделов
|
||||
*/
|
||||
export async function getAllDepartments(ttl = 60000) {
|
||||
const key = 'departments:all';
|
||||
|
||||
return dataManager.fetch(
|
||||
key,
|
||||
async () => {
|
||||
const url = '/api/v1/departments/';
|
||||
const response = await fetch(url, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return Array.isArray(data)
|
||||
? data
|
||||
: data.results || data.items || data.departments || [];
|
||||
},
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить отдел по ID
|
||||
* @param {number|string} departmentId - ID отдела
|
||||
* @param {number} [ttl=60000] - Time to live кеша в мс
|
||||
* @returns {Promise<Object>} Отдел
|
||||
*/
|
||||
export async function getDepartment(departmentId, ttl = 60000) {
|
||||
const key = `departments:${departmentId}`;
|
||||
|
||||
return dataManager.fetch(
|
||||
key,
|
||||
async () => {
|
||||
const url = `/api/v1/departments/${departmentId}/`;
|
||||
const response = await fetch(url, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кеш отделов
|
||||
*/
|
||||
export function invalidateDepartments() {
|
||||
dataManager.invalidatePattern(/^departments:/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кеш моих отделов
|
||||
*/
|
||||
export function invalidateMyDepartments() {
|
||||
dataManager.invalidate('departments:my');
|
||||
}
|
||||
|
||||
/**
|
||||
* Пре-загрузить отделы в кеш
|
||||
* @param {Array} departments - Данные отделов
|
||||
*/
|
||||
export function preloadMyDepartments(departments) {
|
||||
dataManager.preload('departments:my', departments);
|
||||
}
|
||||
@ -4,9 +4,12 @@
|
||||
* Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список
|
||||
*
|
||||
* @module calendarWidget
|
||||
* @version 1.0.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
|
||||
import { getCalendarEvents, invalidateCalendarEvents } from '../api/calendarApi.js';
|
||||
import { getMyDepartments } from '../api/departmentsApi.js';
|
||||
|
||||
/**
|
||||
* Инициализация виджета календаря
|
||||
* @param {Object} options - Параметры инициализации
|
||||
@ -307,8 +310,10 @@ export function initCalendarWidget(options = {}) {
|
||||
|
||||
let raw = [];
|
||||
try {
|
||||
raw = await fetchJSON(API_MY_DEPTS);
|
||||
// Используем кешированный API вместо прямого fetch
|
||||
raw = await getMyDepartments();
|
||||
} catch (e) {
|
||||
console.error('[CalendarWidget] Failed to load departments:', e);
|
||||
raw = [];
|
||||
}
|
||||
|
||||
@ -422,12 +427,21 @@ export function initCalendarWidget(options = {}) {
|
||||
/* ===== Комбинированная загрузка событий (occurrences) ===== */
|
||||
// События для текущего контекста (используется календарём)
|
||||
async function fetchEventsCombined(start, end) {
|
||||
let base = eventsUrl(state.type === 'dept' ? state.deptId : null);
|
||||
const withRange = addRange(base, start, end);
|
||||
try {
|
||||
return await fetchJSON(withRange);
|
||||
const params = {
|
||||
start: ymdLocal(start),
|
||||
end: ymdLocal(end)
|
||||
};
|
||||
|
||||
// Добавляем department_id если нужно
|
||||
if (state.type === 'dept' && state.deptId) {
|
||||
params.department_id = state.deptId;
|
||||
}
|
||||
|
||||
// Используем кешированный API
|
||||
return await getCalendarEvents(params);
|
||||
} catch (e) {
|
||||
console.error('Fetch events failed', e);
|
||||
console.error('[CalendarWidget] Fetch events failed', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -440,28 +454,38 @@ export function initCalendarWidget(options = {}) {
|
||||
await loadDepartments();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const startStr = ymdLocal(start);
|
||||
const endStr = ymdLocal(end);
|
||||
|
||||
// Формируем список источников с подписью
|
||||
const sources = [
|
||||
{ url: addRange(eventsUrl(null), start, end), label: 'Компания', type: 'company', id: null },
|
||||
{ params: { start: startStr, end: endStr }, label: 'Компания', type: 'company', id: null },
|
||||
...departments.map((d) => ({
|
||||
url: addRange(eventsUrl(d.id), start, end),
|
||||
params: { start: startStr, end: endStr, department_id: d.id },
|
||||
label: d.name,
|
||||
type: 'dept',
|
||||
id: d.id,
|
||||
})),
|
||||
];
|
||||
|
||||
// Загружаем все источники параллельно (с кешированием!)
|
||||
const chunks = await Promise.all(
|
||||
sources.map((s) =>
|
||||
fetchJSON(s.url)
|
||||
getCalendarEvents(s.params)
|
||||
.then((arr) =>
|
||||
(arr || []).map((ev) => ({
|
||||
...ev,
|
||||
__source: { label: s.label, type: s.type, id: s.id },
|
||||
}))
|
||||
)
|
||||
.catch(() => [])
|
||||
.catch((err) => {
|
||||
console.error(`[CalendarWidget] Failed to load events for ${s.label}:`, err);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const merged = chunks.flat();
|
||||
// Дедуп по устойчивому ключу
|
||||
const seen = new Set();
|
||||
|
||||
@ -207,6 +207,7 @@ export function initChatMarkRead(options = {}) {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrf,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: new URLSearchParams({ upto_ts: String(ts) }),
|
||||
|
||||
199
backend/static/js/managers/dataManager.js
Normal file
199
backend/static/js/managers/dataManager.js
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @fileoverview DataManager - централизованное управление данными с кешированием и дедупликацией
|
||||
* Предотвращает дублирование запросов и обеспечивает кеширование на клиенте
|
||||
* @module managers/dataManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* Менеджер данных с кешированием и дедупликацией запросов
|
||||
*/
|
||||
class DataManager {
|
||||
constructor() {
|
||||
/** @type {Map<string, {data: any, timestamp: number}>} */
|
||||
this.cache = new Map();
|
||||
|
||||
/** @type {Map<string, Promise<any>>} */
|
||||
this.pending = new Map();
|
||||
|
||||
/** @type {number} */
|
||||
this.defaultTTL = 30000; // 30 секунд по умолчанию
|
||||
|
||||
// Подписчики на изменения данных
|
||||
this.subscribers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить данные с кешированием и дедупликацией
|
||||
* @param {string} key - Уникальный ключ для данных
|
||||
* @param {Function} fetchFn - Функция для загрузки данных (должна возвращать Promise)
|
||||
* @param {number} [ttl] - Time to live в миллисекундах (по умолчанию 30000)
|
||||
* @returns {Promise<any>} Данные
|
||||
*/
|
||||
async fetch(key, fetchFn, ttl = this.defaultTTL) {
|
||||
// 1. Проверяем кеш
|
||||
if (this.cache.has(key)) {
|
||||
const { data, timestamp } = this.cache.get(key);
|
||||
const age = Date.now() - timestamp;
|
||||
|
||||
if (age < ttl) {
|
||||
console.log(`[DataManager] Cache HIT: ${key} (age: ${age}ms)`);
|
||||
return data;
|
||||
} else {
|
||||
console.log(`[DataManager] Cache EXPIRED: ${key} (age: ${age}ms, ttl: ${ttl}ms)`);
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Проверяем pending запросы (дедупликация)
|
||||
if (this.pending.has(key)) {
|
||||
console.log(`[DataManager] Request DEDUPED: ${key}`);
|
||||
return this.pending.get(key);
|
||||
}
|
||||
|
||||
// 3. Делаем запрос
|
||||
console.log(`[DataManager] Fetching: ${key}`);
|
||||
const promise = fetchFn()
|
||||
.then(data => {
|
||||
// Сохраняем в кеш
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Удаляем из pending
|
||||
this.pending.delete(key);
|
||||
|
||||
// Уведомляем подписчиков
|
||||
this.notify(key, data);
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch(error => {
|
||||
// Удаляем из pending при ошибке
|
||||
this.pending.delete(key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Сохраняем promise в pending
|
||||
this.pending.set(key, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать кеш для ключа
|
||||
* @param {string} key - Ключ данных
|
||||
*/
|
||||
invalidate(key) {
|
||||
console.log(`[DataManager] Invalidating: ${key}`);
|
||||
this.cache.delete(key);
|
||||
this.notify(key, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидировать все ключи, соответствующие паттерну
|
||||
* @param {RegExp|string} pattern - Паттерн для поиска ключей
|
||||
*/
|
||||
invalidatePattern(pattern) {
|
||||
const regex = typeof pattern === 'string'
|
||||
? new RegExp(pattern)
|
||||
: pattern;
|
||||
|
||||
const keysToInvalidate = Array.from(this.cache.keys())
|
||||
.filter(key => regex.test(key));
|
||||
|
||||
console.log(`[DataManager] Invalidating pattern "${pattern}":`, keysToInvalidate);
|
||||
|
||||
keysToInvalidate.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.notify(key, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить весь кеш
|
||||
*/
|
||||
clear() {
|
||||
console.log('[DataManager] Clearing all cache');
|
||||
this.cache.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписаться на изменения данных
|
||||
* @param {string} key - Ключ данных
|
||||
* @param {Function} callback - Коллбек (data) => void
|
||||
* @returns {Function} Функция отписки
|
||||
*/
|
||||
subscribe(key, callback) {
|
||||
if (!this.subscribers.has(key)) {
|
||||
this.subscribers.set(key, new Set());
|
||||
}
|
||||
|
||||
this.subscribers.get(key).add(callback);
|
||||
|
||||
// Возвращаем функцию отписки
|
||||
return () => {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
this.subscribers.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомить подписчиков об изменении данных
|
||||
* @param {string} key - Ключ данных
|
||||
* @param {any} data - Новые данные
|
||||
* @private
|
||||
*/
|
||||
notify(key, data) {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (subs) {
|
||||
subs.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error(`[DataManager] Subscriber error for key "${key}":`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику кеша
|
||||
* @returns {Object} Статистика
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
cacheSize: this.cache.size,
|
||||
pendingCount: this.pending.size,
|
||||
subscribersCount: this.subscribers.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Пре-загрузить данные в кеш
|
||||
* @param {string} key - Ключ данных
|
||||
* @param {any} data - Данные для сохранения
|
||||
*/
|
||||
preload(key, data) {
|
||||
console.log(`[DataManager] Preloading: ${key}`);
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем singleton
|
||||
export const dataManager = new DataManager();
|
||||
|
||||
// Для отладки в консоли
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dataManager = dataManager;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user