.
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user