FIX
This commit is contained in:
Generated
+12
-1
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"chart.js": "3.3.2",
|
||||
"chartjs-plugin-datalabels": "^2.0.0",
|
||||
"katex": "^0.16.27",
|
||||
"marked": "^17.0.1",
|
||||
"marked-katex-extension": "^5.1.6",
|
||||
@@ -1404,7 +1405,17 @@
|
||||
"node_modules/chart.js": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.3.2.tgz",
|
||||
"integrity": "sha512-H0hSO7xqTIrwxoACqnSoNromEMfXvfuVnrbuSt2TuXfBDDofbnto4zuZlRtRvC73/b37q3wGAWZyUU41QPvNbA=="
|
||||
"integrity": "sha512-H0hSO7xqTIrwxoACqnSoNromEMfXvfuVnrbuSt2TuXfBDDofbnto4zuZlRtRvC73/b37q3wGAWZyUU41QPvNbA==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/chartjs-plugin-datalabels": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.0.0.tgz",
|
||||
"integrity": "sha512-WBsWihphzM0Y8fmQVm89+iy99mmgejmj5/jcsYqwxSioLRL/zqJ4Scv/eXq5ZqvG3TpojlGzZLeaOaSvDm7fwA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"chart.js": "3.3.2",
|
||||
"chartjs-plugin-datalabels": "^2.0.0",
|
||||
"katex": "^0.16.27",
|
||||
"marked": "^17.0.1",
|
||||
"marked-katex-extension": "^5.1.6",
|
||||
|
||||
@@ -39,7 +39,7 @@ import { useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const STEPS = ['Базовая информация', 'География и чек', 'Продукт и поведение', 'Текущая ситуация', 'Ссылки и настройки'];
|
||||
const STEPS = ['Базовая информация', 'География и чек', 'Продукт и поведение', 'Текущая ситуация'];
|
||||
|
||||
const currentStep = ref(0);
|
||||
const submitting = ref(false);
|
||||
@@ -72,7 +72,7 @@ const formData = ref({
|
||||
userSocialLinks: [],
|
||||
knownCompetitorLinks: [],
|
||||
detailLevel: 'СТАНДАРТНО',
|
||||
analysisType: []
|
||||
analysisType: ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ']
|
||||
});
|
||||
|
||||
// Options для Radio Groups
|
||||
@@ -251,35 +251,19 @@ const validateStep = (stepIndex) => {
|
||||
if (!formData.value.customerBehaviors?.length) {
|
||||
errors.value.customerBehaviors = 'Выберите хотя бы один вариант';
|
||||
isValid = false;
|
||||
} else if (formData.value.customerBehaviors.length > 2) {
|
||||
errors.value.customerBehaviors = 'Не более двух вариантов';
|
||||
isValid = false;
|
||||
} else delete errors.value.customerBehaviors;
|
||||
|
||||
if (!formData.value.decisionPriorities?.length) {
|
||||
errors.value.decisionPriorities = 'Выберите хотя бы один приоритет';
|
||||
isValid = false;
|
||||
} else if (formData.value.decisionPriorities.length > 2) {
|
||||
errors.value.decisionPriorities = 'Не более двух приоритетов';
|
||||
isValid = false;
|
||||
} else delete errors.value.decisionPriorities;
|
||||
|
||||
if (!formData.value.discoveryMethods?.length) {
|
||||
errors.value.discoveryMethods = 'Выберите хотя бы один вариант';
|
||||
isValid = false;
|
||||
} else if (formData.value.discoveryMethods.length > 2) {
|
||||
errors.value.discoveryMethods = 'Не более двух вариантов';
|
||||
isValid = false;
|
||||
} else delete errors.value.discoveryMethods;
|
||||
}
|
||||
|
||||
if (stepIndex === 4) {
|
||||
if (!formData.value.analysisType?.length) {
|
||||
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
|
||||
isValid = false;
|
||||
} else delete errors.value.analysisType;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
@@ -411,7 +395,10 @@ export default {
|
||||
<div class="card-header mb-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Бриф для маркетингового анализа v4.0</h1>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Button icon="pi pi-arrow-left" text rounded size="small" class="text-surface-500 p-0 w-6 h-6 mr-1" @click="$router.push('/marketing-analysis')" v-tooltip.top="'На главную Маркетинга'" />
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0 m-0">Бриф для маркетингового анализа v4.0</h1>
|
||||
</div>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">{{ STEPS[currentStep] }} · шаг {{ currentStep + 1 }} из {{ STEPS.length }}</p>
|
||||
</div>
|
||||
<div class="hidden sm:block text-right text-xs text-surface-500 dark:text-surface-400">
|
||||
@@ -538,31 +525,28 @@ export default {
|
||||
|
||||
<RadioGroupField v-model="formData.visualFactor" label="Насколько для вашей ниши важен визуал?" :options="visualFactorOptions" :error="errors.visualFactor" @update:modelValue="formData.visualFactor = $event" />
|
||||
|
||||
<CheckboxCardsLimitedField
|
||||
<CheckboxCardsField
|
||||
v-model="formData.customerBehaviors"
|
||||
label="Как клиенты обычно принимают решение?"
|
||||
:options="customerBehaviorOptions"
|
||||
:max="2"
|
||||
:error="errors.customerBehaviors"
|
||||
@update:modelValue="formData.customerBehaviors = $event"
|
||||
/>
|
||||
<small v-if="errors.customerBehaviors" class="p-error">{{ errors.customerBehaviors }}</small>
|
||||
|
||||
<CheckboxCardsLimitedField
|
||||
<CheckboxCardsField
|
||||
v-model="formData.decisionPriorities"
|
||||
label="Что для клиентов важнее всего при выборе?"
|
||||
:options="decisionPriorityOptions"
|
||||
:max="2"
|
||||
:error="errors.decisionPriorities"
|
||||
@update:modelValue="formData.decisionPriorities = $event"
|
||||
/>
|
||||
<small v-if="errors.decisionPriorities" class="p-error">{{ errors.decisionPriorities }}</small>
|
||||
|
||||
<CheckboxCardsLimitedField
|
||||
<CheckboxCardsField
|
||||
v-model="formData.discoveryMethods"
|
||||
label="Как клиенты обычно находят вас?"
|
||||
:options="discoveryMethodOptions"
|
||||
:max="2"
|
||||
:error="errors.discoveryMethods"
|
||||
@update:modelValue="formData.discoveryMethods = $event"
|
||||
/>
|
||||
@@ -589,39 +573,6 @@ export default {
|
||||
/>
|
||||
</StepSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentStep === 4" key="step-4" class="space-y-5">
|
||||
<StepSection title="Ссылки и настройки отчёта">
|
||||
<DynamicLinksField
|
||||
v-model="formData.userSocialLinks"
|
||||
label="Ссылки на ваши соцсети"
|
||||
placeholder="Например: https://instagram.com/dentart_kz или https://vk.com/clinic_almaty"
|
||||
:max="5"
|
||||
:error="errors.userSocialLinks"
|
||||
@update:modelValue="formData.userSocialLinks = $event"
|
||||
/>
|
||||
|
||||
<DynamicLinksField
|
||||
v-model="formData.knownCompetitorLinks"
|
||||
label="Ссылки на конкурентов (если знаете)"
|
||||
placeholder="Например: https://instagram.com/competitor_clinic или профиль в 2ГИС"
|
||||
:max="5"
|
||||
:error="errors.knownCompetitorLinks"
|
||||
@update:modelValue="formData.knownCompetitorLinks = $event"
|
||||
/>
|
||||
|
||||
<RadioGroupField v-model="formData.detailLevel" label="Насколько детальным должен быть отчёт?" :options="detailLevelOptions" :error="errors.detailLevel" @update:modelValue="formData.detailLevel = $event" />
|
||||
|
||||
<CheckboxCardsField
|
||||
v-model="formData.analysisType"
|
||||
label="Что важно проанализировать в первую очередь?"
|
||||
:options="analysisTypeOptions"
|
||||
:error="errors.analysisType"
|
||||
@update:modelValue="formData.analysisType = $event"
|
||||
/>
|
||||
<small v-if="errors.analysisType" class="p-error block mt-1">{{ errors.analysisType }}</small>
|
||||
</StepSection>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,77 +1,81 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
icon: { type: String, default: 'pi pi-chart-bar' },
|
||||
color: { type: String, default: 'indigo' },
|
||||
trend: { type: String, default: null } // 'up' | 'down' | 'neutral'
|
||||
trend: { type: String, default: null }, // 'up' | 'down' | 'neutral'
|
||||
tooltip: { type: String, default: null }
|
||||
});
|
||||
|
||||
const colorMap = {
|
||||
indigo: {
|
||||
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
|
||||
border: 'border-indigo-200 dark:border-indigo-800',
|
||||
label: 'text-indigo-500 dark:text-indigo-400',
|
||||
value: 'text-indigo-800 dark:text-indigo-200',
|
||||
iconBg: 'bg-indigo-100 dark:bg-indigo-900/40',
|
||||
iconColor: 'text-indigo-600 dark:text-indigo-300'
|
||||
bg: 'bg-indigo-50 dark:bg-indigo-900/10',
|
||||
border: 'border-indigo-100 dark:border-indigo-800',
|
||||
label: 'text-indigo-400 dark:text-indigo-400',
|
||||
value: 'text-indigo-800 dark:text-indigo-100',
|
||||
iconBg: 'bg-indigo-500',
|
||||
iconColor: 'text-white'
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
label: 'text-blue-500 dark:text-blue-400',
|
||||
value: 'text-blue-800 dark:text-blue-200',
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
|
||||
iconColor: 'text-blue-600 dark:text-blue-300'
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/10',
|
||||
border: 'border-blue-100 dark:border-blue-800',
|
||||
label: 'text-blue-400 dark:text-blue-400',
|
||||
value: 'text-blue-800 dark:text-blue-100',
|
||||
iconBg: 'bg-blue-500',
|
||||
iconColor: 'text-white'
|
||||
},
|
||||
violet: {
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
border: 'border-violet-200 dark:border-violet-800',
|
||||
label: 'text-violet-500 dark:text-violet-400',
|
||||
value: 'text-violet-800 dark:text-violet-200',
|
||||
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||
iconColor: 'text-violet-600 dark:text-violet-300'
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/10',
|
||||
border: 'border-violet-100 dark:border-violet-800',
|
||||
label: 'text-violet-400 dark:text-violet-400',
|
||||
value: 'text-violet-800 dark:text-violet-100',
|
||||
iconBg: 'bg-violet-500',
|
||||
iconColor: 'text-white'
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
label: 'text-amber-500 dark:text-amber-400',
|
||||
value: 'text-amber-800 dark:text-amber-200',
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
|
||||
iconColor: 'text-amber-600 dark:text-amber-300'
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/10',
|
||||
border: 'border-amber-100 dark:border-amber-800',
|
||||
label: 'text-amber-400 dark:text-amber-400',
|
||||
value: 'text-amber-800 dark:text-amber-100',
|
||||
iconBg: 'bg-amber-500',
|
||||
iconColor: 'text-white'
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
border: 'border-emerald-200 dark:border-emerald-800',
|
||||
label: 'text-emerald-500 dark:text-emerald-400',
|
||||
value: 'text-emerald-800 dark:text-emerald-200',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-300'
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/10',
|
||||
border: 'border-emerald-100 dark:border-emerald-800',
|
||||
label: 'text-emerald-400 dark:text-emerald-400',
|
||||
value: 'text-emerald-800 dark:text-emerald-100',
|
||||
iconBg: 'bg-emerald-500',
|
||||
iconColor: 'text-white'
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-50 dark:bg-orange-900/20',
|
||||
border: 'border-orange-200 dark:border-orange-800',
|
||||
label: 'text-orange-500 dark:text-orange-400',
|
||||
value: 'text-orange-800 dark:text-orange-200',
|
||||
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
|
||||
iconColor: 'text-orange-600 dark:text-orange-300'
|
||||
bg: 'bg-orange-50 dark:bg-orange-900/10',
|
||||
border: 'border-orange-100 dark:border-orange-800',
|
||||
label: 'text-orange-400 dark:text-orange-400',
|
||||
value: 'text-orange-800 dark:text-orange-100',
|
||||
iconBg: 'bg-orange-500',
|
||||
iconColor: 'text-white'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['exec-card rounded-xl border p-4 flex flex-col gap-2 transition hover:-translate-y-0.5 hover:shadow-md', colorMap[color].border, colorMap[color].bg]">
|
||||
<div :class="['exec-card rounded-2xl border p-4 flex flex-col gap-2 transition-all duration-300 hover:shadow-xl hover:scale-[1.02] bg-white dark:bg-surface-900', colorMap[color].border]">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest truncate" :class="colorMap[color].label">{{ label }}</p>
|
||||
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full shrink-0" :class="colorMap[color].iconBg">
|
||||
<i :class="[icon, 'text-sm', colorMap[color].iconColor]" />
|
||||
<div class="flex items-center gap-1.5 overflow-hidden">
|
||||
<p class="text-[10px] font-black uppercase tracking-widest truncate" :class="colorMap[color].label">{{ label }}</p>
|
||||
<i v-if="tooltip" class="pi pi-info-circle text-[10px] text-surface-400 cursor-help shrink-0" v-tooltip="tooltip" />
|
||||
</div>
|
||||
<span class="inline-flex h-7 w-7 items-center justify-center rounded-xl shrink-0 shadow-lg" :class="colorMap[color].iconBg">
|
||||
<i :class="[icon, 'text-xs', colorMap[color].iconColor]" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xl font-black leading-none" :class="colorMap[color].value">{{ value }}</p>
|
||||
<div v-if="trend" class="flex items-center gap-1 text-xs font-medium">
|
||||
<p class="text-2xl font-black leading-none tracking-tight" :class="colorMap[color].value">{{ value }}</p>
|
||||
<div v-if="trend" class="flex items-center gap-1 text-[10px] font-bold">
|
||||
<i :class="['pi', trend === 'up' ? 'pi-arrow-up text-emerald-500' : trend === 'down' ? 'pi-arrow-down text-rose-500' : 'pi-minus text-surface-400']" />
|
||||
<span :class="trend === 'up' ? 'text-emerald-600' : trend === 'down' ? 'text-rose-600' : 'text-surface-400'">
|
||||
{{ trend === 'up' ? 'Выше среднего' : trend === 'down' ? 'Ниже среднего' : 'Нейтрально' }}
|
||||
{{ trend === 'up' ? 'Выше рынка' : trend === 'down' ? 'Ниже рынка' : 'Норма' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,10 @@ const arcDash = computed(() => {
|
||||
|
||||
const arcColor = computed(() => {
|
||||
const v = clampedValue.value;
|
||||
if (v >= 70) return '#EF4444';
|
||||
if (v >= 40) return '#F97316';
|
||||
return '#22C55E';
|
||||
if (v >= 75) return '#EF4444'; // Red (Critical)
|
||||
if (v >= 50) return '#F97316'; // Orange (High)
|
||||
if (v >= 25) return '#3B82F6'; // Blue (Moderate)
|
||||
return '#10B981'; // Emerald (Low)
|
||||
});
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
@@ -34,18 +35,27 @@ const displayLabel = computed(() => {
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<svg viewBox="0 0 200 130" class="w-56 h-36">
|
||||
<defs>
|
||||
<linearGradient id="gaugeGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#10B981" />
|
||||
<stop offset="50%" stop-color="#F97316" />
|
||||
<stop offset="100%" stop-color="#EF4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background arc (track) -->
|
||||
<path :d="trackPath" fill="none" stroke="#E5E7EB" stroke-width="18" stroke-linecap="round" class="dark:stroke-surface-700" />
|
||||
<path :d="trackPath" fill="none" stroke="#F3F4F6" stroke-width="16" stroke-linecap="round" class="dark:stroke-surface-800" />
|
||||
<!-- Colored fill arc -->
|
||||
<path :d="trackPath" fill="none" :stroke="arcColor" stroke-width="18" stroke-linecap="round" :stroke-dasharray="arcDash" stroke-dashoffset="0" style="transition: stroke-dasharray 0.9s ease" />
|
||||
<path :d="trackPath" fill="none" :stroke="arcColor" stroke-width="16" stroke-linecap="round" :stroke-dasharray="arcDash" stroke-dashoffset="0" style="transition: all 1s cubic-bezier(0.34, 1.56, 0.64, 1)" />
|
||||
|
||||
<!-- Center value -->
|
||||
<text x="100" y="105" text-anchor="middle" font-size="28" font-weight="800" :fill="arcColor">{{ clampedValue }}</text>
|
||||
<text x="100" y="122" text-anchor="middle" font-size="10" fill="#9CA3AF">из 100</text>
|
||||
<text x="100" y="95" text-anchor="middle" font-size="36" font-weight="900" :fill="arcColor" class="tracking-tighter">{{ clampedValue }}</text>
|
||||
<text x="100" y="115" text-anchor="middle" font-size="10" font-weight="700" fill="#9CA3AF" class="uppercase tracking-widest leading-none">баллов</text>
|
||||
|
||||
<!-- Min / Max labels -->
|
||||
<text x="22" y="118" text-anchor="middle" font-size="9" fill="#9CA3AF">0</text>
|
||||
<text x="178" y="118" text-anchor="middle" font-size="9" fill="#9CA3AF">100</text>
|
||||
<text x="22" y="120" text-anchor="middle" font-size="9" font-weight="bold" fill="#D1D5DB">0</text>
|
||||
<text x="178" y="120" text-anchor="middle" font-size="9" font-weight="bold" fill="#D1D5DB">100</text>
|
||||
</svg>
|
||||
<p v-if="displayLabel" class="mt-1 text-xs font-semibold uppercase tracking-widest" :style="{ color: arcColor }">
|
||||
<p v-if="displayLabel" class="mt-2 px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm bg-white dark:bg-surface-800 border-2" :style="{ color: arcColor, borderColor: arcColor + '40' }">
|
||||
{{ displayLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,15 +7,21 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="id" class="card scroll-mt-4">
|
||||
<div class="flex items-start justify-between mb-4 gap-2">
|
||||
<div>
|
||||
<h2 class="text-base font-bold text-surface-900 dark:text-surface-0 tracking-tight">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="text-xs text-surface-500 dark:text-surface-400 mt-0.5">{{ subtitle }}</p>
|
||||
<div :id="id" class="card border-none shadow-sm hover:shadow-md transition-shadow duration-300 bg-white dark:bg-surface-900 scroll-mt-4 overflow-hidden">
|
||||
<div class="px-6 py-5 bg-gradient-to-r from-surface-50/50 to-transparent dark:from-surface-800/20">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-8 w-1 bg-primary-500 rounded-full" />
|
||||
<div>
|
||||
<h2 class="text-lg font-black text-surface-900 dark:text-surface-0 tracking-tight leading-none">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="text-xs font-medium text-surface-400 dark:text-surface-500 mt-1 uppercase tracking-wider">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="border-t border-surface-200 dark:border-surface-700 pt-4">
|
||||
<div class="px-6 pb-6">
|
||||
<div class="h-[1px] w-full bg-surface-100 dark:bg-surface-800 mb-6" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-6
@@ -277,24 +277,30 @@ const router = createRouter({
|
||||
component: () => import('@/views/pages/marketing/MarketingAnalysisV3List.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/v3/strategies',
|
||||
name: 'marketing-v3-strategies-list',
|
||||
component: () => import('@/views/pages/marketing/MarketingStrategyV3List.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/v3/new',
|
||||
name: 'marketing-v3-wizard',
|
||||
component: () => import('@/components/marketing/MarketingBriefWizardV3.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/v3/:id',
|
||||
name: 'marketing-v3-analysis',
|
||||
component: () => import('@/views/pages/marketing/MarketingAnalysisV3.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/v3/strategy',
|
||||
name: 'marketing-v3-strategy',
|
||||
component: () => import('@/views/pages/marketing/MarketingStrategyV3.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/marketing-analysis/v3/:id',
|
||||
name: 'marketing-v3-analysis',
|
||||
component: () => import('@/views/pages/marketing/MarketingAnalysisV3.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/pages/notfound',
|
||||
name: 'notfound',
|
||||
|
||||
@@ -68,6 +68,25 @@ class MarketingV3Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка стратегий текущего пользователя
|
||||
* GET /api/marketing/v3/strategy/my
|
||||
* @returns {Promise<Array>} Список стратегий
|
||||
*/
|
||||
async getUserStrategies() {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/my`);
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении списка стратегий');
|
||||
}
|
||||
return result?.data ?? result ?? [];
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении списка стратегий v4.0:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ЭТАП 2: Предпросмотр стратегии
|
||||
* GET /api/marketing/v3/{analysisId}/strategy-preview
|
||||
@@ -113,24 +132,164 @@ class MarketingV3Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* ЭТАП 4: Поллинг статуса стратегии
|
||||
* ЭТАП 4: Поллинг статуса стратегии (Она же загрузка по ID)
|
||||
* GET /api/marketing/v3/strategy/{strategyId}
|
||||
* @param {string} strategyId - Идентификатор стратегии
|
||||
* @returns {Promise<Object>} Данные стратегии, включая статус и результаты, когда готово
|
||||
*/
|
||||
async getStrategyStatus(strategyId) {
|
||||
return this.getStrategyById(strategyId);
|
||||
}
|
||||
|
||||
async getStrategyById(strategyId) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/${strategyId}`);
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при проверке статуса стратегии');
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении стратегии по ID');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке статуса стратегии v4.0:', error);
|
||||
console.error('Ошибка при проверке статуса/получении стратегии v4.0:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить стратегию по Analysis ID
|
||||
* GET /api/marketing/v3/{analysisId}/strategy
|
||||
*/
|
||||
async getStrategyByAnalysisId(analysisId) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/${analysisId}/strategy`);
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
// If 404, we just return null to indicate no strategy exists yet
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении стратегии');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении стратегии по analysis id:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск автопостинга для стратегии (B3)
|
||||
* POST /api/marketing/v3/strategy/{strategyId}/start
|
||||
*/
|
||||
async startStrategy(strategyId) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/${strategyId}/start`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при запуске стратегии');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запуске стратегии:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск публикации одного поста немедленно (B4)
|
||||
* POST /api/marketing/v3/tasks/{taskId}/execute
|
||||
*/
|
||||
async executeTaskManually(taskId) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/tasks/${taskId}/execute`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при запуске задачи');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при ручном запуске задачи:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регенерация изображения для одного поста (B2)
|
||||
* POST /api/marketing/v3/strategy/{strategyId}/post/{postIndex}/regenerate-image
|
||||
*/
|
||||
async regeneratePostImage(strategyId, postIndex) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/${strategyId}/post/${postIndex}/regenerate-image`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при регенерации изображения');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при регенерации изображения:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регенерация видео для одного поста
|
||||
* POST /api/marketing/v3/strategy/{strategyId}/post/{postIndex}/regenerate-video
|
||||
*/
|
||||
async regeneratePostVideo(strategyId, postIndex) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/${strategyId}/post/${postIndex}/regenerate-video`, {
|
||||
method: 'POST',
|
||||
...DEFAULT_REQUEST_CONFIG
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || result?.error?.message || 'Ошибка при регенерации видео');
|
||||
}
|
||||
return result?.data ?? result;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при регенерации видео:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка защищенного изображения как Blob URL (для <img>)
|
||||
* GET /api/marketing/v3/images/{filename}
|
||||
*/
|
||||
async loadImageAsBlobUrl(filename) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/images/${filename}`);
|
||||
if (!response.ok) return null;
|
||||
const blob = await response.blob();
|
||||
return window.URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при загрузке картинки ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузка защищенного видео как Blob URL (для <video>)
|
||||
* GET /api/marketing/v3/videos/{filename}
|
||||
*/
|
||||
async loadVideoAsBlobUrl(filename) {
|
||||
try {
|
||||
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/videos/${filename}`);
|
||||
if (!response.ok) return null;
|
||||
const blob = await response.blob();
|
||||
return window.URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при загрузке видео ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MarketingV3Service();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import MarketingV3Service from '@/service/MarketingV3Service';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
@@ -107,7 +108,30 @@ const radarAxes = computed(() => {
|
||||
});
|
||||
|
||||
// ─── Chart Data ───────────────────────────────────────────────────────────────
|
||||
const PALETTE = ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#22C55E', '#06B6D4', '#EAB308'];
|
||||
// ─── Metric Descriptions for Tooltips ──────────────────────────────────────────
|
||||
const METRIC_INFO = {
|
||||
er: 'ER (Engagement Rate) — коэффициент вовлеченности. Показывает, какой процент аудитории активно взаимодействует с контентом (лайки, комментарии). Высокий ER означает живую и лояльную аудиторию.',
|
||||
rating: 'Средний рейтинг на основе отзывов пользователей в Google Maps, 2ГИС и других платформах. Отражает реальное качество продукта и сервиса.',
|
||||
postsPerMonth: 'Частота публикаций — сколько постов или видео бренд выпускает в среднем за месяц. Стабильность критична для алгоритмов соцсетей.',
|
||||
densityIndex: 'Индекс плотности — показатель "тесноты" на рынке. Чем выше значение, тем больше конкурентов борются за внимание одного и того же клиента в данной локации.',
|
||||
cii: 'CII (Competitive Intensity Index) — комплексный индекс интенсивности конкуренции. Объединяет плотность игроков, их активность и репутационный фон. Помогает оценить сложность входа на рынок.',
|
||||
activePlayers: 'Количество активных конкурентов, которые регулярно ведут свои страницы и продвигаются в данном регионе.',
|
||||
videoShare: 'Доля видеоконтента (Reels, TikTok, Shorts). Сейчас это самый эффективный формат для получения бесплатных охватов.',
|
||||
avgCheck: 'Оценка ценовой категории бизнеса по сравнению со средним уровнем в нише.',
|
||||
reputation: 'Общий уровень доверия к бренду на основе отзывов и упоминаний.'
|
||||
};
|
||||
|
||||
// ─── Chart Data ───────────────────────────────────────────────────────────────
|
||||
const PALETTE = ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#10B981', '#06B6D4', '#EAB308'];
|
||||
const PREMIUM_COLORS = {
|
||||
primary: '#4F46E5',
|
||||
secondary: '#7C3AED',
|
||||
accent: '#EC4899',
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
danger: '#EF4444',
|
||||
neutral: '#6B7280'
|
||||
};
|
||||
|
||||
const platformChartData = computed(() => {
|
||||
const pl = landscape1.value?.activePlayersByPlatform || {};
|
||||
@@ -203,18 +227,70 @@ const radarChartData = computed(() => {
|
||||
// ─── Chart Options ────────────────────────────────────────────────────────────
|
||||
const tb = { mode: 'index', intersect: false }; // tooltip base
|
||||
const basePlugin = { legend: { labels: { color: '#6B7280', font: { size: 11 } } }, tooltip: tb };
|
||||
const doughnutOptions = { responsive: true, maintainAspectRatio: false, plugins: { ...basePlugin, legend: { position: 'right', labels: { color: '#6B7280', font: { size: 11 } } } } };
|
||||
const lineOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF' } } } };
|
||||
const pieOptions = { responsive: true, maintainAspectRatio: false, plugins: { ...basePlugin, legend: { position: 'right', labels: { color: '#6B7280', font: { size: 11 } } } } };
|
||||
const barOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF' } } } };
|
||||
const hbarOptions = { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { x: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, y: { ticks: { color: '#9CA3AF' } } } };
|
||||
const areaOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF', maxRotation: 45 } } } };
|
||||
const pieOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
...basePlugin,
|
||||
legend: { position: 'right', labels: { color: '#6B7280', font: { size: 11, weight: '600' }, padding: 20 } },
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: { weight: 'bold', size: 12 },
|
||||
formatter: (value, ctx) => {
|
||||
let sum = 0;
|
||||
let dataArr = ctx.chart.data.datasets[0].data;
|
||||
dataArr.map((data) => {
|
||||
sum += data;
|
||||
});
|
||||
let percentage = ((value * 100) / sum).toFixed(0) + '%';
|
||||
return value > 5 ? percentage : '';
|
||||
},
|
||||
anchor: 'center',
|
||||
align: 'center'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const doughnutOptions = {
|
||||
...pieOptions,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
...pieOptions.plugins,
|
||||
datalabels: { ...pieOptions.plugins.datalabels, color: (ctx) => ctx.dataset.backgroundColor[ctx.dataIndex] }
|
||||
}
|
||||
};
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: tb },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(107,114,128,0.1)' }, ticks: { color: '#9CA3AF', font: { size: 10 } } }, x: { grid: { display: false }, ticks: { color: '#9CA3AF', font: { size: 10 } } } }
|
||||
};
|
||||
const barOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: tb },
|
||||
scales: { y: { beginAtZero: true, grid: { color: 'rgba(107,114,128,0.1)' }, ticks: { color: '#9CA3AF' } }, x: { grid: { display: false }, ticks: { color: '#9CA3AF' } } }
|
||||
};
|
||||
const hbarOptions = {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: tb },
|
||||
scales: { x: { beginAtZero: true, grid: { color: 'rgba(107,114,128,0.1)' }, ticks: { color: '#9CA3AF' } }, y: { grid: { display: false }, ticks: { color: '#9CA3AF' } } }
|
||||
};
|
||||
const areaOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: tb },
|
||||
scales: { y: { beginAtZero: true, grid: { borderDash: [5, 5], color: 'rgba(107,114,128,0.2)' }, ticks: { color: '#9CA3AF' } }, x: { grid: { display: false }, ticks: { color: '#9CA3AF', maxRotation: 45 } } }
|
||||
};
|
||||
const radarOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: basePlugin,
|
||||
scales: {
|
||||
r: { min: 0, max: 100, ticks: { stepSize: 20, color: '#9CA3AF', backdropColor: 'transparent' }, pointLabels: { color: '#6B7280', font: { size: 11 } }, grid: { color: 'rgba(107,114,128,0.2)' }, angleLines: { color: 'rgba(107,114,128,0.2)' } }
|
||||
r: { min: 0, max: 100, ticks: { display: false, stepSize: 20 }, pointLabels: { color: '#6B7280', font: { size: 11, weight: '600' } }, grid: { color: 'rgba(107,114,128,0.2)' }, angleLines: { color: 'rgba(107,114,128,0.2)' } }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -435,22 +511,27 @@ export default {
|
||||
<!-- ======================================================= -->
|
||||
<!-- HEADER: статус + навигация -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-primary-500">МАРКЕТИНГОВЫЙ АНАЛИЗ</span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">v4.0 Data-Driven</span>
|
||||
<div class="card p-4 border-none shadow-xl bg-gradient-to-br from-surface-0 to-surface-50 dark:from-surface-800 dark:to-surface-900">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="h-12 w-12 rounded-2xl bg-primary-500 flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||
<i class="pi pi-chart-bar text-white text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-[10px] font-black tracking-widest uppercase text-primary-500">KONTURAI ANALYSIS</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-primary-100 text-primary-700 dark:bg-primary-500/10 dark:text-primary-400 text-[10px] font-bold">v4.0 PRO </span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-black text-surface-900 dark:text-surface-0 tracking-tight leading-none">Конкурентный разбор рынка</h1>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Конкурентный разбор рынка</h1>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-0.5">
|
||||
Статус:
|
||||
<span :class="statusClass" class="font-semibold ml-1">{{ statusLabel }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Назад к анализам" icon="pi pi-arrow-left" severity="secondary" outlined size="small" @click="$router.push('/marketing-analysis/v3')" />
|
||||
<Button v-if="status === 'COMPLETED'" label="SMM-Стратегия" icon="pi pi-chart-line" icon-pos="right" size="small" @click="generateStrategy" />
|
||||
<div class="text-right mr-3 hidden lg:block">
|
||||
<p class="text-[10px] uppercase font-bold text-surface-400">Статус отчёта</p>
|
||||
<span :class="statusClass" class="text-sm font-black">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<Button label="Список" icon="pi pi-bars" severity="secondary" text @click="$router.push('/marketing-analysis/v3')" />
|
||||
<Button v-if="status === 'COMPLETED'" label="Создать SMM-Стратегию" icon="pi pi-magic-stick" icon-pos="right" severity="primary" class="shadow-lg shadow-primary-500/20" @click="generateStrategy" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -491,10 +572,10 @@ export default {
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<ExecCard label="Стадия бизнеса" :value="exec.businessStage || '—'" icon="pi pi-briefcase" color="indigo" />
|
||||
<ExecCard label="География" :value="exec.geography || '—'" icon="pi pi-map-marker" color="blue" />
|
||||
<ExecCard label="Конкурентов" :value="String(exec.activeCompetitors ?? '—')" icon="pi pi-users" color="violet" :trend="exec.activeCompetitors > 10 ? 'up' : 'neutral'" />
|
||||
<ExecCard label="Конкурентов" :value="String(exec.activeCompetitors ?? '—')" icon="pi pi-users" color="violet" :trend="exec.activeCompetitors > 10 ? 'up' : 'neutral'" :tooltip="METRIC_INFO.activePlayers" />
|
||||
<ExecCard label="Уровень конкурен." :value="exec.competitionLevel || '—'" icon="pi pi-bolt" color="amber" />
|
||||
<ExecCard label="Средний ER" :value="fmtPct(exec.averageNicheEr)" icon="pi pi-percentage" color="emerald" :trend="parseFloat(exec.averageNicheEr) >= 5 ? 'up' : 'down'" />
|
||||
<ExecCard label="Рейтинг ★" :value="exec.averageRating ? exec.averageRating.toFixed(1) : '—'" icon="pi pi-star-fill" color="orange" :trend="exec.averageRating >= 4 ? 'up' : 'down'" />
|
||||
<ExecCard label="Средний ER" :value="fmtPct(exec.averageNicheEr)" icon="pi pi-percentage" color="emerald" :trend="parseFloat(exec.averageNicheEr) >= 5 ? 'up' : 'down'" :tooltip="METRIC_INFO.er" />
|
||||
<ExecCard label="Рейтинг ★" :value="exec.averageRating ? exec.averageRating.toFixed(1) : '—'" icon="pi pi-star-fill" color="orange" :trend="exec.averageRating >= 4 ? 'up' : 'down'" :tooltip="METRIC_INFO.rating" />
|
||||
</div>
|
||||
<!-- Key figures -->
|
||||
<div v-if="exec.keyFigures?.length" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
@@ -512,7 +593,7 @@ export default {
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Распределение по платформам</h3>
|
||||
<div class="flex-1 flex items-center justify-center h-60">
|
||||
<Chart v-if="platformChartData.labels.length" type="doughnut" :data="platformChartData" :options="doughnutOptions" class="max-h-60" />
|
||||
<Chart v-if="platformChartData.labels.length" type="doughnut" :data="platformChartData" :options="doughnutOptions" :plugins="[ChartDataLabels]" class="max-h-60" />
|
||||
<ChartPlaceholder v-else text="Нет данных по платформам" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -556,30 +637,62 @@ export default {
|
||||
<SectionBlock id="s2" title="3. Географическая структура конкуренции" subtitle="Активность игроков по городам">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div class="xl:col-span-2">
|
||||
<!-- ВАЖНО: Выделяем ключевые параметры цветом как просил юзер -->
|
||||
<DataTable :value="geoTable" :sortField="geoSort.field" :sortOrder="geoSort.order" @sort="onGeoSort" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||
<Column field="city" header="Город" sortable />
|
||||
<Column field="activePlayers" header="Игроков" sortable />
|
||||
<Column field="avgEr" header="Ср. ER" sortable>
|
||||
<Column field="avgEr" sortable class="highlight-column">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Ср. ER</span>
|
||||
<i class="pi pi-info-circle text-[10px] text-primary-400 cursor-help" v-tooltip="METRIC_INFO.er" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<span :class="erClass(data.avgEr)">{{ fmtPct(data.avgEr) }}</span>
|
||||
<div class="py-1 px-2 rounded bg-emerald-50 dark:bg-emerald-500/10 inline-block">
|
||||
<span :class="erClass(data.avgEr)">{{ fmtPct(data.avgEr) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="avgRating" header="Рейтинг" sortable>
|
||||
<Column field="avgRating" sortable class="highlight-column">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Рейтинг</span>
|
||||
<i class="pi pi-info-circle text-[10px] text-primary-400 cursor-help" v-tooltip="METRIC_INFO.rating" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="pi pi-star-fill text-amber-400 text-xs" />
|
||||
{{ data.avgRating?.toFixed(1) || '—' }}
|
||||
</span>
|
||||
<div class="py-1 px-2 rounded bg-amber-50 dark:bg-amber-500/10 inline-block">
|
||||
<span class="flex items-center gap-1 font-bold text-amber-700 dark:text-amber-400">
|
||||
<i class="pi pi-star-fill text-amber-500 text-xs" />
|
||||
{{ data.avgRating?.toFixed(1) || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="avgPostsPerMonth" sortable class="highlight-column">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Постов/мес</span>
|
||||
<i class="pi pi-info-circle text-[10px] text-primary-400 cursor-help" v-tooltip="METRIC_INFO.postsPerMonth" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<div class="py-1 px-2 rounded bg-blue-50 dark:bg-blue-500/10 inline-block w-full text-center">
|
||||
<span class="font-bold text-blue-700 dark:text-blue-400">{{ data.avgPostsPerMonth }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="avgPostsPerMonth" header="Постов/мес" sortable />
|
||||
</DataTable>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Индекс плотности</h3>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">Индекс плотности</h3>
|
||||
<i class="pi pi-info-circle text-xs text-primary-400 cursor-help" v-tooltip="METRIC_INFO.densityIndex" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center mt-6">
|
||||
<GaugeChart :value="geo2.densityIndex ?? 0" />
|
||||
<p class="text-sm text-surface-500 mt-2 text-center w-48">Индекс перегретости конкурентной среды</p>
|
||||
<p class="text-xs text-surface-500 mt-2 text-center w-48 leading-tight">Вероятность столкнуться с жесткой конкуренцией за клиента</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -655,9 +768,12 @@ export default {
|
||||
<SectionBlock id="s4" title="5. Контент-профиль рынка" subtitle="AI-классификация контента конкурентов">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Типы контента</h3>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">Типы контента</h3>
|
||||
<i class="pi pi-info-circle text-xs text-primary-400 cursor-help" v-tooltip="'На основе анализа последних 100 постов конкурентов ИИ определил тематику контента.'" />
|
||||
</div>
|
||||
<div class="h-64">
|
||||
<Chart v-if="contentChartData.labels.length" type="pie" :data="contentChartData" :options="pieOptions" class="max-h-64" />
|
||||
<Chart v-if="contentChartData.labels.length" type="pie" :data="contentChartData" :options="pieOptions" :plugins="[ChartDataLabels]" class="max-h-64" />
|
||||
<ChartPlaceholder v-else text="Нет данных по контенту" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,12 +140,16 @@ onMounted(load);
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Button icon="pi pi-arrow-left" text rounded size="small" class="text-surface-500 p-0 w-6 h-6 mr-1" @click="$router.push('/marketing-analysis')" v-tooltip.top="'На главную Маркетинга'" />
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-primary-500">МАРКЕТИНГОВЫЙ АНАЛИЗ v4.0</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои анализы</h1>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-0.5">Data-Driven + SMM Execution Ready</p>
|
||||
</div>
|
||||
<Button label="Создать новый анализ" icon="pi pi-plus" @click="$router.push('/marketing-analysis/v3/new')" class="shrink-0" />
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<Button label="Мои стратегии" icon="pi pi-list" @click="$router.push('/marketing-analysis/v3/strategies')" severity="secondary" outlined />
|
||||
<Button label="Создать новый анализ" icon="pi pi-plus" @click="$router.push('/marketing-analysis/v3/new')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Search + Filter bar ───────────────────────────────────────── -->
|
||||
@@ -227,6 +231,17 @@ onMounted(load);
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 shrink-0" @click.stop>
|
||||
<Button v-if="item.status === 'COMPLETED'" icon="pi pi-eye" v-tooltip.top="'Открыть дашборд'" severity="info" text rounded size="small" @click="openAnalysis(item)" />
|
||||
<Button
|
||||
v-if="item.status === 'COMPLETED'"
|
||||
icon="pi pi-magic-stick"
|
||||
v-tooltip.top="'Сгенерировать стратегию'"
|
||||
severity="success"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="$router.push(`/marketing-analysis/v3/strategy?analysisId=${item.id}`)"
|
||||
class="text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
|
||||
/>
|
||||
<Button v-if="item.status === 'COMPLETED'" icon="pi pi-chart-bar" v-tooltip.top="'Предпросмотр (mock)'" severity="secondary" text rounded size="small" @click="$router.push(`/marketing-analysis/v3/${item.id}?preview=1`)" />
|
||||
<Button v-if="item.status === 'FAILED' || item.status === 'COMPLETED'" icon="pi pi-refresh" v-tooltip.top="'Повторить анализ'" severity="secondary" text rounded size="small" @click="retry(item)" />
|
||||
<Button icon="pi pi-trash" v-tooltip.top="'Удалить'" severity="danger" text rounded size="small" @click="confirmDelete(item)" />
|
||||
|
||||
@@ -1,107 +1,99 @@
|
||||
<script setup>
|
||||
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Menu items
|
||||
const activeMenu = ref('main');
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'analysis-v3',
|
||||
label: 'Маркетинговый анализ v4.0 (новый)',
|
||||
icon: 'pi pi-sparkles',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/v3'
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: 'Маркетинговый анализ',
|
||||
icon: 'pi pi-search',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/analysis'
|
||||
},
|
||||
{
|
||||
id: 'analysis-dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-chart-line',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'analyses-list',
|
||||
label: 'Мои анализы',
|
||||
icon: 'pi pi-list',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/analyses'
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
label: 'Продвижение',
|
||||
icon: 'pi pi-send',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/promotion'
|
||||
},
|
||||
{
|
||||
id: 'strategies-list',
|
||||
label: 'Мои стратегии',
|
||||
icon: 'pi pi-briefcase',
|
||||
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: 'Результаты',
|
||||
icon: 'pi pi-chart-bar',
|
||||
iconColor: 'text-primary-500',
|
||||
route: '/marketing-analysis/results'
|
||||
const navigateTo = (route, query) => {
|
||||
if (query) {
|
||||
router.push({ path: route, query });
|
||||
} else {
|
||||
router.push(route);
|
||||
}
|
||||
];
|
||||
|
||||
// Methods
|
||||
const navigateTo = (route) => {
|
||||
router.push(route);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="marketing-main-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
|
||||
<div class="max-w-[1920px] mx-auto">
|
||||
<div class="flex justify-center">
|
||||
<!-- Navigation Menu -->
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card h-full">
|
||||
<div class="card-header mb-4">
|
||||
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Маркетинг</h2>
|
||||
<div class="marketing-main-page bg-surface-50 dark:bg-surface-950 min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-xs font-black uppercase tracking-widest border border-emerald-500/20 mb-4">
|
||||
<i class="pi pi-sparkles"></i> AI Marketing
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-black text-surface-900 dark:text-white tracking-tight mb-4">ИИ-Маркетолог</h1>
|
||||
<p class="text-lg text-surface-500 dark:text-surface-400 max-w-2xl mx-auto leading-relaxed">Автономная генерация стратегий, контент-планов и медиа-активов на основе глубокого анализа вашей ниши и конкурентов.</p>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||
<!-- 1. Создать анализ -->
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-3xl p-8 cursor-pointer transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-blue-500/20 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-800"
|
||||
@click="navigateTo('/marketing-analysis/v3/new')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-blue-50/50 to-transparent dark:from-blue-900/10 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative z-10">
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400 flex items-center justify-center mb-6 transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 shadow-inner"
|
||||
>
|
||||
<i class="pi pi-search text-3xl font-black"></i>
|
||||
</div>
|
||||
<div class="card-content space-y-3">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.id"
|
||||
class="menu-item p-4 rounded-lg cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'bg-primary-50 dark:bg-primary-900': activeMenu === item.id,
|
||||
'hover:bg-surface-100 dark:hover:bg-surface-800': activeMenu !== item.id
|
||||
}"
|
||||
@click="navigateTo(item.route)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i :class="[item.icon, item.iconColor, 'text-xl']"></i>
|
||||
<span class="font-medium text-surface-900 dark:text-surface-0">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="w-2 h-2 rounded-full bg-surface-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black text-surface-900 dark:text-white mb-3">Сделать анализ</h3>
|
||||
<p class="text-surface-500 dark:text-surface-400 font-medium leading-relaxed">Опишите свой бизнес. Нейросеть соберёт данные по конкурентам, ЦА и предложит лучшее позиционирование.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Мои анализы -->
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-3xl p-8 cursor-pointer transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-indigo-500/20 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-800"
|
||||
@click="navigateTo('/marketing-analysis/v3')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50/50 to-transparent dark:from-indigo-900/10 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative z-10">
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-400 flex items-center justify-center mb-6 transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 shadow-inner"
|
||||
>
|
||||
<i class="pi pi-inbox text-3xl font-black"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black text-surface-900 dark:text-white mb-3">Готовые анализы</h3>
|
||||
<p class="text-surface-500 dark:text-surface-400 font-medium leading-relaxed">Библиотека ваших предыдущих исследований с развернутой аналитикой и инсайтами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Создать стратегию -->
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-3xl p-8 cursor-pointer transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-emerald-500/20 bg-gradient-to-br from-emerald-500 to-teal-600 dark:from-emerald-600 dark:to-teal-800 border-none sm:col-span-2 md:col-span-1"
|
||||
@click="navigateTo('/marketing-analysis/v3')"
|
||||
>
|
||||
<!-- Decorative glow -->
|
||||
<div class="absolute top-0 right-0 w-48 h-48 bg-white/10 blur-3xl rounded-full -mr-20 -mt-20"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="w-16 h-16 rounded-2xl bg-white/20 text-white flex items-center justify-center mb-6 backdrop-blur-md border border-white/30 transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500">
|
||||
<i class="pi pi-bolt text-3xl font-black"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black text-white mb-3">Сгенерировать стратегию</h3>
|
||||
<p class="text-emerald-50/90 font-medium leading-relaxed">
|
||||
Контент-фабрика. ИИ выберет соцсети, напишет посты и отрисует картинки на основе вашего анализа.
|
||||
<span class="block mt-2 text-xs uppercase tracking-widest font-black opacity-75">Требуется готовый анализ</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Мои стратегии -->
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-3xl p-8 cursor-pointer transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl hover:shadow-purple-500/20 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-800"
|
||||
@click="navigateTo('/marketing-analysis/v3/strategies')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-50/50 to-transparent dark:from-purple-900/10 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div class="relative z-10">
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-purple-100 dark:bg-purple-900/40 text-purple-600 dark:text-purple-400 flex items-center justify-center mb-6 transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 shadow-inner"
|
||||
>
|
||||
<i class="pi pi-images text-3xl font-black"></i>
|
||||
</div>
|
||||
<h3 class="text-2xl font-black text-surface-900 dark:text-white mb-3">Мои стратегии</h3>
|
||||
<p class="text-surface-500 dark:text-surface-400 font-medium leading-relaxed">Список сгенерированных контент-планов, медиа-активов и AI-обоснований.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,40 +101,4 @@ const navigateTo = (route) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marketing-main-page {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-card);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup>
|
||||
import { API_CONFIG } from '@/config/api';
|
||||
import MarketingV3Service from '@/service/MarketingV3Service';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Divider from 'primevue/divider';
|
||||
import Slider from 'primevue/slider';
|
||||
import Tag from 'primevue/tag';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
@@ -15,7 +12,7 @@ const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const analysisId = computed(() => route.query.analysisId || '');
|
||||
const strategyId = ref(null);
|
||||
const strategyId = ref(route.query.strategyId || route.params.id || null);
|
||||
|
||||
// Statuses: PREVIEW, QUEUED, PROCESSING, FAILED, COMPLETED
|
||||
const status = ref('PREVIEW');
|
||||
@@ -24,73 +21,82 @@ const status = ref('PREVIEW');
|
||||
const previewLoading = ref(true);
|
||||
const previewData = ref(null);
|
||||
|
||||
// Form Data
|
||||
const formData = ref({
|
||||
durationWeeks: 4,
|
||||
priorityPlatforms: ['Instagram']
|
||||
});
|
||||
|
||||
const platformOptions = [
|
||||
{ label: 'Instagram', value: 'Instagram' },
|
||||
{ label: 'TikTok', value: 'TikTok' },
|
||||
{ label: 'YouTube Shorts', value: 'YouTube' },
|
||||
{ label: 'LinkedIn', value: 'LinkedIn' },
|
||||
{ label: 'VK', value: 'VK' },
|
||||
{ label: 'Telegram', value: 'Telegram' }
|
||||
];
|
||||
|
||||
// Logo Upload Logic
|
||||
// Multi-File Upload Logic
|
||||
const fileInput = ref(null);
|
||||
const logoFile = ref(null);
|
||||
const logoPreviewUrl = ref(null);
|
||||
const selectedFiles = ref([]);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const triggerFileInput = () => fileInput.value?.click();
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) processLogo(file);
|
||||
if (event.target.files) {
|
||||
processFiles(Array.from(event.target.files));
|
||||
}
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
isDragging.value = false;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) processLogo(file);
|
||||
if (event.dataTransfer.files) {
|
||||
processFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const processLogo = (file) => {
|
||||
if (!['image/png', 'image/jpeg', 'image/jpg'].includes(file.type)) {
|
||||
toast.add({ severity: 'error', summary: 'Формат', detail: 'Доступны только PNG и JPG', life: 3000 });
|
||||
return;
|
||||
const processFiles = (files) => {
|
||||
const maxFiles = 10;
|
||||
if (selectedFiles.value.length + files.length > maxFiles) {
|
||||
toast.add({ severity: 'warn', summary: 'Лимит', detail: `Можно загрузить не более ${maxFiles} файлов`, life: 3000 });
|
||||
files = files.slice(0, maxFiles - selectedFiles.value.length);
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.add({ severity: 'error', summary: 'Размер', detail: 'Файл слишком тяжелый (макс 5МБ)', life: 3000 });
|
||||
return;
|
||||
}
|
||||
logoFile.value = file;
|
||||
logoPreviewUrl.value = URL.createObjectURL(file);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!['image/png', 'image/jpeg', 'image/jpg'].includes(file.type)) {
|
||||
toast.add({ severity: 'error', summary: 'Формат', detail: `Файл ${file.name} не является PNG или JPG`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.add({ severity: 'error', summary: 'Размер', detail: `Файл ${file.name} слишком тяжелый (макс 5МБ)`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
selectedFiles.value.push({
|
||||
file: file,
|
||||
previewUrl: URL.createObjectURL(file)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const removeLogo = () => {
|
||||
if (logoPreviewUrl.value) URL.revokeObjectURL(logoPreviewUrl.value);
|
||||
logoFile.value = null;
|
||||
logoPreviewUrl.value = null;
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
const removeFile = (index) => {
|
||||
const item = selectedFiles.value[index];
|
||||
if (item?.previewUrl) {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
}
|
||||
selectedFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const handlePaste = (e) => {
|
||||
// Only accept paste when we are in PREVIEW state so we don't accidentally intercept things later on
|
||||
if (status.value !== 'PREVIEW') return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
const newFiles = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) newFiles.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
processFiles(newFiles);
|
||||
}
|
||||
};
|
||||
|
||||
// Generation Logic
|
||||
const generateLoading = ref(false);
|
||||
const progressPercent = ref(0);
|
||||
const loadingMessageIndex = ref(0);
|
||||
const loadingMessages = [
|
||||
'Анализируем контекст ниши...',
|
||||
'Пишем продающие сценарии...',
|
||||
'Рендерим видео-ролики Veo...',
|
||||
'Генерируем креативы Imagen 3...',
|
||||
'Накладываем логотип на активы...',
|
||||
'Собираем контент-план...',
|
||||
'Финальная проверка качества...'
|
||||
];
|
||||
const loadingMessages = ['Анализируем нишу и подбираем платформы...', 'Проектируем контент-план...', 'Рендерим фотореалистичные креативы и видео...'];
|
||||
const currentLoadingMessage = computed(() => loadingMessages[loadingMessageIndex.value]);
|
||||
|
||||
let pollInterval = null;
|
||||
@@ -113,16 +119,10 @@ const startGeneration = async () => {
|
||||
generateLoading.value = true;
|
||||
const data = new FormData();
|
||||
|
||||
const requestPayload = {
|
||||
durationWeeks: formData.value.durationWeeks,
|
||||
priorityPlatforms: formData.value.priorityPlatforms
|
||||
};
|
||||
|
||||
// КАТЕГОРИЧЕСКИ ВАЖНО для Backend (Multipart RequestPart):
|
||||
data.append('request', new Blob([JSON.stringify(requestPayload)], { type: 'application/json' }), 'request.json');
|
||||
|
||||
if (logoFile.value) {
|
||||
data.append('logo', logoFile.value);
|
||||
if (selectedFiles.value.length > 0) {
|
||||
selectedFiles.value.forEach((item) => {
|
||||
data.append('references', item.file);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await MarketingV3Service.generateStrategy(analysisId.value, data);
|
||||
@@ -151,12 +151,13 @@ const startPolling = () => {
|
||||
try {
|
||||
const data = await MarketingV3Service.getStrategyStatus(strategyId.value);
|
||||
|
||||
if (data.status === 'completed') {
|
||||
if (data.status === 'completed' || data.status === 'COMPLETED') {
|
||||
progressPercent.value = 100;
|
||||
strategyData.value = data;
|
||||
setTimeout(() => {
|
||||
status.value = 'COMPLETED';
|
||||
stopPolling();
|
||||
loadMediaUrls();
|
||||
toast.add({ severity: 'success', summary: 'Готово', detail: 'Ваша стратегия готова к внедрению!', life: 4000 });
|
||||
}, 1000);
|
||||
} else if (data.status === 'failed') {
|
||||
@@ -201,9 +202,35 @@ const copyText = (text, hashtags) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Secure Image/Video Loading
|
||||
const blobUrls = ref({});
|
||||
|
||||
const loadMediaUrls = async () => {
|
||||
if (!strategyData.value?.postCalendar) return;
|
||||
for (const post of strategyData.value.postCalendar) {
|
||||
if (post.videoFilename && !blobUrls.value[post.videoFilename]) {
|
||||
MarketingV3Service.loadVideoAsBlobUrl(post.videoFilename).then((url) => {
|
||||
if (url) blobUrls.value[post.videoFilename] = url;
|
||||
});
|
||||
}
|
||||
if (post.imageFilename && !blobUrls.value[post.imageFilename]) {
|
||||
MarketingV3Service.loadImageAsBlobUrl(post.imageFilename).then((url) => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMediaUrl = (filename) => {
|
||||
if (!filename) return null;
|
||||
return `${API_CONFIG.BASE_URL}/api/marketing/v3/media/${filename}`;
|
||||
return blobUrls.value[filename] || null;
|
||||
};
|
||||
|
||||
// Media Preview Dialog
|
||||
@@ -214,19 +241,35 @@ const previewMedia = (post) => {
|
||||
mediaDialog.value = true;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const init = async () => {
|
||||
// If we have an explicit strategyId, load it directly
|
||||
if (strategyId.value) {
|
||||
await loadStrategyById(strategyId.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!analysisId.value) {
|
||||
router.push('/marketing-analysis/v3');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to find if a strategy already exists for this analysisId
|
||||
try {
|
||||
const existingStrategy = await MarketingV3Service.getStrategyByAnalysisId(analysisId.value);
|
||||
if (existingStrategy && existingStrategy.id) {
|
||||
strategyId.value = existingStrategy.id;
|
||||
await loadStrategyById(strategyId.value);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Strategy by analysis check failed, proceeding to preview mode', e);
|
||||
}
|
||||
|
||||
// Fallback to PREVIEW generation mode
|
||||
try {
|
||||
previewLoading.value = true;
|
||||
const res = await MarketingV3Service.getStrategyPreview(analysisId.value);
|
||||
previewData.value = res;
|
||||
if (res.recommendedPlatforms?.length) {
|
||||
formData.value.priorityPlatforms = res.recommendedPlatforms.filter((p) => platformOptions.some((o) => o.value === p));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось загрузить рекомендации', life: 5000 });
|
||||
} finally {
|
||||
@@ -234,8 +277,113 @@ const init = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(init);
|
||||
onUnmounted(stopPolling);
|
||||
const loadStrategyById = async (id) => {
|
||||
status.value = 'PROCESSING';
|
||||
try {
|
||||
const data = await MarketingV3Service.getStrategyById(id);
|
||||
strategyData.value = data;
|
||||
if (data.status === 'completed' || data.status === 'COMPLETED') {
|
||||
status.value = 'COMPLETED';
|
||||
loadMediaUrls();
|
||||
} else if (data.status === 'failed' || data.status === 'FAILED') {
|
||||
status.value = 'FAILED';
|
||||
} else {
|
||||
status.value = 'QUEUED';
|
||||
startPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось загрузить стратегию', life: 5000 });
|
||||
status.value = 'FAILED';
|
||||
}
|
||||
};
|
||||
|
||||
// B2: Regenerate Media (Photo/Video)
|
||||
const regeneratingPosts = ref(new Set());
|
||||
const handleRegenerateMedia = async (type, post, index) => {
|
||||
const sid = strategyId.value || strategyData.value?.id || strategyData.value?.strategyId;
|
||||
if (!sid) return;
|
||||
|
||||
try {
|
||||
regeneratingPosts.value.add(index);
|
||||
const method = type === 'video' ? 'regeneratePostVideo' : 'regeneratePostImage';
|
||||
const updatedPost = await MarketingV3Service[method](sid, index);
|
||||
|
||||
// Reactively update the post in the calendar
|
||||
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: type === 'video' ? 'ИИ сгенерировал видео!' : 'ИИ обновил изображение!',
|
||||
life: 3000
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Regeneration error:', e);
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось перегенерировать медиа. Попробуйте позже.', life: 4000 });
|
||||
} finally {
|
||||
regeneratingPosts.value.delete(index);
|
||||
}
|
||||
};
|
||||
|
||||
// B3: Start Strategy
|
||||
const isAutoPostingStarted = ref(false);
|
||||
const startingStrategy = ref(false);
|
||||
const handleStartStrategy = async () => {
|
||||
try {
|
||||
startingStrategy.value = true;
|
||||
await MarketingV3Service.startStrategy(strategyData.value.strategyId || strategyId.value);
|
||||
isAutoPostingStarted.value = true;
|
||||
toast.add({ severity: 'success', summary: 'Успех', detail: 'Автопостинг успешно запущен', life: 4000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: e.message || 'Не удалось запустить автопостинг' });
|
||||
} finally {
|
||||
startingStrategy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// B4: Publish Now Manually
|
||||
const executingTasks = ref(new Set());
|
||||
const handlePublishNow = async (post) => {
|
||||
if (!post.taskId) return;
|
||||
try {
|
||||
executingTasks.value.add(post.taskId);
|
||||
await MarketingV3Service.executeTaskManually(post.taskId);
|
||||
post.taskStatus = 'PUBLISHED';
|
||||
toast.add({ severity: 'success', summary: 'Опубликовано', detail: 'Пост успешно опубликован', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось опубликовать пост' });
|
||||
} finally {
|
||||
executingTasks.value.delete(post.taskId);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/marketing-analysis');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
window.addEventListener('paste', handlePaste);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
|
||||
// Revoke any pending ObjectURLs
|
||||
selectedFiles.value.forEach((item) => {
|
||||
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -292,111 +440,81 @@ onUnmounted(stopPolling);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
<!-- Left: Duration & Platforms -->
|
||||
<div class="lg:col-span-8 space-y-8">
|
||||
<div class="card p-6 md:p-8 bg-white dark:bg-slate-900 border-slate-200/60 dark:border-slate-800 shadow-sm">
|
||||
<h3 class="text-lg font-bold mb-8 flex items-center gap-2">
|
||||
<span class="w-1.5 h-6 bg-emerald-500 rounded-full"></span>
|
||||
Настройки кампании
|
||||
</h3>
|
||||
<!-- Pre-Generate AI Screen -->
|
||||
<div class="card p-8 md:p-12 bg-white dark:bg-slate-900 border-none shadow-xl relative overflow-hidden text-center max-w-4xl mx-auto">
|
||||
<!-- Decor elements -->
|
||||
<div class="absolute top-0 right-0 w-64 h-64 bg-emerald-500/10 blur-3xl rounded-full -mt-20 -mr-20"></div>
|
||||
<div class="absolute bottom-0 left-0 w-48 h-48 bg-emerald-500/10 blur-2xl rounded-full -mb-10 -ml-10"></div>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- Duration -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<label class="text-sm font-bold text-slate-700 dark:text-slate-300">Длительность в неделях</label>
|
||||
<span class="px-3 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-lg text-lg font-black">{{ formData.durationWeeks }}</span>
|
||||
</div>
|
||||
<div class="px-1">
|
||||
<Slider v-model="formData.durationWeeks" :min="1" :max="12" :step="1" class="custom-slider" />
|
||||
</div>
|
||||
<div class="flex justify-between mt-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest px-1">
|
||||
<span>1 неделя</span>
|
||||
<span>4 недели</span>
|
||||
<span>12 недель</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="opacity-40" />
|
||||
|
||||
<!-- Platforms -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-slate-700 dark:text-slate-300 mb-6">Каналы продвижения</label>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div v-for="platform in platformOptions" :key="platform.value" class="relative">
|
||||
<input type="checkbox" :id="platform.value" v-model="formData.priorityPlatforms" :value="platform.value" class="peer hidden" />
|
||||
<label
|
||||
:for="platform.value"
|
||||
class="flex items-center gap-3 p-4 rounded-2xl border-2 border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 cursor-pointer transition-all peer-checked:border-emerald-500 peer-checked:bg-emerald-50/30 dark:peer-checked:bg-emerald-500/10 hover:border-emerald-200 dark:hover:border-emerald-800"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors"
|
||||
:class="formData.priorityPlatforms.includes(platform.value) ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-slate-200 dark:border-slate-700'"
|
||||
>
|
||||
<i class="pi pi-check text-[10px] font-bold" v-if="formData.priorityPlatforms.includes(platform.value)" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-slate-600 dark:text-slate-400 peer-checked:text-emerald-700 dark:peer-checked:text-emerald-400">
|
||||
{{ platform.label }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<div class="w-20 h-20 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-inner transform rotate-3">
|
||||
<i class="pi pi-sparkles text-3xl animate-pulse" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-4 tracking-tight">Ваш анализ готов</h2>
|
||||
<p class="text-slate-500 dark:text-slate-400 leading-relaxed text-lg">Наш ИИ-директор готов подобрать идеальные платформы и разработать визуальную стратегию.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Brand & Submit -->
|
||||
<div class="lg:col-span-4 space-y-8">
|
||||
<!-- Logo Upload -->
|
||||
<div class="card p-6 bg-white dark:bg-slate-900 border-slate-200/60 dark:border-slate-800 shadow-sm">
|
||||
<h3 class="text-sm font-bold mb-4 text-slate-400 uppercase tracking-wider">Интеграция бренда</h3>
|
||||
|
||||
<div class="pt-6 relative">
|
||||
<!-- Multi-File Upload -->
|
||||
<div
|
||||
class="relative border-2 border-dashed rounded-2xl p-6 text-center transition-all bg-slate-50/50 dark:bg-slate-800/20"
|
||||
:class="[isDragging ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-200 dark:border-slate-700', logoPreviewUrl ? 'p-2' : 'hover:border-emerald-300']"
|
||||
class="relative border-2 border-dashed rounded-3xl p-8 text-center transition-all bg-slate-50/50 dark:bg-slate-800/20 group"
|
||||
:class="[isDragging ? 'border-emerald-500 bg-emerald-50/50 dark:bg-emerald-900/10' : 'border-slate-200 dark:border-slate-700 hover:border-emerald-300 dark:hover:border-emerald-700']"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input type="file" id="logoUpload" accept="image/*" class="hidden" @change="handleFileSelect" ref="fileInput" />
|
||||
<input type="file" id="logoUpload" accept="image/png, image/jpeg, image/jpg" class="hidden" multiple @change="handleFileSelect" ref="fileInput" />
|
||||
|
||||
<div v-if="logoPreviewUrl" class="relative group aspect-square rounded-xl overflow-hidden shadow-inner bg-white dark:bg-slate-900">
|
||||
<img :src="logoPreviewUrl" alt="Logo preview" class="w-full h-full object-contain p-4" />
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<Button icon="pi pi-trash" rounded severity="danger" class="w-12 h-12" @click="removeLogo" />
|
||||
<div v-if="selectedFiles.length > 0" class="space-y-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div v-for="(item, idx) in selectedFiles" :key="idx" class="relative aspect-square rounded-2xl overflow-hidden shadow-sm bg-white dark:bg-slate-900 group/item">
|
||||
<img :src="item.previewUrl" :alt="'Reference ' + idx" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover/item:opacity-100 flex items-center justify-center transition-opacity duration-300 backdrop-blur-[2px]">
|
||||
<Button icon="pi pi-times" rounded severity="danger" class="w-10 h-10 p-0" @click.stop="removeFile(idx)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add More Button -->
|
||||
<div
|
||||
v-if="selectedFiles.length < 10"
|
||||
class="relative aspect-square rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-600 hover:border-emerald-500 dark:hover:border-emerald-500 flex flex-col items-center justify-center cursor-pointer bg-slate-50 dark:bg-slate-800/50 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all text-slate-400 hover:text-emerald-500"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i class="pi pi-plus text-2xl mb-2" />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Добавить</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 font-medium">Загружено файлов: {{ selectedFiles.length }} / 10</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-4 cursor-pointer" @click="triggerFileInput">
|
||||
<div class="w-12 h-12 bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center mx-auto mb-4 text-emerald-500">
|
||||
<i class="pi pi-upload text-xl" />
|
||||
<div v-else class="py-6 cursor-pointer" @click="triggerFileInput">
|
||||
<div
|
||||
class="w-16 h-16 bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center mx-auto mb-6 text-emerald-500 group-hover:scale-110 group-hover:bg-emerald-50 dark:group-hover:bg-emerald-900/30 transition-all duration-300"
|
||||
>
|
||||
<i class="pi pi-images text-2xl" />
|
||||
</div>
|
||||
<h4 class="text-slate-900 dark:text-white font-bold text-sm mb-1">Логотип бренда</h4>
|
||||
<p class="text-slate-400 text-[10px] font-medium leading-relaxed">
|
||||
Кликните или перетащите <br />
|
||||
PNG, JPG до 5MB
|
||||
</p>
|
||||
<h4 class="text-slate-900 dark:text-white font-bold text-lg mb-2">Интеграция бренда и референсы</h4>
|
||||
<p class="text-slate-500 text-sm font-medium leading-relaxed max-w-sm mx-auto">Загрузите логотипы или фото ваших товаров (до 10 шт). Вы можете перетащить их сюда или просто вставить из буфера обмена (Ctrl+V).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Submit -->
|
||||
<button
|
||||
@click="startGeneration"
|
||||
class="w-full py-5 px-6 rounded-2xl bg-emerald-500 hover:bg-emerald-600 active:scale-[0.98] disabled:bg-slate-200 dark:disabled:bg-slate-800 disabled:text-slate-400 text-white font-black text-lg shadow-xl shadow-emerald-500/20 transition-all flex items-center justify-center gap-3 overflow-hidden group relative"
|
||||
:disabled="!formData.priorityPlatforms.length || generateLoading"
|
||||
>
|
||||
<span v-if="!generateLoading" class="flex items-center gap-3"> ⚡ <span class="tracking-tight">Запустить Фабрику</span> </span>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<i class="pi pi-spin pi-spinner text-xl" />
|
||||
<span>Подготовка...</span>
|
||||
</div>
|
||||
<!-- Subtle shine effect on hover -->
|
||||
<div class="absolute top-0 -left-full w-full h-full bg-gradient-to-r from-transparent via-white/20 to-transparent group-hover:left-[200%] transition-all duration-1000 ease-in-out"></div>
|
||||
</button>
|
||||
<div class="pt-4">
|
||||
<button
|
||||
@click="startGeneration"
|
||||
class="w-full sm:w-auto min-w-[300px] py-5 px-10 rounded-2xl bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 active:scale-[0.98] disabled:from-slate-300 disabled:to-slate-300 dark:disabled:from-slate-800 dark:disabled:to-slate-800 disabled:text-slate-400 text-white font-black text-xl shadow-2xl shadow-emerald-500/30 transition-all flex items-center justify-center gap-3 overflow-hidden group relative mx-auto disabled:cursor-not-allowed"
|
||||
:disabled="generateLoading"
|
||||
>
|
||||
<span v-if="!generateLoading" class="flex items-center gap-3 relative z-10"> ✨ Создать стратегию </span>
|
||||
<div v-else class="flex items-center gap-3 relative z-10">
|
||||
<i class="pi pi-spin pi-spinner text-xl" />
|
||||
<span>Запуск нейросети...</span>
|
||||
</div>
|
||||
<!-- Subtle shine effect on hover -->
|
||||
<div class="absolute top-0 -left-full w-full h-full bg-gradient-to-r from-transparent via-white/30 to-transparent group-hover:left-[200%] transition-all duration-1000 ease-in-out"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -467,14 +585,24 @@ onUnmounted(stopPolling);
|
||||
{{ strategyData.strategyData?.rationale }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full md:w-64 space-y-4">
|
||||
<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-500 text-[10px] font-black uppercase mb-2">Выбранная модель</p>
|
||||
<p class="font-bold text-emerald-400">{{ strategyData.strategyData?.selectedModel || 'Адаптивная' }}</p>
|
||||
<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-white/5 border border-white/10 backdrop-blur-sm">
|
||||
<p class="text-slate-500 text-[10px] font-black uppercase mb-2">Срок реализации</p>
|
||||
<p class="font-bold text-slate-200">{{ strategyData.strategyData?.durationWeeks }} недель</p>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<i :class="getPlatformIcon(platform)"></i>
|
||||
{{ platform }}
|
||||
</div>
|
||||
<div v-if="!(strategyData.priorityPlatforms || strategyData.strategyData?.priorityPlatforms)?.length" class="text-slate-400 text-sm font-medium">Универсальная стратегия</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,19 +677,48 @@ onUnmounted(stopPolling);
|
||||
>
|
||||
<!-- Media Header -->
|
||||
<div class="aspect-square relative bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||
<template v-if="post.contentType === 'video' || post.contentType === 'reels'">
|
||||
<video v-if="getMediaUrl(post.mediaFilename)" :src="getMediaUrl(post.mediaFilename)" autoplay loop muted playsinline class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
|
||||
<div v-else class="text-center p-8 opacity-40">
|
||||
<i class="pi pi-video text-5xl mb-2" />
|
||||
<p class="text-[10px] font-bold uppercase">Видео в обработке</p>
|
||||
<!-- 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" />
|
||||
<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>
|
||||
</div>
|
||||
<div class="absolute top-4 right-4 px-2 py-1 bg-black/60 rounded backdrop-blur-md text-[8px] font-black text-white uppercase tracking-tighter">Video Asset</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" />
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<img v-if="getMediaUrl(post.mediaFilename)" :src="getMediaUrl(post.mediaFilename)" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
|
||||
<div v-else class="text-center p-8 opacity-40">
|
||||
<i class="pi pi-image text-5xl mb-2" />
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest">Генерация визуала...</p>
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
@@ -578,18 +735,54 @@ onUnmounted(stopPolling);
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ formatDate(post.publishDate) }}</p>
|
||||
<h4 class="font-black text-slate-900 dark:text-white line-clamp-1 h-5">{{ post.theme }}</h4>
|
||||
<h4 class="text-lg font-black text-slate-900 dark:text-white line-clamp-2 leading-tight break-words">{{ post.theme }}</h4>
|
||||
</div>
|
||||
<span class="text-[10px] font-black px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded-lg text-slate-500 uppercase">{{ post.publishTime }}</span>
|
||||
</div>
|
||||
|
||||
<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-xs text-slate-600 dark:text-slate-400 line-clamp-3 font-medium leading-relaxed">{{ post.postText }}</p>
|
||||
<p class="text-[13px] text-slate-600 dark:text-slate-300 line-clamp-4 font-medium leading-relaxed">{{ post.postText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-copy" label="Копировать" class="flex-1 text-[10px] font-black uppercase tracking-wider rounded-xl py-3" outlined severity="secondary" @click="copyText(post.postText, post.hashtags)" />
|
||||
<Button icon="pi pi-expand" class="w-12 h-12 rounded-xl" text severity="secondary" v-if="getMediaUrl(post.mediaFilename)" @click="previewMedia(post)" />
|
||||
<div class="flex flex-col gap-2 mb-4">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-image"
|
||||
label="Сделать фото"
|
||||
class="flex-1 text-[9px] font-black uppercase tracking-wider rounded-xl py-2"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="regeneratingPosts.has(idx)"
|
||||
@click="handleRegenerateMedia('image', post, idx)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-video"
|
||||
label="Сделать видео"
|
||||
class="flex-1 text-[9px] font-black uppercase tracking-wider rounded-xl py-2"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="regeneratingPosts.has(idx)"
|
||||
@click="handleRegenerateMedia('video', post, idx)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-copy" label="Копировать" class="flex-1 text-[10px] font-black uppercase tracking-wider rounded-xl py-3" outlined severity="secondary" @click="copyText(post.postText, post.hashtags)" />
|
||||
<Button icon="pi pi-expand" class="w-12 h-12 rounded-xl" text severity="secondary" v-if="getMediaUrl(post.videoFilename || post.imageFilename || post.mediaFilename)" @click="previewMedia(post)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publish Now Task (B4) -->
|
||||
<div v-if="post.taskId" class="pt-2">
|
||||
<Button
|
||||
v-if="post.taskStatus !== 'PUBLISHED' && post.taskStatus !== 'COMPLETED'"
|
||||
label="Опубликовать сейчас"
|
||||
icon="pi pi-send"
|
||||
class="w-full text-xs font-bold rounded-xl py-2"
|
||||
severity="success"
|
||||
:loading="executingTasks.has(post.taskId)"
|
||||
@click="handlePublishNow(post)"
|
||||
/>
|
||||
<Tag v-else severity="success" value="Опубликовано" class="w-full text-xs py-2 rounded-xl flex justify-center" icon="pi pi-check" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,7 +792,21 @@ onUnmounted(stopPolling);
|
||||
<!-- Actions Footer -->
|
||||
<div class="flex flex-col md:flex-row gap-4 pt-10 border-t border-slate-200 dark:border-slate-800">
|
||||
<Button label="Сохранить как PDF" icon="pi pi-file-pdf" severity="secondary" outlined class="flex-1 rounded-2xl font-black py-4" />
|
||||
<Button label="Новая генерация" icon="pi pi-refresh" severity="secondary" class="flex-1 rounded-2xl font-black py-4" @click="status = 'PREVIEW'" />
|
||||
<!-- Start Auto-Posting Button (B3) -->
|
||||
<Button
|
||||
v-if="!isAutoPostingStarted && !strategyData.isStarted"
|
||||
class="flex-[2] rounded-2xl font-black py-4 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-400 hover:to-teal-400 text-white shadow-xl shadow-emerald-500/30 border-none relative overflow-hidden group flex items-center justify-center gap-2"
|
||||
@click="handleStartStrategy"
|
||||
:loading="startingStrategy"
|
||||
>
|
||||
<i class="pi pi-rocket text-xl"></i>
|
||||
<span>Запустить стратегию в работу</span>
|
||||
<span class="absolute top-0 -left-full w-full h-full bg-gradient-to-r from-transparent via-white/30 to-transparent group-hover:left-[200%] transition-all duration-1000 ease-in-out"></span>
|
||||
</Button>
|
||||
<div v-else class="flex-[2] rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800/50 flex items-center justify-center gap-3 text-emerald-600 dark:text-emerald-400 font-bold py-4">
|
||||
<i class="pi pi-check-circle text-xl"></i>
|
||||
<span>Автопостинг уже активен</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -622,8 +829,14 @@ onUnmounted(stopPolling);
|
||||
<Button icon="pi pi-times" rounded text class="absolute top-6 right-6 text-white/50 hover:text-white z-50 bg-white/10 hover:bg-white/20 backdrop-blur-md" @click="mediaDialog = false" />
|
||||
|
||||
<template v-if="activeMedia">
|
||||
<video v-if="activeMedia.contentType === 'video' || activeMedia.contentType === 'reels'" :src="getMediaUrl(activeMedia.mediaFilename)" autoplay controls class="max-h-[90vh] w-auto max-w-full rounded-2xl overflow-hidden" />
|
||||
<img v-else :src="getMediaUrl(activeMedia.mediaFilename)" alt="Full preview" class="max-h-[90vh] w-auto max-w-full rounded-2xl object-contain" />
|
||||
<video
|
||||
v-if="activeMedia.videoFilename || activeMedia.contentType === 'video' || activeMedia.contentType === 'reels'"
|
||||
:src="getMediaUrl(activeMedia.videoFilename || activeMedia.mediaFilename)"
|
||||
autoplay
|
||||
controls
|
||||
class="max-h-[90vh] w-auto max-w-full rounded-2xl overflow-hidden"
|
||||
/>
|
||||
<img v-else :src="getMediaUrl(activeMedia.imageFilename || activeMedia.mediaFilename)" alt="Full preview" class="max-h-[90vh] w-auto max-w-full rounded-2xl object-contain" />
|
||||
|
||||
<!-- Overlay Info in Modal -->
|
||||
<div class="absolute bottom-6 left-6 right-6 p-6 rounded-2xl bg-black/60 backdrop-blur-xl border border-white/10 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import MarketingV3Service from '@/service/MarketingV3Service';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const strategies = ref([]);
|
||||
const loading = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const filterStatus = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: 'Все', value: '' },
|
||||
{ label: 'Завершена', value: 'COMPLETED' },
|
||||
{ label: 'В работе', value: 'PROCESSING' },
|
||||
{ label: 'В очереди', value: 'QUEUED' },
|
||||
{ label: 'Ошибка', value: 'FAILED' }
|
||||
];
|
||||
|
||||
// Load
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await MarketingV3Service.getUserStrategies();
|
||||
strategies.value = Array.isArray(data) ? data.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) : [];
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Ошибка', detail: err.message || 'Не удалось загрузить стратегии', life: 5000 });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered & Paginated
|
||||
const filtered = computed(() => {
|
||||
let list = strategies.value;
|
||||
if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.value);
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
list = list.filter((a) => {
|
||||
const niche = (a.analysisData?.requestData?.businessNiche || '').toLowerCase();
|
||||
const id = (a.strategyId || a.id || '').toLowerCase();
|
||||
return niche.includes(q) || id.includes(q);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const paginated = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize;
|
||||
return filtered.value.slice(start, start + pageSize);
|
||||
});
|
||||
|
||||
const countByStatus = (s) => strategies.value.filter((a) => a.status === s).length;
|
||||
|
||||
// Helpers
|
||||
const formatDate = (dt) => (dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—');
|
||||
|
||||
const statusLabel = (s) => ({ COMPLETED: 'Завершена', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[(s || '').toUpperCase()] || s;
|
||||
|
||||
const statusBg = (s) =>
|
||||
({
|
||||
COMPLETED: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
PROCESSING: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
QUEUED: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
FAILED: 'bg-red-50 dark:bg-red-900/20'
|
||||
})[(s || '').toUpperCase()] || 'bg-surface-100 dark:bg-surface-800';
|
||||
|
||||
const statusIcon = (s) =>
|
||||
({
|
||||
COMPLETED: 'pi pi-check-circle text-emerald-500',
|
||||
PROCESSING: 'pi pi-spin pi-spinner text-blue-500',
|
||||
QUEUED: 'pi pi-clock text-amber-500',
|
||||
FAILED: 'pi pi-times-circle text-red-500'
|
||||
})[(s || '').toUpperCase()] || 'pi pi-circle text-surface-400';
|
||||
|
||||
const statusTagClass = (s) =>
|
||||
({
|
||||
COMPLETED: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
PROCESSING: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
QUEUED: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
})[(s || '').toUpperCase()] || 'bg-surface-200 text-surface-600 dark:bg-surface-700 dark:text-surface-300';
|
||||
|
||||
const getPlatformIcon = (platform) => {
|
||||
const p = platform?.toLowerCase() || '';
|
||||
if (p.includes('insta')) return 'pi pi-instagram';
|
||||
if (p.includes('tik')) return 'pi pi-tiktok';
|
||||
if (p.includes('you')) return 'pi pi-youtube';
|
||||
if (p.includes('tg') || p.includes('tele')) return 'pi pi-telegram';
|
||||
if (p.includes('link')) return 'pi pi-linkedin';
|
||||
if (p.includes('vk')) return 'pi pi-vk';
|
||||
return 'pi pi-share-alt';
|
||||
};
|
||||
|
||||
// Actions
|
||||
const openStrategy = (item) => {
|
||||
const s = (item.status || '').toUpperCase();
|
||||
if (s === 'COMPLETED' || s === 'PROCESSING' || s === 'QUEUED') {
|
||||
const id = item.strategyId || item.id;
|
||||
router.push(`/marketing-analysis/v3/strategy?strategyId=${id}&analysisId=${item.analysisId}`);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface-50 dark:bg-surface-900 min-h-screen p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="text-surface-500 p-0 w-6 h-6 mr-1 hover:bg-surface-200 dark:hover:bg-surface-700"
|
||||
@click="$router.push('/marketing-analysis')"
|
||||
v-tooltip.top="'На главную Маркетинга'"
|
||||
/>
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-emerald-500">CONTENT FACTORY V3</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои стратегии</h1>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-0.5">Сгенерированные контент-планы и медиа-активы</p>
|
||||
</div>
|
||||
<Button label="Мои анализы" icon="pi pi-chart-bar" outlined @click="$router.push('/marketing-analysis/v3')" class="shrink-0" />
|
||||
</div>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<div class="card mb-4">
|
||||
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
<span class="p-input-icon-left flex-1 max-w-xs">
|
||||
<i class="pi pi-search" />
|
||||
<InputText v-model="searchQuery" placeholder="Поиск (ниша, ID)..." class="w-full text-sm" />
|
||||
</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="f in STATUS_FILTERS"
|
||||
:key="f.value"
|
||||
@click="filterStatus = f.value"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
|
||||
filterStatus === f.value ? 'bg-emerald-500 text-white shadow-sm' : 'bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'
|
||||
]"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="f.value" class="ml-1 opacity-75">({{ countByStatus(f.value) }})</span>
|
||||
</button>
|
||||
<Button icon="pi pi-refresh" severity="secondary" text rounded size="small" :loading="loading" @click="load" v-tooltip.top="'Обновить'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<div v-for="i in 4" :key="i" class="card animate-pulse">
|
||||
<div class="h-4 bg-surface-200 dark:bg-surface-700 rounded w-1/3 mb-3" />
|
||||
<div class="h-3 bg-surface-200 dark:bg-surface-700 rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!filtered.length" class="card text-center py-20">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-emerald-50 dark:bg-emerald-900/30 mb-5 text-emerald-500">
|
||||
<i class="pi pi-sparkles text-4xl text-emerald-500" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-surface-900 dark:text-surface-0 mb-2">
|
||||
{{ filterStatus ? 'Нет стратегий с таким статусом' : 'Стратегий пока нет' }}
|
||||
</h2>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 max-w-sm mx-auto mb-6">Сгенерируйте первую стратегию ИИ-маркетологом.</p>
|
||||
<Button label="Перейти к анализам" icon="pi pi-arrow-right" class="bg-emerald-500 border-emerald-500 hover:bg-emerald-600" @click="$router.push('/marketing-analysis/v3')" />
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div v-else class="space-y-3">
|
||||
<TransitionGroup name="list-fade" tag="div" class="space-y-3">
|
||||
<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"
|
||||
@click="openStrategy(item)"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<!-- Status badge -->
|
||||
<div class="shrink-0">
|
||||
<div :class="['w-12 h-12 rounded-xl flex items-center justify-center', statusBg(item.status)]">
|
||||
<i :class="['text-xl', statusIcon(item.status)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<span v-if="(item.priorityPlatforms || item.strategyData?.priorityPlatforms)?.length" class="flex items-center gap-1 font-medium">
|
||||
<i class="pi pi-sitemap" /> Платформы:
|
||||
<span v-for="p in item.priorityPlatforms || item.strategyData?.priorityPlatforms" :key="p" class="bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded text-surface-700 dark:text-surface-300">
|
||||
<i :class="getPlatformIcon(p)" class="mr-1 text-[10px]"></i>{{ p }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1"> <i class="pi pi-clock" /> Создана: {{ formatDate(item.createdAt) }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 shrink-0" @click.stop>
|
||||
<Button
|
||||
v-if="item.status === 'COMPLETED' || item.status === 'PROCESSING'"
|
||||
icon="pi pi-eye"
|
||||
v-tooltip.top="'Открыть стратегию'"
|
||||
severity="info"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="openStrategy(item)"
|
||||
class="text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="filtered.length > pageSize" class="flex items-center justify-between pt-2">
|
||||
<p class="text-xs text-surface-500 dark:text-surface-400">Показано {{ (currentPage - 1) * pageSize + 1 }}–{{ Math.min(currentPage * pageSize, filtered.length) }} из {{ filtered.length }}</p>
|
||||
<Paginator :rows="pageSize" :totalRecords="filtered.length" :first="(currentPage - 1) * pageSize" @page="currentPage = $event.page + 1" template="PrevPageLink PageLinks NextPageLink" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-fade-enter-active,
|
||||
.list-fade-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.list-fade-enter-from,
|
||||
.list-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user