This commit is contained in:
root
2025-12-01 11:01:56 +05:00
parent 41932973e4
commit 24cc534fed
11 changed files with 2547 additions and 456 deletions
+6 -1
View File
@@ -62,7 +62,12 @@ export const API_CONFIG = {
MARKETING_STRATEGY_GET: '/api/marketing/analysis/strategy',
MARKETING_STRATEGY_BY_ANALYSIS: '/api/marketing/analysis',
MARKETING_STRATEGY_MY: '/api/marketing/analysis/strategy/my',
MARKETING_STRATEGY_HISTORY: '/api/marketing/analysis/strategy'
MARKETING_STRATEGY_HISTORY: '/api/marketing/analysis/strategy',
MARKETING_STRATEGY_START: '/api/marketing/analysis/strategy',
// SocialMediaCredentialsController - Управление credentials социальных сетей
SOCIAL_MEDIA_CREDENTIALS: '/api/social-media/credentials',
SOCIAL_MEDIA_CREDENTIALS_BY_PLATFORM: '/api/social-media/credentials'
}
};
+10 -5
View File
@@ -248,11 +248,16 @@ const router = createRouter({
name: 'marketing-analyses-list',
component: () => import('@/views/pages/marketing/MarketingAnalysesList.vue')
},
{
path: '/marketing-analysis/strategies',
name: 'marketing-strategies-list',
component: () => import('@/views/pages/marketing/MarketingStrategiesList.vue')
},
{
path: '/marketing-analysis/strategies',
name: 'marketing-strategies-list',
component: () => import('@/views/pages/marketing/MarketingStrategiesList.vue')
},
{
path: '/marketing-analysis/credentials',
name: 'marketing-credentials',
component: () => import('@/views/pages/marketing/MarketingCredentials.vue')
},
{
path: '/pages/notfound',
name: 'notfound',
+181 -1
View File
@@ -12,6 +12,7 @@ class MarketingService {
* @param {string} data.location - Географическая локация работы
* @param {string} data.client - Тип целевой аудитории
* @param {string} data.differentiator - Уникальные особенности бизнеса
* @param {string} data.analysisType - Тип анализа (РЫНОК, КОНКУРЕНТЫ, ЦА, КАНАЛЫ, SWOT)
* @returns {Promise<Object>} Объект с analysisId и статусом
*/
async startAnalysis(data) {
@@ -23,7 +24,8 @@ class MarketingService {
product: data.product,
location: data.location,
client: data.client,
differentiator: data.differentiator
differentiator: data.differentiator,
analysisType: data.analysisType
})
});
@@ -318,6 +320,184 @@ class MarketingService {
throw error;
}
}
/**
* Запуск стратегии продвижения
* POST /api/marketing/analysis/strategy/{strategyId}/start
* @param {string} strategyId - Идентификатор стратегии для запуска
* @returns {Promise<Object>} Объект с информацией о запуске (tasksCreated, platforms)
*/
async startStrategy(strategyId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.MARKETING_STRATEGY_START}/${strategyId}/start`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify({})
});
const result = await response.json();
if (!response.ok) {
const error = {
message: result.message || result.error?.message || 'Ошибка при запуске стратегии',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
if (!result.success) {
const error = {
message: result.message || 'Ошибка при запуске стратегии',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
return result.data;
} catch (error) {
console.error('Ошибка при запуске стратегии:', error);
throw error;
}
}
/**
* Сохранение/обновление credentials для социальной сети
* POST /api/social-media/credentials
* @param {string} platform - Название платформы (facebook, instagram, etc.)
* @param {string} credentials - Access token или другие credentials
* @returns {Promise<Object>} Объект с информацией о сохраненных credentials
*/
async saveCredentials(platform, credentials) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SOCIAL_MEDIA_CREDENTIALS}`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify({
platform: platform,
credentials: credentials
})
});
const result = await response.json();
if (!response.ok) {
const error = {
message: result.message || result.error?.message || 'Ошибка при сохранении credentials',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
if (!result.success) {
const error = {
message: result.message || 'Ошибка при сохранении credentials',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
return result.data;
} catch (error) {
console.error('Ошибка при сохранении credentials:', error);
throw error;
}
}
/**
* Получение информации о credentials для платформы
* GET /api/social-media/credentials/{platform}
* @param {string} platform - Название платформы
* @returns {Promise<Object>} Объект с информацией о наличии credentials
*/
async getCredentials(platform) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SOCIAL_MEDIA_CREDENTIALS_BY_PLATFORM}/${platform}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при получении credentials');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при получении credentials');
}
return result.data;
} catch (error) {
console.error('Ошибка при получении credentials:', error);
throw error;
}
}
/**
* Получение списка всех credentials
* GET /api/social-media/credentials
* @returns {Promise<Array>} Массив объектов с информацией о credentials для каждой платформы
*/
async getAllCredentials() {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SOCIAL_MEDIA_CREDENTIALS}`);
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || result.error?.message || 'Ошибка при получении списка credentials');
}
if (!result.success) {
throw new Error(result.message || 'Ошибка при получении списка credentials');
}
return result.data;
} catch (error) {
console.error('Ошибка при получении списка credentials:', error);
throw error;
}
}
/**
* Удаление credentials для платформы
* DELETE /api/social-media/credentials/{platform}
* @param {string} platform - Название платформы
* @returns {Promise<void>}
*/
async deleteCredentials(platform) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}${API_CONFIG.ENDPOINTS.SOCIAL_MEDIA_CREDENTIALS_BY_PLATFORM}/${platform}`, {
method: 'DELETE'
});
const result = await response.json();
if (!response.ok) {
const error = {
message: result.message || result.error?.message || 'Ошибка при удалении credentials',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
if (!result.success) {
const error = {
message: result.message || 'Ошибка при удалении credentials',
code: result.error?.code,
details: result.error?.details
};
throw error;
}
return result.data;
} catch (error) {
console.error('Ошибка при удалении credentials:', error);
throw error;
}
}
}
export default new MarketingService();
@@ -21,7 +21,7 @@
:sortField="'createdAt'"
:sortOrder="-1"
filterDisplay="row"
:globalFilterFields="['product', 'location', 'clientType', 'status']"
:globalFilterFields="['product', 'location', 'clientType', 'status', 'analysisType']"
v-model:filters="filters"
:filters="filters"
dataKey="analysisId"
@@ -54,6 +54,13 @@
</template>
</Column>
<Column field="analysisType" header="Тип анализа" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<Tag v-if="slotProps.data.analysisType" :value="getAnalysisTypeLabel(slotProps.data.analysisType)" severity="secondary" />
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
@@ -309,6 +316,18 @@ const getStatusIcon = (status) => {
return icons[status] || 'pi pi-circle';
};
// Get analysis type label
const getAnalysisTypeLabel = (type) => {
const labels = {
РЫНОК: 'Анализ рынка',
КОНКУРЕНТЫ: 'Анализ конкурентов',
ЦА: 'Анализ целевой аудитории',
КАНАЛЫ: 'Анализ маркетинговых каналов',
SWOT: 'SWOT-анализ'
};
return labels[type] || type;
};
// Load on mount
onMounted(() => {
loadAnalyses();
+101 -6
View File
@@ -33,6 +33,22 @@
<small v-if="errors.differentiator" class="p-error">{{ errors.differentiator }}</small>
</div>
<div class="field">
<label for="analysisType" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Тип анализа </label>
<Dropdown
id="analysisType"
v-model="formData.analysisType"
:options="analysisTypeOptions"
optionLabel="label"
optionValue="value"
placeholder="Выберите тип анализа"
class="w-full"
:class="{ 'p-invalid': errors.analysisType }"
/>
<small v-if="errors.analysisType" class="p-error">{{ errors.analysisType }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите тип анализа, который будет сгенерирован</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>
@@ -77,7 +93,12 @@
<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>
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Результаты анализа</h2>
<div v-if="currentAnalysisType" class="mt-2">
<Tag :value="getAnalysisTypeLabel(currentAnalysisType)" severity="info" />
</div>
</div>
<Button label="Скачать PDF" icon="pi pi-download" @click="handleDownloadPdf" :loading="downloadingPdf" />
</div>
</div>
@@ -179,22 +200,32 @@ 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';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const router = useRouter();
const route = useRoute();
// Form data
const formData = ref({
product: '',
location: '',
client: null,
differentiator: ''
differentiator: '',
analysisType: null
});
const clientOptions = ['B2B клиенты', 'B2C клиенты', 'Частные лица', 'Корпорации', 'Малый бизнес'];
const analysisTypeOptions = [
{ label: 'РЫНОК - Анализ рынка', value: 'РЫНОК' },
{ label: 'КОНКУРЕНТЫ - Анализ конкурентов', value: 'КОНКУРЕНТЫ' },
{ label: 'ЦА - Анализ целевой аудитории', value: 'ЦА' },
{ label: 'КАНАЛЫ - Анализ маркетинговых каналов', value: 'КАНАЛЫ' },
{ label: 'SWOT - SWOT-анализ', value: 'SWOT' }
];
const submitting = ref(false);
const errors = ref({});
@@ -202,6 +233,7 @@ const errors = ref({});
const analysisId = ref(null);
const status = ref(null);
const report = ref(null);
const currentAnalysisType = ref(null);
const estimatedCompletionTime = ref(null);
const pollingInterval = ref(null);
const pollingAttempts = ref(0);
@@ -241,6 +273,16 @@ const validateForm = () => {
errors.value.differentiator = 'Описание уникальности не должно превышать 500 символов';
}
// AnalysisType validation
if (!formData.value.analysisType) {
errors.value.analysisType = 'Пожалуйста, выберите тип анализа';
} else {
const validTypes = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
if (!validTypes.includes(formData.value.analysisType)) {
errors.value.analysisType = 'Выбран недопустимый тип анализа';
}
}
return Object.keys(errors.value).length === 0;
};
@@ -268,6 +310,10 @@ const startPolling = (id) => {
if (result.status === 'completed' && result.report) {
stopPolling();
report.value = result.report;
// Сохраняем тип анализа из результата, если он есть
if (result.analysisType) {
currentAnalysisType.value = result.analysisType;
}
toast.add({
severity: 'success',
summary: 'Анализ завершен',
@@ -317,11 +363,13 @@ const handleSubmit = async () => {
product: formData.value.product.trim(),
location: formData.value.location.trim(),
client: formData.value.client,
differentiator: formData.value.differentiator.trim()
differentiator: formData.value.differentiator.trim(),
analysisType: formData.value.analysisType
});
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType;
estimatedCompletionTime.value = result.estimatedCompletionTime;
toast.add({
@@ -379,13 +427,15 @@ const resetAnalysis = () => {
analysisId.value = null;
status.value = null;
report.value = null;
currentAnalysisType.value = null;
estimatedCompletionTime.value = null;
pollingAttempts.value = 0;
formData.value = {
product: '',
location: '',
client: null,
differentiator: ''
differentiator: '',
analysisType: null
};
errors.value = {};
};
@@ -407,6 +457,18 @@ const renderMarkdown = (markdown) => {
}
};
// Get analysis type label
const getAnalysisTypeLabel = (type) => {
const labels = {
РЫНОК: 'Анализ рынка',
КОНКУРЕНТЫ: 'Анализ конкурентов',
ЦА: 'Анализ целевой аудитории',
КАНАЛЫ: 'Анализ маркетинговых каналов',
SWOT: 'SWOT-анализ'
};
return labels[type] || type;
};
// Format date time
const formatDateTime = (dateString) => {
if (!dateString) return '';
@@ -438,6 +500,39 @@ const goToStrategy = () => {
}
};
// Load analysis from query params
onMounted(async () => {
const queryAnalysisId = route.query.analysisId;
if (queryAnalysisId) {
// Try to load existing analysis
try {
analysisId.value = queryAnalysisId;
const result = await MarketingService.getAnalysisResult(queryAnalysisId);
status.value = result.status;
// Сохраняем тип анализа, если он есть
if (result.analysisType) {
currentAnalysisType.value = result.analysisType;
}
if (result.status === 'completed' && result.report) {
report.value = result.report;
} else if (result.status === 'processing' || result.status === 'queued') {
estimatedCompletionTime.value = result.estimatedCompletionTime;
startPolling(queryAnalysisId);
}
} catch (error) {
console.error('Ошибка при загрузке анализа:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось загрузить анализ',
life: 5000
});
}
}
});
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
@@ -0,0 +1,384 @@
<template>
<div class="marketing-credentials-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1200px] mx-auto">
<div class="card mb-4">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Управление credentials социальных сетей</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">Настройте access tokens для платформ, на которых вы хотите автоматически публиковать контент</p>
</div>
<div class="card-content">
<form @submit.prevent="handleSaveCredentials" class="space-y-4">
<div class="grid">
<div class="col-12 md:col-4">
<div class="field">
<label for="platform" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">
Платформа <span class="text-red-500">*</span>
</label>
<Dropdown
id="platform"
v-model="formData.platform"
:options="platformOptions"
placeholder="Выберите платформу"
class="w-full"
:class="{ 'p-invalid': errors.platform }"
/>
<small v-if="errors.platform" class="p-error">{{ errors.platform }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="credentials" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">
Access Token <span class="text-red-500">*</span>
</label>
<InputText
id="credentials"
v-model="formData.credentials"
type="password"
placeholder="Введите access token"
class="w-full"
:class="{ 'p-invalid': errors.credentials }"
/>
<small v-if="errors.credentials" class="p-error">{{ errors.credentials }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">
Токен будет зашифрован перед сохранением
</small>
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">&nbsp;</label>
<Button
type="submit"
label="Сохранить"
icon="pi pi-save"
class="w-full"
:loading="saving"
/>
</div>
</div>
</div>
</form>
</div>
</div>
<div 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>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadCredentials" :loading="loading" />
</div>
</div>
<div class="card-content">
<DataTable
:value="credentials"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
dataKey="platform"
:emptyMessage="'Нет настроенных платформ'"
>
<Column field="platform" header="Платформа" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<Tag :value="slotProps.data.platform" severity="info" />
</template>
</Column>
<Column field="hasCredentials" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag
:value="slotProps.data.hasCredentials ? 'Настроено' : 'Не настроено'"
:severity="slotProps.data.hasCredentials ? 'success' : 'warning'"
/>
</template>
</Column>
<Column field="createdAt" header="Создано" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.createdAt">{{ formatDateTime(slotProps.data.createdAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="updatedAt" header="Обновлено" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.updatedAt">{{ formatDateTime(slotProps.data.updatedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column header="Действия" style="min-width: 150px">
<template #body="slotProps">
<Button
icon="pi pi-trash"
severity="danger"
size="small"
v-tooltip.top="'Удалить credentials'"
@click="confirmDelete(slotProps.data.platform)"
:disabled="!slotProps.data.hasCredentials"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог подтверждения удаления -->
<Dialog
v-model:visible="deleteDialogVisible"
:header="'Подтверждение удаления'"
:style="{ width: '450px' }"
:modal="true"
>
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span>Вы уверены, что хотите удалить credentials для платформы <b>{{ platformToDelete }}</b>?</span>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="deleteDialogVisible = false" severity="secondary" />
<Button label="Удалить" icon="pi pi-check" @click="handleDeleteCredentials" severity="danger" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<script setup>
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
const toast = useToast();
// Form data
const formData = ref({
platform: null,
credentials: ''
});
const platformOptions = ['facebook', 'instagram', 'linkedin', 'telegram', 'tiktok', 'youtube'];
const saving = ref(false);
const errors = ref({});
// Data
const credentials = ref([]);
const loading = ref(false);
const deleteDialogVisible = ref(false);
const platformToDelete = ref(null);
const deleting = ref(false);
// Validation
const validateForm = () => {
errors.value = {};
if (!formData.value.platform) {
errors.value.platform = 'Пожалуйста, выберите платформу';
}
if (!formData.value.credentials || formData.value.credentials.trim().length === 0) {
errors.value.credentials = 'Access token обязателен';
} else if (formData.value.credentials.trim().length < 10) {
errors.value.credentials = 'Access token слишком короткий';
}
return Object.keys(errors.value).length === 0;
};
// Load credentials
const loadCredentials = async () => {
loading.value = true;
try {
const data = await MarketingService.getAllCredentials();
credentials.value = data || [];
} catch (error) {
console.error('Ошибка при загрузке credentials:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось загрузить список credentials',
life: 5000
});
} finally {
loading.value = false;
}
};
// Handle save credentials
const handleSaveCredentials = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
}
saving.value = true;
try {
await MarketingService.saveCredentials(formData.value.platform, formData.value.credentials.trim());
toast.add({
severity: 'success',
summary: 'Успешно',
detail: `Credentials для ${formData.value.platform} успешно сохранены`,
life: 3000
});
// Reset form
formData.value = {
platform: null,
credentials: ''
};
errors.value = {};
// Reload credentials list
await loadCredentials();
} catch (error) {
console.error('Ошибка при сохранении credentials:', error);
// Handle specific error codes
if (error.code === 'VALIDATION_ERROR') {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: error.message || 'Проверьте правильность введенных данных',
life: 5000
});
} else if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось сохранить credentials',
life: 5000
});
}
} finally {
saving.value = false;
}
};
// Confirm delete
const confirmDelete = (platform) => {
platformToDelete.value = platform;
deleteDialogVisible.value = true;
};
// Handle delete credentials
const handleDeleteCredentials = async () => {
if (!platformToDelete.value) return;
deleting.value = true;
try {
await MarketingService.deleteCredentials(platformToDelete.value);
toast.add({
severity: 'success',
summary: 'Успешно',
detail: `Credentials для ${platformToDelete.value} успешно удалены`,
life: 3000
});
deleteDialogVisible.value = false;
platformToDelete.value = null;
// Reload credentials list
await loadCredentials();
} catch (error) {
console.error('Ошибка при удалении credentials:', error);
if (error.code === 'NOT_FOUND') {
toast.add({
severity: 'warn',
summary: 'Не найдено',
detail: `Credentials для ${platformToDelete.value} не найдены`,
life: 5000
});
} else if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось удалить credentials',
life: 5000
});
}
} finally {
deleting.value = false;
}
};
// Format date time
const formatDateTime = (dateString) => {
if (!dateString) return '—';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Load on mount
onMounted(() => {
loadCredentials();
});
</script>
<style scoped>
.marketing-credentials-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;
}
.confirmation-content {
display: flex;
align-items: center;
gap: 1rem;
}
</style>
@@ -72,6 +72,13 @@ const menuItems = [
iconColor: 'text-primary-500',
route: '/marketing-analysis/strategies'
},
{
id: 'credentials',
label: 'Credentials социальных сетей',
icon: 'pi pi-key',
iconColor: 'text-primary-500',
route: '/marketing-analysis/credentials'
},
{
id: 'results',
label: 'Результаты',
@@ -77,7 +77,17 @@
<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 class="flex gap-2">
<Button
label="Запустить стратегию"
icon="pi pi-play"
severity="success"
@click="handleStartStrategy"
:loading="startingStrategy"
:disabled="startingStrategy"
/>
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="secondary" @click="resetStrategy" />
</div>
</div>
</div>
</div>
@@ -215,12 +225,48 @@
</div>
</div>
</div>
<!-- Диалог для настройки credentials -->
<Dialog
v-model:visible="credentialsDialogVisible"
:header="'Настройка credentials'"
:style="{ width: '600px' }"
:modal="true"
>
<div class="credentials-dialog-content">
<p class="text-surface-700 dark:text-surface-300 mb-4">
Для запуска стратегии необходимо настроить credentials для следующих платформ:
</p>
<div class="mb-4">
<Tag
v-for="platform in missingPlatforms"
:key="platform"
:value="platform"
severity="warning"
class="mr-2 mb-2"
/>
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">
Перейдите на страницу управления credentials для настройки необходимых платформ.
</p>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="credentialsDialogVisible = false" severity="secondary" />
<Button
label="Настроить credentials"
icon="pi pi-cog"
@click="goToCredentials"
severity="info"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
@@ -229,10 +275,11 @@ 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';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const route = useRoute();
const router = useRouter();
// Form data
const formData = ref({
@@ -255,6 +302,9 @@ const pollingAttempts = ref(0);
const maxPollingAttempts = 60; // 10 minutes with 10 second intervals
const pollingIntervalMs = 10000; // 10 seconds
const groupByDate = ref(true);
const startingStrategy = ref(false);
const credentialsDialogVisible = ref(false);
const missingPlatforms = ref([]);
// Computed
const postsByDate = computed(() => {
@@ -462,6 +512,98 @@ const toggleGroupByDate = () => {
groupByDate.value = !groupByDate.value;
};
// Handle start strategy
const handleStartStrategy = async () => {
if (!strategyId.value) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'ID стратегии не найден',
life: 3000
});
return;
}
startingStrategy.value = true;
try {
const result = await MarketingService.startStrategy(strategyId.value);
toast.add({
severity: 'success',
summary: 'Стратегия запущена',
detail: `Стратегия успешно запущена. Создано задач: ${result.tasksCreated}. Платформы: ${result.platforms?.join(', ') || 'N/A'}`,
life: 5000
});
} catch (error) {
console.error('Ошибка при запуске стратегии:', error);
// Handle specific error codes
if (error.code === 'MISSING_CREDENTIALS') {
// Extract platform from error message (format: "Credentials not found for platform: facebook")
const platformMatch = error.message?.match(/platform:\s*(\w+)/i) || error.message?.match(/for platform\s+(\w+)/i);
if (platformMatch) {
missingPlatforms.value = [platformMatch[1].toLowerCase()];
} else {
// Try to get platforms from strategy data
const platforms = strategyData.value?.priorityPlatforms || strategyData.value?.strategy?.postCalendar?.map(p => p.platform).filter((v, i, a) => a.indexOf(v) === i) || [];
missingPlatforms.value = platforms.length > 0 ? platforms : ['facebook']; // Default to facebook if unknown
}
credentialsDialogVisible.value = true;
} else if (error.code === 'INVALID_STATUS') {
toast.add({
severity: 'warn',
summary: 'Стратегия не готова',
detail: error.message || 'Стратегия еще не завершена. Дождитесь завершения генерации.',
life: 5000
});
} else if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else if (error.code === 'NOT_FOUND') {
toast.add({
severity: 'error',
summary: 'Не найдено',
detail: 'Стратегия не найдена',
life: 5000
});
} else if (error.code === 'FORBIDDEN') {
toast.add({
severity: 'error',
summary: 'Доступ запрещен',
detail: 'У вас нет доступа к этой стратегии',
life: 5000
});
} else if (error.code === 'VALIDATION_ERROR') {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: error.message || 'Проверьте правильность данных',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить стратегию. Попробуйте позже.',
life: 5000
});
}
} finally {
startingStrategy.value = false;
}
};
// Go to credentials page
const goToCredentials = () => {
credentialsDialogVisible.value = false;
router.push({ name: 'marketing-credentials' });
};
// Format date
const formatDate = (dateString) => {
const date = new Date(dateString);