This commit is contained in:
arys
2026-03-26 15:19:50 +05:00
parent 71fb3347bc
commit 2ca3c8aa5e
4 changed files with 463 additions and 120 deletions
+46
View File
@@ -0,0 +1,46 @@
export const STRATEGY_MODELS = {
ENTRY: {
name: 'ENTRY',
title: 'Entry — Вход / Формирование присутствия',
description: 'Для новых бизнесов. Цель — узнаваемость и первые обращения.',
color: '#6366F1', // indigo
colorLight: '#EEF2FF',
icon: '🚀',
badge: 'Старт'
},
AUTHORITY: {
name: 'AUTHORITY',
title: 'Authority — Экспертность / Компетентность',
description: 'Длинный цикл сделки, высокий чек, B2B. Цель — экспертный статус.',
color: '#0EA5E9', // sky blue
colorLight: '#F0F9FF',
icon: '🏆',
badge: 'Экспертиза'
},
TRUST: {
name: 'TRUST',
title: 'Trust — Усиление доверия / Перегретый рынок',
description: 'Высокая конкуренция. Цель — снять недоверие через доказательства.',
color: '#10B981', // emerald
colorLight: '#ECFDF5',
icon: '🛡️',
badge: 'Доверие'
},
CONVERSION: {
name: 'CONVERSION',
title: 'Conversion — Прямая конверсия / Импульс',
description: 'Быстрые покупки, B2C, визуальный продукт. Цель — максимум заявок.',
color: '#F59E0B', // amber
colorLight: '#FFFBEB',
icon: '⚡',
badge: 'Продажи'
}
};
export const DEFAULT_STRATEGY_MODEL = {
title: 'Стратегия',
icon: '📋',
color: '#6B7280',
colorLight: '#F9FAFB',
badge: 'Стратегия'
};
+338 -113
View File
@@ -1,5 +1,6 @@
<script setup>
import MarketingV3Service from '@/service/MarketingV3Service';
import { STRATEGY_MODELS, DEFAULT_STRATEGY_MODEL } from '@/config/marketingModels';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Tag from 'primevue/tag';
@@ -103,6 +104,24 @@ let pollInterval = null;
let messageInterval = null;
const strategyData = ref(null);
const activeWeeks = ref([1]);
const videoPollingIntervals = ref({});
const toggleMuteFromBtn = (event) => {
const container = event.currentTarget.closest('.video-container');
if (!container) return;
const video = container.querySelector('video');
if (video) {
video.muted = !video.muted;
const icon = event.currentTarget.querySelector('i');
if (video.muted) {
icon.className = 'pi pi-volume-off';
event.currentTarget.title = "Включить звук";
} else {
icon.className = 'pi pi-volume-up';
event.currentTarget.title = "Выключить звук";
}
}
};
const toggleWeek = (week) => {
if (activeWeeks.value.includes(week)) {
@@ -189,6 +208,23 @@ const getPlatformIcon = (platform) => {
return 'pi pi-share-alt';
};
const getModelInfo = (modelName) => {
return STRATEGY_MODELS[modelName?.toUpperCase()] || DEFAULT_STRATEGY_MODEL;
};
// Clean rationale from debug scores
const formatRationale = (text) => {
if (!text) return '';
return text.replace(/\(баллы:.*?\)\.?/g, '').trim();
};
const getMediaState = (post) => {
if (post.videoUrl === 'generating...') return 'video-generating';
if (post.videoUrl && post.videoUrl !== 'generating...') return 'video';
if (post.imageUrl) return 'image';
return 'empty';
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' });
@@ -208,6 +244,8 @@ const blobUrls = ref({});
const loadMediaUrls = async () => {
if (!strategyData.value?.postCalendar) return;
for (const post of strategyData.value.postCalendar) {
if (post.videoUrl === 'generating...') continue;
if (post.videoFilename && !blobUrls.value[post.videoFilename]) {
MarketingV3Service.loadVideoAsBlobUrl(post.videoFilename).then((url) => {
if (url) blobUrls.value[post.videoFilename] = url;
@@ -218,11 +256,15 @@ const loadMediaUrls = async () => {
if (url) blobUrls.value[post.imageFilename] = url;
});
}
// Fallback for legacy data or if filenames are in mediaFilename
if (!post.videoFilename && !post.imageFilename && post.mediaFilename && !blobUrls.value[post.mediaFilename]) {
const method = post.contentType === 'видео' || post.contentType === 'video' || post.contentType === 'reels' ? 'loadVideoAsBlobUrl' : 'loadImageAsBlobUrl';
MarketingV3Service[method](post.mediaFilename).then((url) => {
if (url) blobUrls.value[post.mediaFilename] = url;
// Fallback or explicit mapping
if (post.imageUrl && !post.imageFilename && !blobUrls.value[post.imageUrl]) {
MarketingV3Service.loadImageAsBlobUrl(post.imageUrl).then((url) => {
if (url) blobUrls.value[post.imageUrl] = url;
});
}
if (post.videoUrl && post.videoUrl !== 'generating...' && !post.videoFilename && !blobUrls.value[post.videoUrl]) {
MarketingV3Service.loadVideoAsBlobUrl(post.videoUrl).then((url) => {
if (url) blobUrls.value[post.videoUrl] = url;
});
}
}
@@ -285,6 +327,13 @@ const loadStrategyById = async (id) => {
if (data.status === 'completed' || data.status === 'COMPLETED') {
status.value = 'COMPLETED';
loadMediaUrls();
if (data.postCalendar) {
data.postCalendar.forEach((post, index) => {
if (post.videoUrl === 'generating...') {
pollPostVideo(id, index);
}
});
}
} else if (data.status === 'failed' || data.status === 'FAILED') {
status.value = 'FAILED';
} else {
@@ -306,27 +355,35 @@ const handleRegenerateMedia = async (type, post, index) => {
try {
regeneratingPosts.value.add(index);
const method = type === 'video' ? 'regeneratePostVideo' : 'regeneratePostImage';
const updatedPost = await MarketingV3Service[method](sid, index);
const result = await MarketingV3Service[method](sid, index);
// Reactively update the post in the calendar
strategyData.value.postCalendar[index] = updatedPost;
if (type === 'video') {
// Async video generation (202 Accepted)
strategyData.value.postCalendar[index].videoUrl = 'generating...';
pollPostVideo(sid, index);
toast.add({
severity: 'info',
summary: 'Генерация',
detail: 'Видео генерируется через Veo 3...',
life: 3000
});
} else {
// Sync image generation
const updatedPost = result;
strategyData.value.postCalendar[index] = updatedPost;
// Load new media URLs immediately
if (updatedPost.videoFilename) {
const url = await MarketingV3Service.loadVideoAsBlobUrl(updatedPost.videoFilename);
if (url) blobUrls.value[updatedPost.videoFilename] = url;
if (updatedPost.imageFilename) {
const url = await MarketingV3Service.loadImageAsBlobUrl(updatedPost.imageFilename);
if (url) blobUrls.value[updatedPost.imageFilename] = url;
}
toast.add({
severity: 'success',
summary: 'Обновлено',
detail: 'ИИ обновил изображение!',
life: 3000
});
}
if (updatedPost.imageFilename) {
const url = await MarketingV3Service.loadImageAsBlobUrl(updatedPost.imageFilename);
if (url) blobUrls.value[updatedPost.imageFilename] = url;
}
toast.add({
severity: 'success',
summary: 'Обновлено',
detail: type === 'video' ? 'ИИ сгенерировал видео!' : 'ИИ обновил изображение!',
life: 3000
});
} catch (e) {
console.error('Regeneration error:', e);
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось перегенерировать медиа. Попробуйте позже.', life: 4000 });
@@ -335,6 +392,27 @@ const handleRegenerateMedia = async (type, post, index) => {
}
};
const pollPostVideo = (sid, index) => {
if (videoPollingIntervals.value[index]) return;
videoPollingIntervals.value[index] = setInterval(async () => {
try {
const strategy = await MarketingV3Service.getStrategyById(sid);
const post = strategy.postCalendar[index];
if (post?.videoUrl && post.videoUrl !== 'generating...') {
clearInterval(videoPollingIntervals.value[index]);
delete videoPollingIntervals.value[index];
strategyData.value.postCalendar[index] = post;
if (post.videoFilename) {
const url = await MarketingV3Service.loadVideoAsBlobUrl(post.videoFilename);
if (url) blobUrls.value[post.videoFilename] = url;
}
}
} catch (e) {
console.error('Video polling error', e);
}
}, 15000);
};
// B3: Start Strategy
const isAutoPostingStarted = ref(false);
const startingStrategy = ref(false);
@@ -379,6 +457,8 @@ onUnmounted(() => {
stopPolling();
window.removeEventListener('paste', handlePaste);
Object.values(videoPollingIntervals.value).forEach(clearInterval);
// Revoke any pending ObjectURLs
selectedFiles.value.forEach((item) => {
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
@@ -398,7 +478,11 @@ onUnmounted(() => {
<span class="px-2 py-1 rounded-md bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold tracking-wider uppercase">Content Factory V3</span>
</div>
<h1 class="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Генерация стратегии</h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">Создание визуального и текстового контента на основе вашего анализа</p>
<div v-if="strategyData?.strategyData?.modelTitle || strategyData?.modelTitle" class="mt-2 flex items-center gap-2">
<span class="text-[10px] font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Модель:</span>
<span class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ strategyData?.strategyData?.modelTitle || strategyData?.modelTitle }}</span>
</div>
<p v-else class="text-slate-500 dark:text-slate-400 mt-1">Создание визуального и текстового контента на основе вашего анализа</p>
</div>
<Button label="К анализу" icon="pi pi-arrow-left" severity="secondary" text class="rounded-xl hover:bg-white dark:hover:bg-slate-900 shadow-sm border border-slate-200 dark:border-slate-800" @click="goBack" />
</div>
@@ -418,25 +502,60 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="previewData" class="card relative p-8 bg-gradient-to-br from-emerald-500 to-emerald-600 dark:from-emerald-600 dark:to-emerald-800 border-none shadow-2xl shadow-emerald-200 dark:shadow-none overflow-hidden">
<!-- Abstract Background elements -->
<div class="absolute top-0 right-0 -mt-10 -mr-10 w-64 h-64 bg-white/10 blur-3xl rounded-full"></div>
<div class="absolute bottom-0 left-0 -mb-10 -ml-10 w-48 h-48 bg-black/5 blur-2xl rounded-full"></div>
<div class="flex flex-col md:flex-row items-start md:items-center gap-6 relative z-10 text-white">
<div class="p-4 bg-white/20 backdrop-blur-md rounded-2xl border border-white/30 shadow-inner">
<i class="pi pi-sparkles text-3xl animate-pulse" />
<div v-else-if="previewData" class="card relative p-8 bg-white dark:bg-slate-900 border-none shadow-xl overflow-hidden">
<div class="flex flex-col md:flex-row items-center gap-8 relative z-10">
<!-- Model Icon Badge -->
<div
:style="{ backgroundColor: getModelInfo(previewData.recommendedModel).colorLight, color: getModelInfo(previewData.recommendedModel).color }"
class="w-24 h-24 rounded-3xl flex items-center justify-center text-4xl shadow-sm shrink-0"
>
{{ getModelInfo(previewData.recommendedModel).icon }}
</div>
<div class="flex-1">
<h3 class="text-xl font-black mb-2 flex items-center gap-2">
{{ previewData.modelTitle || 'Оптимальная стратегия' }}
<i class="pi pi-info-circle text-xs opacity-60" />
<div class="flex-1 text-center md:text-left">
<div class="flex items-center justify-center md:justify-start gap-2 mb-2">
<span :style="{ color: getModelInfo(previewData.recommendedModel).color }" class="text-[10px] font-black uppercase tracking-[0.2em]">Система рекомендует</span>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
</div>
<h3 class="text-3xl font-black mb-1 tracking-tight text-slate-900 dark:text-white">
{{ previewData.modelTitle || getModelInfo(previewData.recommendedModel).title }}
</h3>
<p class="text-emerald-50/90 leading-relaxed font-medium">
{{ previewData.description }}
<p class="text-slate-500 dark:text-slate-400 leading-relaxed font-medium">
{{ previewData.description || getModelInfo(previewData.recommendedModel).description }}
</p>
</div>
</div>
<!-- Score Bars in Preview -->
<div v-if="previewData.scores" class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-6 p-6 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800">
<div v-for="(score, key) in previewData.scores" :key="key" class="space-y-2">
<div class="flex justify-between items-center text-[10px] font-black uppercase tracking-widest">
<span :style="{ color: STRATEGY_MODELS[key.toUpperCase()]?.color }">{{ STRATEGY_MODELS[key.toUpperCase()]?.badge }}</span>
<span class="text-slate-400">{{ score }}</span>
</div>
<div class="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-1000"
:style="{
width: `${Math.round((score / Math.max(...Object.values(previewData.scores))) * 100)}%`,
backgroundColor: STRATEGY_MODELS[key.toUpperCase()]?.color,
opacity: key.toUpperCase() === previewData.recommendedModel ? 1 : 0.4
}"
></div>
</div>
</div>
</div>
<!-- Active Modifiers -->
<div v-if="previewData.activeModifiers?.length" class="mt-6 flex flex-wrap gap-2 justify-center md:justify-start">
<span
v-for="mod in previewData.activeModifiers"
:key="mod.key"
class="px-3 py-1.5 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold border border-emerald-100 dark:border-emerald-800"
>
# {{ mod.title }}
</span>
</div>
</div>
</div>
@@ -568,40 +687,96 @@ onUnmounted(() => {
<!-- 4. STAGE: COMPLETED (Result Display) -->
<div v-else-if="status === 'COMPLETED' && strategyData" class="space-y-10 animate-in fade-in slide-in-from-bottom-5 duration-1000">
<!-- Strategy Rationale Banner -->
<div class="card p-8 md:p-12 bg-slate-900 dark:bg-black text-white relative overflow-hidden group">
<div class="absolute top-0 right-0 w-96 h-96 bg-emerald-500/20 blur-3xl rounded-full -mr-48 -mt-48 group-hover:bg-emerald-500/30 transition-colors duration-1000"></div>
<div class="relative z-10 flex flex-col md:flex-row gap-10 items-start">
<div class="flex-1">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-400 text-[10px] font-black uppercase tracking-wider border border-emerald-500/30 mb-6">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
Готовый результат
</div>
<h2 class="text-3xl font-black mb-6 tracking-tight">Стратегическое обоснование</h2>
<p class="text-slate-400 leading-relaxed text-lg font-medium whitespace-pre-line">
{{ strategyData.strategyData?.rationale }}
</p>
</div>
<div class="w-full md:w-72 space-y-4">
<div class="p-5 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-sm">
<p class="text-slate-400 text-[10px] font-black uppercase mb-2 flex items-center gap-2"><i class="pi pi-calendar" /> Длительность кампании</p>
<p class="font-bold text-slate-200 text-xl">{{ strategyData.durationWeeks || strategyData.strategyData?.durationWeeks || 4 }} <span class="text-sm font-medium text-slate-500">недели</span></p>
</div>
<div class="p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 backdrop-blur-sm">
<p class="text-emerald-500 text-[10px] font-black uppercase mb-3 flex items-center gap-2"><i class="pi pi-sitemap" /> Рекомендованные платформы</p>
<div class="flex flex-wrap gap-2">
<div class="card p-0 bg-white dark:bg-slate-900 border-none shadow-xl overflow-hidden">
<div class="p-8 md:p-10">
<!-- Main Info Grid -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10 items-stretch">
<!-- Left Column: Model & Rationale -->
<div class="lg:col-span-7 xl:col-span-8 space-y-8">
<div class="flex items-center gap-6">
<div
v-for="platform in strategyData.priorityPlatforms || strategyData.strategyData?.priorityPlatforms || []"
:key="platform"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/20 text-emerald-400 text-xs font-bold border border-emerald-500/30"
:style="{ backgroundColor: getModelInfo(strategyData.scoringModelName).colorLight, color: getModelInfo(strategyData.scoringModelName).color, borderColor: getModelInfo(strategyData.scoringModelName).color + '20' }"
class="w-24 h-24 rounded-[2.5rem] flex items-center justify-center text-5xl shadow-2xl shadow-inner shrink-0 border-2 transition-transform duration-700 hover:rotate-12"
>
<i :class="getPlatformIcon(platform)"></i>
{{ platform }}
{{ getModelInfo(strategyData.scoringModelName).icon }}
</div>
<div class="min-w-0">
<div class="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] mb-2">
<span :style="{ color: getModelInfo(strategyData.scoringModelName).color }">Модель бизнеса</span>
<span class="px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 border border-slate-200/50 dark:border-slate-700/50">{{ strategyData.scoringModelName || 'STRATEGY' }}</span>
</div>
<h2 class="text-3xl md:text-4xl lg:text-5xl font-black text-slate-900 dark:text-white leading-tight break-words">
{{ strategyData.scoringModelTitle || getModelInfo(strategyData.scoringModelName).title }}
</h2>
</div>
</div>
<div class="relative group/rationale">
<div class="absolute -inset-1 bg-gradient-to-r from-emerald-500/20 to-blue-500/20 rounded-3xl blur opacity-25 group-hover/rationale:opacity-50 transition duration-1000"></div>
<div class="relative p-8 rounded-3xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 shadow-sm">
<p class="text-slate-500 dark:text-slate-400 text-[10px] font-black uppercase tracking-[0.3em] mb-4 flex items-center gap-2">
<i class="pi pi-info-circle text-emerald-500" /> Обоснование стратегии
</p>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed font-semibold italic text-xl lg:text-2xl font-serif">
«{{ formatRationale(strategyData.strategyData?.scoringRationale || strategyData.strategyData?.rationale) }}»
</p>
</div>
</div>
</div>
<!-- Right Column: Score Bars -->
<div v-if="strategyData.strategyData?.scores" class="lg:col-span-5 xl:col-span-4">
<div class="p-8 rounded-[2.5rem] bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 h-full flex flex-col justify-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 flex items-center justify-between">
<span>Веса стратегии</span>
<i class="pi pi-chart-bar text-emerald-500" />
</p>
<div class="space-y-6">
<div v-for="(score, key) in strategyData.strategyData.scores" :key="key" class="space-y-2 group/bar">
<div class="flex justify-between items-center text-[10px] font-black uppercase tracking-widest">
<span :style="{ color: STRATEGY_MODELS[key.toUpperCase()]?.color }" class="group-hover/bar:translate-x-1 transition-transform inline-block">{{ STRATEGY_MODELS[key.toUpperCase()]?.badge }}</span>
<span class="text-slate-400 bg-white dark:bg-slate-900 px-2 py-1 rounded-lg shadow-sm border border-slate-100 dark:border-slate-800">{{ score }}</span>
</div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden shadow-inner p-0.5">
<div
class="h-full rounded-full transition-all duration-1000 relative overflow-hidden"
:style="{
width: `${Math.round((score / Math.max(...Object.values(strategyData.strategyData.scores))) * 100)}%`,
backgroundColor: STRATEGY_MODELS[key.toUpperCase()]?.color,
opacity: key.toUpperCase() === strategyData.scoringModelName ? 1 : 0.4
}"
>
<div v-if="key.toUpperCase() === strategyData.scoringModelName" class="absolute inset-0 bg-white/30 animate-pulse"></div>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent skew-x-12 -translate-x-full group-hover/bar:translate-x-full transition-transform duration-1000"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8 flex flex-wrap gap-4 pt-8 border-t border-slate-100 dark:border-slate-800">
<div class="flex items-center gap-4 group/item">
<div class="w-12 h-12 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-500 flex items-center justify-center shadow-sm group-hover/item:scale-110 transition-transform">
<i class="pi pi-calendar text-xl" />
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Длительность</p>
<p class="font-black text-slate-900 dark:text-white text-lg">{{ strategyData.durationWeeks || strategyData.strategyData?.durationWeeks || 4 }} <span class="text-xs font-medium text-slate-500 uppercase tracking-widest ml-1">недели</span></p>
</div>
</div>
<div class="flex items-center gap-4 group/item">
<div class="w-12 h-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shadow-sm group-hover/item:scale-110 transition-transform">
<i class="pi pi-sitemap text-xl" />
</div>
<div class="space-y-1">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Платформы</p>
<div class="flex gap-2">
<div v-for="p in strategyData.priorityPlatforms || strategyData.strategyData?.priorityPlatforms || []" :key="p" class="w-8 h-8 rounded-lg bg-white dark:bg-slate-800 flex items-center justify-center border border-slate-100 dark:border-slate-700 shadow-sm transition-all hover:border-blue-500/50 hover:-translate-y-1">
<i :class="getPlatformIcon(p)" class="text-slate-600 dark:text-slate-400" v-tooltip.top="p" />
</div>
</div>
<div v-if="!(strategyData.priorityPlatforms || strategyData.strategyData?.priorityPlatforms)?.length" class="text-slate-400 text-sm font-medium">Универсальная стратегия</div>
</div>
</div>
</div>
@@ -659,15 +834,12 @@ onUnmounted(() => {
</div>
</div>
<!-- Content Calendar -->
<!-- Post Calendar -->
<div v-if="strategyData.postCalendar?.length">
<div class="flex items-center justify-between mb-8 px-2">
<h3 class="text-xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<i class="pi pi-images text-emerald-500" />
Медиа-активы и посты
</h3>
<Button label="Скачать календарь" icon="pi pi-download" size="small" severity="secondary" rounded text class="font-bold" />
</div>
<h3 class="text-xl font-black text-slate-900 dark:text-white mb-6 px-2 flex items-center gap-3">
<i class="pi pi-images text-emerald-500" />
Контент-план (Посты)
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
@@ -676,56 +848,104 @@ onUnmounted(() => {
class="card p-0 overflow-hidden bg-white dark:bg-slate-900 border-none shadow-xl shadow-slate-200/50 dark:shadow-none hover:-translate-y-2 transition-transform duration-500 group"
>
<!-- Media Header -->
<div class="aspect-square relative bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden">
<div :class="[
'relative w-full overflow-hidden group/media bg-slate-100 dark:bg-slate-800 flex flex-col items-center justify-center',
(getMediaState(post).includes('video') || (getMediaState(post) === 'empty' && !post.imageUrl)) ? 'aspect-[9/16]' : 'aspect-[3/4]'
]">
<!-- Loading Overlay -->
<div v-if="regeneratingPosts.has(idx)" class="absolute inset-0 z-30 bg-black/60 backdrop-blur-md flex flex-col items-center justify-center text-white p-6 text-center">
<i class="pi pi-spin pi-spinner text-4xl mb-3 text-emerald-400" />
<div v-if="regeneratingPosts.has(idx) && getMediaState(post) !== 'video-generating'" class="absolute inset-0 z-30 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md flex flex-col items-center justify-center text-slate-900 dark:text-white p-6 text-center">
<i class="pi pi-spin pi-spinner text-4xl mb-3 text-emerald-500" />
<p class="text-sm font-bold animate-pulse">Магия нейросетей в процессе...</p>
<p class="text-[10px] opacity-60 mt-2">Генерируем контент... (до 2 минут)</p>
</div>
<!-- Smart Content Rendering -->
<template v-if="post.contentType === 'видео'">
<video
v-if="post.videoFilename && getMediaUrl(post.videoFilename)"
:src="getMediaUrl(post.videoFilename)"
autoplay
loop
muted
playsinline
controls
class="w-full h-full object-cover transition-transform duration-700"
/>
<div v-else class="text-center p-8 opacity-40 bg-slate-200 dark:bg-slate-800 w-full h-full flex flex-col items-center justify-center">
<i class="pi pi-exclamation-triangle text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase italic">Не удалось сгенерировать видео</p>
<template v-if="getMediaState(post) === 'video-generating'">
<div class="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900 gap-3">
<div class="relative">
<div class="w-16 h-16 rounded-full bg-emerald-500/20 animate-ping absolute inset-0" />
<div class="w-16 h-16 rounded-full bg-emerald-500/30 flex items-center justify-center relative shadow-[0_0_15px_rgba(16,185,129,0.5)]">
<i class="pi pi-video text-emerald-400 text-2xl" />
</div>
</div>
<div class="text-center px-4 mt-2">
<p class="text-white font-medium text-sm">Veo 3 генерирует видео</p>
<p class="text-slate-400 text-xs mt-1">Обычно занимает 38 минут</p>
</div>
<div class="w-32 h-1 bg-slate-700/50 rounded-full overflow-hidden mt-1">
<div class="h-full bg-emerald-500 rounded-full w-full animate-pulse blur-[1px]"></div>
</div>
<p class="text-slate-500/80 text-[10px] mt-1 uppercase tracking-widest font-black">со звуком 🔊</p>
</div>
<div class="absolute top-4 right-4 px-2 py-1 bg-black/60 rounded backdrop-blur-md text-[8px] font-bold text-white uppercase tracking-tighter z-10">Video AI</div>
</template>
<template v-else-if="post.contentType === 'фото'">
<img v-if="post.imageFilename && getMediaUrl(post.imageFilename)" :src="getMediaUrl(post.imageFilename)" class="w-full h-full object-cover transition-transform duration-700" />
<template v-else-if="getMediaState(post) === 'video'">
<div class="absolute inset-0 bg-black video-container flex items-center justify-center">
<video
v-if="getMediaUrl(post.videoFilename || post.videoUrl)"
:src="getMediaUrl(post.videoFilename || post.videoUrl)"
:poster="getMediaUrl(post.imageFilename || post.imageUrl) || undefined"
autoplay
loop
muted
playsinline
:controls="false"
class="w-full h-full object-cover transition-transform duration-700 group-hover/media:scale-105"
/>
<div v-else class="text-center p-8 opacity-40 bg-slate-200 dark:bg-slate-800 w-full h-full flex flex-col items-center justify-center">
<i class="pi pi-exclamation-triangle text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase italic">Не удалось загрузить видео</p>
</div>
<!-- Mute Button -->
<button v-if="getMediaUrl(post.videoFilename || post.videoUrl)"
@click="toggleMuteFromBtn($event)"
class="absolute bottom-3 right-3 bg-black/60 hover:bg-black/80 text-white rounded-full w-9 h-9 flex items-center justify-center transition-all backdrop-blur-sm z-20"
title="Включить звук"
>
<i class="pi pi-volume-off" />
</button>
<!-- Label Veo 3 -->
<div class="absolute top-2 right-2 bg-black/60 text-white text-[10px] px-2 py-1 rounded-full z-20 tracking-wide font-bold backdrop-blur-sm shadow-sm border border-white/10">
Veo 3 · AI видео
</div>
</div>
</template>
<template v-else-if="getMediaState(post) === 'image'">
<img v-if="getMediaUrl(post.imageFilename || post.imageUrl)" :src="getMediaUrl(post.imageFilename || post.imageUrl)" class="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover/media:scale-105" />
<div v-else class="text-center p-8 opacity-40 bg-slate-200 dark:bg-slate-800 w-full h-full flex flex-col items-center justify-center">
<i class="pi pi-exclamation-triangle text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase italic">Не удалось сгенерировать фото</p>
<p class="text-[10px] font-bold uppercase italic">Не удалось загрузить фото</p>
</div>
<div class="absolute top-4 right-4 px-2 py-1 bg-black/60 rounded backdrop-blur-md text-[8px] font-bold text-white uppercase tracking-tighter z-10">Image AI</div>
<div class="absolute top-2 right-2 px-2 py-1 bg-black/60 rounded-full backdrop-blur-md text-[10px] font-bold text-white tracking-wide border border-white/10 z-10">AI Photo</div>
</template>
<template v-else>
<!-- Fallback for other potential types or old data -->
<img v-if="getMediaUrl(post.imageFilename || post.mediaFilename)" :src="getMediaUrl(post.imageFilename || post.mediaFilename)" class="w-full h-full object-cover" />
<video v-else-if="getMediaUrl(post.videoFilename)" :src="getMediaUrl(post.videoFilename)" autoplay loop muted playsinline controls class="w-full h-full object-cover" />
<div v-else class="text-center p-8 opacity-40 bg-slate-200 dark:bg-slate-800 w-full h-full flex flex-col items-center justify-center">
<i class="pi pi-images text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase italic">Медиа недоступно</p>
<div class="absolute inset-0 bg-slate-100 dark:bg-slate-800 border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-emerald-400 dark:hover:border-emerald-500 transition-colors group/empty m-2 rounded-xl"
@click="handleRegenerateMedia('video', post, idx)">
<div class="w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center group-hover/empty:scale-110 transition-transform">
<i class="pi pi-video text-emerald-500 text-2xl" />
</div>
<div class="text-center px-4">
<p class="text-slate-700 dark:text-slate-300 font-medium text-sm">
{{ regeneratingPosts.has(idx) ? 'Запускаю генерацию...' : 'Сгенерировать видео' }}
</p>
<p class="text-slate-500 text-xs mt-1">Veo 3 · со звуком · 9:16</p>
</div>
<button v-if="!regeneratingPosts.has(idx)" class="mt-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs rounded-lg font-medium transition-colors shadow-sm">
🎬 Создать видео
</button>
</div>
</template>
<div class="absolute inset-0 bg-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="absolute top-4 left-4 flex gap-2">
<div class="p-2 rounded-xl bg-white/90 dark:bg-slate-900/90 backdrop-blur-md shadow-lg">
<i :class="getPlatformIcon(post.platform)" class="text-lg text-emerald-500" />
<div class="absolute inset-0 bg-emerald-500/10 opacity-0 group-hover/media:opacity-100 transition-opacity pointer-events-none"></div>
<div class="absolute top-2 left-2 flex gap-2 z-10">
<div class="p-1.5 rounded-lg bg-white/90 dark:bg-slate-900/90 backdrop-blur-md shadow-lg border border-slate-100 dark:border-slate-800">
<i :class="getPlatformIcon(post.platform)" class="text-base text-emerald-500" />
</div>
</div>
</div>
@@ -742,6 +962,10 @@ onUnmounted(() => {
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 mb-6 group-hover:bg-emerald-50/30 dark:group-hover:bg-emerald-900/10 transition-colors">
<p class="text-[13px] text-slate-600 dark:text-slate-300 line-clamp-4 font-medium leading-relaxed">{{ post.postText }}</p>
<!-- Hashtags -->
<div v-if="post.hashtags?.length" class="mt-3 flex flex-wrap gap-1">
<span v-for="tag in post.hashtags" :key="tag" class="text-[10px] font-bold text-emerald-600 dark:text-emerald-400">{{ tag.startsWith('#') ? tag : '#' + tag }}</span>
</div>
</div>
<div class="flex flex-col gap-2 mb-4">
@@ -756,11 +980,12 @@ onUnmounted(() => {
@click="handleRegenerateMedia('image', post, idx)"
/>
<Button
v-if="getMediaState(post) !== 'video-generating'"
icon="pi pi-video"
label="Сделать видео"
:label="getMediaState(post) === 'video' ? 'Новое видео' : 'Видео + звук'"
class="flex-1 text-[9px] font-black uppercase tracking-wider rounded-xl py-2"
severity="secondary"
outlined
:severity="getMediaState(post) === 'video' ? 'secondary' : 'success'"
:outlined="getMediaState(post) === 'video'"
:disabled="regeneratingPosts.has(idx)"
@click="handleRegenerateMedia('video', post, idx)"
/>
@@ -1,5 +1,6 @@
<script setup>
import MarketingV3Service from '@/service/MarketingV3Service';
import { STRATEGY_MODELS, DEFAULT_STRATEGY_MODEL } from '@/config/marketingModels';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -96,6 +97,18 @@ const getPlatformIcon = (platform) => {
return 'pi pi-share-alt';
};
const getModelInfo = (modelName) => {
if (!modelName) return DEFAULT_STRATEGY_MODEL;
return STRATEGY_MODELS[modelName.toUpperCase()] || DEFAULT_STRATEGY_MODEL;
};
const getLatestStatusMessage = (item) => {
if (item.statusHistory && item.statusHistory.length > 0) {
return item.statusHistory[item.statusHistory.length - 1].message;
}
return item.status === 'QUEUED' ? 'В очереди...' : 'Фабрика генерирует контент...';
};
// Actions
const openStrategy = (item) => {
const s = (item.status || '').toUpperCase();
@@ -184,9 +197,11 @@ onMounted(load);
<div
v-for="item in paginated"
:key="item.strategyId || item.id"
class="card cursor-pointer hover:-translate-y-0.5 hover:shadow-md transition-all duration-200 border border-surface-200 dark:border-surface-700"
class="card group relative overflow-hidden cursor-pointer hover:-translate-y-1 hover:shadow-2xl hover:shadow-emerald-500/10 transition-all duration-300 border border-surface-200 dark:border-surface-800 bg-white/50 dark:bg-surface-900/50 backdrop-blur-sm"
@click="openStrategy(item)"
>
<!-- Background Glow on Hover -->
<div class="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Status badge -->
<div class="shrink-0">
@@ -198,12 +213,22 @@ onMounted(load);
<!-- Main info -->
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<h3 class="text-sm font-bold text-surface-900 dark:text-surface-0 truncate">
{{ item.analysisData?.requestData?.businessNiche || 'Стратегия #' + (item.strategyId || item.id || '').slice(-6) }}
<h3 class="text-base font-extrabold text-surface-900 dark:text-surface-0 truncate group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{{ item.analysisTitle || (item.analysisData?.requestData?.productName ? `${item.analysisData.requestData.productName}${item.analysisData.requestData.businessNiche}` : (item.analysisData?.requestData?.businessNiche || 'Стратегия #' + (item.strategyId || item.id || '').slice(-6))) }}
</h3>
<span :class="['inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold', statusTagClass(item.status)]">
{{ statusLabel(item.status) }}
</span>
<div class="flex items-center gap-2">
<span :class="['inline-flex items-center px-1.5 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-wider', statusTagClass(item.status)]">
{{ statusLabel(item.status) }}
</span>
<!-- Strategy Model Badge -->
<div
:style="{ backgroundColor: getModelInfo(item.scoringModelName).colorLight, color: getModelInfo(item.scoringModelName).color, borderColor: getModelInfo(item.scoringModelName).color + '20' }"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border shadow-sm transition-transform group-hover:scale-105"
>
<span class="text-xs">{{ getModelInfo(item.scoringModelName).icon }}</span>
{{ getModelInfo(item.scoringModelName).badge }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-surface-500 dark:text-surface-400 mt-2">
<span v-if="item.durationWeeks || item.strategyData?.durationWeeks" class="flex items-center gap-1 font-medium"> <i class="pi pi-calendar" /> {{ item.durationWeeks || item.strategyData?.durationWeeks }} недель </span>
@@ -237,7 +262,7 @@ onMounted(load);
<!-- Progress bar for PROCESSING -->
<div v-if="item.status === 'PROCESSING' || item.status === 'QUEUED'" class="mt-3">
<div class="flex justify-between text-xs text-surface-500 dark:text-surface-400 mb-1">
<span>{{ item.status === 'QUEUED' ? 'В очереди...' : 'Фабрика генерирует контент...' }}</span>
<span>{{ getLatestStatusMessage(item) }}</span>
</div>
<div class="h-1.5 bg-surface-200 dark:bg-surface-700 rounded-full overflow-hidden">
<div class="h-1.5 rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 animate-pulse" :style="{ width: item.status === 'QUEUED' ? '10%' : '60%' }" />
@@ -0,0 +1,47 @@
// vite.config.mjs
import { fileURLToPath, URL } from "node:url";
import { PrimeVueResolver } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@primevue/auto-import-resolver/index.mjs";
import vue from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import Components from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/unplugin-vue-components/dist/vite.js";
import { defineConfig } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/vite/dist/node/index.js";
var __vite_injected_original_import_meta_url = "file:///C:/Users/777/IdeaProjects/marketing/vite.config.mjs";
var vite_config_default = defineConfig({
optimizeDeps: {
noDiscovery: true
},
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()]
})
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", __vite_injected_original_import_meta_url))
}
},
server: {
proxy: {
"/api": {
target: "https://api.konturai.kz",
changeOrigin: true,
secure: true,
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log("Received Response from the Target:", proxyRes.statusCode, req.url);
});
}
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcNzc3XFxcXElkZWFQcm9qZWN0c1xcXFxtYXJrZXRpbmdcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkM6XFxcXFVzZXJzXFxcXDc3N1xcXFxJZGVhUHJvamVjdHNcXFxcbWFya2V0aW5nXFxcXHZpdGUuY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovVXNlcnMvNzc3L0lkZWFQcm9qZWN0cy9tYXJrZXRpbmcvdml0ZS5jb25maWcubWpzXCI7aW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnO1xuXG5pbXBvcnQgeyBQcmltZVZ1ZVJlc29sdmVyIH0gZnJvbSAnQHByaW1ldnVlL2F1dG8taW1wb3J0LXJlc29sdmVyJztcbmltcG9ydCB2dWUgZnJvbSAnQHZpdGVqcy9wbHVnaW4tdnVlJztcbmltcG9ydCBDb21wb25lbnRzIGZyb20gJ3VucGx1Z2luLXZ1ZS1jb21wb25lbnRzL3ZpdGUnO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XG5cbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICAgIG9wdGltaXplRGVwczoge1xuICAgICAgICBub0Rpc2NvdmVyeTogdHJ1ZVxuICAgIH0sXG4gICAgcGx1Z2luczogW1xuICAgICAgICB2dWUoKSxcbiAgICAgICAgQ29tcG9uZW50cyh7XG4gICAgICAgICAgICByZXNvbHZlcnM6IFtQcmltZVZ1ZVJlc29sdmVyKCldXG4gICAgICAgIH0pXG4gICAgXSxcbiAgICByZXNvbHZlOiB7XG4gICAgICAgIGFsaWFzOiB7XG4gICAgICAgICAgICAnQCc6IGZpbGVVUkxUb1BhdGgobmV3IFVSTCgnLi9zcmMnLCBpbXBvcnQubWV0YS51cmwpKVxuICAgICAgICB9XG4gICAgfSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgICAgcHJveHk6IHtcbiAgICAgICAgICAgICcvYXBpJzoge1xuICAgICAgICAgICAgICAgIHRhcmdldDogJ2h0dHBzOi8vYXBpLmtvbnR1cmFpLmt6JyxcbiAgICAgICAgICAgICAgICBjaGFuZ2VPcmlnaW46IHRydWUsXG4gICAgICAgICAgICAgICAgc2VjdXJlOiB0cnVlLFxuICAgICAgICAgICAgICAgIGNvbmZpZ3VyZTogKHByb3h5LCBfb3B0aW9ucykgPT4ge1xuICAgICAgICAgICAgICAgICAgICBwcm94eS5vbignZXJyb3InLCAoZXJyLCBfcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygncHJveHkgZXJyb3InLCBlcnIpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICAgICAgcHJveHkub24oJ3Byb3h5UmVxJywgKHByb3h5UmVxLCByZXEsIF9yZXMpID0+IHtcbiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnNvbGUubG9nKCdTZW5kaW5nIFJlcXVlc3QgdG8gdGhlIFRhcmdldDonLCByZXEubWV0aG9kLCByZXEudXJsKTtcbiAgICAgICAgICAgICAgICAgICAgfSk7XG4gICAgICAgICAgICAgICAgICAgIHByb3h5Lm9uKCdwcm94eVJlcycsIChwcm94eVJlcywgcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnUmVjZWl2ZWQgUmVzcG9uc2UgZnJvbSB0aGUgVGFyZ2V0OicsIHByb3h5UmVzLnN0YXR1c0NvZGUsIHJlcS51cmwpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICB9XG4gICAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICB9XG59KTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBdVMsU0FBUyxlQUFlLFdBQVc7QUFFMVUsU0FBUyx3QkFBd0I7QUFDakMsT0FBTyxTQUFTO0FBQ2hCLE9BQU8sZ0JBQWdCO0FBQ3ZCLFNBQVMsb0JBQW9CO0FBTDJKLElBQU0sMkNBQTJDO0FBUXpPLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQ3hCLGNBQWM7QUFBQSxJQUNWLGFBQWE7QUFBQSxFQUNqQjtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ0wsSUFBSTtBQUFBLElBQ0osV0FBVztBQUFBLE1BQ1AsV0FBVyxDQUFDLGlCQUFpQixDQUFDO0FBQUEsSUFDbEMsQ0FBQztBQUFBLEVBQ0w7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNMLE9BQU87QUFBQSxNQUNILEtBQUssY0FBYyxJQUFJLElBQUksU0FBUyx3Q0FBZSxDQUFDO0FBQUEsSUFDeEQ7QUFBQSxFQUNKO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDSixPQUFPO0FBQUEsTUFDSCxRQUFRO0FBQUEsUUFDSixRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxRQUFRO0FBQUEsUUFDUixXQUFXLENBQUMsT0FBTyxhQUFhO0FBQzVCLGdCQUFNLEdBQUcsU0FBUyxDQUFDLEtBQUssTUFBTSxTQUFTO0FBQ25DLG9CQUFRLElBQUksZUFBZSxHQUFHO0FBQUEsVUFDbEMsQ0FBQztBQUNELGdCQUFNLEdBQUcsWUFBWSxDQUFDLFVBQVUsS0FBSyxTQUFTO0FBQzFDLG9CQUFRLElBQUksa0NBQWtDLElBQUksUUFBUSxJQUFJLEdBQUc7QUFBQSxVQUNyRSxDQUFDO0FBQ0QsZ0JBQU0sR0FBRyxZQUFZLENBQUMsVUFBVSxLQUFLLFNBQVM7QUFDMUMsb0JBQVEsSUFBSSxzQ0FBc0MsU0FBUyxZQUFZLElBQUksR0FBRztBQUFBLFVBQ2xGLENBQUM7QUFBQSxRQUNMO0FBQUEsTUFDSjtBQUFBLElBQ0o7QUFBQSxFQUNKO0FBQ0osQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K