This commit is contained in:
root
2026-01-17 15:08:36 +05:00
parent f7626d77b7
commit fb8586d450
3 changed files with 287 additions and 21 deletions
+90 -4
View File
@@ -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();
}
+6
View File
@@ -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;
}
}
+191 -17
View File
@@ -162,6 +162,20 @@
<div class="h-[260px]">
<Chart type="line" :data="marketDynamicsLineData" :options="marketDynamicsLineOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Месяц</div>
<div class="col-span-4 px-3 py-2 text-right">Индекс</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in marketDynamicsTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -296,6 +310,20 @@
<div class="h-[240px]">
<Chart type="bar" :data="ageBarData" :options="compactBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Возраст</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in ageRows" :key="row.key" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
@@ -310,6 +338,20 @@
<div class="h-[220px]">
<Chart type="bar" :data="incomeBarData" :options="compactBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Категория</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in incomeTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -372,6 +414,20 @@
<div class="h-[280px]">
<Chart type="bar" :data="visibilityBarData" :options="visibilityBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Конкурент</div>
<div class="col-span-4 px-3 py-2 text-right">Видимость</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in comparisonRows" :key="row.key" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.visibility ?? '—' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-5">
@@ -467,6 +523,20 @@
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Тип запроса</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in queryTypesTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-7">
@@ -483,6 +553,20 @@
<div class="h-[220px]">
<Chart type="bar" :data="decisionFactorsBarData" :options="compactBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Фактор</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in decisionFactorsTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12">
@@ -497,6 +581,20 @@
<div class="h-[200px]">
<Chart type="bar" :data="refusalReasonsBarData" :options="compactBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Причина</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in refusalReasonsTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -633,6 +731,20 @@
<div class="h-[280px]">
<Chart type="bar" :data="channelSharesBarData" :options="channelSharesBarOptions" />
</div>
<div class="mt-4 pt-4 border-t border-slate-200/70">
<div class="overflow-hidden rounded-xl border border-slate-200/70">
<div class="grid grid-cols-12 bg-slate-50 text-[12px] font-semibold text-slate-600">
<div class="col-span-8 px-3 py-2">Канал</div>
<div class="col-span-4 px-3 py-2 text-right">Доля</div>
</div>
<div class="divide-y divide-slate-200/70">
<div v-for="row in channelSharesTableRows" :key="row.label" class="grid grid-cols-12 px-3 py-2.5">
<div class="col-span-8 text-[13px] text-slate-800">{{ row.label }}</div>
<div class="col-span-4 text-[13px] text-slate-800 text-right">{{ row.value }}%</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-span-12 lg:col-span-5">
@@ -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,