diff --git a/src/service/AuthService.js b/src/service/AuthService.js index 4969cfc..7e95e5a 100644 --- a/src/service/AuthService.js +++ b/src/service/AuthService.js @@ -76,19 +76,105 @@ async function readErrorMessage(response) { } async function authFetch(input, init = {}) { - const headers = new Headers(init.headers || {}); + // Support optional timeout via AbortController (fetch has no default timeout) + const { timeoutMs, signal: providedSignal, ...restInit } = init || {}; + + const headers = new Headers(restInit.headers || {}); const token = getAccessToken(); if (token) headers.set('Authorization', `Bearer ${token}`); headers.set('Content-Type', headers.get('Content-Type') || 'application/json'); - let response = await fetch(input, { ...init, headers }); + + let timeoutId = null; + let controller = null; + let signal = providedSignal; + + if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) { + controller = new AbortController(); + signal = controller.signal; + + // If caller provided a signal, propagate its abort to our controller + if (providedSignal) { + if (providedSignal.aborted) { + controller.abort(providedSignal.reason); + } else { + providedSignal.addEventListener( + 'abort', + () => { + try { + controller.abort(providedSignal.reason); + } catch (_) { + controller.abort(); + } + }, + { once: true } + ); + } + } + + timeoutId = window.setTimeout(() => { + try { + controller.abort(new DOMException('Request timed out', 'TimeoutError')); + } catch (_) { + controller.abort(); + } + }, timeoutMs); + } + + let response; + try { + response = await fetch(input, { ...restInit, headers, signal }); + } finally { + if (timeoutId) window.clearTimeout(timeoutId); + } if (response.status !== 401) return response; // try refresh once try { const newAccess = await refreshToken(); - const retryHeaders = new Headers(init.headers || {}); + const retryHeaders = new Headers(restInit.headers || {}); retryHeaders.set('Authorization', `Bearer ${newAccess}`); if (!retryHeaders.get('Content-Type')) retryHeaders.set('Content-Type', 'application/json'); - response = await fetch(input, { ...init, headers: retryHeaders }); + + // Retry uses the same timeout/signal settings + let retryTimeoutId = null; + let retryController = null; + let retrySignal = providedSignal; + + if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) { + retryController = new AbortController(); + retrySignal = retryController.signal; + + if (providedSignal) { + if (providedSignal.aborted) { + retryController.abort(providedSignal.reason); + } else { + providedSignal.addEventListener( + 'abort', + () => { + try { + retryController.abort(providedSignal.reason); + } catch (_) { + retryController.abort(); + } + }, + { once: true } + ); + } + } + + retryTimeoutId = window.setTimeout(() => { + try { + retryController.abort(new DOMException('Request timed out', 'TimeoutError')); + } catch (_) { + retryController.abort(); + } + }, timeoutMs); + } + + try { + response = await fetch(input, { ...restInit, headers: retryHeaders, signal: retrySignal }); + } finally { + if (retryTimeoutId) window.clearTimeout(retryTimeoutId); + } } catch (_) { await logout(); } diff --git a/src/service/MarketingService.js b/src/service/MarketingService.js index 2609e0b..58f29c3 100644 --- a/src/service/MarketingService.js +++ b/src/service/MarketingService.js @@ -25,6 +25,7 @@ class MarketingService { async startAnalysis(data, options = {}) { try { const generateV2 = options?.generateV2 === true; + const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 45000; const requestBody = { businessNiche: data.businessNiche, product: data.product, @@ -47,6 +48,7 @@ class MarketingService { const response = await AuthService.authFetch(url, { method: 'POST', ...DEFAULT_REQUEST_CONFIG, + timeoutMs, body: JSON.stringify(requestBody) }); @@ -64,6 +66,10 @@ class MarketingService { return result.data || null; } catch (error) { console.error('Ошибка при запуске маркетингового анализа:', error); + // Fetch timeout / abort + if (error?.name === 'AbortError' || error?.name === 'TimeoutError') { + throw new Error('Таймаут при запуске анализа. Сервер не ответил вовремя — попробуйте ещё раз позже.'); + } throw error; } } diff --git a/src/views/pages/MarketingAnalysis.vue b/src/views/pages/MarketingAnalysis.vue index aa7d184..d79d315 100644 --- a/src/views/pages/MarketingAnalysis.vue +++ b/src/views/pages/MarketingAnalysis.vue @@ -162,6 +162,20 @@
+
+
+
+
Месяц
+
Индекс
+
+
+
+
{{ row.label }}
+
{{ row.value }}
+
+
+
+
@@ -296,6 +310,20 @@
+
+
+
+
Возраст
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -310,6 +338,20 @@
+
+
+
+
Категория
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -372,6 +414,20 @@
+
+
+
+
Конкурент
+
Видимость
+
+
+
+
{{ row.label }}
+
{{ row.visibility ?? '—' }}
+
+
+
+
@@ -467,6 +523,20 @@
+
+
+
+
Тип запроса
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -483,6 +553,20 @@
+
+
+
+
Фактор
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -497,6 +581,20 @@
+
+
+
+
Причина
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -633,6 +731,20 @@
+
+
+
+
Канал
+
Доля
+
+
+
+
{{ row.label }}
+
{{ row.value }}%
+
+
+
+
@@ -1171,25 +1283,25 @@ function hexToRgba(hex, alpha) { } // Charts: Market dynamics (line / area) +const MARKET_MONTHS = [ + { key: 'january', label: 'Янв' }, + { key: 'february', label: 'Фев' }, + { key: 'march', label: 'Мар' }, + { key: 'april', label: 'Апр' }, + { key: 'may', label: 'Май' }, + { key: 'june', label: 'Июн' }, + { key: 'july', label: 'Июл' }, + { key: 'august', label: 'Авг' }, + { key: 'september', label: 'Сен' }, + { key: 'october', label: 'Окт' }, + { key: 'november', label: 'Ноя' }, + { key: 'december', label: 'Дек' } +]; + const marketDynamicsLineData = computed(() => { const monthly = report.value?.sections?.market_overview?.monthly_online_index ?? {}; - const months = [ - { key: 'january', label: 'Янв' }, - { key: 'february', label: 'Фев' }, - { key: 'march', label: 'Мар' }, - { key: 'april', label: 'Апр' }, - { key: 'may', label: 'Май' }, - { key: 'june', label: 'Июн' }, - { key: 'july', label: 'Июл' }, - { key: 'august', label: 'Авг' }, - { key: 'september', label: 'Сен' }, - { key: 'october', label: 'Окт' }, - { key: 'november', label: 'Ноя' }, - { key: 'december', label: 'Дек' } - ]; - - const labels = months.map((m) => m.label); - const values = months.map((m) => { + const labels = MARKET_MONTHS.map((m) => m.label); + const values = MARKET_MONTHS.map((m) => { const v = monthly?.[m.key]; return typeof v === 'number' ? v : 0; }); @@ -1219,6 +1331,18 @@ const marketDynamicsLineData = computed(() => { }; }); +const marketDynamicsTableRows = computed(() => { + const monthly = report.value?.sections?.market_overview?.monthly_online_index ?? {}; + return MARKET_MONTHS.map((m) => { + const raw = monthly?.[m.key]; + const parsed = typeof raw === 'number' ? raw : Number(String(raw ?? '').replace(/[^\d.-]/g, '')); + return { + label: m.label, + value: Number.isFinite(parsed) ? parsed : '—' + }; + }); +}); + const marketDynamicsLineOptions = computed(() => ({ maintainAspectRatio: false, plugins: { legend: { display: false } }, @@ -1413,6 +1537,15 @@ const incomeBarData = computed(() => { }; }); +const incomeTableRows = computed(() => { + const above = aboveAvgIncomeValue.value; + const other = Math.max(0, 100 - above); + return [ + { label: 'Остальные', value: other }, + { label: 'Выше среднего', value: above } + ]; +}); + // Segments list const segmentRows = computed(() => { const segs = report.value?.sections?.target_audience?.segments ?? []; @@ -1628,6 +1761,16 @@ const queryTypesMetrics = computed(() => { .filter((metric) => metric.value > 0); }); +const queryTypesTableRows = computed(() => { + const q = report.value?.sections?.demand_and_behavior?.search_query_types ?? {}; + return [ + { label: 'Прямой интерес', value: percentToNumber(q.direct_interest_catalog) }, + { label: 'Поиск по вопросу', value: percentToNumber(q.question_based_articles) }, + { label: 'Конкуренты', value: percentToNumber(q.competitors_reviews) }, + { label: 'Решение проблемы', value: percentToNumber(q.problem_solving_hot_promo) } + ]; +}); + const decisionFactorsBarData = computed(() => { const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; const labels = ['Акция/скидка', 'Отзыв/рекоменд.', 'Гарантия/качество', 'Удобство/сервис', 'Наличие мест', 'Цена']; @@ -1649,6 +1792,18 @@ const decisionFactorsBarData = computed(() => { }; }); +const decisionFactorsTableRows = computed(() => { + const f = report.value?.sections?.demand_and_behavior?.decision_factors ?? {}; + return [ + { label: 'Акция/скидка', value: percentToNumber(f.promo_discount) }, + { label: 'Отзыв/рекоменд.', value: percentToNumber(f.review_recommendation) }, + { label: 'Гарантия/качество', value: percentToNumber(f.warranty_quality) }, + { label: 'Удобство/сервис', value: percentToNumber(f.convenience_service) }, + { label: 'Наличие мест', value: percentToNumber(f.availability) }, + { label: 'Цена', value: percentToNumber(f.price) } + ]; +}); + const refusalReasonsBarData = computed(() => { const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {}; const labels = ['Высокая цена', 'Нет отзывов', 'Сложности с выбором']; @@ -1670,6 +1825,15 @@ const refusalReasonsBarData = computed(() => { }; }); +const refusalReasonsTableRows = computed(() => { + const r = report.value?.sections?.demand_and_behavior?.reasons_for_refusal ?? {}; + return [ + { label: 'Высокая цена', value: percentToNumber(r.high_price) }, + { label: 'Нет отзывов', value: percentToNumber(r.no_reviews_social_proof) }, + { label: 'Сложности с выбором', value: percentToNumber(r.choice_difficulty_unclear_offer) } + ]; +}); + // Acquisition channels charts/tables const channelSharesBarData = computed(() => { const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {}; @@ -1698,6 +1862,16 @@ const channelSharesBarData = computed(() => { }; }); +const channelSharesTableRows = computed(() => { + const shares = report.value?.sections?.acquisition_channels?.channel_shares ?? {}; + return [ + { label: 'Яндекс Директ', value: percentToNumber(shares.yandex_direct) }, + { label: 'SEO (сиб.тайт)', value: percentToNumber(shares.seo) }, + { label: 'Ретаргетинг', value: percentToNumber(shares.retargeting) }, + { label: 'VKontakte', value: percentToNumber(shares.vkontakte) } + ]; +}); + const channelSharesBarOptions = computed(() => ({ indexAxis: 'y', maintainAspectRatio: false,