This commit is contained in:
root
2025-12-05 20:00:11 +05:00
parent 0b074d6281
commit 855a0d595e
9 changed files with 2400 additions and 110 deletions
@@ -57,7 +57,9 @@
<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" />
<div v-if="slotProps.data.analysisType" class="flex flex-wrap gap-1">
<Tag v-for="(type, index) in Array.isArray(slotProps.data.analysisType) ? slotProps.data.analysisType : [slotProps.data.analysisType]" :key="index" :value="getAnalysisTypeLabel(type)" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
+169 -47
View File
@@ -22,15 +22,60 @@
</div>
<div class="field">
<label for="targetAudience" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
<Textarea id="targetAudience" v-model="formData.targetAudience" placeholder="Опишите вашу целевую аудиторию детально" rows="3" class="w-full" :class="{ 'p-invalid': errors.targetAudience }" />
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Целевая аудитория * </label>
<!-- Гендер -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Гендер:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="gender in VALID_GENDERS" :key="gender" class="flex align-items-center">
<Checkbox :inputId="`gender-${gender}`" :value="gender" v-model="formData.targetAudience.genders" />
<label :for="`gender-${gender}`" class="ml-2 cursor-pointer">{{ gender }}</label>
</div>
</div>
</div>
<!-- Возраст -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Возраст:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="age in VALID_AGE_RANGES" :key="age" class="flex align-items-center">
<Checkbox :inputId="`age-${age}`" :value="age" v-model="formData.targetAudience.ageRanges" />
<label :for="`age-${age}`" class="ml-2 cursor-pointer">{{ age }}</label>
</div>
</div>
</div>
<!-- Тип -->
<div class="mb-3">
<label class="block text-sm font-medium mb-2 text-surface-600 dark:text-surface-400">Тип:</label>
<div class="flex gap-4 flex-wrap">
<div v-for="type in VALID_AUDIENCE_TYPES" :key="type" class="flex align-items-center">
<Checkbox :inputId="`type-${type}`" :value="type" v-model="formData.targetAudience.types" />
<label :for="`type-${type}`" class="ml-2 cursor-pointer">{{ type }}</label>
</div>
</div>
</div>
<!-- Отображение выбранных значений -->
<div v-if="formData.targetAudience.genders.length > 0 || formData.targetAudience.ageRanges.length > 0 || formData.targetAudience.types.length > 0" class="mt-2 p-2 border-round bg-surface-100 dark:bg-surface-800">
<small class="text-surface-600 dark:text-surface-400">
<strong>Выбрано:</strong>
<span v-if="formData.targetAudience.genders.length > 0"> Гендер: {{ formData.targetAudience.genders.join(', ') }}</span>
<span v-if="formData.targetAudience.ageRanges.length > 0"> Возраст: {{ formData.targetAudience.ageRanges.join(', ') }}</span>
<span v-if="formData.targetAudience.types.length > 0"> Тип: {{ formData.targetAudience.types.join(', ') }}</span>
</small>
</div>
<small v-if="errors.targetAudience" class="p-error">{{ errors.targetAudience }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите хотя бы одну опцию для целевой аудитории</small>
</div>
<div class="field">
<label for="region" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Регион (город Казахстана) * </label>
<Dropdown id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите город" class="w-full" :class="{ 'p-invalid': errors.region }" />
<MultiSelect id="region" v-model="formData.region" :options="VALID_REGIONS" placeholder="Выберите регионы" class="w-full" display="chip" :class="{ 'p-invalid': errors.region }" />
<small v-if="errors.region" class="p-error">{{ errors.region }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите один или несколько регионов</small>
</div>
<div class="field">
@@ -67,18 +112,21 @@
<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 }"
/>
<MultiSelect id="analysisType" v-model="formData.analysisType" :options="VALID_ANALYSIS_TYPES" placeholder="Выберите типы анализа" class="w-full" display="chip" :class="{ 'p-invalid': errors.analysisType }">
<template #value="slotProps">
<div v-if="slotProps.value && slotProps.value.length > 0" class="flex flex-wrap gap-2">
<Tag v-for="type in slotProps.value" :key="type" :value="getAnalysisTypeLabel(type)" severity="info" />
</div>
<span v-else class="text-surface-400">{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex align-items-center">
<span>{{ getAnalysisTypeLabel(slotProps.option) }}</span>
</div>
</template>
</MultiSelect>
<small v-if="errors.analysisType" class="p-error">{{ errors.analysisType }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block">Выберите тип анализа, который будет сгенерирован</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" />
@@ -226,8 +274,9 @@
import MarketingService from '@/service/MarketingService';
import { marked } from 'marked';
import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import Checkbox from 'primevue/checkbox';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import ProgressSpinner from 'primevue/progressspinner';
import RadioButton from 'primevue/radiobutton';
import Tag from 'primevue/tag';
@@ -265,19 +314,28 @@ const VALID_REGIONS = [
'Рудный'
];
const VALID_GENDERS = ['Женщины', 'Мужчины'];
const VALID_AGE_RANGES = ['20-40', '25-45', '18-25', '40-60', '60+'];
const VALID_AUDIENCE_TYPES = ['Семьи', 'Молодёжь', 'Все подряд'];
const VALID_ANALYSIS_TYPES = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
const DETAIL_LEVELS = ['КРАТКО', 'СТАНДАРТНО', 'ПОДРОБНО'];
// Form data
const formData = ref({
businessNiche: '',
product: '',
targetAudience: '',
region: null,
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: null
analysisType: []
});
const analysisTypeOptions = [
@@ -322,17 +380,42 @@ const validateForm = () => {
}
// TargetAudience validation
if (!formData.value.targetAudience || formData.value.targetAudience.trim().length < 3) {
errors.value.targetAudience = 'Целевая аудитория должна содержать минимум 3 символа';
} else if (formData.value.targetAudience.trim().length > 300) {
errors.value.targetAudience = 'Целевая аудитория не должна превышать 300 символов';
const hasGenders = formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0;
const hasAgeRanges = formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0;
const hasTypes = formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0;
if (!hasGenders && !hasAgeRanges && !hasTypes) {
errors.value.targetAudience = 'Выберите хотя бы одну опцию для целевой аудитории (гендер, возраст или тип)';
} else {
// Валидация значений
if (formData.value.targetAudience.genders) {
const invalidGenders = formData.value.targetAudience.genders.filter((g) => !VALID_GENDERS.includes(g));
if (invalidGenders.length > 0) {
errors.value.targetAudience = `Недопустимые значения гендера: ${invalidGenders.join(', ')}`;
}
}
if (formData.value.targetAudience.ageRanges) {
const invalidAges = formData.value.targetAudience.ageRanges.filter((a) => !VALID_AGE_RANGES.includes(a));
if (invalidAges.length > 0) {
errors.value.targetAudience = `Недопустимые значения возраста: ${invalidAges.join(', ')}`;
}
}
if (formData.value.targetAudience.types) {
const invalidTypes = formData.value.targetAudience.types.filter((t) => !VALID_AUDIENCE_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.targetAudience = `Недопустимые значения типа: ${invalidTypes.join(', ')}`;
}
}
}
// Region validation
if (!formData.value.region) {
errors.value.region = 'Пожалуйста, выберите регион';
} else if (!VALID_REGIONS.includes(formData.value.region)) {
errors.value.region = 'Выбран недопустимый регион';
if (!formData.value.region || formData.value.region.length === 0) {
errors.value.region = 'Выберите хотя бы один регион';
} else {
const invalidRegions = formData.value.region.filter((r) => !VALID_REGIONS.includes(r));
if (invalidRegions.length > 0) {
errors.value.region = `Недопустимые регионы: ${invalidRegions.join(', ')}`;
}
}
// Goal validation
@@ -358,12 +441,12 @@ const validateForm = () => {
}
// AnalysisType validation
if (!formData.value.analysisType) {
errors.value.analysisType = 'Пожалуйста, выберите тип анализа';
if (!formData.value.analysisType || formData.value.analysisType.length === 0) {
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
} else {
const validTypes = ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ', 'SWOT'];
if (!validTypes.includes(formData.value.analysisType)) {
errors.value.analysisType = 'Выбран недопустимый тип анализа';
const invalidTypes = formData.value.analysisType.filter((t) => !VALID_ANALYSIS_TYPES.includes(t));
if (invalidTypes.length > 0) {
errors.value.analysisType = `Недопустимые типы анализа: ${invalidTypes.join(', ')}`;
}
}
@@ -443,10 +526,22 @@ const handleSubmit = async () => {
submitting.value = true;
try {
// Формируем объект targetAudience (удаляем пустые массивы)
const targetAudience = {};
if (formData.value.targetAudience.genders && formData.value.targetAudience.genders.length > 0) {
targetAudience.genders = formData.value.targetAudience.genders;
}
if (formData.value.targetAudience.ageRanges && formData.value.targetAudience.ageRanges.length > 0) {
targetAudience.ageRanges = formData.value.targetAudience.ageRanges;
}
if (formData.value.targetAudience.types && formData.value.targetAudience.types.length > 0) {
targetAudience.types = formData.value.targetAudience.types;
}
const requestData = {
businessNiche: formData.value.businessNiche.trim(),
product: formData.value.product.trim(),
targetAudience: formData.value.targetAudience.trim(),
targetAudience: targetAudience,
region: formData.value.region,
goal: formData.value.goal.trim(),
detailLevel: formData.value.detailLevel,
@@ -463,20 +558,43 @@ const handleSubmit = async () => {
const result = await MarketingService.startAnalysis(requestData);
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType;
estimatedCompletionTime.value = result.estimatedCompletionTime;
// Обработка ответа: может быть массив или один объект
if (Array.isArray(result)) {
// Множественные анализы
const firstAnalysis = result[0];
analysisId.value = firstAnalysis.analysisId;
status.value = firstAnalysis.status;
estimatedCompletionTime.value = firstAnalysis.estimatedCompletion;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Сохраняем типы анализов
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
// Start polling
startPolling(result.analysisId);
toast.add({
severity: 'success',
summary: 'Анализы запущены',
detail: `Создано ${result.length} анализов. Они будут обработаны параллельно.`,
life: 5000
});
// Запускаем polling для первого анализа (можно расширить для всех)
startPolling(firstAnalysis.analysisId);
} else {
// Один анализ
analysisId.value = result.analysisId;
status.value = result.status;
currentAnalysisType.value = formData.value.analysisType.length === 1 ? formData.value.analysisType[0] : null;
estimatedCompletionTime.value = result.estimatedCompletion;
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: result.message || 'Анализ успешно запущен. Результаты будут готовы в течение 5-10 минут.',
life: 5000
});
// Start polling
startPolling(result.analysisId);
}
} catch (error) {
console.error('Ошибка при запуске анализа:', error);
toast.add({
@@ -529,13 +647,17 @@ const resetAnalysis = () => {
formData.value = {
businessNiche: '',
product: '',
targetAudience: '',
region: null,
targetAudience: {
genders: [],
ageRanges: [],
types: []
},
region: [],
goal: '',
detailLevel: 'СТАНДАРТНО',
strongSide: '',
weakSide: '',
analysisType: null
analysisType: []
};
errors.value = {};
};
+234 -36
View File
@@ -78,14 +78,7 @@
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">Длительность: {{ strategyData.durationWeeks }} {{ pluralize(strategyData.durationWeeks, 'неделя', 'недели', 'недель') }}</p>
</div>
<div class="flex gap-2">
<Button
label="Запустить стратегию"
icon="pi pi-play"
severity="success"
@click="handleStartStrategy"
:loading="startingStrategy"
:disabled="startingStrategy"
/>
<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>
@@ -160,6 +153,7 @@
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
@@ -167,6 +161,18 @@
<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 v-if="hasImage(post) && !hasImageError(`${date}-${idx}`)" class="mb-3">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(`${date}-${idx}`)" />
</div>
<div v-else-if="hasImage(post) && hasImageError(`${date}-${idx}`)" class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение недоступно</p>
</div>
<div v-else class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение не сгенерировано</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>
@@ -181,6 +187,17 @@
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
<Button
v-if="post.taskId"
label="Запустить"
icon="pi pi-play"
size="small"
severity="success"
class="flex-1"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handleExecuteTask(post.taskId, post)"
/>
</div>
</div>
</div>
@@ -196,6 +213,7 @@
<div class="flex gap-2 flex-wrap">
<Tag :value="post.platform" severity="info" />
<Tag :value="post.contentType" severity="secondary" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-sm text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
@@ -203,6 +221,18 @@
<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 v-if="hasImage(post) && !hasImageError(idx)" class="mb-3">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image" loading="lazy" @error="handleImageError(idx)" />
</div>
<div v-else-if="hasImage(post) && hasImageError(idx)" class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение недоступно</p>
</div>
<div v-else class="mb-3 no-image-placeholder">
<i class="pi pi-image text-4xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-2">Изображение не сгенерировано</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>
@@ -217,6 +247,17 @@
</div>
<div class="flex gap-2">
<Button label="Копировать текст" icon="pi pi-copy" size="small" severity="secondary" class="flex-1" @click="handleCopyPost(post)" />
<Button
v-if="post.taskId"
label="Запустить"
icon="pi pi-play"
size="small"
severity="success"
class="flex-1"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handleExecuteTask(post.taskId, post)"
/>
</div>
</div>
</div>
@@ -227,37 +268,17 @@
</div>
<!-- Диалог для настройки credentials -->
<Dialog
v-model:visible="credentialsDialogVisible"
:header="'Настройка credentials'"
:style="{ width: '600px' }"
:modal="true"
>
<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>
<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"
/>
<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>
<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"
/>
<Button label="Настроить credentials" icon="pi pi-cog" @click="goToCredentials" severity="info" />
</template>
</Dialog>
</div>
@@ -305,6 +326,9 @@ const groupByDate = ref(true);
const startingStrategy = ref(false);
const credentialsDialogVisible = ref(false);
const missingPlatforms = ref([]);
const imageErrors = ref(new Set()); // Track image load errors by post index
const imageBlobUrls = ref({}); // Cache blob URLs for images with auth (reactive object)
const executingTasks = ref(new Set()); // Track which tasks are currently being executed
// Computed
const postsByDate = computed(() => {
@@ -320,6 +344,57 @@ const postsByDate = computed(() => {
}, {});
});
// Get image URL for a post (with auth via blob URL)
const getImageUrl = (post) => {
if (!post?.imageFilename) return null;
return imageBlobUrls.value[post.imageFilename] || null;
};
// Preload all images when strategy data is loaded
const preloadImages = async () => {
if (!strategyData.value?.strategy?.postCalendar) return;
const imagePromises = strategyData.value.strategy.postCalendar
.filter((post) => post?.imageFilename)
.map(async (post) => {
if (!imageBlobUrls.value[post.imageFilename]) {
try {
const blobUrl = await MarketingService.loadImageAsBlobUrl(post.imageFilename);
if (blobUrl) {
imageBlobUrls.value[post.imageFilename] = blobUrl;
}
} catch (error) {
console.error(`Ошибка при предзагрузке изображения ${post.imageFilename}:`, error);
}
}
});
await Promise.all(imagePromises);
};
// Cleanup blob URLs
const cleanupBlobUrls = () => {
Object.values(imageBlobUrls.value).forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
imageBlobUrls.value = {};
};
// Check if post has image
const hasImage = (post) => {
return post?.imageUrl && post?.imageFilename;
};
// Check if image has error
const hasImageError = (postIndex) => {
return imageErrors.value.has(postIndex);
};
// Handle image error
const handleImageError = (postIndex) => {
imageErrors.value.add(postIndex);
};
// Validation
const validateForm = () => {
errors.value = {};
@@ -359,6 +434,9 @@ const startPolling = (id) => {
if (result.status === 'completed' && result.strategy) {
stopPolling();
strategyData.value = result;
imageErrors.value.clear(); // Reset image errors when new strategy is loaded
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
toast.add({
severity: 'success',
summary: 'Стратегия готова',
@@ -446,6 +524,9 @@ const resetStrategy = () => {
status.value = null;
strategyData.value = null;
pollingAttempts.value = 0;
imageErrors.value.clear(); // Clear image errors when resetting
cleanupBlobUrls(); // Cleanup blob URLs when resetting
executingTasks.value.clear(); // Clear executing tasks when resetting
formData.value = {
analysisId: '',
durationWeeks: 4,
@@ -477,6 +558,81 @@ const handleCopyPost = (post) => {
});
};
// Handle execute task
const handleExecuteTask = async (taskId, post) => {
if (!taskId) {
toast.add({
severity: 'warn',
summary: 'Ошибка',
detail: 'ID задачи не найден',
life: 3000
});
return;
}
// Add task to executing set
executingTasks.value.add(taskId);
try {
const result = await MarketingService.executeTask(taskId);
toast.add({
severity: 'success',
summary: 'Задача запущена',
detail: `Задача публикации успешно запущена. Платформа: ${result.platform || post?.platform || 'N/A'}`,
life: 5000
});
} catch (error) {
console.error('Ошибка при запуске задачи:', error);
// Handle specific error codes
if (error.code === 'UNAUTHORIZED') {
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 === 'NOT_FOUND') {
toast.add({
severity: 'error',
summary: 'Задача не найдена',
detail: 'Задача с указанным ID не найдена',
life: 5000
});
} else if (error.code === 'INVALID_STATUS') {
toast.add({
severity: 'warn',
summary: 'Задача не может быть запущена',
detail: error.message || 'Задача не может быть выполнена в текущем статусе. Только задачи со статусом "pending" или "failed" могут быть запущены вручную.',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить задачу. Попробуйте позже.',
life: 5000
});
}
} finally {
// Remove task from executing set
executingTasks.value.delete(taskId);
}
};
// Check if task is executing
const isTaskExecuting = (taskId) => {
return taskId ? executingTasks.value.has(taskId) : false;
};
// Export to CSV
const exportToCsv = () => {
if (!strategyData.value?.strategy?.postCalendar) return;
@@ -528,7 +684,7 @@ const handleStartStrategy = async () => {
try {
const result = await MarketingService.startStrategy(strategyId.value);
toast.add({
severity: 'success',
summary: 'Стратегия запущена',
@@ -537,16 +693,24 @@ const handleStartStrategy = async () => {
});
} catch (error) {
console.error('Ошибка при запуске стратегии:', error);
// Handle specific error codes
if (error.code === 'MISSING_CREDENTIALS') {
// Show error message in toast
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить стратегию',
life: 5000
});
// 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) || [];
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;
@@ -638,6 +802,8 @@ onMounted(async () => {
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
@@ -653,6 +819,8 @@ onMounted(async () => {
status.value = result.status;
if (result.status === 'completed' && result.strategy) {
strategyData.value = result;
cleanupBlobUrls(); // Cleanup old blob URLs
await preloadImages(); // Preload all images with auth
} else if (result.status === 'processing' || result.status === 'queued') {
startPolling(result.strategyId);
}
@@ -666,6 +834,8 @@ onMounted(async () => {
// Cleanup on unmount
onBeforeUnmount(() => {
stopPolling();
cleanupBlobUrls(); // Cleanup blob URLs on unmount
executingTasks.value.clear(); // Clear executing tasks on unmount
});
</script>
@@ -722,4 +892,32 @@ onBeforeUnmount(() => {
padding-bottom: 0;
margin-bottom: 0;
}
.post-image {
width: 100%;
max-width: 512px;
height: auto;
border-radius: 8px;
object-fit: cover;
display: block;
margin: 0 auto;
}
.no-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
background: var(--surface-50);
border: 1px dashed var(--surface-300);
border-radius: 8px;
min-height: 200px;
text-align: center;
}
.dark .no-image-placeholder {
background: var(--surface-800);
border-color: var(--surface-700);
}
</style>
@@ -18,8 +18,7 @@
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="multiple"
removableSort
:sortField="'createdAt'"
:sortOrder="-1"
v-model:multiSortMeta="multiSortMeta"
filterDisplay="row"
:globalFilterFields="['analysisId', 'status']"
v-model:filters="filters"
@@ -36,18 +35,21 @@
</div>
</template>
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px">
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.analysisId }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px">
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" :options="statusOptions" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
@@ -55,41 +57,56 @@
</template>
</Column>
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px">
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ slotProps.data.durationWeeks }} {{ pluralize(slotProps.data.durationWeeks, 'неделя', 'недели', 'недель') }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px">
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<div v-if="slotProps.data.priorityPlatforms && slotProps.data.priorityPlatforms.length > 0" class="flex flex-wrap gap-1">
<Tag v-for="platform in slotProps.data.priorityPlatforms" :key="platform" :value="platform" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px">
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column header="Действия" style="min-width: 200px">
<Column header="Действия" style="min-width: 200px" :showFilterMenu="false" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewStrategy(slotProps.data.strategyId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.strategyId)" />
</div>
</template>
<template #filter>
<span></span>
</template>
</Column>
</DataTable>
</div>
@@ -161,6 +178,9 @@ const filters = ref({
status: { value: null, matchMode: FilterMatchMode.EQUALS }
});
// Multi-sort meta (for multiple column sorting)
const multiSortMeta = ref([{ field: 'createdAt', order: -1 }]);
// Status options for filter
const statusOptions = [
{ label: 'В очереди', value: 'queued', severity: 'info' },
@@ -174,9 +194,11 @@ const loadStrategies = async () => {
loading.value = true;
try {
const data = await MarketingService.getMyStrategies();
strategies.value = data || [];
// Ensure data is always an array
strategies.value = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Ошибка при загрузке списка стратегий:', error);
strategies.value = []; // Reset to empty array on error
toast.add({
severity: 'error',
summary: 'Ошибка',