This commit is contained in:
root
2025-11-27 10:23:04 +05:00
parent d808b9acff
commit b4c6790ebc
12 changed files with 3509 additions and 1 deletions
+11 -1
View File
@@ -48,7 +48,17 @@ export const API_CONFIG = {
PUBLISHING_POST: '/publishing/post',
// AnalyticsController - Аналитика
ANALYTICS_COLLECT: '/analytics/collect-now'
ANALYTICS_COLLECT: '/analytics/collect-now',
// MarketingAnalysisController - Маркетинговый анализ
MARKETING_ANALYSIS_START: '/api/marketing/analysis/start',
MARKETING_ANALYSIS_GET: '/api/marketing/analysis',
MARKETING_ANALYSIS_DOWNLOAD: '/api/marketing/analysis',
// MarketingStrategyController - Стратегия продвижения
MARKETING_STRATEGY_GENERATE: '/api/marketing/analysis/strategy/generate',
MARKETING_STRATEGY_GET: '/api/marketing/analysis/strategy',
MARKETING_STRATEGY_BY_ANALYSIS: '/api/marketing/analysis'
}
};
+20
View File
@@ -223,6 +223,26 @@ const router = createRouter({
name: 'landing',
component: () => import('@/views/pages/Landing.vue')
},
{
path: '/marketing-analysis',
name: 'marketing-main',
component: () => import('@/views/pages/marketing/MarketingMain.vue')
},
{
path: '/marketing-analysis/analysis',
name: 'marketing-analysis',
component: () => import('@/views/pages/marketing/MarketingAnalysis.vue')
},
{
path: '/marketing-analysis/promotion',
name: 'marketing-promotion',
component: () => import('@/views/pages/marketing/MarketingPromotion.vue')
},
{
path: '/marketing-analysis/results',
name: 'marketing-results',
component: () => import('@/views/pages/marketing/MarketingResults.vue')
},
{
path: '/pages/notfound',
name: 'notfound',
+217
View File
@@ -0,0 +1,217 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
import AuthService from './AuthService';
const API_BASE_URL = API_CONFIG.BASE_URL;
class MarketingService {
/**
* Запуск маркетингового анализа
* POST /api/marketing/analysis/start
* @param {Object} data - Данные для анализа
* @param {string} data.product - Название продукта или услуги
* @param {string} data.location - Географическая локация работы
* @param {string} data.client - Тип целевой аудитории
* @param {string} data.differentiator - Уникальные особенности бизнеса
* @returns {Promise<Object>} Объект с analysisId и статусом
*/
async startAnalysis(data) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_START}`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify({
product: data.product,
location: data.location,
client: data.client,
differentiator: data.differentiator
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при запуске анализа');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при запуске анализа');
}
return result.data;
} catch (error) {
console.error('Ошибка при запуске маркетингового анализа:', error);
throw error;
}
}
/**
* Получение результата анализа
* GET /api/marketing/analysis/{analysisId}
* @param {string} analysisId - Идентификатор анализа
* @returns {Promise<Object>} Объект с статусом и результатами анализа
*/
async getAnalysisResult(analysisId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_GET}/${analysisId}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при получении результата анализа');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при получении результата анализа');
}
return result.data;
} catch (error) {
console.error('Ошибка при получении результата анализа:', error);
throw error;
}
}
/**
* Скачивание PDF отчета
* GET /api/marketing/analysis/{analysisId}/download
* @param {string} analysisId - Идентификатор анализа
* @returns {Promise<Blob>} Blob с PDF файлом
*/
async downloadPdf(analysisId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_ANALYSIS_DOWNLOAD}/${analysisId}/download`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error?.message || 'Ошибка при скачивании PDF');
}
return await response.blob();
} catch (error) {
console.error('Ошибка при скачивании PDF:', error);
throw error;
}
}
/**
* Вспомогательный метод для скачивания PDF в браузере
* @param {string} analysisId - Идентификатор анализа
*/
async downloadPdfFile(analysisId) {
try {
const blob = await this.downloadPdf(analysisId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `marketing_analysis_${analysisId}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Ошибка при скачивании PDF файла:', error);
throw error;
}
}
/**
* Запуск генерации стратегии продвижения
* POST /api/marketing/strategy/generate?analysisId={analysisId}
* @param {string} analysisId - ID завершенного маркетингового анализа
* @param {Object} options - Опции генерации стратегии
* @param {number} options.durationWeeks - Длительность стратегии в неделях (1-12)
* @param {string[]} options.priorityPlatforms - Приоритетные платформы для продвижения
* @returns {Promise<Object>} Объект с strategyId и статусом
*/
async generateStrategy(analysisId, options = {}) {
try {
const params = new URLSearchParams();
params.append('analysisId', analysisId);
const body = {};
if (options.durationWeeks) {
body.durationWeeks = options.durationWeeks;
}
if (options.priorityPlatforms && options.priorityPlatforms.length > 0) {
body.priorityPlatforms = options.priorityPlatforms;
}
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_STRATEGY_GENERATE}?${params}`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при запуске генерации стратегии');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при запуске генерации стратегии');
}
return result.data;
} catch (error) {
console.error('Ошибка при запуске генерации стратегии:', error);
throw error;
}
}
/**
* Получение стратегии по ID
* GET /api/marketing/strategy/{strategyId}
* @param {string} strategyId - Идентификатор стратегии
* @returns {Promise<Object>} Объект с статусом и результатами стратегии
*/
async getStrategy(strategyId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_STRATEGY_GET}/${strategyId}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при получении стратегии');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при получении стратегии');
}
return result.data;
} catch (error) {
console.error('Ошибка при получении стратегии:', error);
throw error;
}
}
/**
* Получение стратегии по ID анализа
* GET /api/marketing/analysis/{analysisId}/strategy
* @param {string} analysisId - Идентификатор анализа
* @returns {Promise<Object>} Объект с статусом и результатами стратегии
*/
async getStrategyByAnalysis(analysisId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_STRATEGY_BY_ANALYSIS}/${analysisId}/strategy`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при получении стратегии');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при получении стратегии');
}
return result.data;
} catch (error) {
console.error('Ошибка при получении стратегии по анализу:', error);
throw error;
}
}
}
export default new MarketingService();
@@ -0,0 +1,598 @@
<template>
<div class="marketing-analysis-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1200px] mx-auto">
<!-- Форма запуска анализа -->
<div v-if="!analysisId" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Запуск анализа</h2>
</div>
<div class="card-content">
<form @submit.prevent="handleSubmit" class="space-y-4 max-w-2xl">
<div class="field">
<label for="product" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Что вы продаете? </label>
<InputText id="product" v-model="formData.product" placeholder="Введите название продукта" class="w-full" :class="{ 'p-invalid': errors.product }" />
<small v-if="errors.product" class="p-error">{{ errors.product }}</small>
</div>
<div class="field">
<label for="location" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Где вы работаете? </label>
<InputText id="location" v-model="formData.location" placeholder="Введите локацию" class="w-full" :class="{ 'p-invalid': errors.location }" />
<small v-if="errors.location" class="p-error">{{ errors.location }}</small>
</div>
<div class="field">
<label for="client" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Кто ваш клиент? </label>
<Dropdown id="client" v-model="formData.client" :options="clientOptions" placeholder="Выберите тип клиента" class="w-full" :class="{ 'p-invalid': errors.client }" />
<small v-if="errors.client" class="p-error">{{ errors.client }}</small>
</div>
<div class="field">
<label for="differentiator" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Чем ваш бизнес выделяется? </label>
<InputText id="differentiator" v-model="formData.differentiator" placeholder="Опишите уникальность" class="w-full" :class="{ 'p-invalid': errors.differentiator }" />
<small v-if="errors.differentiator" class="p-error">{{ errors.differentiator }}</small>
</div>
<Button type="submit" label="Начать анализ" icon="pi pi-send" class="w-full p-button-primary" :loading="submitting" />
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">На основе ваших ответов будет создан подробный и полезный маркетинговый отчет</p>
</form>
</div>
</div>
<!-- Статус обработки -->
<div v-else-if="status === 'processing' || status === 'queued'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Анализ выполняется</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<ProgressSpinner size="50" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ обрабатывается...</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Это может занять 5-10 минут. Пожалуйста, подождите.</p>
<p v-if="estimatedCompletionTime" class="mt-2 text-sm text-surface-500 dark:text-surface-400">Ожидаемое время завершения: {{ formatDateTime(estimatedCompletionTime) }}</p>
<Button label="Отменить" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetAnalysis" />
</div>
</div>
</div>
<!-- Ошибка -->
<div v-else-if="status === 'failed'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка анализа</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Анализ завершился с ошибкой</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Попробуйте запустить анализ снова или обратитесь в поддержку.</p>
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetAnalysis" />
</div>
</div>
</div>
<!-- Результаты анализа -->
<div v-else-if="status === 'completed' && report" class="space-y-4">
<!-- Заголовок с кнопкой скачивания -->
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Результаты анализа</h2>
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
</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 whitespace-pre-line">{{ report.summary }}</p>
</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>
</div>
</div>
</div>
<!-- Рекомендации -->
<div v-if="report.recommendations && report.recommendations.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">
<ul class="list-none p-0 m-0">
<li v-for="(recommendation, index) in report.recommendations" :key="index" class="flex align-items-start mb-3">
<i class="pi pi-check-circle text-green-500 mr-2 mt-1"></i>
<span class="text-surface-700 dark:text-surface-300">{{ recommendation }}</span>
</li>
</ul>
</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">
<div class="flex gap-3 justify-content-center flex-wrap">
<Button label="Создать стратегию на основе анализа" icon="pi pi-magic" severity="success" @click="goToStrategy" />
<Button label="Создать новый анализ" icon="pi pi-plus" severity="secondary" @click="resetAnalysis" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import MarketingService from '@/service/MarketingService';
import { marked } from 'marked';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';
import { onBeforeUnmount, ref } from 'vue';
import { useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
// Form data
const formData = ref({
product: '',
location: '',
client: null,
differentiator: ''
});
const clientOptions = ['B2B клиенты', 'B2C клиенты', 'Частные лица', 'Корпорации', 'Малый бизнес'];
const submitting = ref(false);
const errors = ref({});
// Analysis state
const analysisId = ref(null);
const status = ref(null);
const report = ref(null);
const estimatedCompletionTime = ref(null);
const pollingInterval = ref(null);
const pollingAttempts = ref(0);
const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
const pollingIntervalMs = 10000; // 10 seconds
const downloadingPdf = ref(false);
// Validation
const validateForm = () => {
errors.value = {};
// Product validation
if (!formData.value.product || formData.value.product.trim().length < 3) {
errors.value.product = 'Название продукта должно содержать минимум 3 символа';
} else if (formData.value.product.trim().length > 200) {
errors.value.product = 'Название продукта не должно превышать 200 символов';
}
// Location validation
if (!formData.value.location || formData.value.location.trim().length < 2) {
errors.value.location = 'Локация должна содержать минимум 2 символа';
} else if (formData.value.location.trim().length > 150) {
errors.value.location = 'Локация не должна превышать 150 символов';
}
// Client validation
if (!formData.value.client) {
errors.value.client = 'Пожалуйста, выберите тип клиента';
} else if (!clientOptions.includes(formData.value.client)) {
errors.value.client = 'Выбран недопустимый тип клиента';
}
// Differentiator validation
if (!formData.value.differentiator || formData.value.differentiator.trim().length < 10) {
errors.value.differentiator = 'Описание уникальности должно содержать минимум 10 символов';
} else if (formData.value.differentiator.trim().length > 500) {
errors.value.differentiator = 'Описание уникальности не должно превышать 500 символов';
}
return Object.keys(errors.value).length === 0;
};
// Start polling for analysis status
const startPolling = (id) => {
pollingAttempts.value = 0;
pollingInterval.value = setInterval(async () => {
pollingAttempts.value++;
if (pollingAttempts.value > maxPollingAttempts) {
stopPolling();
toast.add({
severity: 'warn',
summary: 'Таймаут',
detail: 'Превышено время ожидания анализа. Попробуйте проверить результат позже.',
life: 5000
});
return;
}
try {
const result = await MarketingService.getAnalysisResult(id);
status.value = result.status;
if (result.status === 'completed' && result.report) {
stopPolling();
report.value = result.report;
toast.add({
severity: 'success',
summary: 'Анализ завершен',
detail: 'Маркетинговый анализ успешно завершен',
life: 3000
});
} else if (result.status === 'failed') {
stopPolling();
toast.add({
severity: 'error',
summary: 'Ошибка анализа',
detail: 'Анализ завершился с ошибкой',
life: 5000
});
}
} catch (error) {
console.error('Ошибка при проверке статуса анализа:', error);
// Continue polling on error, but log it
}
}, pollingIntervalMs);
};
// Stop polling
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
// Handle form submit
const handleSubmit = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
submitting.value = true;
try {
const result = await MarketingService.startAnalysis({
product: formData.value.product.trim(),
location: formData.value.location.trim(),
client: formData.value.client,
differentiator: formData.value.differentiator.trim()
});
analysisId.value = result.analysisId;
status.value = result.status;
estimatedCompletionTime.value = result.estimatedCompletionTime;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Start polling
startPolling(result.analysisId);
} catch (error) {
console.error('Ошибка при запуске анализа:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить анализ. Пожалуйста, попробуйте снова.',
life: 5000
});
} finally {
submitting.value = false;
}
};
// Handle PDF download
const handleDownloadPdf = async () => {
if (!analysisId.value) return;
downloadingPdf.value = true;
try {
await MarketingService.downloadPdfFile(analysisId.value);
toast.add({
severity: 'success',
summary: 'PDF скачан',
detail: 'PDF отчет успешно скачан',
life: 3000
});
} catch (error) {
console.error('Ошибка при скачивании PDF:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось скачать PDF отчет',
life: 5000
});
} finally {
downloadingPdf.value = false;
}
};
// Reset analysis and form
const resetAnalysis = () => {
stopPolling();
analysisId.value = null;
status.value = null;
report.value = null;
estimatedCompletionTime.value = null;
pollingAttempts.value = 0;
formData.value = {
product: '',
location: '',
client: null,
differentiator: ''
};
errors.value = {};
};
// Configure marked options
marked.setOptions({
breaks: true,
gfm: true
});
// Render markdown to HTML
const renderMarkdown = (markdown) => {
if (!markdown) return '';
try {
return marked.parse(markdown);
} catch (error) {
console.error('Error rendering markdown:', error);
return markdown;
}
};
// Format date time
const formatDateTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
date.setHours(date.getHours() + 5);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Navigate to strategy page
const goToStrategy = () => {
if (analysisId.value) {
router.push({
name: 'marketing-promotion',
query: { analysisId: analysisId.value }
});
} else {
toast.add({
severity: 'warn',
summary: 'Предупреждение',
detail: 'ID анализа не найден',
life: 3000
});
}
};
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
});
</script>
<style scoped>
.marketing-analysis-page {
font-family: var(--font-family);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid var(--surface-border);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-content {
padding-top: 0;
}
.markdown-content {
line-height: 1.7;
color: var(--text-color);
font-size: 0.95rem;
}
.markdown-content :deep(h1) {
font-size: 1.75rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
color: var(--text-color);
line-height: 1.3;
}
.markdown-content :deep(h1:first-child) {
margin-top: 0;
}
.markdown-content :deep(h2) {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--text-color);
line-height: 1.3;
}
.markdown-content :deep(h3) {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(h4) {
font-size: 1.1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
font-size: 1rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
color: var(--text-color);
line-height: 1.4;
}
.markdown-content :deep(p) {
margin-bottom: 1rem;
color: var(--text-color);
line-height: 1.7;
}
.markdown-content :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
margin-top: 0.5rem;
margin-bottom: 1rem;
padding-left: 2rem;
color: var(--text-color);
}
.markdown-content :deep(ul) {
list-style-type: disc;
}
.markdown-content :deep(ol) {
list-style-type: decimal;
}
.markdown-content :deep(li) {
margin-bottom: 0.5rem;
line-height: 1.7;
color: var(--text-color);
}
.markdown-content :deep(li > p) {
margin-bottom: 0.5rem;
}
.markdown-content :deep(li > ul),
.markdown-content :deep(li > ol) {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-content :deep(strong) {
font-weight: 600;
color: var(--text-color);
}
.markdown-content :deep(em) {
font-style: italic;
}
.markdown-content :deep(code) {
background-color: var(--surface-100);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-family: 'Courier New', monospace;
}
.markdown-content :deep(pre) {
background-color: var(--surface-100);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.markdown-content :deep(pre code) {
background-color: transparent;
padding: 0;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="marketing-main-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<div class="max-w-[1920px] mx-auto">
<div class="flex justify-center">
<!-- Navigation Menu -->
<div class="w-full max-w-md">
<div class="card h-full">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Маркетинг</h2>
</div>
<div class="card-content space-y-3">
<div
v-for="item in menuItems"
:key="item.id"
class="menu-item p-4 rounded-lg cursor-pointer transition-colors"
:class="{
'bg-primary-50 dark:bg-primary-900': activeMenu === item.id,
'hover:bg-surface-100 dark:hover:bg-surface-800': activeMenu !== item.id
}"
@click="navigateTo(item.route)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i :class="[item.icon, item.iconColor, 'text-xl']"></i>
<span class="font-medium text-surface-900 dark:text-surface-0">{{ item.label }}</span>
</div>
<div class="w-2 h-2 rounded-full bg-surface-400"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
// Menu items
const activeMenu = ref('main');
const menuItems = [
{
id: 'analysis',
label: 'Маркетинговый анализ',
icon: 'pi pi-search',
iconColor: 'text-primary-500',
route: '/marketing-analysis/analysis'
},
{
id: 'promotion',
label: 'Продвижение',
icon: 'pi pi-send',
iconColor: 'text-primary-500',
route: '/marketing-analysis/promotion'
},
{
id: 'results',
label: 'Результаты',
icon: 'pi pi-chart-bar',
iconColor: 'text-primary-500',
route: '/marketing-analysis/results'
}
];
// Methods
const navigateTo = (route) => {
router.push(route);
};
</script>
<style scoped>
.marketing-main-page {
font-family: var(--font-family);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid var(--surface-border);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-content {
padding-top: 0;
}
.menu-item {
transition: all 0.2s ease;
}
</style>
@@ -0,0 +1,583 @@
<template>
<div class="marketing-promotion-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1400px] mx-auto">
<!-- Форма запуска генерации стратегии -->
<div v-if="!strategyId && !strategyData" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Генерация стратегии продвижения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">Создайте детальную стратегию продвижения на основе завершенного маркетингового анализа</p>
</div>
<div class="card-content">
<form @submit.prevent="handleGenerateStrategy" class="space-y-4 max-w-2xl">
<div class="field">
<label for="analysisId" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> ID анализа <span class="text-red-500">*</span> </label>
<InputText id="analysisId" v-model="formData.analysisId" placeholder="Введите ID завершенного анализа" class="w-full" :class="{ 'p-invalid': errors.analysisId }" />
<small v-if="errors.analysisId" class="p-error">{{ errors.analysisId }}</small>
<small class="text-surface-500 dark:text-surface-400"> ID можно получить из завершенного маркетингового анализа </small>
</div>
<div class="field">
<label for="durationWeeks" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Длительность стратегии (недели) </label>
<InputNumber id="durationWeeks" v-model="formData.durationWeeks" :min="1" :max="12" placeholder="4" class="w-full" :class="{ 'p-invalid': errors.durationWeeks }" />
<small v-if="errors.durationWeeks" class="p-error">{{ errors.durationWeeks }}</small>
<small class="text-surface-500 dark:text-surface-400">От 1 до 12 недель (по умолчанию: 4)</small>
</div>
<div class="field">
<label for="priorityPlatforms" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Приоритетные платформы </label>
<MultiSelect id="priorityPlatforms" v-model="formData.priorityPlatforms" :options="platformOptions" placeholder="Выберите платформы (необязательно)" class="w-full" display="chip" />
<small class="text-surface-500 dark:text-surface-400"> Если не указано, будут использованы все популярные платформы </small>
</div>
<Button type="submit" label="Сгенерировать стратегию" icon="pi pi-magic" class="w-full p-button-primary" :loading="generating" />
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">Генерация стратегии занимает примерно 3-5 минут</p>
</form>
</div>
</div>
<!-- Статус обработки -->
<div v-else-if="status === 'processing' || status === 'queued'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Генерация стратегии</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<ProgressSpinner size="50" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Стратегия генерируется...</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Это может занять 3-5 минут. Пожалуйста, подождите.</p>
<Button label="Отменить ожидание" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetStrategy" />
</div>
</div>
</div>
<!-- Ошибка -->
<div v-else-if="status === 'failed'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка генерации</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Генерация стратегии завершилась с ошибкой</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Попробуйте запустить генерацию снова или обратитесь в поддержку.</p>
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetStrategy" />
</div>
</div>
</div>
<!-- Результаты стратегии -->
<div v-else-if="status === 'completed' && strategyData?.strategy" class="space-y-4">
<!-- Заголовок -->
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Стратегия продвижения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">Длительность: {{ strategyData.durationWeeks }} {{ pluralize(strategyData.durationWeeks, 'неделя', 'недели', 'недель') }}</p>
</div>
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="secondary" @click="resetStrategy" />
</div>
</div>
</div>
<!-- Недельный план -->
<div v-if="strategyData.strategy.weeklyPlans && strategyData.strategy.weeklyPlans.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="plan in strategyData.strategy.weeklyPlans" :key="plan.weekNumber" class="col-12 md:col-6 lg:col-4 mb-4">
<div class="week-plan-card p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700">
<div class="flex align-items-center justify-content-between mb-3">
<!-- <h4 class="text-lg font-semibold text-surface-900 dark:text-surface-0">Неделя {{ plan.weekNumber }}</h4> -->
<Tag :value="`Неделя ${plan.weekNumber}`" severity="info" />
</div>
<div v-if="plan.mainThemes && plan.mainThemes.length > 0" class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-2">Основные темы:</p>
<ul class="list-none p-0 m-0">
<li v-for="(theme, idx) in plan.mainThemes" :key="idx" class="flex align-items-start mb-2">
<i class="pi pi-circle-fill text-primary-500 text-xs mr-2 mt-1"></i>
<span class="text-surface-700 dark:text-surface-300 text-sm">{{ theme }}</span>
</li>
</ul>
</div>
<div v-if="plan.contentRecommendations" class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-2">Рекомендации:</p>
<p class="text-surface-700 dark:text-surface-300 text-sm whitespace-pre-line">
{{ plan.contentRecommendations }}
</p>
</div>
<div v-if="plan.priorityPlatforms && plan.priorityPlatforms.length > 0">
<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="platform in plan.priorityPlatforms" :key="platform" :value="platform" severity="secondary" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Календарь постов -->
<div v-if="strategyData.strategy.postCalendar && strategyData.strategy.postCalendar.length > 0" class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Календарь постов</h3>
<div class="flex gap-2">
<Button label="Экспорт CSV" icon="pi pi-download" severity="secondary" size="small" @click="exportToCsv" />
<Button label="Группировать по датам" icon="pi pi-calendar" severity="secondary" size="small" @click="toggleGroupByDate" />
</div>
</div>
</div>
<div class="card-content">
<!-- Группировка по датам -->
<div v-if="groupByDate">
<div v-for="(posts, date) in postsByDate" :key="date" class="date-group mb-4 pb-4 border-bottom-1 border-surface-200 dark:border-surface-700">
<h4 class="text-lg font-semibold text-surface-900 dark:text-surface-0 mb-3">
{{ formatDate(date) }}
</h4>
<div class="grid">
<div v-for="(post, idx) in posts" :key="idx" class="col-12 md:col-6 lg:col-4 mb-3">
<div class="post-card p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
<div class="flex align-items-center justify-content-between mb-3">
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
<div class="mb-2">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-sm font-semibold text-surface-900 dark:text-surface-0">{{ post.theme }}</p>
</div>
<div class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-3">{{ post.postText }}</p>
</div>
<div v-if="post.hashtags && post.hashtags.length > 0" class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Хештеги:</p>
<div class="flex flex-wrap gap-1">
<span v-for="(tag, tagIdx) in post.hashtags" :key="tagIdx" class="text-xs px-2 py-1 border-round bg-primary-50 dark:bg-primary-900 text-primary-700 dark:text-primary-300">
{{ tag }}
</span>
</div>
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Список всех постов -->
<div v-else class="grid">
<div v-for="(post, idx) in strategyData.strategy.postCalendar" :key="idx" class="col-12 md:col-6 lg:col-4 mb-3">
<div class="post-card p-4 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 h-full">
<div class="flex align-items-center justify-content-between mb-3">
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
<div class="mb-2">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-sm font-semibold text-surface-900 dark:text-surface-0">{{ post.theme }}</p>
</div>
<div class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-3">{{ post.postText }}</p>
</div>
<div v-if="post.hashtags && post.hashtags.length > 0" class="mb-3">
<p class="text-sm font-medium text-surface-600 dark:text-surface-400 mb-1">Хештеги:</p>
<div class="flex flex-wrap gap-1">
<span v-for="(tag, tagIdx) in post.hashtags" :key="tagIdx" class="text-xs px-2 py-1 border-round bg-primary-50 dark:bg-primary-900 text-primary-700 dark:text-primary-300">
{{ tag }}
</span>
</div>
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const toast = useToast();
const route = useRoute();
// Form data
const formData = ref({
analysisId: '',
durationWeeks: 4,
priorityPlatforms: []
});
const platformOptions = ['Instagram', 'Facebook', 'LinkedIn', 'Telegram', 'TikTok', 'YouTube', '21MC'];
const generating = ref(false);
const errors = ref({});
// Strategy state
const strategyId = ref(null);
const status = ref(null);
const strategyData = ref(null);
const pollingInterval = ref(null);
const pollingAttempts = ref(0);
const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
const pollingIntervalMs = 10000; // 10 seconds
const groupByDate = ref(true);
// Computed
const postsByDate = computed(() => {
if (!strategyData.value?.strategy?.postCalendar) return {};
return strategyData.value.strategy.postCalendar.reduce((acc, post) => {
const date = new Date(post.publishDate).toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(post);
return acc;
}, {});
});
// Validation
const validateForm = () => {
errors.value = {};
if (!formData.value.analysisId || formData.value.analysisId.trim().length === 0) {
errors.value.analysisId = 'ID анализа обязателен';
}
if (formData.value.durationWeeks && (formData.value.durationWeeks < 1 || formData.value.durationWeeks > 12)) {
errors.value.durationWeeks = 'Длительность должна быть от 1 до 12 недель';
}
return Object.keys(errors.value).length === 0;
};
// Start polling for strategy status
const startPolling = (id) => {
pollingAttempts.value = 0;
pollingInterval.value = setInterval(async () => {
pollingAttempts.value++;
if (pollingAttempts.value > maxPollingAttempts) {
stopPolling();
toast.add({
severity: 'warn',
summary: 'Таймаут',
detail: 'Превышено время ожидания генерации стратегии. Попробуйте проверить результат позже.',
life: 5000
});
return;
}
try {
const result = await MarketingService.getStrategy(id);
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
stopPolling();
strategyData.value = result;
toast.add({
severity: 'success',
summary: 'Стратегия готова',
detail: 'Стратегия продвижения успешно сгенерирована',
life: 3000
});
} else if (result.status === 'failed') {
stopPolling();
toast.add({
severity: 'error',
summary: 'Ошибка генерации',
detail: 'Генерация стратегии завершилась с ошибкой',
life: 5000
});
}
} catch (error) {
console.error('Ошибка при проверке статуса стратегии:', error);
// Continue polling on error, but log it
}
}, pollingIntervalMs);
};
// Stop polling
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
// Handle form submit
const handleGenerateStrategy = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
generating.value = true;
try {
const options = {};
if (formData.value.durationWeeks) {
options.durationWeeks = formData.value.durationWeeks;
}
if (formData.value.priorityPlatforms && formData.value.priorityPlatforms.length > 0) {
options.priorityPlatforms = formData.value.priorityPlatforms;
}
const result = await MarketingService.generateStrategy(formData.value.analysisId.trim(), options);
strategyId.value = result.strategyId;
status.value = result.status;
toast.add({
severity: 'success',
summary: 'Генерация запущена',
detail: 'Генерация стратегии успешно запущена. Результаты будут готовы в течение 3-5 минут.',
life: 5000
});
// Start polling
startPolling(result.strategyId);
} catch (error) {
console.error('Ошибка при запуске генерации стратегии:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить генерацию стратегии. Пожалуйста, попробуйте снова.',
life: 5000
});
} finally {
generating.value = false;
}
};
// Reset strategy and form
const resetStrategy = () => {
stopPolling();
strategyId.value = null;
status.value = null;
strategyData.value = null;
pollingAttempts.value = 0;
formData.value = {
analysisId: '',
durationWeeks: 4,
priorityPlatforms: []
};
errors.value = {};
};
// Handle copy post
const handleCopyPost = (post) => {
const textToCopy = `${post.postText}\n\n${post.hashtags ? post.hashtags.join(' ') : ''}`;
navigator.clipboard
.writeText(textToCopy)
.then(() => {
toast.add({
severity: 'success',
summary: 'Скопировано',
detail: 'Текст поста скопирован в буфер обмена',
life: 2000
});
})
.catch(() => {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 3000
});
});
};
// Export to CSV
const exportToCsv = () => {
if (!strategyData.value?.strategy?.postCalendar) return;
const headers = ['Дата', 'Время', 'Платформа', 'Тип контента', 'Тема', 'Текст поста', 'Хештеги'];
const rows = strategyData.value.strategy.postCalendar.map((post) => {
const date = new Date(post.publishDate);
return [date.toLocaleDateString('ru-RU'), post.publishTime || '', post.platform || '', post.contentType || '', post.theme || '', `"${(post.postText || '').replace(/"/g, '""')}"`, (post.hashtags || []).join(' ')];
});
const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `strategy_calendar_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.add({
severity: 'success',
summary: 'Экспорт выполнен',
detail: 'Календарь постов экспортирован в CSV',
life: 3000
});
};
// Toggle group by date
const toggleGroupByDate = () => {
groupByDate.value = !groupByDate.value;
};
// Format date
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
});
};
// Pluralize
const pluralize = (count, one, few, many) => {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
};
// Load strategy from query params or try to get by analysisId
onMounted(async () => {
const queryAnalysisId = route.query.analysisId;
const queryStrategyId = route.query.strategyId;
if (queryStrategyId) {
// Try to load existing strategy
try {
const result = await MarketingService.getStrategy(queryStrategyId);
strategyId.value = result.strategyId;
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
} catch (error) {
console.error('Ошибка при загрузке стратегии:', error);
}
} else if (queryAnalysisId) {
// Try to get strategy by analysisId
formData.value.analysisId = queryAnalysisId;
try {
const result = await MarketingService.getStrategyByAnalysis(queryAnalysisId);
strategyId.value = result.strategyId;
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
} catch (error) {
// Strategy doesn't exist yet, show form
console.log('Стратегия для анализа не найдена, показываем форму');
}
}
});
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
});
</script>
<style scoped>
.marketing-promotion-page {
font-family: var(--font-family);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid var(--surface-border);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-content {
padding-top: 0;
}
.week-plan-card {
transition:
transform 0.2s,
box-shadow 0.2s;
}
.week-plan-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.post-card {
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.date-group:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
</style>
@@ -0,0 +1,144 @@
<template>
<div class="marketing-results-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<div class="max-w-[1200px] mx-auto">
<div class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Результаты кампании</h2>
</div>
<div class="card-content">
<!-- Metrics -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="metric-card bg-surface-0 dark:bg-surface-800 rounded-lg p-4 text-center">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-1">Охват</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">15 000</p>
</div>
<div class="metric-card bg-surface-0 dark:bg-surface-800 rounded-lg p-4 text-center">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-1">Клики</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">1 200</p>
</div>
<div class="metric-card bg-surface-0 dark:bg-surface-800 rounded-lg p-4 text-center">
<p class="text-sm text-surface-500 dark:text-surface-400 mb-1">Заявки</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">150</p>
</div>
</div>
<!-- Chart -->
<div class="chart-container mb-6" style="height: 300px">
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
<!-- By Channels -->
<div class="mb-6">
<h4 class="text-sm font-semibold mb-4 text-surface-700 dark:text-surface-300">По каналам</h4>
<div class="flex gap-6 justify-center">
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center mb-2 mx-auto">
<i class="pi pi-instagram text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<p class="text-xl font-semibold text-surface-900 dark:text-surface-0">900</p>
</div>
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center mb-2 mx-auto">
<i class="pi pi-send text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<p class="text-xl font-semibold text-surface-900 dark:text-surface-0">250</p>
</div>
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center mb-2 mx-auto">
<i class="pi pi-hashtag text-primary-600 dark:text-primary-300 text-2xl"></i>
</div>
<p class="text-xl font-semibold text-surface-900 dark:text-surface-0">50</p>
</div>
</div>
</div>
<!-- Recommendation -->
<div class="recommendation-card bg-primary-50 dark:bg-primary-900 rounded-lg p-4">
<h4 class="text-sm font-semibold mb-2 text-surface-900 dark:text-surface-0">Рекомендация</h4>
<p class="text-sm text-surface-700 dark:text-surface-300">Instagram стал ведущим каналом - вам стоит продолжать работу с визуальным контентом</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import Chart from 'primevue/chart';
import { ref } from 'vue';
// Chart data
const chartData = ref({
labels: ['Неделя 1', 'Неделя 2', 'Неделя 3', 'Неделя 4', 'Неделя 5', 'Неделя 6'],
datasets: [
{
label: 'Охват',
data: [5000, 8000, 10000, 12000, 13000, 15000],
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}
]
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
</script>
<style scoped>
.marketing-results-page {
font-family: var(--font-family);
}
.card {
background: var(--surface-card);
border-radius: 12px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.24);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid var(--surface-border);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-content {
padding-top: 0;
}
.metric-card {
border: 1px solid var(--surface-border);
}
.recommendation-card {
border: 1px solid var(--primary-200);
}
.chart-container {
position: relative;
}
</style>