This commit is contained in:
root
2025-12-10 20:04:54 +05:00
parent d866fbe690
commit c46c0be173
3 changed files with 412 additions and 321 deletions
+60
View File
@@ -0,0 +1,60 @@
**Role:** Senior Vue.js Frontend Developer.
**Task:** Refactor `MarketingAnalysis.vue` and `MarketingService.js` to support the new backend response structure and rendering logic.
**Context:**
The backend logic has changed.
1. **Endpoint:** The endpoint to fetch analysis results is now `GET api/marketing/analysis/{id}`.
2. **Data Structure:** The `report` object in the response now contains a field `fullAnalysis`. This field is a huge Markdown string.
3. **Embedded Charts:** Inside this Markdown string, there are code blocks containing JSON data for charts/tables. They look like this:
````markdown
... text ...
```json:seasonality
{ ...data... }
```
````
... text ...
```
```
**Requirements:**
1. **Update Service:** Ensure `MarketingService.getAnalysisResult(id)` calls the correct endpoint (`api/marketing/analysis/{id}`).
2. **Refactor Rendering Logic (`MarketingAnalysis.vue`):**
- Instead of hardcoded sections for "Summary", "Charts", "SWOT", etc., we need a **Dynamic Markdown Renderer**.
- Create a computed property or a helper function that parses `report.fullAnalysis`.
- It should split the markdown string into an array of segments based on the regex for code blocks: \`\`\`\`json:(\\w+)\\n([\\s\\S]\*?)\\n\`\`\` \`.
- **Iterate through these segments in the template:**
- If the segment is **Text**: Render it using `marked` (v-html).
- If the segment is a **Code Block**, parse the JSON and render the appropriate component based on the identifier (e.g., `seasonality`, `competitors`, `funnel`, `swot`, `channelsPotential`, `audienceSegmentation`).
3. **Component Mapping:**
Reuse the existing UI logic (PrimeVue Charts and Cards) but apply them dynamically inside the loop:
- `json:seasonality` -\> Render `Chart type="line"` (reuse existing logic for options/data).
- `json:funnel` -\> Render `Chart type="bar"` (reuse existing logic).
- `json:channelsPotential` -\> Render `Chart type="bar"` + list of justifications.
- `json:audienceSegmentation` -\> Render Pie Charts.
- `json:competitors` -\> Render the grid of competitor cards (reuse existing UI code).
- `json:swot` -\> Render the 4-block SWOT grid (reuse existing UI code).
4. **Cleanup:**
- Remove the old static blocks (Cards for "Краткое резюме", "Графики", "SWOT", "Стратегия"), as they are now all included in the `fullAnalysis` stream.
- Keep the "Header" (Download PDF button) and "Recommendations" (if it's still a separate array in JSON, keep it, otherwise remove).
- Keep the Form for starting the analysis.
**Goal:** The user should see a seamless document flow where text explains the data, and charts/tables appear immediately after the relevant text, exactly as they are placed in the Markdown string.
**Technical Hint:**
You can create a reactive variable `parsedContent` which is an array of objects: `{ type: 'markdown' | 'chart', content: string | object, chartType?: string }`.
Begin implementation.
+1 -1
View File
@@ -74,7 +74,7 @@ class MarketingService {
*/
async getAnalysisResult(analysisId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_GET}/${analysisId}`);
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/analysis/${analysisId}`);
const result = await response.json().catch(() => null);
+351 -320
View File
@@ -184,35 +184,144 @@
</div>
</div>
<!-- Краткое резюме -->
<div class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Краткое резюме</h3>
</div>
<div class="card-content">
<div v-if="report.summary" class="markdown-content" v-html="renderedSummary"></div>
<div v-else class="text-surface-500 dark:text-surface-400">Резюме недоступно</div>
</div>
</div>
<!-- Динамическое содержимое отчета -->
<div v-if="parsedContent.length" class="space-y-4">
<div v-for="(segment, index) in parsedContent" :key="index">
<div v-if="segment.type === 'markdown'" class="card">
<div class="card-content">
<div class="markdown-content" v-html="segment.html"></div>
</div>
</div>
<!-- Целевая аудитория -->
<div class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Целевая аудитория</h3>
</div>
<div class="card-content">
<p class="text-surface-700 dark:text-surface-300 mb-4 whitespace-pre-line">{{ report.targetAudience?.description }}</p>
<div v-if="report.targetAudience?.channels && report.targetAudience.channels.length > 0">
<p class="text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">Рекомендуемые каналы:</p>
<div class="flex flex-wrap gap-2">
<Tag v-for="channel in report.targetAudience.channels" :key="channel" :value="channel" severity="info" />
<div v-else-if="segment.type === 'chart'" class="card">
<div class="card-header mb-4" v-if="segment.title">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">{{ segment.title }}</h3>
</div>
<div class="card-content">
<div class="chart-container" :style="segment.chartHeight">
<Chart :type="segment.chartType" :data="segment.data" :options="segment.options" />
</div>
<div v-if="segment.key === 'channelsPotential' && segment.channels?.length" class="mt-4 space-y-2">
<div v-for="channel in segment.channels" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
<div class="flex justify-content-between align-items-center mb-2">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
</div>
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="segment.type === 'audienceSegmentation'" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Сегментация целевой аудитории</h3>
</div>
<div class="card-content">
<div class="grid">
<div v-if="segment.ageGroupsChartData" class="col-12 md:col-6 mb-4">
<h4 class="text-lg font-semibold mb-3 text-surface-700 dark:text-surface-300">По возрасту</h4>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="segment.ageGroupsChartData" :options="pieChartOptions" />
</div>
</div>
<div v-if="segment.gendersChartData" class="col-12 md:col-6 mb-4">
<h4 class="text-lg font-semibold mb-3 text-surface-700 dark:text-surface-300">По полу</h4>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="segment.gendersChartData" :options="pieChartOptions" />
</div>
</div>
</div>
</div>
</div>
<div v-else-if="segment.type === 'competitors'" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Анализ конкурентов</h3>
</div>
<div class="card-content">
<div class="grid">
<div v-for="competitor in segment.competitors" :key="competitor.name" class="col-12 md:col-6 mb-4">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<h4 class="text-lg font-semibold mb-3 text-surface-900 dark:text-surface-0">{{ competitor.name }}</h4>
<div class="space-y-2 mb-3">
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Охват:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reach?.toLocaleString() || 0 }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Отзывы:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reviews?.toLocaleString() || 0 }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Активность:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.activity }}%</span>
</div>
</div>
<div v-if="competitor.strengths?.length" class="mb-3">
<p class="text-sm font-semibold mb-2 text-green-600 dark:text-green-400">Сильные стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in competitor.strengths" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
</ul>
</div>
<div v-if="competitor.weaknesses?.length">
<p class="text-sm font-semibold mb-2 text-red-600 dark:text-red-400">Слабые стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in competitor.weaknesses" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="segment.type === 'swot'" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">SWOT-анализ</h3>
</div>
<div class="card-content">
<div class="grid">
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700">
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300">Сильные стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in segment.data.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700">
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300">Слабые стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in segment.data.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
<ul class="list-none p-0 m-0">
<li v-for="(opportunity, idx) in segment.data.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
<ul class="list-none p-0 m-0">
<li v-for="(threat, idx) in segment.data.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Рекомендации -->
<div v-if="report.recommendations && report.recommendations.length > 0" class="card">
<div v-if="report.recommendations?.length" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Рекомендации</h3>
</div>
@@ -226,195 +335,6 @@
</div>
</div>
<!-- Графики и визуализации -->
<div v-if="report.chartsData" class="space-y-4">
<!-- Сезонность -->
<div v-if="report.chartsData.seasonality && seasonalityChartData" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Сезонность спроса</h3>
</div>
<div class="card-content">
<div class="chart-container" style="height: 300px">
<Chart type="line" :data="seasonalityChartData" :options="seasonalityChartOptions" />
</div>
</div>
</div>
<!-- Воронка конверсии -->
<div v-if="report.chartsData.conversionFunnel && report.chartsData.conversionFunnel.length > 0 && conversionFunnelChartData" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Воронка конверсии</h3>
</div>
<div class="card-content">
<div class="chart-container" style="height: 400px">
<Chart type="bar" :data="conversionFunnelChartData" :options="conversionFunnelChartOptions" />
</div>
</div>
</div>
<!-- Потенциал каналов -->
<div v-if="report.chartsData.channelsPotential && report.chartsData.channelsPotential.length > 0 && channelsPotentialChartData" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Потенциал каналов продвижения</h3>
</div>
<div class="card-content">
<div class="chart-container" style="height: 300px">
<Chart type="bar" :data="channelsPotentialChartData" :options="channelsPotentialChartOptions" />
</div>
<div class="mt-4 space-y-2">
<div v-for="channel in report.chartsData.channelsPotential" :key="channel.name" class="p-3 border-round bg-surface-50 dark:bg-surface-800">
<div class="flex justify-content-between align-items-center mb-2">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ channel.name }}</span>
<Tag :value="`Потенциал: ${channel.potential}`" :severity="channel.potential >= 80 ? 'success' : channel.potential >= 60 ? 'warning' : 'info'" />
</div>
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.justification }}</p>
</div>
</div>
</div>
</div>
<!-- Сегментация аудитории -->
<div v-if="report.chartsData.audienceSegmentation && (ageGroupsChartData || gendersChartData)" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Сегментация целевой аудитории</h3>
</div>
<div class="card-content">
<div class="grid">
<div v-if="report.chartsData.audienceSegmentation.ageGroups && report.chartsData.audienceSegmentation.ageGroups.length > 0 && ageGroupsChartData" class="col-12 md:col-6 mb-4">
<h4 class="text-lg font-semibold mb-3 text-surface-700 dark:text-surface-300">По возрасту</h4>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="ageGroupsChartData" :options="pieChartOptions" />
</div>
</div>
<div v-if="report.chartsData.audienceSegmentation.genders && report.chartsData.audienceSegmentation.genders.length > 0 && gendersChartData" class="col-12 md:col-6 mb-4">
<h4 class="text-lg font-semibold mb-3 text-surface-700 dark:text-surface-300">По полу</h4>
<div class="chart-container" style="height: 300px">
<Chart type="pie" :data="gendersChartData" :options="pieChartOptions" />
</div>
</div>
</div>
</div>
</div>
<!-- Конкуренты -->
<div v-if="report.chartsData.competitors && report.chartsData.competitors.length > 0" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Анализ конкурентов</h3>
</div>
<div class="card-content">
<div class="grid">
<div v-for="competitor in report.chartsData.competitors" :key="competitor.name" class="col-12 md:col-6 mb-4">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<h4 class="text-lg font-semibold mb-3 text-surface-900 dark:text-surface-0">{{ competitor.name }}</h4>
<div class="space-y-2 mb-3">
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Охват:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reach?.toLocaleString() || 0 }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Отзывы:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.reviews?.toLocaleString() || 0 }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-surface-600 dark:text-surface-400">Активность:</span>
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ competitor.activity }}%</span>
</div>
</div>
<div v-if="competitor.strengths && competitor.strengths.length > 0" class="mb-3">
<p class="text-sm font-semibold mb-2 text-green-600 dark:text-green-400">Сильные стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in competitor.strengths" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
</ul>
</div>
<div v-if="competitor.weaknesses && competitor.weaknesses.length > 0">
<p class="text-sm font-semibold mb-2 text-red-600 dark:text-red-400">Слабые стороны:</p>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in competitor.weaknesses" :key="idx" class="text-sm text-surface-700 dark:text-surface-300 mb-1"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- SWOT анализ -->
<div v-if="report.chartsData.swot" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">SWOT-анализ</h3>
</div>
<div class="card-content">
<div class="grid">
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-green-50 dark:bg-green-900 border-1 border-green-200 dark:border-green-700">
<h4 class="text-lg font-semibold mb-3 text-green-700 dark:text-green-300">Сильные стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(strength, idx) in report.chartsData.swot.strengths" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-check-circle text-green-500 mr-2"></i>{{ strength }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-red-50 dark:bg-red-900 border-1 border-red-200 dark:border-red-700">
<h4 class="text-lg font-semibold mb-3 text-red-700 dark:text-red-300">Слабые стороны</h4>
<ul class="list-none p-0 m-0">
<li v-for="(weakness, idx) in report.chartsData.swot.weaknesses" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-times-circle text-red-500 mr-2"></i>{{ weakness }}</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-blue-50 dark:bg-blue-900 border-1 border-blue-200 dark:border-blue-700">
<h4 class="text-lg font-semibold mb-3 text-blue-700 dark:text-blue-300">Возможности</h4>
<ul class="list-none p-0 m-0">
<li v-for="(opportunity, idx) in report.chartsData.swot.opportunities" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2">
<i class="pi pi-arrow-up-circle text-blue-500 mr-2"></i>{{ opportunity }}
</li>
</ul>
</div>
</div>
<div class="col-12 md:col-6 mb-3">
<div class="p-4 border-round bg-orange-50 dark:bg-orange-900 border-1 border-orange-200 dark:border-orange-700">
<h4 class="text-lg font-semibold mb-3 text-orange-700 dark:text-orange-300">Угрозы</h4>
<ul class="list-none p-0 m-0">
<li v-for="(threat, idx) in report.chartsData.swot.threats" :key="idx" class="text-surface-700 dark:text-surface-300 mb-2"><i class="pi pi-exclamation-triangle text-orange-500 mr-2"></i>{{ threat }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Стратегия -->
<div v-if="report.strategy" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Маркетинговая стратегия</h3>
</div>
<div class="card-content">
<div class="grid">
<div v-if="report.strategy.duration" class="col-12 md:col-4 mb-3">
<div class="p-3 border-round bg-blue-50 dark:bg-blue-900">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Длительность</p>
<p class="text-lg font-semibold text-surface-900 dark:text-surface-0">{{ report.strategy.duration }}</p>
</div>
</div>
<div v-if="report.strategy.channels && (Array.isArray(report.strategy.channels) ? report.strategy.channels.length > 0 : report.strategy.channels.trim())" class="col-12 mb-3">
<div class="p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-3">Каналы</p>
<div class="markdown-content" v-html="renderMarkdown(Array.isArray(report.strategy.channels) ? report.strategy.channels.join('\n') : report.strategy.channels)"></div>
</div>
</div>
<div v-if="report.strategy.contentTypes && report.strategy.contentTypes.length > 0" class="col-12 md:col-4 mb-3">
<div class="p-3 border-round bg-purple-50 dark:bg-purple-900">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-2">Типы контента</p>
<div class="flex flex-wrap gap-2">
<Tag v-for="type in report.strategy.contentTypes" :key="type" :value="type" severity="secondary" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="card">
<div class="card-content text-center">
@@ -837,34 +757,6 @@ const renderMarkdown = (markdown) => {
}
};
// Computed свойство для рендеринга summary
const renderedSummary = computed(() => {
if (!report.value || !report.value.summary) return '';
return renderMarkdown(report.value.summary);
});
// Chart data computed properties
const seasonalityChartData = computed(() => {
if (!report.value?.chartsData?.seasonality) return null;
const seasonality = report.value.chartsData.seasonality;
const months = Object.keys(seasonality);
const values = Object.values(seasonality);
return {
labels: months,
datasets: [
{
label: 'Спрос (%)',
data: values,
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}
]
};
});
const seasonalityChartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -887,32 +779,6 @@ const seasonalityChartOptions = {
}
};
const conversionFunnelChartData = computed(() => {
if (!report.value?.chartsData?.conversionFunnel || report.value.chartsData.conversionFunnel.length === 0) return null;
const funnel = report.value.chartsData.conversionFunnel;
return {
labels: funnel.map((item) => item.stage),
datasets: [
{
label: 'Значение',
data: funnel.map((item) => item.value),
backgroundColor: '#3B82F6',
borderColor: '#2563EB',
borderWidth: 1
},
{
label: 'Конверсия (%)',
data: funnel.map((item) => item.conversion),
backgroundColor: '#10B981',
borderColor: '#059669',
borderWidth: 1,
yAxisID: 'y1'
}
]
};
});
const conversionFunnelChartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -951,32 +817,6 @@ const conversionFunnelChartOptions = {
}
};
const channelsPotentialChartData = computed(() => {
if (!report.value?.chartsData?.channelsPotential || report.value.chartsData.channelsPotential.length === 0) return null;
const channels = report.value.chartsData.channelsPotential;
return {
labels: channels.map((ch) => ch.name),
datasets: [
{
label: 'Потенциал',
data: channels.map((ch) => ch.potential),
backgroundColor: channels.map((ch) => {
if (ch.potential >= 80) return '#10B981';
if (ch.potential >= 60) return '#F59E0B';
return '#3B82F6';
}),
borderColor: channels.map((ch) => {
if (ch.potential >= 80) return '#059669';
if (ch.potential >= 60) return '#D97706';
return '#2563EB';
}),
borderWidth: 1
}
]
};
});
const channelsPotentialChartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -998,42 +838,233 @@ const channelsPotentialChartOptions = {
}
};
const ageGroupsChartData = computed(() => {
if (!report.value?.chartsData?.audienceSegmentation?.ageGroups || report.value.chartsData.audienceSegmentation.ageGroups.length === 0) return null;
const ageGroups = report.value.chartsData.audienceSegmentation.ageGroups;
const buildSeasonalityChartData = (seasonality) => {
if (!seasonality || typeof seasonality !== 'object') return null;
const months = Object.keys(seasonality);
const values = Object.values(seasonality);
if (!months.length) return null;
return {
labels: months,
datasets: [
{
label: 'Спрос (%)',
data: values,
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}
]
};
};
const buildConversionFunnelChartData = (funnel) => {
if (!Array.isArray(funnel) || funnel.length === 0) return null;
return {
labels: funnel.map((item) => item.stage),
datasets: [
{
label: 'Значение',
data: funnel.map((item) => item.value),
backgroundColor: '#3B82F6',
borderColor: '#2563EB',
borderWidth: 1
},
{
label: 'Конверсия (%)',
data: funnel.map((item) => item.conversion),
backgroundColor: '#10B981',
borderColor: '#059669',
borderWidth: 1,
yAxisID: 'y1'
}
]
};
};
const buildChannelsPotentialChartData = (channels) => {
if (!Array.isArray(channels) || channels.length === 0) return null;
return {
labels: channels.map((ch) => ch.name),
datasets: [
{
label: 'Потенциал',
data: channels.map((ch) => ch.potential),
backgroundColor: channels.map((ch) => {
if (ch.potential >= 80) return '#10B981';
if (ch.potential >= 60) return '#F59E0B';
return '#3B82F6';
}),
borderColor: channels.map((ch) => {
if (ch.potential >= 80) return '#059669';
if (ch.potential >= 60) return '#D97706';
return '#2563EB';
}),
borderWidth: 1
}
]
};
};
const buildAudienceSegmentationChartData = (segmentation) => {
if (!segmentation) return { ageGroupsChartData: null, gendersChartData: null };
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
return {
labels: ageGroups.map((ag) => ag.label),
datasets: [
{
data: ageGroups.map((ag) => parseFloat(ag.value)),
backgroundColor: ageGroups.map((_, idx) => colors[idx % colors.length]),
borderColor: '#fff',
borderWidth: 2
const ageGroupsChartData =
Array.isArray(segmentation.ageGroups) && segmentation.ageGroups.length > 0
? {
labels: segmentation.ageGroups.map((ag) => ag.label),
datasets: [
{
data: segmentation.ageGroups.map((ag) => parseFloat(ag.value)),
backgroundColor: segmentation.ageGroups.map((_, idx) => colors[idx % colors.length]),
borderColor: '#fff',
borderWidth: 2
}
]
}
: null;
const gendersChartData =
Array.isArray(segmentation.genders) && segmentation.genders.length > 0
? {
labels: segmentation.genders.map((g) => g.label),
datasets: [
{
data: segmentation.genders.map((g) => parseFloat(g.value)),
backgroundColor: segmentation.genders.map((_, idx) => ['#EC4899', '#3B82F6'][idx % 2]),
borderColor: '#fff',
borderWidth: 2
}
]
}
: null;
return { ageGroupsChartData, gendersChartData };
};
const mapJsonBlockToSegment = (identifier, data, rawText) => {
const key = identifier?.toLowerCase();
switch (key) {
case 'seasonality': {
const chartData = buildSeasonalityChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'seasonality',
title: 'Сезонность спроса',
chartType: 'line',
data: chartData,
options: seasonalityChartOptions,
chartHeight: 'height: 300px'
};
}
case 'funnel':
case 'conversionfunnel': {
const chartData = buildConversionFunnelChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'funnel',
title: 'Воронка конверсии',
chartType: 'bar',
data: chartData,
options: conversionFunnelChartOptions,
chartHeight: 'height: 400px'
};
}
case 'channelspotential': {
const chartData = buildChannelsPotentialChartData(data);
if (!chartData) return null;
return {
type: 'chart',
key: 'channelsPotential',
title: 'Потенциал каналов продвижения',
chartType: 'bar',
data: chartData,
options: channelsPotentialChartOptions,
chartHeight: 'height: 300px',
channels: Array.isArray(data) ? data : []
};
}
case 'audiencesegmentation': {
const { ageGroupsChartData, gendersChartData } = buildAudienceSegmentationChartData(data || {});
if (!ageGroupsChartData && !gendersChartData) return null;
return {
type: 'audienceSegmentation',
ageGroupsChartData,
gendersChartData
};
}
case 'competitors': {
if (!Array.isArray(data) || data.length === 0) return null;
return {
type: 'competitors',
competitors: data
};
}
case 'swot': {
if (!data || typeof data !== 'object') return null;
return {
type: 'swot',
data: {
strengths: data.strengths || [],
weaknesses: data.weaknesses || [],
opportunities: data.opportunities || [],
threats: data.threats || []
}
};
}
default:
// Unknown block type: render raw markdown so nothing is lost
return {
type: 'markdown',
html: renderMarkdown(rawText || '')
};
}
};
const parsedContent = computed(() => {
if (!report.value?.fullAnalysis) return [];
const content = report.value.fullAnalysis;
const regex = /```json:(\w+)\n([\s\S]*?)\n```/g;
const segments = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(content)) !== null) {
const [fullMatch, identifier, jsonBlock] = match;
if (match.index > lastIndex) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: 'markdown', html: renderMarkdown(textBefore) });
}
]
};
});
}
const gendersChartData = computed(() => {
if (!report.value?.chartsData?.audienceSegmentation?.genders || report.value.chartsData.audienceSegmentation.genders.length === 0) return null;
const genders = report.value.chartsData.audienceSegmentation.genders;
let parsedData = null;
try {
parsedData = JSON.parse(jsonBlock);
} catch (error) {
console.error(`Не удалось распарсить блок ${identifier}:`, error);
}
const colors = ['#EC4899', '#3B82F6'];
const segment = mapJsonBlockToSegment(identifier, parsedData, fullMatch);
if (segment) {
segments.push(segment);
}
return {
labels: genders.map((g) => g.label),
datasets: [
{
data: genders.map((g) => parseFloat(g.value)),
backgroundColor: genders.map((_, idx) => colors[idx % colors.length]),
borderColor: '#fff',
borderWidth: 2
}
]
};
lastIndex = regex.lastIndex;
}
const remaining = content.slice(lastIndex);
if (remaining.trim()) {
segments.push({ type: 'markdown', html: renderMarkdown(remaining) });
}
return segments;
});
const pieChartOptions = {