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,