From 38c4d3aeaba91967082ec30bc23697deb7d68d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 28 Nov 2025 14:24:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20DataManager=20=D0=B8=20API=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit НОВОЕ: - 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 для отладки --- backend/static/js/api/calendarApi.js | 155 ++++++++++++++ backend/static/js/api/departmentsApi.js | 150 +++++++++++++ .../static/js/components/calendarWidget.js | 44 +++- backend/static/js/components/chatMarkRead.js | 1 + backend/static/js/managers/dataManager.js | 199 ++++++++++++++++++ 5 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 backend/static/js/api/calendarApi.js create mode 100644 backend/static/js/api/departmentsApi.js create mode 100644 backend/static/js/managers/dataManager.js diff --git a/backend/static/js/api/calendarApi.js b/backend/static/js/api/calendarApi.js new file mode 100644 index 00000000..a53f6d49 --- /dev/null +++ b/backend/static/js/api/calendarApi.js @@ -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} Массив событий + */ +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} Событие + */ +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); +} diff --git a/backend/static/js/api/departmentsApi.js b/backend/static/js/api/departmentsApi.js new file mode 100644 index 00000000..794c2558 --- /dev/null +++ b/backend/static/js/api/departmentsApi.js @@ -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} Массив отделов + */ +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} Массив отделов + */ +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} Отдел + */ +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); +} diff --git a/backend/static/js/components/calendarWidget.js b/backend/static/js/components/calendarWidget.js index 06f8bfd2..9612ff37 100644 --- a/backend/static/js/components/calendarWidget.js +++ b/backend/static/js/components/calendarWidget.js @@ -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(); diff --git a/backend/static/js/components/chatMarkRead.js b/backend/static/js/components/chatMarkRead.js index 7d91c986..37204461 100644 --- a/backend/static/js/components/chatMarkRead.js +++ b/backend/static/js/components/chatMarkRead.js @@ -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) }), diff --git a/backend/static/js/managers/dataManager.js b/backend/static/js/managers/dataManager.js new file mode 100644 index 00000000..423bebda --- /dev/null +++ b/backend/static/js/managers/dataManager.js @@ -0,0 +1,199 @@ +/** + * @fileoverview DataManager - централизованное управление данными с кешированием и дедупликацией + * Предотвращает дублирование запросов и обеспечивает кеширование на клиенте + * @module managers/dataManager + */ + +/** + * Менеджер данных с кешированием и дедупликацией запросов + */ +class DataManager { + constructor() { + /** @type {Map} */ + this.cache = new Map(); + + /** @type {Map>} */ + 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} Данные + */ + 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; +}