From c46c0be173e00cd72a928a0ed10fb9b86947aa16 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 10 Dec 2025 20:04:54 +0500 Subject: [PATCH] . --- frontend-chart-rendering.md | 60 ++ src/service/MarketingService.js | 2 +- .../pages/marketing/MarketingAnalysis.vue | 671 +++++++++--------- 3 files changed, 412 insertions(+), 321 deletions(-) create mode 100644 frontend-chart-rendering.md diff --git a/frontend-chart-rendering.md b/frontend-chart-rendering.md new file mode 100644 index 0000000..41bd702 --- /dev/null +++ b/frontend-chart-rendering.md @@ -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. diff --git a/src/service/MarketingService.js b/src/service/MarketingService.js index 88b617e..714c78c 100644 --- a/src/service/MarketingService.js +++ b/src/service/MarketingService.js @@ -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); diff --git a/src/views/pages/marketing/MarketingAnalysis.vue b/src/views/pages/marketing/MarketingAnalysis.vue index bf8c63e..f05fc73 100644 --- a/src/views/pages/marketing/MarketingAnalysis.vue +++ b/src/views/pages/marketing/MarketingAnalysis.vue @@ -184,35 +184,144 @@ - -
-
-

Краткое резюме

-
-
-
-
Резюме недоступно
-
-
+ +
+
+
+
+
+
+
- -
-
-

Целевая аудитория

-
-
-

{{ report.targetAudience?.description }}

-
-

Рекомендуемые каналы:

-
- +
+
+

{{ segment.title }}

+
+
+
+ +
+
+
+
+ {{ channel.name }} + +
+

{{ channel.justification }}

+
+
+
+
+ +
+
+

Сегментация целевой аудитории

+
+
+
+
+

По возрасту

+
+ +
+
+
+

По полу

+
+ +
+
+
+
+
+ +
+
+

Анализ конкурентов

+
+
+
+
+
+

{{ competitor.name }}

+
+
+ Охват: + {{ competitor.reach?.toLocaleString() || 0 }} +
+
+ Отзывы: + {{ competitor.reviews?.toLocaleString() || 0 }} +
+
+ Активность: + {{ competitor.activity }}% +
+
+
+

Сильные стороны:

+
    +
  • {{ strength }}
  • +
+
+
+

Слабые стороны:

+
    +
  • {{ weakness }}
  • +
+
+
+
+
+
+
+ +
+
+

SWOT-анализ

+
+
+
+
+
+

Сильные стороны

+
    +
  • {{ strength }}
  • +
+
+
+
+
+

Слабые стороны

+
    +
  • {{ weakness }}
  • +
+
+
+
+
+

Возможности

+
    +
  • {{ opportunity }}
  • +
+
+
+
+
+

Угрозы

+
    +
  • {{ threat }}
  • +
+
+
+
-
+

Рекомендации

@@ -226,195 +335,6 @@
- -
- -
-
-

Сезонность спроса

-
-
-
- -
-
-
- - -
-
-

Воронка конверсии

-
-
-
- -
-
-
- - -
-
-

Потенциал каналов продвижения

-
-
-
- -
-
-
-
- {{ channel.name }} - -
-

{{ channel.justification }}

-
-
-
-
- - -
-
-

Сегментация целевой аудитории

-
-
-
-
-

По возрасту

-
- -
-
-
-

По полу

-
- -
-
-
-
-
- - -
-
-

Анализ конкурентов

-
-
-
-
-
-

{{ competitor.name }}

-
-
- Охват: - {{ competitor.reach?.toLocaleString() || 0 }} -
-
- Отзывы: - {{ competitor.reviews?.toLocaleString() || 0 }} -
-
- Активность: - {{ competitor.activity }}% -
-
-
-

Сильные стороны:

-
    -
  • {{ strength }}
  • -
-
-
-

Слабые стороны:

-
    -
  • {{ weakness }}
  • -
-
-
-
-
-
-
- - -
-
-

SWOT-анализ

-
-
-
-
-
-

Сильные стороны

-
    -
  • {{ strength }}
  • -
-
-
-
-
-

Слабые стороны

-
    -
  • {{ weakness }}
  • -
-
-
-
-
-

Возможности

-
    -
  • - {{ opportunity }} -
  • -
-
-
-
-
-

Угрозы

-
    -
  • {{ threat }}
  • -
-
-
-
-
-
-
- - -
-
-

Маркетинговая стратегия

-
-
-
-
-
-

Длительность

-

{{ report.strategy.duration }}

-
-
-
-
-

Каналы

-
-
-
-
-
-

Типы контента

-
- -
-
-
-
-
-
-
@@ -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 = {