This commit is contained in:
arys
2026-04-06 12:10:39 +05:00
parent 2ca3c8aa5e
commit 338d4cfb35
46 changed files with 8942 additions and 678 deletions
+60
View File
@@ -0,0 +1,60 @@
# 📘 Гайд по интеграции Facebook Marketing (Frontend)
Этот документ описывает обновленный флоу автоматизации постинга. Теперь система работает по принципу «Настроил и забыл», автоматически обменивая временные токены на долгоживущие.
---
## 1. Одинкратная настройка (Token Exchange)
Прежде чем запускать постинг, нужно получить долгоживущий токен страницы. Это делается один раз через специальный хелпер-эндпоинт.
### Эндпоинт
`POST /api/facebook/config/exchange-token`
### Payload
```json
{
"shortToken": "USER_ACCESS_TOKEN_FROM_META_DEVELOPERS",
"pageId": "YOUR_FACEBOOK_PAGE_ID"
}
```
### Что происходит
- Фронтенд берет временный User Access Token (из Meta for Developers или через FB Login).
- Бекенд обращается к Graph API и обменивает его на бессрочный Page Access Token.
- Бекенд возвращает этот токен. Его нужно один раз прописать в `application.properties` в поле `facebook.page.token`.
---
## 2. Запуск маркетинговой кампании (Launch)
Когда у пользователя готова стратегия (V3 Analysis -> V3 Strategy), он нажимает кнопку «Запустить кампанию».
### Эндпоинт
`POST /api/targeting/launch/{strategyId}`
### Описание процесса
- Бекенд находит стратегию по `strategyId`.
- Извлекает первый запланированный пост из `postCalendar`.
- ИИ генерирует финальные креативы (текст, хэштеги).
- `FacebookPostingService` автоматически публикует пост на стену страницы, используя сохраненный долгоживущий токен.
- В ответе фронтенд получает `TargetingLaunchResultDto` со статусом публикации.
---
## 3. Статус кампании и предсказания
Теперь эндпоинты работают стабильно и автоматически запускают генерацию, если данных еще нет.
### Проверка предсказания (AI Prediction)
`GET /api/marketing/targeting/campaigns/{id}/prediction`
- Если готово: возвращает объект предсказания (JSON).
- Если в процессе: возвращает статус `202 Accepted` и сообщение `"status": "PROCESSING"`. Фронтенду нужно повторить запрос через 5-10 секунд.
### Проверка статуса
`GET /api/marketing/targeting/campaigns/{id}/status`
---
## 💡 Советы для фронтенда
- Лоадеры: при вызове `/prediction` всегда обрабатывайте статус `202` — показывайте пользователю «ИИ анализирует вашу кампанию...».
- Обработка ошибок: если бекенд вернет ошибку токена, направьте пользователя на страницу настроек для повторного обмена токена (Шаг 1).
Этот гайд можно смело отдавать фронтенд-разработчикам. Всё настроено так, чтобы им не пришлось возиться с низкоуровневой логикой Meta API.
+56
View File
@@ -0,0 +1,56 @@
# Frontend Guide: Marketing API (2026-04-03)
## Media (Images / Videos)
Media files are available via direct links and **do not require** `Authorization: Bearer ...`.
Use `imageFilename` (or `mediaFilename`) from API responses and prefix it with:
`https://api.konturai.kz/api/marketing/`
Example:
```html
<img src="https://api.konturai.kz/api/marketing/image_1772616225367_1567876836.png" />
```
## Campaign Status Polling
Endpoint:
`GET /api/marketing/targeting/campaigns/{id}/status`
Response includes `isTerminal`:
- If `isTerminal === true`, campaign status is terminal (success or error).
- Stop polling when terminal and proceed to prediction fetch.
Suggested polling loop:
```js
const poll = setInterval(async () => {
const res = await fetch(`/api/marketing/targeting/campaigns/${id}/status`);
const data = await res.json();
setStatus(data.status);
if (data.isTerminal) {
clearInterval(poll);
fetchPrediction();
}
}, 3000);
```
## AI Prediction
Endpoint:
`GET /api/marketing/targeting/campaigns/{id}/prediction`
Behavior:
- `200 OK`: prediction is ready.
- `202 Accepted`: prediction is generating in background. Repeat request in ~10-15 seconds.
Optional manual trigger (if automation did not start):
`POST /api/marketing/targeting/campaigns/{id}/predict`
+147
View File
@@ -11,9 +11,11 @@
"@primeuix/themes": "^1.0.0",
"chart.js": "3.3.2",
"chartjs-plugin-datalabels": "^2.0.0",
"date-fns": "^4.1.0",
"katex": "^0.16.27",
"marked": "^17.0.1",
"marked-katex-extension": "^5.1.6",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.3.1",
"tailwindcss-primeui": "^0.5.0",
@@ -1089,6 +1091,30 @@
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.3.tgz",
"integrity": "sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw=="
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/eslint-config-prettier": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz",
@@ -1289,6 +1315,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -1487,6 +1522,21 @@
"integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==",
"dev": true
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -1516,6 +1566,16 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
@@ -2105,6 +2165,12 @@
"node": ">= 0.4"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
@@ -2231,6 +2297,18 @@
"node": ">=8"
}
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2467,6 +2545,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz",
@@ -2694,6 +2778,12 @@
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2718,6 +2808,36 @@
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pirates": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
@@ -3023,6 +3143,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -3165,6 +3291,15 @@
"node": ">=0.10.0"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -3322,6 +3457,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+2
View File
@@ -11,9 +11,11 @@
"@primeuix/themes": "^1.0.0",
"chart.js": "3.3.2",
"chartjs-plugin-datalabels": "^2.0.0",
"date-fns": "^4.1.0",
"katex": "^0.16.27",
"marked": "^17.0.1",
"marked-katex-extension": "^5.1.6",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.3.1",
"tailwindcss-primeui": "^0.5.0",
+160
View File
@@ -0,0 +1,160 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
import AuthService from '@/service/AuthService';
import type {
ApiEnvelope,
CampaignDetails,
CampaignInsight,
CampaignPredictionResponse,
CampaignStatusResponse
} from '@/types/campaign.types';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRequestError = Error & {
status?: number;
data?: unknown;
};
const toApiError = (fallback: string, payload: unknown, status?: number): ApiRequestError => {
const base = toError(fallback, payload) as ApiRequestError;
if (status !== undefined) {
base.status = status;
}
if (payload !== null && payload !== undefined) {
base.data = payload;
}
return base;
};
const toError = (fallback: string, payload: unknown): Error => {
if (typeof payload === 'object' && payload !== null && 'message' in payload) {
const message = (payload as { message?: unknown }).message;
if (typeof message === 'string' && message.trim()) {
return new Error(message);
}
}
return new Error(fallback);
};
const unwrap = <T>(payload: unknown): T => {
if (typeof payload === 'object' && payload !== null && 'data' in payload) {
const env = payload as ApiEnvelope<T>;
if (env.data !== undefined) {
return env.data;
}
}
return payload as T;
};
const requestWithFallback = async <T>(
method: HttpMethod,
paths: string[],
fallbackMessage: string,
body?: Record<string, unknown>
): Promise<T> => {
let lastError: Error | null = null;
for (const path of paths) {
try {
const response = await AuthService.authFetch(`${API_CONFIG.BASE_URL}${path}`, {
method,
...DEFAULT_REQUEST_CONFIG,
...(body ? { body: JSON.stringify(body) } : {})
});
const raw = await response.json().catch(() => null);
if (!response.ok) {
if (response.status === 404) {
lastError = toApiError(fallbackMessage, raw, response.status);
continue;
}
throw toApiError(fallbackMessage, raw, response.status);
}
return unwrap<T>(raw);
} catch (error) {
lastError = error instanceof Error ? error : new Error(fallbackMessage);
}
}
throw lastError ?? new Error(fallbackMessage);
};
const requestTreat202AsPending = async <T>(method: HttpMethod, path: string, fallbackMessage: string): Promise<T> => {
const response = await AuthService.authFetch(`${API_CONFIG.BASE_URL}${path}`, {
method,
...DEFAULT_REQUEST_CONFIG
});
const raw = await response.json().catch(() => null);
// For prediction, 202 means "PROCESSING" and should be handled by polling logic.
if (response.status === 202) {
throw toApiError(fallbackMessage, raw, response.status);
}
if (!response.ok) {
throw toApiError(fallbackMessage, raw, response.status);
}
return unwrap<T>(raw);
};
const campaignPath = (id: string): string[] => [`/api/marketing/targeting/campaigns/${id}`];
const campaignSubPath = (id: string, suffix: string): string[] => [
`/api/marketing/targeting/campaigns/${id}/${suffix}`
];
const insightsPaths = (id: string): string[] => [
`/api/marketing/targeting/campaigns/${id}/insights`,
// Some backend versions require `datePreset` (Meta API style). Keep fallbacks so Insights tab always works.
`/api/marketing/targeting/campaigns/${id}/insights?datePreset=last_7d`,
`/api/marketing/targeting/campaigns/${id}/insights?datePreset=lifetime`
];
export const campaignApi = {
getCampaignById: (id: string) =>
requestWithFallback<CampaignDetails>('GET', campaignPath(id), 'Не удалось загрузить кампанию'),
getCampaignStatus: (id: string) =>
requestWithFallback<CampaignStatusResponse>('GET', campaignSubPath(id, 'status'), 'Не удалось получить статус кампании'),
getCampaignPrediction: (id: string) =>
requestTreat202AsPending<CampaignPredictionResponse>(
'GET',
`/api/marketing/targeting/campaigns/${id}/prediction`,
'Не удалось загрузить AI-прогноз кампании'
),
predictCampaign: (id: string) =>
requestWithFallback<void>(
'POST',
campaignSubPath(id, 'predict'),
'Не удалось обновить AI-прогноз кампании'
),
getCampaignInsights: (id: string) =>
requestWithFallback<CampaignInsight[]>('GET', insightsPaths(id), 'Не удалось загрузить инсайты кампании'),
syncCampaignInsights: (id: string) =>
requestWithFallback<void>('POST', campaignSubPath(id, 'sync-insights'), 'Не удалось синхронизировать инсайты'),
pauseCampaign: (id: string) =>
requestWithFallback<CampaignStatusResponse>('POST', campaignSubPath(id, 'pause'), 'Не удалось поставить кампанию на паузу'),
resumeCampaign: (id: string) =>
requestWithFallback<CampaignStatusResponse>('POST', campaignSubPath(id, 'resume'), 'Не удалось возобновить кампанию'),
retryCampaignLaunch: (id: string) =>
requestWithFallback<CampaignStatusResponse>('POST', campaignSubPath(id, 'retry'), 'Не удалось повторно запустить кампанию'),
updateBudget: (id: string, totalBudget: number, dailyBudget: number) =>
requestWithFallback<CampaignDetails>('PUT', campaignSubPath(id, 'budget'), 'Не удалось обновить бюджет', {
totalBudget,
dailyBudget
}),
updateAudience: (id: string, audience: any) =>
requestWithFallback<CampaignDetails>('PUT', campaignSubPath(id, 'audience'), 'Не удалось обновить аудиторию', audience),
getAdSets: (id: string) =>
requestWithFallback<any[]>('GET', campaignSubPath(id, 'adsets'), 'Не удалось загрузить группы объявлений'),
updateAdSet: (id: string, adSetId: string, adSet: any) =>
requestWithFallback<CampaignDetails>(
'PUT',
[`/api/marketing/targeting/campaigns/${id}/adsets/${adSetId}`],
'Не удалось обновить группу объявлений',
adSet
)
};
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
topic: {
type: String,
default: ''
},
postType: {
type: String,
default: ''
},
cta: {
type: String,
default: 'Узнать подробнее'
},
imageUrl: {
type: String,
default: ''
}
});
const normalizedTopic = computed(() => (props.topic || '').toLowerCase());
const previewTheme = computed(() => {
const t = normalizedTopic.value;
if (/детск|сад|школ/.test(t)) return { gradient: 'linear-gradient(135deg, #2563eb 0%, #1e3a8a 100%)', emoji: '🎓' };
if (/фитнес|спорт|gym/.test(t)) return { gradient: 'linear-gradient(135deg, #16a34a 0%, #14532d 100%)', emoji: '💪' };
if (/еда|ресторан|кафе/.test(t)) return { gradient: 'linear-gradient(135deg, #f97316 0%, #7c2d12 100%)', emoji: '🍽️' };
if (/красот|салон|nail/.test(t)) return { gradient: 'linear-gradient(135deg, #ec4899 0%, #7e22ce 100%)', emoji: '💅' };
if (/юрид|адвокат/.test(t)) return { gradient: 'linear-gradient(135deg, #1f2937 0%, #111827 100%)', emoji: '⚖️' };
if (/медицин|клиник/.test(t)) return { gradient: 'linear-gradient(135deg, #38bdf8 0%, #1d4ed8 100%)', emoji: '🏥' };
if (/недвижим|квартир/.test(t)) return { gradient: 'linear-gradient(135deg, #64748b 0%, #334155 100%)', emoji: '🏠' };
return { gradient: 'linear-gradient(135deg, #6d28d9 0%, #312e81 100%)', emoji: '🚀' };
});
const displayTitle = computed(() => props.topic || 'Ваш бизнес');
const displayType = computed(() => props.postType || 'Рекламный пост');
const hasImage = computed(() => !!props.imageUrl);
</script>
<template>
<div
class="aspect-square rounded-2xl overflow-hidden relative flex flex-col items-center justify-center text-white p-6 shadow-sm"
:style="{ background: previewTheme.gradient }"
>
<img
v-if="hasImage"
:src="imageUrl"
alt="Preview"
class="absolute inset-0 h-full w-full object-cover"
/>
<div class="absolute inset-0 bg-black/35" v-if="hasImage"></div>
<div class="absolute -top-10 -left-10 w-32 h-32 bg-white/20 rounded-full"></div>
<div class="absolute -bottom-8 -right-8 w-28 h-28 bg-black/20 rounded-full"></div>
<div class="absolute top-4 left-4 px-3 py-1 rounded-full bg-white/20 text-xs font-semibold tracking-wide">
{{ displayType }}
</div>
<div class="text-6xl mb-4">{{ previewTheme.emoji }}</div>
<h3 class="text-xl font-bold text-center leading-tight mb-4">{{ displayTitle }}</h3>
<button type="button" class="px-4 py-2 rounded-xl bg-white text-gray-900 font-semibold text-sm shadow">
{{ cta }}
</button>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup>
import Button from 'primevue/button';
defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
eyebrow: {
type: String,
default: ''
},
badge: {
type: String,
default: ''
},
backLabel: {
type: String,
default: ''
}
});
const emit = defineEmits(['back']);
</script>
<template>
<div class="mb-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<Button v-if="backLabel" :label="backLabel" icon="pi pi-arrow-left" text class="mb-3 -ml-3" @click="emit('back')" />
<p v-if="eyebrow" class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-2">{{ eyebrow }}</p>
<h1 class="text-2xl font-bold text-gray-900">{{ title }}</h1>
<p v-if="subtitle" class="text-gray-500 mt-2 max-w-3xl">{{ subtitle }}</p>
</div>
<div v-if="badge" class="inline-flex items-center rounded-full bg-primary-50 text-primary px-3 py-1 text-sm font-medium">
{{ badge }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,26 @@
<script setup>
defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
compact: {
type: Boolean,
default: false
}
});
</script>
<template>
<section class="bg-white border border-gray-200 rounded-xl shadow-sm" :class="compact ? 'p-4' : 'p-5 lg:p-6'">
<div v-if="title || subtitle" class="mb-4">
<h2 v-if="title" class="text-lg font-semibold text-gray-800">{{ title }}</h2>
<p v-if="subtitle" class="text-sm text-gray-500 mt-1">{{ subtitle }}</p>
</div>
<slot />
</section>
</template>
@@ -0,0 +1,37 @@
<script setup>
import Button from 'primevue/button';
defineProps({
mode: {
type: String,
default: 'loading'
},
message: {
type: String,
default: ''
},
retryLabel: {
type: String,
default: 'Попробовать снова'
}
});
const emit = defineEmits(['retry']);
</script>
<template>
<div
class="rounded-xl border p-5 lg:p-6"
:class="mode === 'error' ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 shadow-sm'"
>
<div v-if="mode === 'loading'" class="flex flex-col items-center py-12 gap-4">
<div class="w-12 h-12 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
<p class="text-gray-500">{{ message }}</p>
</div>
<div v-else class="flex flex-col items-start gap-3">
<p class="text-sm text-red-700"> {{ message }}</p>
<Button :label="retryLabel" size="small" severity="danger" outlined @click="emit('retry')" />
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
import { computed, unref, type Ref } from 'vue';
import type { CampaignDetails } from '@/types/campaign.types';
type CampaignSource = CampaignDetails | null | Ref<CampaignDetails | null>;
export const useCampaignBudget = (campaignSource: CampaignSource) => {
const campaign = computed(() => unref(campaignSource));
const budgetRecommendation = computed(
() => campaign.value?.aiRecommendations?.targetingRecommendation?.budgetRecommendation ?? null
);
const dailyBudget = computed<number | null>(() => budgetRecommendation.value?.dailyBudget ?? null);
const monthlyBudget = computed<number | null>(() => budgetRecommendation.value?.monthlyBudget ?? null);
const totalBudget = computed<number | null>(
() => campaign.value?.totalBudgetKzt ?? campaign.value?.budgetKzt ?? monthlyBudget.value ?? null
);
const campaignDays = computed<number | null>(() => {
if (campaign.value?.plannedDays != null) {
return campaign.value.plannedDays;
}
if (dailyBudget.value && monthlyBudget.value) {
return Math.round(monthlyBudget.value / dailyBudget.value);
}
return null;
});
const targetReach = computed<number | null>(
() => campaign.value?.targetReach ?? budgetRecommendation.value?.estimatedDailyReach ?? null
);
return {
budgetRecommendation,
dailyBudget,
monthlyBudget,
totalBudget,
campaignDays,
targetReach
};
};
+155
View File
@@ -0,0 +1,155 @@
import type { CampaignMetricRange } from '@/types/campaign.types';
export type MetricTrend = 'up' | 'down' | 'neutral' | 'none';
export interface MetricDisplayState {
value: string;
isNoData: boolean;
isZero: boolean;
tooltip: string | null;
helperText: string | null;
}
const numberFormatter = new Intl.NumberFormat('ru-RU');
const currencyFormatter = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT',
maximumFractionDigits: 0
});
const toNumber = (value: string | number | null | undefined): number | null => {
if (value == null) {
return null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
const normalized = value.replace(',', '.').replace(/[^0-9.-]/g, '');
if (!normalized.trim()) {
return null;
}
const numeric = Number(normalized);
return Number.isFinite(numeric) ? numeric : null;
};
export const formatGeo = (geo: string[] | string): string => {
if (Array.isArray(geo)) {
return geo.join(', ');
}
return geo;
};
export const formatArrayField = (value: string[] | string | null | undefined): string => {
if (!value) {
return '—';
}
return Array.isArray(value) ? value.join(', ') : value;
};
export const formatMetricValue = (value: string | number | null | undefined, unit?: string): string => {
if (value == null) {
return '—';
}
if (typeof value === 'string') {
if (value.includes('%')) {
return value;
}
const numericFromString = toNumber(value);
if (numericFromString == null) {
return value;
}
if (unit === '%') {
return `${numberFormatter.format(numericFromString)}%`;
}
if (unit === 'KZT') {
return currencyFormatter.format(numericFromString);
}
if (Math.abs(numericFromString) >= 1000) {
return numberFormatter.format(numericFromString);
}
return Number.isInteger(numericFromString) ? String(numericFromString) : numericFromString.toFixed(2);
}
if (unit === '%') {
return `${numberFormatter.format(value)}%`;
}
if (unit === 'KZT') {
return currencyFormatter.format(value);
}
if (Math.abs(value) >= 1000) {
return numberFormatter.format(value);
}
return Number.isInteger(value) ? String(value) : value.toFixed(2);
};
export const getMetricDisplayState = (
value: string | number | null | undefined,
unit?: string
): MetricDisplayState => {
if (value == null) {
return {
value: '—',
isNoData: true,
isZero: false,
tooltip: 'Нет данных',
helperText: null
};
}
const numeric = toNumber(value);
if (numeric === 0) {
return {
value: formatMetricValue(0, unit),
isNoData: false,
isZero: true,
tooltip: null,
helperText: 'Данные появятся через 24-48 ч'
};
}
return {
value: formatMetricValue(value, unit),
isNoData: false,
isZero: false,
tooltip: null,
helperText: null
};
};
export const compareMetricWithRange = (
actual: string | number | null | undefined,
range: CampaignMetricRange | null | undefined
): MetricTrend => {
const actualNumber = toNumber(actual);
if (actualNumber == null || !range) {
return 'none';
}
const min = range.min ?? null;
const max = range.max ?? null;
if (min == null || max == null) {
return 'none';
}
if (actualNumber > max) {
return 'up';
}
if (actualNumber < min) {
return 'down';
}
return 'neutral';
};
export const formatRangeLabel = (range: CampaignMetricRange | null | undefined): string => {
if (!range || range.min == null || range.max == null) {
return 'Прогноз: —';
}
const unit = range.unit ?? undefined;
const minLabel = formatMetricValue(range.min, unit);
const maxLabel = formatMetricValue(range.max, unit);
return `Прогноз: ${minLabel} ${maxLabel}`;
};
+138
View File
@@ -0,0 +1,138 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { campaignApi } from '@/api/campaign.api';
import type { CampaignStatus, CampaignStatusResponse } from '@/types/campaign.types';
interface UseCampaignStatusPollerOptions {
campaignId: Readonly<Ref<string>>;
status: Ref<CampaignStatus | null>;
onStatusUpdate?: (payload: CampaignStatusResponse) => void | Promise<void>;
onTerminal?: (payload: CampaignStatusResponse) => void | Promise<void>;
}
const FINAL_STATUSES: CampaignStatus[] = ['ACTIVE', 'FAILED', 'COMPLETED'];
const normalizeCampaignStatus = (value: unknown): CampaignStatus => {
const raw = String(value ?? '').trim();
const lower = raw.toLowerCase();
if (lower === 'draft' || lower === 'created') return 'CREATED';
if (lower === 'orchestrating' || lower === 'processing') return 'ORCHESTRATING';
if (lower === 'mapping') return 'MAPPING';
if (lower === 'publishing') return 'PUBLISHING';
if (lower === 'active') return 'ACTIVE';
if (lower === 'paused') return 'PAUSED';
if (lower === 'completed' || lower === 'done') return 'COMPLETED';
if (lower === 'failed' || lower === 'error') return 'FAILED';
return raw.toUpperCase() as CampaignStatus;
};
export const useCampaignStatusPoller = ({ campaignId, status, onStatusUpdate, onTerminal }: UseCampaignStatusPollerOptions) => {
const toast = useToast();
const intervalId = ref<number | null>(null);
const terminalReached = ref(false);
const isPolling = computed(() => intervalId.value !== null);
const shouldPoll = computed(() => {
if (!campaignId.value) {
return false;
}
return !terminalReached.value;
});
const stopPolling = (): void => {
if (intervalId.value !== null) {
window.clearInterval(intervalId.value);
intervalId.value = null;
}
};
const handleStatusPayload = async (payload: CampaignStatusResponse): Promise<void> => {
const normalizedPayload: CampaignStatusResponse = {
...payload,
status: normalizeCampaignStatus(payload.status)
};
const previous = status.value;
status.value = normalizedPayload.status;
if (onStatusUpdate) {
await onStatusUpdate(normalizedPayload);
}
const isTerminal = normalizedPayload.isTerminal ?? FINAL_STATUSES.includes(normalizedPayload.status);
if (normalizedPayload.status === 'ACTIVE' && previous !== 'ACTIVE') {
toast.add({
severity: 'success',
summary: 'Статус кампании',
detail: 'Кампания запущена!',
life: 4000
});
}
if (normalizedPayload.status === 'FAILED' && previous !== 'FAILED') {
toast.add({
severity: 'error',
summary: 'Ошибка кампании',
detail: normalizedPayload.message || 'Не удалось запустить кампанию',
life: 5000
});
}
if (isTerminal) {
terminalReached.value = true;
if (onTerminal) {
await onTerminal(normalizedPayload);
}
stopPolling();
}
};
const pollOnce = async (): Promise<void> => {
if (!campaignId.value) {
return;
}
try {
const payload = await campaignApi.getCampaignStatus(campaignId.value);
await handleStatusPayload(payload);
} catch {
// Ошибки поллинга не пробрасываем, чтобы не ломать интерфейс.
}
};
const startPolling = (): void => {
if (!campaignId.value || intervalId.value !== null || !shouldPoll.value) {
return;
}
void pollOnce();
intervalId.value = window.setInterval(() => {
void pollOnce();
}, 3000);
};
watch(
() => status.value,
() => {
if (shouldPoll.value) {
startPolling();
} else {
stopPolling();
}
}
);
onMounted(() => {
if (shouldPoll.value) {
startPolling();
}
});
onBeforeUnmount(() => {
stopPolling();
});
return {
isPolling,
startPolling,
stopPolling
};
};
+28
View File
@@ -0,0 +1,28 @@
import {
CAMPAIGN_STATUS_LABELS,
OBJECTIVE_LABELS,
PLATFORM_LABELS,
TARGETING_TYPE_LABELS
} from '@/constants/targetingLabels';
const toLabel = (value: string | null | undefined, map: Record<string, string>): string => {
if (!value) {
return '—';
}
return map[value] ?? value;
};
export const useEnumLabels = () => {
const targetingTypeLabel = (value: string | null | undefined): string => toLabel(value, TARGETING_TYPE_LABELS);
const platformLabel = (value: string | null | undefined): string => toLabel(value, PLATFORM_LABELS);
const campaignStatusLabel = (value: string | null | undefined): string => toLabel(value, CAMPAIGN_STATUS_LABELS);
const objectiveLabel = (value: string | null | undefined): string => toLabel(value, OBJECTIVE_LABELS);
return {
targetingTypeLabel,
platformLabel,
campaignStatusLabel,
objectiveLabel
};
};
+36
View File
@@ -0,0 +1,36 @@
import { API_CONFIG } from '@/config/api';
/**
* Marketing media (image_*.png, video_*.mp4) is served by backend without auth headers.
* Backend expects URLs like: `${BASE_URL}/api/marketing/<filename>`.
*/
export const resolveMarketingMediaUrl = (value: string | null | undefined): string => {
if (!value) return '';
const trimmed = String(value).trim();
if (!trimmed) return '';
// Data URIs and absolute URLs are already good to go.
if (trimmed.startsWith('data:')) return trimmed;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
// If backend already returned a full API path, just prefix domain.
if (withoutLeadingSlash.startsWith('api/')) {
return `${API_CONFIG.BASE_URL}/${withoutLeadingSlash}`;
}
// Common case: backend returns just `image_*.png` / `video_*.mp4`.
if (
withoutLeadingSlash.startsWith('image_') ||
withoutLeadingSlash.startsWith('video_') ||
withoutLeadingSlash.startsWith('audio_')
) {
return `${API_CONFIG.BASE_URL}/api/marketing/${withoutLeadingSlash}`;
}
// Fallback: treat as root-relative path on API domain.
return `${API_CONFIG.BASE_URL}/${withoutLeadingSlash}`;
};
+65
View File
@@ -0,0 +1,65 @@
import AuthService from '@/service/AuthService';
import { API_CONFIG } from '@/config/api';
const TARGETING_API_BASE_URL = import.meta.env.VITE_TARGETING_API_URL || API_CONFIG.BASE_URL;
async function parseResponse(response) {
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(data?.message || data?.error || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data?.data ?? data;
}
export function useTargetingApi() {
async function generateAnalysis(topic) {
const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/analysis`, {
method: 'POST',
body: JSON.stringify({ topic })
});
return parseResponse(response);
}
async function generateStrategy(analysis) {
const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/strategy`, {
method: 'POST',
body: JSON.stringify({ analysis })
});
return parseResponse(response);
}
async function generateCreative(captionIdea, analysis, strategy) {
const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/creative`, {
method: 'POST',
body: JSON.stringify({
captionIdea,
analysis,
strategy
})
});
return parseResponse(response);
}
async function launchFromStrategy(strategyId) {
const response = await AuthService.authFetch(`${TARGETING_API_BASE_URL}/api/targeting/launch/${strategyId}`, {
method: 'POST'
});
return parseResponse(response);
}
return {
generateAnalysis,
generateStrategy,
generateCreative,
launchFromStrategy
};
}
+34
View File
@@ -0,0 +1,34 @@
export const TARGETING_TYPE_LABELS: Record<string, string> = {
COLD_INTEREST: 'Холодная аудитория',
BEHAVIORAL: 'Поведенческий таргетинг',
RETARGETING: 'Ретаргетинг',
LOOKALIKE: 'Похожая аудитория',
MIXED: 'Смешанный таргетинг'
};
export const PLATFORM_LABELS: Record<string, string> = {
FACEBOOK: 'Facebook',
INSTAGRAM: 'Instagram',
TIKTOK: 'TikTok',
YOUTUBE: 'YouTube'
};
export const CAMPAIGN_STATUS_LABELS: Record<string, string> = {
CREATED: 'Кампания создана',
ORCHESTRATING: 'ИИ строит сегменты',
MAPPING: 'Маппинг адсетов',
PUBLISHING: 'Публикация в Meta/TikTok',
ACTIVE: 'Кампания активна',
PAUSED: 'На паузе',
COMPLETED: 'Завершена',
FAILED: 'Ошибка публикации'
};
export const OBJECTIVE_LABELS: Record<string, string> = {
LEADS: 'Лиды',
TRAFFIC: 'Трафик',
AWARENESS: 'Охват',
CONVERSIONS: 'Конверсии',
ENGAGEMENT: 'Вовлечённость',
APP_INSTALLS: 'Установки приложения'
};
+4
View File
@@ -1,4 +1,5 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
@@ -8,11 +9,14 @@ import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice';
import '@/assets/styles.scss';
import '@/assets/kai-v4.css';
import 'katex/dist/katex.min.css';
const app = createApp(App);
const pinia = createPinia();
app.use(router);
app.use(pinia);
app.use(PrimeVue, {
theme: {
preset: Aura,
+42
View File
@@ -1,6 +1,7 @@
import AppLayout from '@/layout/AppLayout.vue';
import AuthService from '@/service/AuthService';
import IdentityService from '@/service/IdentityService';
import { useTargetingStore } from '@/stores/targeting';
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
@@ -197,6 +198,18 @@ const router = createRouter({
name: 'smm-dashboard',
component: () => import('@/views/smm/SmmDashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/targeting',
component: () => import('@/views/targeting/TargetingLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', redirect: '/targeting/input' },
{ path: 'input', name: 'targeting-input', component: () => import('@/views/targeting/TargetingInputView.vue') },
{ path: 'analysis', name: 'targeting-analysis', component: () => import('@/views/targeting/TargetingAnalysisView.vue') },
{ path: 'strategy', name: 'targeting-strategy', component: () => import('@/views/targeting/TargetingStrategyView.vue') },
{ path: 'creative', name: 'targeting-creative', component: () => import('@/views/targeting/TargetingCreativeView.vue') }
]
}
]
},
@@ -301,6 +314,24 @@ const router = createRouter({
component: () => import('@/views/pages/marketing/MarketingAnalysisV3.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/targetologist/dashboard',
name: 'marketing-targetologist-dashboard',
component: () => import('@/views/pages/marketing/targetologist/TargetologistDashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/targetologist/wizard',
name: 'marketing-targetologist-wizard',
component: () => import('@/views/pages/marketing/targetologist/CampaignWizard.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/targetologist/campaign/:id',
name: 'marketing-targetologist-campaign',
component: () => import('@/views/pages/marketing/targetologist/CampaignInsightsDetail.vue'),
meta: { requiresAuth: true }
},
{
path: '/pages/notfound',
name: 'notfound',
@@ -344,6 +375,17 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { redirect: to.fullPath } };
}
}
const targetingStore = useTargetingStore();
if (to.name === 'targeting-analysis' && !targetingStore.analysis.value) {
return { name: 'targeting-input' };
}
if (to.name === 'targeting-strategy' && !targetingStore.strategy.value) {
return { name: 'targeting-input' };
}
if (to.name === 'targeting-creative' && !targetingStore.creative.value && !to.query.strategyId) {
return { name: 'targeting-input' };
}
});
export default router;
+22 -1
View File
@@ -202,7 +202,7 @@ class MarketingV3Service {
*/
async executeTaskManually(taskId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/tasks/${taskId}/execute`, {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/analysis/tasks/${taskId}/execute`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG
});
@@ -217,6 +217,27 @@ class MarketingV3Service {
}
}
/**
* Умная публикация первого поста стратегии в Facebook
* POST /api/marketing/targeting/strategy/{strategyId}/facebook/publish
*/
async publishStrategyToFacebook(strategyId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/strategy/${strategyId}/facebook/publish`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG
});
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при автоматической публикации FB');
}
return result;
} catch (error) {
console.error('Ошибка при автоматической публикации FB:', error);
throw error;
}
}
/**
* Регенерация изображения для одного поста (B2)
* POST /api/marketing/v3/strategy/{strategyId}/post/{postIndex}/regenerate-image
+161
View File
@@ -0,0 +1,161 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
import AuthService from './AuthService';
const API_BASE_URL = API_CONFIG.BASE_URL;
class TargetingService {
// --- OAuth ---
async getFacebookOAuthUrl() {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/facebook/oauth-url`);
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка генерации OAuth-ссылки Facebook');
return result?.url ?? (result?.data?.url);
} catch (error) {
console.error('Ошибка Facebook OAuth:', error);
throw error;
}
}
async getTikTokOAuthUrl() {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/tiktok/oauth-url`);
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка генерации OAuth-ссылки TikTok');
return result?.url ?? (result?.data?.url);
} catch (error) {
console.error('Ошибка TikTok OAuth:', error);
throw error;
}
}
// --- Account Selection ---
async getFacebookAdAccounts() {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/facebook/ad-accounts`);
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка загрузки рекламных кабинетов Facebook');
return result?.data ?? result;
} catch (error) {
console.error('Ошибка загрузки Facebook Accounts:', error);
throw error;
}
}
async selectAdAccount(platform, adAccountId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/account/select`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify({ platform: platform.toUpperCase(), adAccountId })
});
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка привязки рекламного кабинета');
return result?.data ?? result;
} catch (error) {
console.error('Ошибка выбора кабинета:', error);
throw error;
}
}
// --- Campaigns Orchestration ---
async createCampaign(payload) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка при запуске кампании ИИ');
return result?.data ?? result;
} catch (error) {
console.error('Ошибка запуска AI-кампании:', error);
throw error;
}
}
async getCampaignById(id) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}`);
const result = await response.json();
if (!response.ok) throw new Error(result?.message || `Ошибка получения данных кампании ${id}`);
return result?.data ?? result;
} catch (error) {
console.error('Ошибка загрузки кампании:', error);
throw error;
}
}
async getCampaigns(page = 0, size = 10) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns?page=${page}&size=${size}`);
const result = await response.json();
if (!response.ok) throw new Error(result?.message || 'Ошибка получения списка кампаний');
// Support both array returned and page DTO wrapper from spring
return result?.data?.content ?? result?.content ?? result?.data ?? result;
} catch (error) {
console.error('Ошибка загрузки списка кампаний:', error);
throw error;
}
}
// --- Campaign Control & Analytics ---
async pauseCampaign(id) {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/pause`, { method: 'POST' });
if (!response.ok) throw new Error('Не удалось остановить кампанию');
return await response.json();
}
async resumeCampaign(id) {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/resume`, { method: 'POST' });
if (!response.ok) throw new Error('Не удалось возобновить кампанию');
return await response.json();
}
async publishCampaign(id) {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/publish`, { method: 'POST' });
if (!response.ok) throw new Error('Не удалось опубликовать кампанию в Facebook');
return await response.json();
}
/**
* Умная публикация первого поста стратегии в Facebook.
* POST /api/marketing/targeting/strategy/{strategyId}/facebook/publish
* Бэкенд сам находит первый FB-пост, скачивает медиа из MinIO и публикует.
*/
async publishStrategyToFacebook(strategyId) {
const response = await AuthService.authFetch(
`${API_BASE_URL}/api/marketing/targeting/strategy/${strategyId}/facebook/publish`,
{ method: 'POST' }
);
const result = await response.json().catch(() => null);
if (!response.ok) {
const errMsg = result?.error || result?.message || 'Не удалось опубликовать пост в Facebook';
throw new Error(errMsg);
}
return result;
}
async getCampaignInsights(id, datePreset = 'last_7d') {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/insights?datePreset=${datePreset}`);
if (!response.ok) throw new Error('Ошибка при загрузке аналитики кампании');
const r = await response.json();
return r?.data ?? r;
}
async syncCampaignInsights(id) {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/campaigns/${id}/sync-insights`, { method: 'POST' });
if (!response.ok) throw new Error('Ошибка обновления статистики Facebook API');
const r = await response.json();
return r?.data ?? r;
}
async optimizeBudget() {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/targeting/budget/optimize`, { method: 'POST' });
if (!response.ok) throw new Error('Ошибка запуска оптимизатора бюджета');
return await response.json();
}
}
export default new TargetingService();
+340
View File
@@ -0,0 +1,340 @@
import { defineStore } from 'pinia';
import { campaignApi } from '@/api/campaign.api';
import type {
CampaignDetails,
CampaignInsight,
CampaignPredictionResponse,
CampaignStatus,
CampaignStatusResponse
} from '@/types/campaign.types';
interface CampaignLoadingState {
campaign: boolean;
prediction: boolean;
insights: boolean;
status: boolean;
action: boolean;
}
interface ApiRequestError extends Error {
status?: number;
}
interface CampaignStoreState {
campaign: CampaignDetails | null;
prediction: CampaignPredictionResponse | null;
insights: CampaignInsight[];
adSets: any[];
status: CampaignStatus | null;
loading: CampaignLoadingState;
error: string | null;
}
const defaultLoadingState = (): CampaignLoadingState => ({
campaign: false,
prediction: false,
insights: false,
status: false,
action: false
});
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const normalizeCampaignStatus = (value: unknown): CampaignStatus | null => {
if (value == null) return null;
const raw = String(value).trim();
const lower = raw.toLowerCase();
// Backend may return lowercase pipeline statuses: draft -> orchestrating -> active.
if (lower === 'draft' || lower === 'created') return 'CREATED';
if (lower === 'orchestrating' || lower === 'processing') return 'ORCHESTRATING';
if (lower === 'mapping') return 'MAPPING';
if (lower === 'publishing') return 'PUBLISHING';
if (lower === 'active') return 'ACTIVE';
if (lower === 'paused') return 'PAUSED';
if (lower === 'completed' || lower === 'done') return 'COMPLETED';
if (lower === 'failed' || lower === 'error') return 'FAILED';
const upper = raw.toUpperCase();
const allowed: CampaignStatus[] = ['CREATED', 'ORCHESTRATING', 'MAPPING', 'PUBLISHING', 'ACTIVE', 'PAUSED', 'COMPLETED', 'FAILED'];
return allowed.includes(upper as CampaignStatus) ? (upper as CampaignStatus) : null;
};
export const useCampaignStore = defineStore('campaign', {
state: (): CampaignStoreState => ({
campaign: null,
prediction: null,
insights: [],
adSets: [],
status: null,
loading: defaultLoadingState(),
error: null
}),
actions: {
clearError(): void {
this.error = null;
},
setStatus(status: CampaignStatus): void {
this.status = normalizeCampaignStatus(status) ?? status;
if (this.campaign) {
this.campaign.status = this.status;
}
},
applyStatusPayload(payload: CampaignStatusResponse): void {
this.status = normalizeCampaignStatus(payload.status) ?? payload.status;
if (this.campaign) {
this.campaign.status = this.status;
}
},
async fetchCampaign(id: string): Promise<CampaignDetails> {
this.loading.campaign = true;
this.clearError();
try {
const campaign = await campaignApi.getCampaignById(id);
campaign.status = normalizeCampaignStatus(campaign.status) ?? campaign.status;
this.campaign = campaign;
this.status = campaign.status;
return campaign;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось загрузить кампанию';
throw error;
} finally {
this.loading.campaign = false;
}
},
async fetchStatus(id: string): Promise<CampaignStatusResponse> {
this.loading.status = true;
this.clearError();
try {
const statusPayload = await campaignApi.getCampaignStatus(id);
statusPayload.status = normalizeCampaignStatus(statusPayload.status) ?? statusPayload.status;
this.applyStatusPayload(statusPayload);
return statusPayload;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось загрузить статус кампании';
throw error;
} finally {
this.loading.status = false;
}
},
async fetchPrediction(id: string): Promise<CampaignPredictionResponse> {
this.loading.prediction = true;
this.clearError();
try {
const prediction = await campaignApi.getCampaignPrediction(id);
this.prediction = prediction;
return prediction;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось загрузить прогноз кампании';
throw error;
} finally {
this.loading.prediction = false;
}
},
async refreshPrediction(id: string): Promise<CampaignPredictionResponse> {
this.loading.prediction = true;
this.clearError();
try {
await campaignApi.predictCampaign(id);
const prediction = await this.waitForPrediction(id);
if (!prediction) {
throw new Error('Прогноз пока не готов. Попробуйте повторить через несколько секунд.');
}
return prediction;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось пересчитать прогноз кампании';
throw error;
} finally {
this.loading.prediction = false;
}
},
async waitForPrediction(id: string, attempts = 10, delayMs = 5000): Promise<CampaignPredictionResponse | null> {
this.loading.prediction = true;
this.clearError();
try {
for (let i = 0; i < attempts; i += 1) {
try {
const prediction = await campaignApi.getCampaignPrediction(id);
this.prediction = prediction;
return prediction;
} catch (error) {
const status = (error as ApiRequestError)?.status;
if (status === 404 || status === 202) {
if (i < attempts - 1) {
await sleep(delayMs);
continue;
}
this.prediction = null;
return null;
}
throw error;
}
}
this.prediction = null;
return null;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось дождаться прогноза кампании';
throw error;
} finally {
this.loading.prediction = false;
}
},
async triggerPredictionGeneration(id: string): Promise<void> {
this.loading.prediction = true;
this.clearError();
try {
await campaignApi.predictCampaign(id);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось запустить генерацию прогноза';
throw error;
} finally {
this.loading.prediction = false;
}
},
async fetchInsights(id: string): Promise<CampaignInsight[]> {
this.loading.insights = true;
this.clearError();
try {
const insights = await campaignApi.getCampaignInsights(id);
this.insights = insights;
return insights;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось загрузить инсайты кампании';
throw error;
} finally {
this.loading.insights = false;
}
},
async syncInsights(id: string): Promise<void> {
this.loading.action = true;
this.clearError();
try {
await campaignApi.syncCampaignInsights(id);
await this.fetchInsights(id);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось синхронизировать статистику';
throw error;
} finally {
this.loading.action = false;
}
},
async fetchAdSets(id: string): Promise<any[]> {
this.loading.campaign = true;
this.clearError();
try {
const adSets = await campaignApi.getAdSets(id);
this.adSets = adSets;
return adSets;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось загрузить группы объявлений';
throw error;
} finally {
this.loading.campaign = false;
}
},
async updateBudget(id: string, total: number, daily: number): Promise<CampaignDetails> {
this.loading.action = true;
this.clearError();
try {
const campaign = await campaignApi.updateBudget(id, total, daily);
this.campaign = campaign;
return campaign;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось обновить бюджет';
throw error;
} finally {
this.loading.action = false;
}
},
async updateAudience(id: string, audience: any): Promise<CampaignDetails> {
this.loading.action = true;
this.clearError();
try {
const campaign = await campaignApi.updateAudience(id, audience);
this.campaign = campaign;
return campaign;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось обновить аудиторию';
throw error;
} finally {
this.loading.action = false;
}
},
async updateAdSet(id: string, adSetId: string, adSet: any): Promise<CampaignDetails> {
this.loading.action = true;
this.clearError();
try {
const campaign = await campaignApi.updateAdSet(id, adSetId, adSet);
this.campaign = campaign;
return campaign;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось обновить группу объявлений';
throw error;
} finally {
this.loading.action = false;
}
},
async pauseCampaign(id: string): Promise<CampaignStatusResponse> {
this.loading.action = true;
this.clearError();
try {
const payload = await campaignApi.pauseCampaign(id);
this.applyStatusPayload(payload);
return payload;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось поставить кампанию на паузу';
throw error;
} finally {
this.loading.action = false;
}
},
async resumeCampaign(id: string): Promise<CampaignStatusResponse> {
this.loading.action = true;
this.clearError();
try {
const payload = await campaignApi.resumeCampaign(id);
this.applyStatusPayload(payload);
return payload;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось возобновить кампанию';
throw error;
} finally {
this.loading.action = false;
}
},
async retryCampaignLaunch(id: string): Promise<CampaignStatusResponse> {
this.loading.action = true;
this.clearError();
try {
const payload = await campaignApi.retryCampaignLaunch(id);
this.applyStatusPayload(payload);
return payload;
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось повторить запуск кампании';
throw error;
} finally {
this.loading.action = false;
}
},
async createSimilarCampaign(id: string): Promise<CampaignDetails> {
this.loading.action = true;
this.clearError();
try {
// Since 'similar' endpoint was removed from controller, we redirect to wizard with strategyId
if (this.campaign?.strategyId) {
window.location.href = `/marketing-analysis/v3/targetologist/wizard?strategyId=${this.campaign.strategyId}`;
}
throw new Error('Функционал копирования через API удален. Используйте мастер создания.');
} catch (error) {
this.error = error instanceof Error ? error.message : 'Не удалось создать похожую кампанию';
throw error;
} finally {
this.loading.action = false;
}
}
}
});
+38
View File
@@ -0,0 +1,38 @@
import { ref } from 'vue';
const topic = ref('');
const analysis = ref(null);
const strategy = ref(null);
const creative = ref(null);
const selectedCreative = ref(null);
const launchResult = ref(null);
const sourceStrategyId = ref(null);
const sourceStrategyName = ref('');
const sourceContext = ref('targeting');
function reset() {
topic.value = '';
analysis.value = null;
strategy.value = null;
creative.value = null;
selectedCreative.value = null;
launchResult.value = null;
sourceStrategyId.value = null;
sourceStrategyName.value = '';
sourceContext.value = 'targeting';
}
export function useTargetingStore() {
return {
topic,
analysis,
strategy,
creative,
selectedCreative,
launchResult,
sourceStrategyId,
sourceStrategyName,
sourceContext,
reset
};
}
+249
View File
@@ -0,0 +1,249 @@
export type CampaignStatus =
| 'CREATED'
| 'ORCHESTRATING'
| 'MAPPING'
| 'PUBLISHING'
| 'ACTIVE'
| 'PAUSED'
| 'COMPLETED'
| 'FAILED';
export type PlatformType = 'FACEBOOK' | 'INSTAGRAM' | 'TIKTOK' | 'YOUTUBE';
export type TargetingType = 'COLD_INTEREST' | 'BEHAVIORAL' | 'RETARGETING' | 'LOOKALIKE' | 'MIXED';
export type InsightType = 'SUCCESS' | 'WARNING' | 'TIP' | 'ALERT';
export type RecommendationPriority = 'HIGH' | 'MEDIUM' | 'LOW';
export type MarketSaturationLevel = 'HIGH' | 'MEDIUM' | 'LOW';
export interface ApiEnvelope<T> {
data?: T;
message?: string;
}
export interface StatusHistoryItem {
status: CampaignStatus;
message?: string | null;
createdAt?: string | null;
timestamp?: string | null;
}
export interface CampaignMetricRange {
min: number | null;
max: number | null;
unit?: string | null;
}
export interface PerformanceMetrics {
dataAvailable: boolean;
reach: number | null;
impressions: number | null;
clicks: number | null;
ctr: number | null | string;
conversions: number | null;
cpl: number | null;
spend: number | null;
roas: number | null;
}
export interface AudienceProfile {
primarySegment?: string | null;
secondarySegment?: string | null;
ageRange?: string | null;
gender?: string | null;
language?: string | null;
geography?: string[] | string | null;
locations?: string[] | string | null;
interests?: string[] | string | null;
behaviors?: string[] | string | null;
exclusions?: string[] | string | null;
}
export interface BudgetRecommendation {
dailyBudget: number | null;
monthlyBudget: number | null;
bidStrategy?: string | null;
estimatedDailyReach?: number | null;
rationale?: string | null;
}
export interface RecommendationPhase {
phase?: number | null;
name?: string | null;
duration?: string | null;
objective?: string | null;
budget?: string | null;
targetingType?: string | null;
kpi?: string | null;
tactics?: string[] | null;
expectedSpend?: number | null;
}
export interface TargetingRecommendation {
recommendedType?: TargetingType | string | null;
recommendedTypeLabel?: string | null;
typeRationale?: string | null;
campaignObjective?: string | null;
campaignObjectiveLabel?: string | null;
campaignObjectiveRationale?: string | null;
estimatedCtr?: number | string | null;
estimatedCpl?: number | null;
estimatedReach?: number | null;
estimatedFrequency?: number | null;
estimatedConversions?: number | null;
recommendedPlatforms?: PlatformType[] | string[] | null;
recommendedAdFormats?: string[] | string | null;
recommendedPlacements?: string[] | string | null;
audienceProfile?: AudienceProfile | null;
budgetRecommendation?: BudgetRecommendation | null;
phases?: RecommendationPhase[] | null;
quickWins?: string[] | null;
warnings?: string[] | null;
competitiveContext?: string | null;
differentiationAdvice?: string | null;
ciiLevel?: MarketSaturationLevel | string | null;
primaryCta?: string | null;
funnelStage?: string | null;
conversionMechanism?: string | null;
}
export interface AdCreative {
adId?: string | null;
id?: string | null;
headline?: string | null;
primaryText?: string | null;
callToAction?: string | null;
mediaUrl?: string | null;
mediaSize?: string | null;
contentType?: string | null;
}
export interface CampaignAdSet {
adSetId?: string | null;
id?: string | null;
name?: string | null;
platform?: PlatformType | string | null;
status?: 'DRAFT' | 'ACTIVE' | 'PAUSED' | string | null;
budgetKzt?: number | null;
targetAudience?: string | string[] | null;
ads?: AdCreative[] | null;
}
export interface CampaignAiRecommendations {
targetingRecommendation?: TargetingRecommendation | null;
}
export interface CampaignDetails {
id: string;
name: string;
status: CampaignStatus;
strategyId?: string | null;
objective?: string | null;
platforms?: Array<PlatformType | string> | null;
createdAt?: string | null;
startedAt?: string | null;
completedAt?: string | null;
metaCampaignUrl?: string | null;
budgetKzt?: number | null;
totalBudgetKzt?: number | null;
targetReach?: number | null;
plannedDays?: number | null;
performanceMetrics?: PerformanceMetrics | null;
predictedMetrics?: Record<string, CampaignMetricRange> | null;
aiRecommendations?: CampaignAiRecommendations | null;
adSets?: CampaignAdSet[] | null;
statusHistory?: StatusHistoryItem[] | null;
}
export interface CampaignStatusResponse {
campaignId?: string;
status: CampaignStatus;
isTerminal?: boolean;
message?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface PredictedMetricMap {
ctr?: CampaignMetricRange;
cpl?: CampaignMetricRange;
reach?: CampaignMetricRange;
conversions?: CampaignMetricRange;
roas?: CampaignMetricRange;
impressions?: CampaignMetricRange;
clicks?: CampaignMetricRange;
spend?: CampaignMetricRange;
[key: string]: CampaignMetricRange | undefined;
}
export interface WeeklyForecastPoint {
week: number;
expectedLeads: number;
expectedReach: number;
phaseName?: string | null;
phase?: string | null;
expectedSpend?: number | null;
}
export interface TopRecommendation {
id?: string | null;
priority: RecommendationPriority | string;
category: string;
title: string;
description: string;
expectedImpact: string;
}
export interface CampaignPredictionResponse {
id?: string | null;
campaignId?: string | null;
createdAt?: string | null;
latest?: boolean | null;
isLatest?: boolean | null;
modelVersion?: string | null;
predictionScore: number;
predictionLabel: string;
confidence: number;
predictedMetrics: PredictedMetricMap;
weeklyForecast: WeeklyForecastPoint[];
strengthFactors: string[];
riskFactors: string[];
topRecommendations: TopRecommendation[];
audienceInsights?: {
bestPerformingSegment?: string | null;
recommendedExpansion?: string | null;
exclusionAdvice?: string | null;
} | null;
budgetOptimization?: {
currentAllocation?: string | null;
recommendedReallocation?: string | null;
potentialCplReduction?: string | null;
} | null;
competitiveAnalysis?: {
marketSaturation?: MarketSaturationLevel | string | null;
differentiationScore?: number | null;
competitiveAdvantages?: string[] | null;
} | null;
bestPerformingSegment?: string | null;
recommendedExpansion?: string | null;
exclusionAdvice?: string | null;
currentAllocation?: string | null;
recommendedReallocation?: string | null;
potentialCplReduction?: string | null;
marketSaturation?: MarketSaturationLevel | string | null;
differentiationScore?: number | null;
competitiveAdvantages?: string[] | null;
updatedAt?: string | null;
}
export interface CampaignInsight {
id: string;
type: InsightType;
title: string;
description: string;
actionRequired: boolean;
suggestedAction?: string | null;
createdAt?: string | null;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* @typedef {Object} TargetAudience
* @property {string} primary
* @property {string} secondary
* @property {string} ageRange
* @property {string} gender
* @property {string} income
* @property {string[]} interests
* @property {string[]} painPoints
* @property {string[]} motivations
*/
/**
* @typedef {Object} Platform
* @property {string} name
* @property {'Высокий'|'Средний'|'Низкий'} priority
* @property {string} reason
*/
/**
* @typedef {Object} BudgetRecommendation
* @property {string} minDaily
* @property {string} optimalDaily
* @property {string} monthly
*/
/**
* @typedef {Object} MarketingAnalysis
* @property {string} topic
* @property {string} marketOverview
* @property {TargetAudience} targetAudience
* @property {Platform[]} platforms
* @property {string[]} bestAdFormats
* @property {string[]} postingTimes
* @property {BudgetRecommendation} budgetRecommendation
* @property {string[]} keyMessages
* @property {string} competitorsInsight
* @property {string} recommendedVisuals
*/
/**
* @typedef {Object} Phase
* @property {number} phase
* @property {string} name
* @property {string} duration
* @property {string} objective
* @property {string[]} tactics
* @property {string} kpi
*/
/**
* @typedef {Object} AdCreativeNeed
* @property {number} id
* @property {string} type
* @property {string} theme
* @property {string} captionIdea
* @property {string} imageDescription
* @property {string} cta
* @property {string} targetSegment
*/
/**
* @typedef {Object} ExpectedResults
* @property {string} reach
* @property {string} clicks
* @property {string} conversions
* @property {string} cpl
*/
/**
* @typedef {Object} MarketingStrategy
* @property {string} strategyName
* @property {string} goal
* @property {string} duration
* @property {Phase[]} phases
* @property {AdCreativeNeed[]} adCreativesNeeded
* @property {string} totalBudget
* @property {ExpectedResults} expectedResults
* @property {string=} strategyId
* @property {string=} id
*/
/**
* @typedef {Object} TargetingSettings
* @property {string} audience
* @property {string} age
* @property {string[]} interests
* @property {string} placement
* @property {string} budgetPerDay
* @property {string} estimatedReach
*/
/**
* @typedef {Object} AdCreative
* @property {string} headline
* @property {string} caption
* @property {string[]} hashtags
* @property {string} ctaButton
* @property {TargetingSettings} targetingSettings
*/
/**
* @typedef {Object} TargetingLaunchResult
* @property {string} strategyId
* @property {string} analysisId
* @property {string} scoringModelName
* @property {string} scoringModelTitle
* @property {number} postIndex
* @property {string} platform
* @property {string} contentType
* @property {string} theme
* @property {string} postText
* @property {string[]} hashtags
* @property {string} imageUrl
* @property {boolean} imagePosted
* @property {string} facebookPostId
* @property {'PUBLISHED'|'TEXT_ONLY'|'FAILED'} facebookStatus
* @property {TargetingSettings} targetingSettings
* @property {string} message
*/
export {};
@@ -24,7 +24,6 @@ const STATUS_FILTERS = [
{ label: 'Ошибка', value: 'FAILED' }
];
// ─── Load ─────────────────────────────────────────────────────────────────────
const load = async () => {
loading.value = true;
try {
@@ -37,13 +36,12 @@ const load = async () => {
}
};
// ─── Filtered & paginated ─────────────────────────────────────────────────────
const filtered = computed(() => {
let list = analyses.value;
if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.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) => {
list = list.filter(a => {
const niche = (a.requestData?.businessNiche || a.businessNiche || '').toLowerCase();
const city = cityLabel(a).toLowerCase();
return niche.includes(q) || city.includes(q);
@@ -56,64 +54,44 @@ const paginated = computed(() => {
return filtered.value.slice(start, start + pageSize);
});
const countByStatus = (s) => analyses.value.filter((a) => a.status === s).length;
const countByStatus = s => analyses.value.filter(a => a.status === s).length;
// ─── Helpers ──────────────────────────────────────────────────────────────────
const cityLabel = (item) => {
const cityLabel = item => {
const rd = item.requestData || item;
if (rd.mainCity) return rd.mainCity;
if (rd.presenceCities?.length) return rd.presenceCities.join(', ');
if (rd.promotionCities?.length) return rd.promotionCities.join(', ');
// Fallback if none of the above are set but it's a V3 analysis
if (rd.geoScope === 'FULL_COUNTRY') return 'Вся страна';
if (rd.geoScope === 'ONLINE') return 'Онлайн';
return 'География не указана';
return '';
};
const formatDate = (dt) => (dt ? new Date(dt).toLocaleString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—');
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] || s;
const statusLabel = (s) => ({ COMPLETED: 'Завершён', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[s] || s;
const statusStyle = s => ({
COMPLETED: { color: 'var(--kai-green)', bg: 'rgba(0,212,139,0.1)', border: 'rgba(0,212,139,0.25)' },
PROCESSING: { color: 'var(--kai-blue)', bg: 'rgba(45,123,255,0.1)', border: 'rgba(45,123,255,0.25)' },
QUEUED: { color: 'var(--kai-yellow)', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.25)' },
FAILED: { color: 'var(--kai-red)', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)' }
})[s] || { color: 'var(--kai-txt3)', bg: 'rgba(255,255,255,0.05)', border: 'rgba(255,255,255,0.1)' };
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] || 'pi pi-circle text-surface-400';
const statusIcon = s => ({
COMPLETED: 'pi pi-check-circle',
PROCESSING: 'pi pi-spin pi-spinner',
QUEUED: 'pi pi-clock',
FAILED: 'pi pi-times-circle'
})[s] || 'pi pi-circle';
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] || 'bg-surface-100 dark:bg-surface-800';
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] || 'bg-surface-200 text-surface-600 dark:bg-surface-700 dark:text-surface-300';
// ─── Actions ──────────────────────────────────────────────────────────────────
const openAnalysis = (item) => {
if (item.status === 'COMPLETED') {
router.push(`/marketing-analysis/v3/${item.id}`);
} else if (item.status === 'PROCESSING' || item.status === 'QUEUED') {
const openAnalysis = item => {
if (['COMPLETED', 'PROCESSING', 'QUEUED'].includes(item.status)) {
router.push(`/marketing-analysis/v3/${item.id}`);
}
};
const retry = (item) => {
router.push('/marketing-analysis/v3/new');
};
const retry = () => router.push('/marketing-analysis/v3/new');
const confirmDelete = (item) => {
const confirmDelete = item => {
confirm.require({
message: `Удалить анализ "${item.requestData?.businessNiche || 'без названия'}"?`,
header: 'Подтверждение удаления',
@@ -122,8 +100,7 @@ const confirmDelete = (item) => {
rejectLabel: 'Отмена',
acceptClass: 'p-button-danger',
accept: () => {
// TODO: добавить DELETE /api/v3/marketing-analysis/{id} когда появится в API
analyses.value = analyses.value.filter((a) => a.id !== item.id);
analyses.value = analyses.value.filter(a => a.id !== item.id);
toast.add({ severity: 'success', summary: 'Удалено', detail: 'Анализ удалён', life: 3000 });
}
});
@@ -133,154 +110,279 @@ onMounted(load);
</script>
<template>
<div class="bg-surface-50 dark:bg-surface-900 min-h-screen p-4 md:p-6">
<div class="al-page">
<Toast />
<ConfirmDialog />
<!-- 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" @click="$router.push('/marketing-analysis')" v-tooltip.top="'На главную Маркетинга'" />
<span class="text-xs font-bold tracking-widest uppercase text-primary-500">МАРКЕТИНГОВЫЙ АНАЛИЗ v4.0</span>
<div class="al-inner">
<!-- HEADER -->
<div class="al-header">
<div class="al-header__left">
<button class="al-back-btn" @click="$router.push('/marketing-analysis')">
<i class="pi pi-arrow-left"></i>
</button>
<div>
<div class="al-eyebrow">Маркетинговый анализ · v4.0</div>
<h1 class="al-title">Мои анализы</h1>
</div>
</div>
<div class="al-header__right">
<button class="al-btn al-btn--ghost" @click="$router.push('/marketing-analysis/v3/strategies')">
<i class="pi pi-list"></i> Стратегии
</button>
<button class="al-btn al-btn--primary" @click="$router.push('/marketing-analysis/v3/new')">
<i class="pi pi-plus"></i> Новый анализ
</button>
</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>
<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 -->
<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="Поиск по нише, городу..." class="w-full text-sm" />
</span>
<div class="flex items-center gap-2 flex-wrap">
<!-- STATS ROW -->
<div class="al-stats">
<div class="al-stat" v-for="f in STATUS_FILTERS.filter(f=>f.value)" :key="f.value">
<span class="al-stat__num" :style="{ color: statusStyle(f.value).color }">{{ countByStatus(f.value) }}</span>
<span class="al-stat__label">{{ f.label }}</span>
</div>
<div class="al-stat">
<span class="al-stat__num" style="color:var(--kai-txt);">{{ analyses.length }}</span>
<span class="al-stat__label">Всего</span>
</div>
</div>
<!-- FILTERS -->
<div class="al-filters">
<div class="al-search">
<i class="pi pi-search al-search__icon"></i>
<input v-model="searchQuery" type="text" placeholder="Поиск по нише, городу..." class="al-search__input" />
</div>
<div class="al-filter-pills">
<button
v-for="f in STATUS_FILTERS"
:key="f.value"
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-primary-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'
]"
class="al-pill"
:class="{ 'al-pill--active': filterStatus === f.value }"
>
{{ f.label }}
<span v-if="f.value" class="ml-1 opacity-75">({{ countByStatus(f.value) }})</span>
<span v-if="f.value" class="al-pill__count">{{ countByStatus(f.value) }}</span>
</button>
<button class="al-icon-btn" :class="{ 'al-icon-btn--loading': loading }" @click="load" title="Обновить">
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }"></i>
</button>
<Button icon="pi pi-refresh" severity="secondary" text rounded size="small" :loading="loading" @click="load" v-tooltip.top="'Обновить'" />
</div>
</div>
</div>
<!-- Loading skeleton -->
<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" />
<!-- LOADING -->
<div v-if="loading" class="al-skeleton-list">
<div v-for="i in 4" :key="i" class="al-skeleton-item">
<div class="al-skeleton-icon"></div>
<div class="al-skeleton-lines">
<div class="al-skeleton-line al-skeleton-line--wide"></div>
<div class="al-skeleton-line al-skeleton-line--narrow"></div>
</div>
</div>
</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-primary-50 dark:bg-primary-900/30 mb-5">
<i class="pi pi-chart-line text-4xl text-primary-500" />
<!-- EMPTY -->
<div v-else-if="!filtered.length" class="al-empty">
<div class="al-empty__icon">
<i class="pi pi-chart-line"></i>
</div>
<h2 class="al-empty__title">{{ filterStatus ? 'Нет анализов с таким статусом' : 'Анализов пока нет' }}</h2>
<p class="al-empty__desc">Запустите первый маркетинговый анализ ИИ соберёт данные по конкурентам и даст конкретные рекомендации.</p>
<button class="al-btn al-btn--primary" @click="$router.push('/marketing-analysis/v3/new')">
<i class="pi pi-plus"></i> Создать первый анализ
</button>
</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-plus" @click="$router.push('/marketing-analysis/v3/new')" />
</div>
<!-- Analysis Cards Grid -->
<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.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="openAnalysis(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>
<!-- LIST -->
<TransitionGroup v-else name="al-list" tag="div" class="al-list">
<div
v-for="item in paginated" :key="item.id"
class="al-item"
:class="{ 'al-item--clickable': ['COMPLETED','PROCESSING','QUEUED'].includes(item.status) }"
@click="openAnalysis(item)"
>
<!-- Status icon -->
<div class="al-item__status-icon"
:style="{ background: statusStyle(item.status).bg, border: '1px solid ' + statusStyle(item.status).border }">
<i :class="statusIcon(item.status)" :style="{ color: statusStyle(item.status).color }"></i>
</div>
<!-- Content -->
<div class="al-item__content">
<div class="al-item__row">
<h3 class="al-item__name">
{{ item.requestData?.businessNiche || item.businessNiche || 'Анализ #' + (item.id || '').slice(-6) }}
</h3>
<span class="al-item__badge"
:style="{ color: statusStyle(item.status).color, background: statusStyle(item.status).bg, borderColor: statusStyle(item.status).border }">
{{ statusLabel(item.status) }}
</span>
</div>
<div class="al-item__meta">
<span v-if="item.requestData?.productName || item.productName">
<i class="pi pi-tag"></i> {{ item.requestData?.productName || item.productName }}
</span>
<span v-if="cityLabel(item)">
<i class="pi pi-map-marker"></i> {{ cityLabel(item) }}
</span>
<span><i class="pi pi-calendar"></i> {{ formatDate(item.createdAt) }}</span>
</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.requestData?.businessNiche || item.businessNiche || 'Анализ #' + 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">
<span v-if="item.requestData?.productName || item.productName"> <i class="pi pi-tag mr-1" />{{ item.requestData?.productName || item.productName }} </span>
<span v-if="cityLabel(item)"> <i class="pi pi-map-marker mr-1" />{{ cityLabel(item) }} </span>
<span v-if="item.requestData?.analysisType?.length"> <i class="pi pi-list mr-1" />{{ (item.requestData.analysisType || []).join(', ') }} </span>
<span> <i class="pi pi-calendar mr-1" />{{ formatDate(item.createdAt) }} </span>
<span v-if="item.completedAt && item.status === 'COMPLETED'"> <i class="pi pi-check-circle mr-1 text-emerald-500" />{{ formatDate(item.completedAt) }} </span>
</div>
</div>
<!-- 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)" />
<!-- Progress bar -->
<div v-if="item.status === 'PROCESSING' || item.status === 'QUEUED'" class="al-item__progress">
<div class="al-item__progress-bar" :style="{ width: item.status === 'QUEUED' ? '8%' : '55%' }"></div>
</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>
<span>{{ item.status === 'QUEUED' ? '0%' : '~50%' }}</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-primary-400 to-primary-600 animate-pulse" :style="{ width: item.status === 'QUEUED' ? '10%' : '55%' }" />
</div>
<!-- Actions -->
<div class="al-item__actions" @click.stop>
<button v-if="item.status === 'COMPLETED'" class="al-icon-btn" title="Открыть"
@click="openAnalysis(item)">
<i class="pi pi-eye"></i>
</button>
<button v-if="item.status === 'COMPLETED'" class="al-icon-btn al-icon-btn--green" title="Генерировать стратегию"
@click="$router.push(`/marketing-analysis/v3/strategy?analysisId=${item.id}`)">
<i class="pi pi-magic-stick"></i>
</button>
<button v-if="['FAILED','COMPLETED'].includes(item.status)" class="al-icon-btn" title="Повторить"
@click="retry(item)">
<i class="pi pi-refresh"></i>
</button>
<button class="al-icon-btn al-icon-btn--danger" title="Удалить"
@click="confirmDelete(item)">
<i class="pi pi-trash"></i>
</button>
</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" />
<!-- PAGINATION -->
<div v-if="filtered.length > pageSize" class="al-pagination">
<span class="al-pagination__info">
Показано {{ (currentPage - 1) * pageSize + 1 }}{{ Math.min(currentPage * pageSize, filtered.length) }} из {{ filtered.length }}
</span>
<Paginator
:rows="pageSize"
:totalRecords="filtered.length"
:first="(currentPage - 1) * pageSize"
@page="currentPage = $event.page + 1"
template="PrevPageLink PageLinks NextPageLink"
/>
</div>
</div>
<!-- Delete Confirm Dialog -->
<ConfirmDialog />
</div>
</div>
</template>
<style scoped>
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.25s ease;
/* ── Page ── */
.al-page { background: var(--kai-bg); min-height: 100vh; font-family: 'Onest', sans-serif; padding: 32px 24px 80px; }
.al-inner { max-width: 1100px; margin: 0 auto; }
/* ── Header ── */
.al-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
.al-header__left { display: flex; align-items: center; gap: 16px; }
.al-header__right { display: flex; gap: 10px; }
.al-back-btn {
width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--kai-border);
background: var(--kai-card); color: var(--kai-txt2); display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 0.2s;
}
.list-fade-enter-from,
.list-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
.al-back-btn:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
.al-eyebrow { font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.08em; color: var(--kai-blue); margin-bottom: 4px; }
.al-title { font-family: 'Unbounded', sans-serif; font-size: 24px; font-weight: 900; color: var(--kai-txt); line-height: 1.2; }
/* ── Buttons ── */
.al-btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 18px; border-radius: 10px; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; border: 1px solid transparent; font-family: 'Onest', sans-serif; }
.al-btn--primary { background: var(--kai-blue); color: #fff; border-color: var(--kai-blue); }
.al-btn--primary:hover { background: #1a6bff; box-shadow: 0 0 20px rgba(45,123,255,0.4); transform: translateY(-1px); }
.al-btn--ghost { background: var(--kai-card); color: var(--kai-txt2); border-color: var(--kai-border); }
.al-btn--ghost:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
/* ── Stats ── */
.al-stats { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.al-stat { display: flex; flex-direction: column; gap: 2px; }
.al-stat__num { font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 900; line-height: 1; }
.al-stat__label { font-size: 11px; color: var(--kai-txt3); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
/* ── Filters ── */
.al-filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.al-search { position: relative; flex: 1; min-width: 200px; max-width: 320px; }
.al-search__icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--kai-txt3); font-size: 13px; }
.al-search__input {
width: 100%; padding: 9px 12px 9px 36px; border-radius: 10px;
background: var(--kai-card); border: 1px solid var(--kai-border); color: var(--kai-txt);
font-size: 13px; font-family: 'Onest', sans-serif; outline: none; transition: border-color 0.2s;
}
.al-search__input:focus { border-color: var(--kai-border-hover); }
.al-search__input::placeholder { color: var(--kai-txt3); }
.al-filter-pills { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.al-pill {
padding: 6px 14px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid var(--kai-border); background: var(--kai-card); color: var(--kai-txt2);
cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 5px;
}
.al-pill:hover { border-color: var(--kai-border-hover); color: var(--kai-txt); }
.al-pill--active { background: var(--kai-blue); border-color: var(--kai-blue); color: #fff; }
.al-pill__count { opacity: 0.75; font-size: 10px; }
.al-icon-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--kai-border);
background: var(--kai-card); color: var(--kai-txt2); display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 0.2s; font-size: 13px;
}
.al-icon-btn:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
.al-icon-btn--green:hover { background: rgba(0,212,139,0.1); color: var(--kai-green); border-color: rgba(0,212,139,0.3); }
.al-icon-btn--danger:hover { background: rgba(239,68,68,0.1); color: var(--kai-red); border-color: rgba(239,68,68,0.3); }
/* ── Skeleton ── */
.al-skeleton-list { display: flex; flex-direction: column; gap: 10px; }
.al-skeleton-item { display: flex; gap: 14px; align-items: center; padding: 18px 20px; background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 14px; }
.al-skeleton-icon { width: 44px; height: 44px; border-radius: 12px; background: rgba(255,255,255,0.06); flex-shrink: 0; position: relative; overflow: hidden; }
.al-skeleton-icon::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent); animation: shimmer 1.5s infinite; }
.al-skeleton-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.al-skeleton-line { height: 12px; border-radius: 6px; background: rgba(255,255,255,0.06); position: relative; overflow: hidden; }
.al-skeleton-line::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent); animation: shimmer 1.5s infinite; }
.al-skeleton-line--wide { width: 45%; }
.al-skeleton-line--narrow { width: 65%; }
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
/* ── Empty ── */
.al-empty { text-align: center; padding: 80px 24px; background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 20px; }
.al-empty__icon { width: 72px; height: 72px; border-radius: 20px; background: rgba(45,123,255,0.1); border: 1px solid rgba(45,123,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 30px; color: var(--kai-blue); margin: 0 auto 20px; }
.al-empty__title { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 700; color: var(--kai-txt); margin-bottom: 10px; }
.al-empty__desc { font-size: 14px; color: var(--kai-txt2); max-width: 400px; margin: 0 auto 24px; line-height: 1.6; }
/* ── List items ── */
.al-list { display: flex; flex-direction: column; gap: 8px; }
.al-item {
display: flex; align-items: center; gap: 16px;
padding: 18px 20px; border-radius: 14px;
background: var(--kai-card); border: 1px solid var(--kai-border);
transition: all 0.25s; position: relative; overflow: hidden;
}
.al-item--clickable { cursor: pointer; }
.al-item--clickable:hover {
background: var(--kai-card-hover); border-color: var(--kai-border-hover);
transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.al-item__status-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.al-item__content { flex: 1; min-width: 0; }
.al-item__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.al-item__name { font-weight: 700; font-size: 14px; color: var(--kai-txt); line-height: 1.3; }
.al-item__badge { padding: 2px 10px; border-radius: 999px; font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid; }
.al-item__meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--kai-txt3); font-weight: 500; }
.al-item__meta i { margin-right: 3px; font-size: 11px; }
.al-item__progress { margin-top: 8px; height: 3px; background: rgba(255,255,255,0.06); border-radius: 999px; overflow: hidden; }
.al-item__progress-bar { height: 100%; background: linear-gradient(90deg, var(--kai-blue), rgba(45,123,255,0.5)); border-radius: 999px; animation: progress-pulse 2s ease-in-out infinite; }
@keyframes progress-pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } }
.al-item__actions { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
/* ── Pagination ── */
.al-pagination { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; margin-top: 8px; flex-wrap: wrap; gap: 12px; }
.al-pagination__info { font-size: 12px; color: var(--kai-txt3); }
/* ── Transition ── */
.al-list-enter-active, .al-list-leave-active { transition: all 0.25s ease; }
.al-list-enter-from, .al-list-leave-to { opacity: 0; transform: translateY(-6px); }
</style>
+401 -79
View File
@@ -1,104 +1,426 @@
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const navigateTo = (route, query) => {
if (query) {
router.push({ path: route, query });
} else {
router.push(route);
}
};
const nav = (path, query) => query ? router.push({ path, query }) : router.push(path);
</script>
<template>
<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 class="mkt-hub">
<!-- AMBIENT GLOW -->
<div class="mkt-hub__glow1"></div>
<div class="mkt-hub__glow2"></div>
<div class="mkt-hub__inner">
<!-- HEADER -->
<div class="mkt-hub__header">
<div class="mkt-hub__badge">
<span class="mkt-hub__badge-dot"></span>
AI Marketing Suite · v4.0
</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>
<h1 class="mkt-hub__title">ИИ-Маркетолог</h1>
<p class="mkt-hub__sub">Автономная генерация стратегий, контент-планов и медиа-активов на основе глубокого анализа ниши и конкурентов</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>
<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>
<!-- GRID -->
<div class="mkt-hub__grid">
<!-- Создать анализ -->
<div class="mkt-card mkt-card--blue" @click="nav('/marketing-analysis/v3/new')">
<div class="mkt-card__icon" style="background:rgba(45,123,255,0.15);color:#2D7BFF;">
<i class="pi pi-search"></i>
</div>
<h3 class="mkt-card__title">Сделать анализ</h3>
<p class="mkt-card__desc">Опишите бизнес ИИ соберёт данные по конкурентам, ЦА и предложит лучшее позиционирование</p>
<div class="mkt-card__arrow"><i class="pi pi-arrow-right"></i></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 class="mkt-card mkt-card--purple" @click="nav('/marketing-analysis/v3')">
<div class="mkt-card__icon" style="background:rgba(168,85,247,0.15);color:#A855F7;">
<i class="pi pi-inbox"></i>
</div>
<h3 class="mkt-card__title">Готовые анализы</h3>
<p class="mkt-card__desc">Библиотека ваших исследований с развёрнутой аналитикой, инсайтами и экспортом</p>
<div class="mkt-card__arrow"><i class="pi pi-arrow-right"></i></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 class="mkt-card mkt-card--accent" @click="nav('/marketing-analysis/v3')">
<div class="mkt-card__accent-glow"></div>
<div class="mkt-card__icon" style="background:rgba(0,212,139,0.15);color:#00D48B;">
<i class="pi pi-bolt"></i>
</div>
<h3 class="mkt-card__title">Сгенерировать стратегию</h3>
<p class="mkt-card__desc">Контент-фабрика. ИИ выберет соцсети, напишет посты и отрисует картинки на основе анализа</p>
<div class="mkt-card__tag">Требуется анализ</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 class="mkt-card mkt-card--orange" @click="nav('/marketing-analysis/v3/strategies')">
<div class="mkt-card__icon" style="background:rgba(255,122,45,0.15);color:#FF7A2D;">
<i class="pi pi-images"></i>
</div>
<h3 class="mkt-card__title">Мои стратегии</h3>
<p class="mkt-card__desc">Список сгенерированных контент-планов, медиа-активов и AI-обоснований</p>
<div class="mkt-card__arrow"><i class="pi pi-arrow-right"></i></div>
</div>
</div>
<!-- AI TARGETOLOGIST full-width banner -->
<div class="mkt-banner" @click="nav('/marketing-analysis/v3/targetologist/dashboard')">
<div class="mkt-banner__glow"></div>
<div class="mkt-banner__left">
<div class="mkt-banner__icon">
<i class="pi pi-bullseye"></i>
</div>
<div>
<div class="mkt-banner__label">
<span class="mkt-banner__dot"></span>
Beta API
</div>
<h2 class="mkt-banner__title">ИИ-Таргетолог</h2>
<p class="mkt-banner__desc">Развёртывание рекламных кампаний ИИ создаст аудитории, оптимизирует бюджет и запустит рекламу в Facebook, Instagram и TikTok</p>
</div>
</div>
<div class="mkt-banner__cta">
<span>Открыть кабинет</span>
<i class="pi pi-arrow-right"></i>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
<style scoped>
/* ───── Page layout ───── */
.mkt-hub {
background: var(--kai-bg);
min-height: 100vh;
font-family: 'Onest', sans-serif;
position: relative;
overflow: hidden;
padding: 48px 24px 80px;
}
.mkt-hub__glow1 {
position: absolute;
top: -120px;
right: -100px;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(45,123,255,0.08) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__glow2 {
position: absolute;
bottom: -80px;
left: -60px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0,212,139,0.06) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__inner {
max-width: 1100px;
margin: 0 auto;
position: relative;
z-index: 1;
}
/* ───── Header ───── */
.mkt-hub__header {
text-align: center;
margin-bottom: 52px;
}
.mkt-hub__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(45,123,255,0.3);
background: rgba(45,123,255,0.08);
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--kai-blue);
margin-bottom: 20px;
}
.mkt-hub__badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--kai-blue);
box-shadow: 0 0 8px var(--kai-blue);
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%,100% { opacity:1; transform: scale(1); }
50% { opacity:0.5; transform: scale(0.8); }
}
.mkt-hub__title {
font-family: 'Unbounded', sans-serif;
font-size: clamp(32px, 5vw, 56px);
font-weight: 900;
color: var(--kai-txt);
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 16px;
}
.mkt-hub__sub {
font-size: 16px;
color: var(--kai-txt2);
max-width: 560px;
margin: 0 auto;
line-height: 1.65;
font-weight: 400;
}
/* ───── Grid ───── */
.mkt-hub__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 640px) {
.mkt-hub__grid { grid-template-columns: 1fr; }
}
/* ───── Cards ───── */
.mkt-card {
background: var(--kai-card);
border: 1px solid var(--kai-border);
border-radius: 20px;
padding: 28px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
position: relative;
overflow: hidden;
}
.mkt-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s;
border-radius: 20px;
pointer-events: none;
}
.mkt-card:hover {
transform: translateY(-4px);
border-color: var(--kai-border-hover);
background: var(--kai-card-hover);
box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 40px rgba(45,123,255,0.08);
}
.mkt-card:hover::before { opacity: 1; }
.mkt-card--blue::before { background: radial-gradient(circle at top right, rgba(45,123,255,0.1), transparent 60%); }
.mkt-card--purple::before { background: radial-gradient(circle at top right, rgba(168,85,247,0.1), transparent 60%); }
.mkt-card--orange::before { background: radial-gradient(circle at top right, rgba(255,122,45,0.1), transparent 60%); }
.mkt-card--accent::before { background: radial-gradient(circle at top right, rgba(0,212,139,0.1), transparent 60%); }
.mkt-card__icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 20px;
transition: transform 0.3s;
}
.mkt-card:hover .mkt-card__icon { transform: scale(1.1) rotate(3deg); }
.mkt-card__title {
font-family: 'Unbounded', sans-serif;
font-size: 17px;
font-weight: 700;
color: var(--kai-txt);
margin-bottom: 10px;
line-height: 1.25;
}
.mkt-card__desc {
font-size: 13px;
color: var(--kai-txt2);
line-height: 1.6;
font-weight: 400;
}
.mkt-card__arrow {
position: absolute;
bottom: 24px;
right: 24px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(45,123,255,0.1);
display: flex;
align-items: center;
justify-content: center;
color: var(--kai-blue);
font-size: 13px;
opacity: 0;
transform: translateX(-6px);
transition: all 0.3s;
}
.mkt-card:hover .mkt-card__arrow {
opacity: 1;
transform: translateX(0);
}
.mkt-card__accent-glow {
position: absolute;
top: -30px;
right: -30px;
width: 120px;
height: 120px;
background: radial-gradient(circle, rgba(0,212,139,0.15), transparent 70%);
pointer-events: none;
}
.mkt-card__tag {
display: inline-block;
margin-top: 14px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid rgba(0,212,139,0.25);
background: rgba(0,212,139,0.07);
color: var(--kai-green);
font-size: 10px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ───── Targetologist Banner ───── */
.mkt-banner {
background: linear-gradient(135deg, rgba(45,123,255,0.12) 0%, rgba(99,51,220,0.08) 100%);
border: 1px solid rgba(45,123,255,0.3);
border-radius: 24px;
padding: 32px 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.mkt-banner:hover {
border-color: rgba(45,123,255,0.6);
background: linear-gradient(135deg, rgba(45,123,255,0.18) 0%, rgba(99,51,220,0.12) 100%);
transform: translateY(-2px);
box-shadow: 0 24px 60px rgba(45,123,255,0.15);
}
.mkt-banner__glow {
position: absolute;
top: 0;
right: -60px;
width: 300px;
height: 200px;
background: radial-gradient(circle, rgba(45,123,255,0.15), transparent 70%);
pointer-events: none;
}
.mkt-banner__left {
display: flex;
align-items: center;
gap: 24px;
flex: 1;
min-width: 0;
}
.mkt-banner__icon {
width: 64px;
height: 64px;
border-radius: 18px;
background: rgba(45,123,255,0.15);
border: 1px solid rgba(45,123,255,0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: var(--kai-blue);
flex-shrink: 0;
transition: transform 0.3s;
}
.mkt-banner:hover .mkt-banner__icon { transform: scale(1.08) rotate(3deg); }
.mkt-banner__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--kai-blue);
margin-bottom: 6px;
}
.mkt-banner__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--kai-blue);
animation: pulse-dot 2s infinite;
}
.mkt-banner__title {
font-family: 'Unbounded', sans-serif;
font-size: 24px;
font-weight: 900;
color: var(--kai-txt);
margin-bottom: 8px;
line-height: 1.2;
}
.mkt-banner__desc {
font-size: 14px;
color: var(--kai-txt2);
line-height: 1.55;
max-width: 580px;
}
.mkt-banner__cta {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
background: var(--kai-blue);
color: #fff;
font-size: 13px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.3s;
}
.mkt-banner:hover .mkt-banner__cta {
background: #1a6bff;
box-shadow: 0 0 24px rgba(45,123,255,0.45);
transform: translateX(4px);
}
@media (max-width: 768px) {
.mkt-banner { flex-direction: column; align-items: flex-start; }
.mkt-banner__cta { align-self: stretch; justify-content: center; }
.mkt-hub { padding: 32px 16px 60px; }
}
</style>
+158 -206
View File
@@ -1,5 +1,5 @@
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
@@ -10,12 +10,14 @@ import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
import { useToast } from 'primevue/usetoast';
import { useTargetingStore } from '@/stores/targeting';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const toast = useToast();
const route = useRoute();
const router = useRouter();
const targetingStore = useTargetingStore();
// Form data
const formData = ref({
@@ -80,7 +82,7 @@ const preloadImages = async () => {
imageBlobUrls.value[post.imageFilename] = blobUrl;
}
} catch (error) {
console.error(`Ошибка при предзагрузке изображения ${post.imageFilename}:`, error);
console.error(`Ошибка при предзагрузке изображения ${post.imageFilename}:`, error);
}
}
});
@@ -116,11 +118,11 @@ const validateForm = () => {
errors.value = {};
if (!formData.value.analysisId || formData.value.analysisId.trim().length === 0) {
errors.value.analysisId = 'ID анализа обязателен';
errors.value.analysisId = 'ID анализа обязателен';
}
if (formData.value.durationWeeks && (formData.value.durationWeeks < 1 || formData.value.durationWeeks > 12)) {
errors.value.durationWeeks = 'Длительность должна быть от 1 до 12 недель';
errors.value.durationWeeks = 'Длительность должна быть от 1 до 12 недель';
}
return Object.keys(errors.value).length === 0;
@@ -136,8 +138,8 @@ const startPolling = (id) => {
stopPolling();
toast.add({
severity: 'warn',
summary: 'Таймаут',
detail: 'Превышено время ожидания генерации стратегии. Попробуйте проверить результат позже.',
summary: 'Таймаут',
detail: 'Превышено время ожидания генерации стратегии. Попробуйте проверить результат позже.',
life: 5000
});
return;
@@ -155,21 +157,21 @@ const startPolling = (id) => {
await preloadImages(); // Preload all images with auth
toast.add({
severity: 'success',
summary: 'Стратегия готова',
detail: 'Стратегия продвижения успешно сгенерирована',
summary: 'Стратегия готова',
detail: 'Стратегия продвижения успешно сгенерирована',
life: 3000
});
} else if (result.status === 'failed') {
stopPolling();
toast.add({
severity: 'error',
summary: 'Ошибка генерации',
detail: 'Генерация стратегии завершилась с ошибкой',
summary: 'Ошибка генерации',
detail: 'Генерация стратегии завершилась с ошибкой',
life: 5000
});
}
} catch (error) {
console.error('Ошибка при проверке статуса стратегии:', error);
console.error('Ошибка при проверке статуса стратегии:', error);
// Continue polling on error, but log it
}
}, pollingIntervalMs);
@@ -188,8 +190,8 @@ const handleGenerateStrategy = async () => {
if (!validateForm()) {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
summary: 'Ошибка валидации',
detail: 'Пожалуйста, исправьте ошибки в форме',
life: 3000
});
return;
@@ -213,19 +215,19 @@ const handleGenerateStrategy = async () => {
toast.add({
severity: 'success',
summary: 'Генерация запущена',
detail: 'Генерация стратегии успешно запущена. Результаты будут готовы в течение 3-5 минут.',
summary: 'Генерация запущена',
detail: 'Генерация стратегии успешно запущена. Результаты будут готовы в течение 3-5 минут.',
life: 5000
});
// Start polling
startPolling(result.strategyId);
} catch (error) {
console.error('Ошибка при запуске генерации стратегии:', error);
console.error('Ошибка при запуске генерации стратегии:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить генерацию стратегии. Пожалуйста, попробуйте снова.',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить генерацию стратегии. Пожалуйста, попробуйте снова.',
life: 5000
});
} finally {
@@ -259,16 +261,16 @@ const handleCopyPost = (post) => {
.then(() => {
toast.add({
severity: 'success',
summary: 'Скопировано',
detail: 'Текст поста скопирован в буфер обмена',
summary: 'Скопировано',
detail: 'Текст поста скопирован в буфер обмена',
life: 2000
});
})
.catch(() => {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 3000
});
});
@@ -279,8 +281,8 @@ const handleExecuteTask = async (taskId, post) => {
if (!taskId) {
toast.add({
severity: 'warn',
summary: 'Ошибка',
detail: 'ID задачи не найден',
summary: 'Ошибка',
detail: 'ID задачи не найден',
life: 3000
});
return;
@@ -294,47 +296,47 @@ const handleExecuteTask = async (taskId, post) => {
toast.add({
severity: 'success',
summary: 'Задача запущена',
detail: `Задача публикации успешно запущена. Платформа: ${result.platform || post?.platform || 'N/A'}`,
summary: 'Задача запущена',
detail: `Задача публикации успешно запущена. Платформа: ${result.platform || post?.platform || 'N/A'}`,
life: 5000
});
} catch (error) {
console.error('Ошибка при запуске задачи:', error);
console.error('Ошибка при запуске задачи:', error);
// Handle specific error codes
if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else if (error.code === 'FORBIDDEN') {
toast.add({
severity: 'error',
summary: 'Доступ запрещен',
detail: 'У вас нет доступа к этой задаче',
summary: 'Доступ запрещен',
detail: 'У вас нет доступа к этой задаче',
life: 5000
});
} else if (error.code === 'NOT_FOUND') {
toast.add({
severity: 'error',
summary: 'Задача не найдена',
detail: 'Задача с указанным ID не найдена',
summary: 'Задача не найдена',
detail: 'Задача с указанным ID не найдена',
life: 5000
});
} else if (error.code === 'INVALID_STATUS') {
toast.add({
severity: 'warn',
summary: 'Задача не может быть запущена',
detail: error.message || 'Задача не может быть выполнена в текущем статусе. Только задачи со статусом "pending" или "failed" могут быть запущены вручную.',
summary: 'Задача не может быть запущена',
detail: error.message || 'Задача не может быть выполнена в текущем статусе. Только задачи со статусом "pending" или "failed" могут быть запущены вручную.',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить задачу. Попробуйте позже.',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить задачу. Попробуйте позже.',
life: 5000
});
}
@@ -349,8 +351,8 @@ const handlePublishPost = async (post) => {
if (!post.taskId) {
toast.add({
severity: 'warn',
summary: 'Ошибка',
detail: 'Задача публикации не создана. Сначала запустите стратегию.',
summary: 'Ошибка',
detail: 'Задача публикации не создана. Сначала запустите стратегию.',
life: 3000
});
return;
@@ -380,8 +382,8 @@ const handleRegenerateImage = async (post, postIndex) => {
if (!strategyId.value || postIndex < 0) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось определить параметры для регенерации изображения',
summary: 'Ошибка',
detail: 'Не удалось определить параметры для регенерации изображения',
life: 3000
});
return;
@@ -395,8 +397,8 @@ const handleRegenerateImage = async (post, postIndex) => {
toast.add({
severity: 'success',
summary: 'Изображение регенерируется',
detail: 'Изображение успешно отправлено на регенерацию. Оно будет обновлено через несколько секунд.',
summary: 'Изображение регенерируется',
detail: 'Изображение успешно отправлено на регенерацию. Оно будет обновлено через несколько секунд.',
life: 5000
});
@@ -434,33 +436,33 @@ const handleRegenerateImage = async (post, postIndex) => {
}
}
} catch (error) {
console.error('Ошибка при обновлении стратегии после регенерации:', error);
console.error('Ошибка при обновлении стратегии после регенерации:', error);
}
}, 3000);
} catch (error) {
console.error('Ошибка при регенерации изображения:', error);
console.error('Ошибка при регенерации изображения:', error);
// Handle specific error types
let errorMessage = error.message || 'Не удалось регенерировать изображение. Попробуйте позже.';
let errorSummary = 'Ошибка';
let errorMessage = error.message || 'Не удалось регенерировать изображение. Попробуйте позже.';
let errorSummary = 'Ошибка';
let errorLife = 5000;
if (error.code === 'RATE_LIMIT_EXCEEDED' || error.status === 429) {
errorSummary = 'Превышен лимит запросов';
errorMessage = error.message || 'Превышен лимит запросов к сервису генерации изображений. Пожалуйста, подождите несколько минут и попробуйте снова.';
errorSummary = 'Превышен лимит запросов';
errorMessage = error.message || 'Превышен лимит запросов к сервису генерации изображений. Пожалуйста, подождите несколько минут и попробуйте снова.';
errorLife = 7000;
} else if (error.code === 'UNAUTHORIZED' || error.status === 401) {
errorSummary = 'Ошибка авторизации';
errorMessage = 'Необходимо войти в систему';
errorSummary = 'Ошибка авторизации';
errorMessage = 'Необходимо войти в систему';
} else if (error.code === 'FORBIDDEN' || error.status === 403) {
errorSummary = 'Доступ запрещен';
errorMessage = 'У вас нет доступа к этой операции';
errorSummary = 'Доступ запрещен';
errorMessage = 'У вас нет доступа к этой операции';
} else if (error.code === 'NOT_FOUND' || error.status === 404) {
errorSummary = 'Не найдено';
errorMessage = 'Стратегия или пост не найдены';
errorSummary = 'Не найдено';
errorMessage = 'Стратегия или пост не найдены';
} else if (error.code === 'VALIDATION_ERROR' || error.status === 400) {
errorSummary = 'Ошибка валидации';
errorMessage = error.message || 'Проверьте правильность данных';
errorSummary = 'Ошибка валидации';
errorMessage = error.message || 'Проверьте правильность данных';
}
toast.add({
@@ -479,7 +481,7 @@ const handleRegenerateImage = async (post, postIndex) => {
const exportToCsv = () => {
if (!strategyData.value?.strategy?.postCalendar) return;
const headers = ['Дата', 'Время', 'Платформа', 'Тип контента', 'Тема', 'Текст поста', 'Хештеги'];
const headers = ['Дата', 'Время', 'Платформа', 'Тип контента', 'Тема', 'Текст поста', 'Хештеги'];
const rows = strategyData.value.strategy.postCalendar.map((post) => {
const date = new Date(post.publishDate);
return [date.toLocaleDateString('ru-RU'), post.publishTime || '', post.platform || '', post.contentType || '', post.theme || '', `"${(post.postText || '').replace(/"/g, '""')}"`, (post.hashtags || []).join(' ')];
@@ -499,8 +501,8 @@ const exportToCsv = () => {
toast.add({
severity: 'success',
summary: 'Экспорт выполнен',
detail: 'Календарь постов экспортирован в CSV',
summary: 'Экспорт выполнен',
detail: 'Календарь постов экспортирован в CSV',
life: 3000
});
};
@@ -525,85 +527,32 @@ const handleStartStrategy = async () => {
startingStrategy.value = true;
try {
const result = await MarketingService.startStrategy(strategyId.value);
targetingStore.creative.value = null;
targetingStore.launchResult.value = null;
targetingStore.sourceStrategyId.value = strategyId.value;
targetingStore.sourceStrategyName.value = strategyData.value?.analysis?.topic || 'Маркетинговая стратегия';
targetingStore.sourceContext.value = 'marketing-legacy';
toast.add({
severity: 'success',
summary: 'Стратегия запущена',
detail: `Стратегия успешно запущена. Создано задач: ${result.tasksCreated}. Платформы: ${result.platforms?.join(', ') || 'N/A'}`,
life: 5000
await router.push({
name: 'targeting-creative',
query: {
strategyId: strategyId.value,
strategyName: targetingStore.sourceStrategyName.value,
autostart: '1'
}
});
} catch (error) {
console.error('Ошибка при запуске стратегии:', error);
// Handle specific error codes
if (error.code === 'MISSING_CREDENTIALS') {
// Show error message in toast
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить стратегию',
life: 5000
});
// Extract platform from error message (format: "Credentials not found for platform: facebook")
const platformMatch = error.message?.match(/platform:\s*(\w+)/i) || error.message?.match(/for platform\s+(\w+)/i);
if (platformMatch) {
missingPlatforms.value = [platformMatch[1].toLowerCase()];
} else {
// Try to get platforms from strategy data
const platforms = strategyData.value?.priorityPlatforms || strategyData.value?.strategy?.postCalendar?.map((p) => p.platform).filter((v, i, a) => a.indexOf(v) === i) || [];
missingPlatforms.value = platforms.length > 0 ? platforms : ['facebook']; // Default to facebook if unknown
}
credentialsDialogVisible.value = true;
} else if (error.code === 'INVALID_STATUS') {
toast.add({
severity: 'warn',
summary: 'Стратегия не готова',
detail: error.message || 'Стратегия еще не завершена. Дождитесь завершения генерации.',
life: 5000
});
} else if (error.code === 'UNAUTHORIZED') {
toast.add({
severity: 'error',
summary: 'Ошибка авторизации',
detail: 'Необходимо войти в систему',
life: 5000
});
} else if (error.code === 'NOT_FOUND') {
toast.add({
severity: 'error',
summary: 'Не найдено',
detail: 'Стратегия не найдена',
life: 5000
});
} else if (error.code === 'FORBIDDEN') {
toast.add({
severity: 'error',
summary: 'Доступ запрещен',
detail: 'У вас нет доступа к этой стратегии',
life: 5000
});
} else if (error.code === 'VALIDATION_ERROR') {
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: error.message || 'Проверьте правильность данных',
life: 5000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось запустить стратегию. Попробуйте позже.',
life: 5000
});
}
console.error('Ошибка при переходе к новому запуску таргета:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error.message || 'Не удалось открыть новый запуск таргета.',
life: 5000
});
} finally {
startingStrategy.value = false;
}
};
// Go to credentials page
const goToCredentials = () => {
credentialsDialogVisible.value = false;
@@ -650,7 +599,7 @@ onMounted(async () => {
startPolling(result.strategyId);
}
} catch (error) {
console.error('Ошибка при загрузке стратегии:', error);
console.error('Ошибка при загрузке стратегии:', error);
}
} else if (queryAnalysisId) {
// Try to get strategy by analysisId
@@ -668,7 +617,7 @@ onMounted(async () => {
}
} catch (error) {
// Strategy doesn't exist yet, show form
console.log('Стратегия для анализа не найдена, показываем форму');
console.log('Стратегия для анализа не найдена, показываем форму');
}
}
});
@@ -685,104 +634,104 @@ onBeforeUnmount(() => {
<div class="marketing-promotion-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="w-full mx-auto px-4">
<!-- Форма запуска генерации стратегии -->
<!-- ФоÑма запуска генеÑаÑии сÑÑаÑегии -->
<div v-if="!strategyId && !strategyData" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Генерация стратегии продвижения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">Создайте детальную стратегию продвижения на основе завершенного маркетингового анализа</p>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">ÐенеÑаÑия сÑÑаÑегии пÑодвиÐения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">СоздайÑе деÑальную сÑÑаÑегию пÑодвиÐения на основе завеÑшенного маÑкеÑингового анализа</p>
</div>
<div class="card-content">
<form @submit.prevent="handleGenerateStrategy" class="space-y-4 max-w-2xl">
<div class="field">
<label for="analysisId" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> ID анализа <span class="text-red-500">*</span> </label>
<InputText id="analysisId" v-model="formData.analysisId" placeholder="Введите ID завершенного анализа" class="w-full" :class="{ 'p-invalid': errors.analysisId }" />
<label for="analysisId" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> ID анализа <span class="text-red-500">*</span> </label>
<InputText id="analysisId" v-model="formData.analysisId" placeholder="Введите ID завершенного анализа" class="w-full" :class="{ 'p-invalid': errors.analysisId }" />
<small v-if="errors.analysisId" class="p-error">{{ errors.analysisId }}</small>
<small class="text-surface-500 dark:text-surface-400"> ID можно получить из завершенного маркетингового анализа </small>
<small class="text-surface-500 dark:text-surface-400"> ID моÐно полуÑиÑÑŒ из завеÑшенного маÑкеÑингового анализа </small>
</div>
<div class="field">
<label for="durationWeeks" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Длительность стратегии (недели) </label>
<label for="durationWeeks" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> ÐлиÑельносÑÑŒ сÑÑаÑегии (недели) </label>
<InputNumber id="durationWeeks" v-model="formData.durationWeeks" :min="1" :max="12" placeholder="4" class="w-full" :class="{ 'p-invalid': errors.durationWeeks }" />
<small v-if="errors.durationWeeks" class="p-error">{{ errors.durationWeeks }}</small>
<small class="text-surface-500 dark:text-surface-400">От 1 до 12 недель (по умолчанию: 4)</small>
<small class="text-surface-500 dark:text-surface-400">ÐžÑ 1 до 12 недель (по умолÑанию: 4)</small>
</div>
<div class="field">
<label for="priorityPlatforms" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Приоритетные платформы </label>
<MultiSelect id="priorityPlatforms" v-model="formData.priorityPlatforms" :options="platformOptions" placeholder="Выберите платформы (необязательно)" class="w-full" display="chip" />
<small class="text-surface-500 dark:text-surface-400"> Если не указано, будут использованы все популярные платформы </small>
<label for="priorityPlatforms" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> ПÑиоÑиÑеÑнÑе плаÑÑоÑÐ¼Ñ </label>
<MultiSelect id="priorityPlatforms" v-model="formData.priorityPlatforms" :options="platformOptions" placeholder="Выберите платформы (необязательно)" class="w-full" display="chip" />
<small class="text-surface-500 dark:text-surface-400"> Ðсли не указано, Ð±ÑƒÐ´ÑƒÑ Ð¸ÑÐ¿Ð¾Ð»ÑŒÐ·Ð¾Ð²Ð°Ð½Ñ Ð²ÑÐµ популяÑнÑе плаÑÑоÑÐ¼Ñ </small>
</div>
<Button type="submit" label="Сгенерировать стратегию" icon="pi pi-magic" class="w-full p-button-primary" :loading="generating" />
<Button type="submit" label="Сгенерировать стратегию" icon="pi pi-magic" class="w-full p-button-primary" :loading="generating" />
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">Генерация стратегии занимает примерно 3-5 минут</p>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-3 text-center">ÐенеÑаÑия сÑÑаÑегии Ð·Ð°Ð½Ð¸Ð¼Ð°ÐµÑ Ð¿ÑимеÑно 3-5 минуÑ</p>
</form>
</div>
</div>
<!-- Статус обработки -->
<!-- СÑаÑус обÑабоÑки -->
<div v-else-if="status === 'processing' || status === 'queued'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Генерация стратегии</h2>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">ÐенеÑаÑия сÑÑаÑегии</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<ProgressSpinner size="50" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Стратегия генерируется...</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Это может занять 3-5 минут. Пожалуйста, подождите.</p>
<Button label="Отменить ожидание" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetStrategy" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">СÑÑаÑегия генеÑиÑуеÑся...</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">ЭÑо моÐÐµÑ Ð·Ð°Ð½ÑÑÑŒ 3-5 минуÑ. ПоÐалуйсÑа, подоÐдиÑе.</p>
<Button label="Отменить ожидание" icon="pi pi-times" severity="secondary" class="mt-4" @click="resetStrategy" />
</div>
</div>
</div>
<!-- Ошибка -->
<!-- Ошибка -->
<div v-else-if="status === 'failed'" class="card">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка генерации</h2>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Ошибка генеÑаÑии</h2>
</div>
<div class="card-content">
<div class="text-center py-6">
<i class="pi pi-exclamation-triangle text-6xl text-red-500 mb-3"></i>
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">Генерация стратегии завершилась с ошибкой</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">Попробуйте запустить генерацию снова или обратитесь в поддержку.</p>
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetStrategy" />
<p class="mt-4 text-lg text-surface-700 dark:text-surface-300">ÐенеÑаÑия сÑÑаÑегии завеÑшилась с ошибкой</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400">ПопÑобуйÑе запусÑиÑÑŒ генеÑаÑию снова или обÑаÑиÑесь в поддеÑÐку.</p>
<Button label="Попробовать снова" icon="pi pi-refresh" class="mt-4" @click="resetStrategy" />
</div>
</div>
</div>
<!-- Результаты стратегии -->
<!-- РезульÑаÑÑ ÑÑÑаÑегии -->
<div v-else-if="status === 'completed' && strategyData?.strategy" class="space-y-4">
<!-- Заголовок -->
<!-- Ðаголовок -->
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<div>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Стратегия продвижения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">Длительность: {{ strategyData.durationWeeks }} {{ pluralize(strategyData.durationWeeks, 'неделя', 'недели', 'недель') }}</p>
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">СÑÑаÑегия пÑодвиÐения</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">ÐлиÑельносÑÑŒ: {{ strategyData.durationWeeks }} {{ pluralize(strategyData.durationWeeks, 'неделя', 'недели', 'недель') }}</p>
</div>
<div class="flex gap-2">
<Button label="Запустить стратегию" icon="pi pi-play" severity="success" @click="handleStartStrategy" :loading="startingStrategy" :disabled="startingStrategy" />
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="secondary" @click="resetStrategy" />
<Button label="Запустить таргет" icon="pi pi-play" severity="success" @click="handleStartStrategy" :loading="startingStrategy" :disabled="startingStrategy" />
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="secondary" @click="resetStrategy" />
</div>
</div>
</div>
</div>
<!-- Недельный план -->
<!-- НедельнÑй план -->
<div v-if="strategyData.strategy.weeklyPlans && strategyData.strategy.weeklyPlans.length > 0" class="card">
<div class="card-header mb-4">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Недельный план</h3>
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">НедельнÑй план</h3>
</div>
<div class="card-content">
<div class="weekly-plans-container">
<div v-for="plan in strategyData.strategy.weeklyPlans" :key="plan.weekNumber" class="weekly-plan-item">
<div class="week-plan-card p-3 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 square-card">
<div class="flex align-items-center justify-content-between mb-2">
<Tag :value="`Неделя ${plan.weekNumber}`" severity="info" />
<Tag :value="`Неделя ${plan.weekNumber}`" severity="info" />
</div>
<div v-if="plan.mainThemes && plan.mainThemes.length > 0" class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Основные темы:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">ОсновнÑе ÑемÑ:</p>
<ul class="list-none p-0 m-0">
<li v-for="(theme, idx) in plan.mainThemes" :key="idx" class="flex align-items-start mb-1">
<i class="pi pi-circle-fill text-primary-500 text-xs mr-1 mt-1"></i>
@@ -792,14 +741,14 @@ onBeforeUnmount(() => {
</div>
<div v-if="plan.contentRecommendations" class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Рекомендации:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">РекомендаÑии:</p>
<p class="text-surface-700 dark:text-surface-300 text-xs whitespace-pre-line line-height-2">
{{ plan.contentRecommendations }}
</p>
</div>
<div v-if="plan.priorityPlatforms && plan.priorityPlatforms.length > 0">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Платформы:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">ПлаÑÑоÑмÑ:</p>
<div class="flex flex-wrap gap-1">
<Tag v-for="platform in plan.priorityPlatforms" :key="platform" :value="platform" severity="secondary" class="text-xs" />
</div>
@@ -810,19 +759,19 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Календарь постов -->
<!-- КалендаÑÑŒ посÑов -->
<div v-if="strategyData.strategy.postCalendar && strategyData.strategy.postCalendar.length > 0" class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">Календарь постов</h3>
<h3 class="text-xl font-semibold text-surface-900 dark:text-surface-0">КалендаÑÑŒ посÑов</h3>
<div class="flex gap-2">
<Button label="Экспорт CSV" icon="pi pi-download" severity="secondary" size="small" @click="exportToCsv" />
<Button label="Группировать по датам" icon="pi pi-calendar" severity="secondary" size="small" @click="toggleGroupByDate" />
<Button label="Экспорт CSV" icon="pi pi-download" severity="secondary" size="small" @click="exportToCsv" />
<Button label="Группировать по датам" icon="pi pi-calendar" severity="secondary" size="small" @click="toggleGroupByDate" />
</div>
</div>
</div>
<div class="card-content">
<!-- Группировка по датам -->
<!-- ÐÑуппиÑовка по даÑам -->
<div v-if="groupByDate">
<div v-for="(posts, date) in postsByDate" :key="date" class="date-group mb-4 pb-4 border-bottom-1 border-surface-200 dark:border-surface-700">
<h4 class="text-lg font-semibold text-surface-900 dark:text-surface-0 mb-3">
@@ -835,15 +784,15 @@ onBeforeUnmount(() => {
<div class="flex gap-1 flex-wrap">
<Tag :value="post.platform" severity="info" class="text-xs" />
<Tag :value="post.contentType" severity="secondary" class="text-xs" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-xs text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-xs font-semibold text-surface-900 dark:text-surface-0 line-height-2">{{ post.theme }}</p>
</div>
<!-- Изображение поста -->
<!-- ИзобÑаÐение посÑа -->
<div v-if="hasImage(post) && !hasImageError(`${date}-${idx}`)" class="mb-2 post-media" style="position: relative">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image-small" loading="lazy" @error="handleImageError(`${date}-${idx}`)" />
<Button
@@ -853,13 +802,13 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, getPostIndex(post))"
/>
</div>
<div v-else-if="hasImage(post) && hasImageError(`${date}-${idx}`)" class="mb-2 no-image-placeholder-small post-media" style="position: relative">
<i class="pi pi-image text-2xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">Изображение недоступно</p>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">ИзобÑаÐение недосÑупно</p>
<Button
icon="pi pi-refresh"
size="small"
@@ -867,13 +816,13 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, getPostIndex(post))"
/>
</div>
<div v-else class="mb-2 no-image-placeholder-small post-media" style="position: relative">
<i class="pi pi-image text-2xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">Изображение не сгенерировано</p>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">ИзобÑаÐение не сгенеÑиÑовано</p>
<Button
icon="pi pi-refresh"
size="small"
@@ -881,16 +830,16 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, getPostIndex(post))"
/>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Ð¢ÐµÐºÑÑ Ð¿Ð¾ÑÑа:</p>
<p class="text-xs text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-2">{{ post.postText }}</p>
</div>
<div v-if="post.hashtags && post.hashtags.length > 0" class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Хештеги:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">ХешÑеги:</p>
<div class="flex flex-wrap gap-1">
<span v-for="(tag, tagIdx) in post.hashtags" :key="tagIdx" class="text-xs px-1 py-0 border-round bg-primary-50 dark:bg-primary-900 text-primary-700 dark:text-primary-300">
{{ tag }}
@@ -898,9 +847,9 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="flex gap-1 mt-auto">
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
<Button
label="Опубликовать"
label="Опубликовать"
icon="pi pi-send"
size="small"
severity="success"
@@ -916,7 +865,7 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Список всех постов -->
<!-- Список Ð²ÑÐµÑ Ð¿Ð¾ÑÑов -->
<div v-else class="posts-container">
<div v-for="(post, idx) in strategyData.strategy.postCalendar" :key="idx" class="post-item">
<div class="post-card p-3 border-round bg-surface-0 dark:bg-surface-800 border-1 border-surface-200 dark:border-surface-700 square-card">
@@ -924,15 +873,15 @@ onBeforeUnmount(() => {
<div class="flex gap-1 flex-wrap">
<Tag :value="post.platform" severity="info" class="text-xs" />
<Tag :value="post.contentType" severity="secondary" class="text-xs" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
<Tag v-if="post.taskId" :value="`Задача: ${post.taskId.substring(0, 8)}...`" severity="success" class="text-xs" />
</div>
<span class="text-xs text-surface-500 dark:text-surface-400">{{ post.publishTime }}</span>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Тема:</p>
<p class="text-xs font-semibold text-surface-900 dark:text-surface-0 line-height-2">{{ post.theme }}</p>
</div>
<!-- Изображение поста -->
<!-- ИзобÑаÐение посÑа -->
<div v-if="hasImage(post) && !hasImageError(idx)" class="mb-2 post-media" style="position: relative">
<img :src="getImageUrl(post)" :alt="post.theme" class="post-image-small" loading="lazy" @error="handleImageError(idx)" />
<Button
@@ -942,13 +891,13 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, idx)"
/>
</div>
<div v-else-if="hasImage(post) && hasImageError(idx)" class="mb-2 no-image-placeholder-small post-media" style="position: relative">
<i class="pi pi-image text-2xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">Изображение недоступно</p>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">ИзобÑаÐение недосÑупно</p>
<Button
icon="pi pi-refresh"
size="small"
@@ -956,13 +905,13 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, idx)"
/>
</div>
<div v-else class="mb-2 no-image-placeholder-small post-media" style="position: relative">
<i class="pi pi-image text-2xl text-surface-400 dark:text-surface-600"></i>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">Изображение не сгенерировано</p>
<p class="text-xs text-surface-500 dark:text-surface-400 mt-1">ИзобÑаÐение не сгенеÑиÑовано</p>
<Button
icon="pi pi-refresh"
size="small"
@@ -970,16 +919,16 @@ onBeforeUnmount(() => {
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
v-tooltip.top="'Регенерировать изображение'"
v-tooltip.top="'Регенерировать изображение'"
@click="handleRegenerateImage(post, idx)"
/>
</div>
<div class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Текст поста:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Ð¢ÐµÐºÑÑ Ð¿Ð¾ÑÑа:</p>
<p class="text-xs text-surface-700 dark:text-surface-300 whitespace-pre-line line-height-2">{{ post.postText }}</p>
</div>
<div v-if="post.hashtags && post.hashtags.length > 0" class="mb-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">Хештеги:</p>
<p class="text-xs font-medium text-surface-600 dark:text-surface-400 mb-1">ХешÑеги:</p>
<div class="flex flex-wrap gap-1">
<span v-for="(tag, tagIdx) in post.hashtags" :key="tagIdx" class="text-xs px-1 py-0 border-round bg-primary-50 dark:bg-primary-900 text-primary-700 dark:text-primary-300">
{{ tag }}
@@ -987,10 +936,10 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="flex gap-1 mt-auto">
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
<Button label="Копировать" icon="pi pi-copy" size="small" severity="secondary" class="flex-1 text-xs" @click="handleCopyPost(post)" />
<Button
v-if="post.taskId"
label="Опубликовать"
label="Опубликовать"
icon="pi pi-send"
size="small"
severity="success"
@@ -1008,18 +957,18 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Диалог для настройки credentials -->
<Dialog v-model:visible="credentialsDialogVisible" :header="'Настройка credentials'" :style="{ width: '600px' }" :modal="true">
<!-- Ðиалог для насÑÑойки credentials -->
<Dialog v-model:visible="credentialsDialogVisible" :header="'Настройка credentials'" :style="{ width: '600px' }" :modal="true">
<div class="credentials-dialog-content">
<p class="text-surface-700 dark:text-surface-300 mb-4">Для запуска стратегии необходимо настроить credentials для следующих платформ:</p>
<p class="text-surface-700 dark:text-surface-300 mb-4">Ðля запуска сÑÑаÑегии необÑодимо насÑÑоиÑÑŒ credentials для следуюÑÐ¸Ñ Ð¿Ð»Ð°ÑÑоÑм:</p>
<div class="mb-4">
<Tag v-for="platform in missingPlatforms" :key="platform" :value="platform" severity="warning" class="mr-2 mb-2" />
</div>
<p class="text-surface-600 dark:text-surface-400 text-sm">Перейдите на страницу управления credentials для настройки необходимых платформ.</p>
<p class="text-surface-600 dark:text-surface-400 text-sm">ПеÑейдиÑе на сÑÑаниÑу упÑавления credentials для насÑÑойки необÑодимÑÑ Ð¿Ð»Ð°ÑÑоÑм.</p>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="credentialsDialogVisible = false" severity="secondary" />
<Button label="Настроить credentials" icon="pi pi-cog" @click="goToCredentials" severity="info" />
<Button label="Отмена" icon="pi pi-times" @click="credentialsDialogVisible = false" severity="secondary" />
<Button label="Настроить credentials" icon="pi pi-cog" @click="goToCredentials" severity="info" />
</template>
</Dialog>
</div>
@@ -1229,3 +1178,6 @@ onBeforeUnmount(() => {
width: 100%;
}
</style>
+201 -43
View File
@@ -5,12 +5,14 @@ import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import { useTargetingStore } from '@/stores/targeting';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const targetingStore = useTargetingStore();
const analysisId = computed(() => route.query.analysisId || '');
const strategyId = ref(route.query.strategyId || route.params.id || null);
@@ -417,13 +419,30 @@ const pollPostVideo = (sid, index) => {
const isAutoPostingStarted = ref(false);
const startingStrategy = ref(false);
const handleStartStrategy = async () => {
const currentStrategyId = strategyData.value?.strategyId || strategyId.value;
if (!currentStrategyId) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не найден идентификатор стратегии для запуска таргета.' });
return;
}
try {
startingStrategy.value = true;
await MarketingV3Service.startStrategy(strategyData.value.strategyId || strategyId.value);
isAutoPostingStarted.value = true;
toast.add({ severity: 'success', summary: 'Успех', detail: 'Автопостинг успешно запущен', life: 4000 });
targetingStore.creative.value = null;
targetingStore.launchResult.value = null;
targetingStore.sourceStrategyId.value = currentStrategyId;
targetingStore.sourceStrategyName.value = strategyData.value?.strategyName || strategyData.value?.strategyData?.strategyName || 'V3 стратегия';
targetingStore.sourceContext.value = 'marketing-v3';
await router.push({
name: 'targeting-creative',
query: {
strategyId: currentStrategyId,
strategyName: targetingStore.sourceStrategyName.value,
autostart: '1'
}
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: e.message || 'Не удалось запустить автопостинг' });
toast.add({ severity: 'error', summary: 'Ошибка', detail: e.message || 'Не удалось открыть новый запуск таргета.' });
} finally {
startingStrategy.value = false;
}
@@ -439,16 +458,82 @@ const handlePublishNow = async (post) => {
post.taskStatus = 'PUBLISHED';
toast.add({ severity: 'success', summary: 'Опубликовано', detail: 'Пост успешно опубликован', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось опубликовать пост' });
toast.add({ severity: 'error', summary: 'Ошибка', detail: e.message || 'Не удалось опубликовать пост', life: 5000 });
} finally {
executingTasks.value.delete(post.taskId);
}
};
const launchPostTarget = (post) => {
const currentStrategyId = strategyData.value?.id || strategyData.value?.strategyId || strategyId.value || route.query.strategyId;
const currentAnalysisId = analysisId.value || strategyData.value?.analysisId || route.query.analysisId;
router.push({
path: '/marketing-analysis/v3/targetologist/wizard',
query: {
strategyId: currentStrategyId,
analysisId: currentAnalysisId,
postText: encodeURIComponent(post.postText?.substring(0, 150) || ''),
platform: post.platform || ''
}
});
};
const goBack = () => {
router.push('/marketing-analysis');
};
const openTargetingWizard = () => {
const currentStrategyId =
strategyData.value?.id ||
strategyData.value?.strategyId ||
strategyId.value ||
route.query.strategyId;
const currentAnalysisId =
analysisId.value ||
strategyData.value?.analysisId ||
route.query.analysisId;
if (!currentStrategyId) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не найден ID стратегии для запуска таргета.', life: 3500 });
return;
}
router.push({
path: '/marketing-analysis/v3/targetologist/wizard',
query: {
strategyId: currentStrategyId,
...(currentAnalysisId ? { analysisId: currentAnalysisId } : {})
}
});
};
const isAutoPublishing = ref(false);
const autoPublishFirstPost = async () => {
const currentStrategyId =
strategyData.value?.id ||
strategyData.value?.strategyId ||
strategyId.value ||
route.query.strategyId;
if (!currentStrategyId) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'ID стратегии не найден', life: 3500 });
return;
}
isAutoPublishing.value = true;
try {
const result = await MarketingV3Service.publishStrategyToFacebook(currentStrategyId);
toast.add({ severity: 'success', summary: 'Опубликовано', detail: result.message || 'Пост успешно опубликован в Facebook', life: 3500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: e.message || 'Не удалось опубликовать', life: 5000 });
} finally {
isAutoPublishing.value = false;
}
};
onMounted(() => {
init();
window.addEventListener('paste', handlePaste);
@@ -467,7 +552,7 @@ onUnmounted(() => {
</script>
<template>
<div class="marketing-strategy-v3-page bg-slate-50 dark:bg-slate-950 min-h-screen p-4 md:p-8">
<div class="marketing-strategy-v3-page min-h-screen p-4 md:p-8">
<Toast />
<div class="max-w-5xl mx-auto">
@@ -779,6 +864,18 @@ onUnmounted(() => {
</div>
</div>
</div>
<!-- Targetologist Action -->
<div class="ml-auto flex flex-1 flex-wrap items-center justify-end shrink-0 gap-3">
<Button label="Создать первую публикацию" icon="pi pi-facebook"
class="bg-[#1877F2] hover:bg-[#166fe5] text-white border-none shadow-xl shadow-[#1877F2]/30 font-black px-6 py-3 rounded-2xl transition-all hover:scale-105"
:loading="isAutoPublishing"
@click="autoPublishFirstPost" />
<Button label="Запустить ИИ Таргет" icon="pi pi-bolt"
class="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white border-none shadow-xl shadow-blue-500/30 font-black px-6 py-3 rounded-2xl transition-all hover:scale-105"
@click="openTargetingWizard" />
</div>
</div>
</div>
</div>
@@ -996,8 +1093,8 @@ onUnmounted(() => {
</div>
</div>
<!-- Publish Now Task (B4) -->
<div v-if="post.taskId" class="pt-2">
<!-- Actions -->
<div v-if="post.taskId" class="pt-2 flex flex-col gap-2">
<Button
v-if="post.taskStatus !== 'PUBLISHED' && post.taskStatus !== 'COMPLETED'"
label="Опубликовать сейчас"
@@ -1008,6 +1105,15 @@ onUnmounted(() => {
@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" />
<Button
label="Запустить таргет"
icon="pi pi-rocket"
class="w-full text-xs font-bold rounded-xl py-2"
severity="secondary"
outlined
@click="launchPostTarget(post)"
/>
</div>
</div>
</div>
@@ -1017,20 +1123,20 @@ onUnmounted(() => {
<!-- 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" />
<!-- Start Auto-Posting Button (B3) -->
<!-- Start Targeting Button -->
<Button
v-if="!isAutoPostingStarted && !strategyData.isStarted"
v-if="!isAutoPostingStarted"
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>Запустить таргет по новой логике</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>
<span>Переходим к запуску таргета</span>
</div>
</div>
</div>
@@ -1080,39 +1186,91 @@ onUnmounted(() => {
</template>
<style scoped>
.custom-slider :deep(.p-slider-handle) {
background-color: #10b981;
border-color: #10b981;
width: 20px;
height: 20px;
}
.custom-slider :deep(.p-slider-range) {
background-color: #10b981;
/* ───── Page base ───── */
.marketing-strategy-v3-page {
background: var(--kai-bg);
font-family: 'Onest', sans-serif;
color: var(--kai-txt);
}
/* Animations */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.5s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-20px);
/* ───── Cards ───── */
.marketing-strategy-v3-page :deep(.card),
.marketing-strategy-v3-page .card {
background: rgba(255,255,255,0.04) !important;
border: 1px solid rgba(45,123,255,0.15) !important;
box-shadow: none !important;
color: var(--kai-txt);
}
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease-in-out;
max-height: 500px;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
/* ───── Reduce large font sizes ───── */
.marketing-strategy-v3-page .text-5xl { font-size: 26px !important; }
.marketing-strategy-v3-page .text-4xl { font-size: 20px !important; }
.marketing-strategy-v3-page .text-3xl { font-size: 17px !important; }
.marketing-strategy-v3-page .text-2xl { font-size: 15px !important; }
.marketing-strategy-v3-page .text-xl { font-size: 14px !important; }
.marketing-strategy-v3-page .text-lg { font-size: 13px !important; }
.marketing-strategy-v3-page h1 { font-family: 'Unbounded', sans-serif !important; font-size: 20px !important; font-weight: 900 !important; }
.marketing-strategy-v3-page h2 { font-family: 'Unbounded', sans-serif !important; font-size: 16px !important; font-weight: 700 !important; }
.marketing-strategy-v3-page h3 { font-family: 'Unbounded', sans-serif !important; font-size: 13px !important; font-weight: 700 !important; }
.marketing-strategy-v3-page h4 { font-size: 13px !important; font-weight: 700 !important; }
/* ───── Text colors ───── */
.marketing-strategy-v3-page .text-slate-900 { color: var(--kai-txt) !important; }
.marketing-strategy-v3-page .text-slate-700 { color: var(--kai-txt2) !important; }
.marketing-strategy-v3-page .text-slate-600 { color: var(--kai-txt2) !important; }
.marketing-strategy-v3-page .text-slate-500 { color: var(--kai-txt3) !important; }
.marketing-strategy-v3-page .text-slate-400 { color: var(--kai-txt3) !important; }
/* ───── Background overrides ───── */
.marketing-strategy-v3-page .bg-white { background: rgba(255,255,255,0.04) !important; }
.marketing-strategy-v3-page .bg-slate-50 { background: rgba(255,255,255,0.02) !important; }
.marketing-strategy-v3-page .bg-slate-100 { background: rgba(255,255,255,0.05) !important; }
.marketing-strategy-v3-page .bg-slate-800 { background: rgba(255,255,255,0.06) !important; }
.marketing-strategy-v3-page .bg-slate-900 { background: rgba(255,255,255,0.04) !important; }
/* ───── Border overrides ───── */
.marketing-strategy-v3-page .border-slate-100,
.marketing-strategy-v3-page .border-slate-200 { border-color: rgba(45,123,255,0.15) !important; }
.marketing-strategy-v3-page .border-slate-700,
.marketing-strategy-v3-page .border-slate-800 { border-color: rgba(45,123,255,0.15) !important; }
/* ───── Hover overrides ───── */
.marketing-strategy-v3-page [class*="hover:bg-slate"]:hover { background: rgba(255,255,255,0.06) !important; }
/* ───── Score bars track ───── */
.marketing-strategy-v3-page .h-2.rounded-full,
.marketing-strategy-v3-page .h-3.rounded-full,
.marketing-strategy-v3-page .h-1\.5.rounded-full { background: rgba(255,255,255,0.06) !important; }
/* ───── Emerald accent ───── */
.marketing-strategy-v3-page .text-emerald-600,
.marketing-strategy-v3-page .text-emerald-400 { color: var(--kai-green) !important; }
.marketing-strategy-v3-page .text-emerald-500 { color: var(--kai-green) !important; }
.marketing-strategy-v3-page .bg-emerald-100 { background: rgba(0,212,139,0.1) !important; }
.marketing-strategy-v3-page .border-emerald-500 { border-color: rgba(0,212,139,0.4) !important; }
.marketing-strategy-v3-page .bg-emerald-50 { background: rgba(0,212,139,0.06) !important; }
/* ───── Upload zone ───── */
.marketing-strategy-v3-page .border-dashed { border-color: rgba(45,123,255,0.25) !important; }
.marketing-strategy-v3-page .border-dashed:hover { border-color: rgba(45,123,255,0.5) !important; }
/* ───── Sprint week number badge ───── */
.marketing-strategy-v3-page .bg-slate-900.text-white { background: var(--kai-blue) !important; }
/* ───── Italic quote ───── */
.marketing-strategy-v3-page .italic { color: var(--kai-txt2) !important; font-size: 13px !important; }
/* ───── Generate button override ───── */
.marketing-strategy-v3-page button.min-w-\[300px\] { font-size: 14px !important; padding: 13px 28px !important; }
/* ───── Animations ───── */
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.5s ease; }
.slide-up-enter-from { opacity: 0; transform: translateY(20px); }
.slide-up-leave-to { opacity: 0; transform: translateY(-20px); }
.expand-enter-active, .expand-leave-active { transition: all 0.3s ease-in-out; max-height: 500px; overflow: hidden; }
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
.custom-slider :deep(.p-slider-handle) { background-color: var(--kai-green); border-color: var(--kai-green); width: 18px; height: 18px; }
.custom-slider :deep(.p-slider-range) { background-color: var(--kai-green); }
</style>
@@ -23,7 +23,6 @@ const STATUS_FILTERS = [
{ label: 'Ошибка', value: 'FAILED' }
];
// Load
const load = async () => {
loading.value = true;
try {
@@ -36,13 +35,12 @@ const load = async () => {
}
};
// Filtered & Paginated
const filtered = computed(() => {
let list = strategies.value;
if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.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) => {
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);
@@ -55,64 +53,52 @@ const paginated = computed(() => {
return filtered.value.slice(start, start + pageSize);
});
const countByStatus = (s) => strategies.value.filter((a) => a.status === s).length;
const countByStatus = s => strategies.value.filter(a => a.status === s).length;
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;
// 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 statusStyle = s => ({
COMPLETED: { color: 'var(--kai-green)', bg: 'rgba(0,212,139,0.1)', border: 'rgba(0,212,139,0.25)' },
PROCESSING: { color: 'var(--kai-blue)', bg: 'rgba(45,123,255,0.1)', border: 'rgba(45,123,255,0.25)' },
QUEUED: { color: 'var(--kai-yellow)', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.25)' },
FAILED: { color: 'var(--kai-red)', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)' }
})[(s||'').toUpperCase()] || { color: 'var(--kai-txt3)', bg: 'rgba(255,255,255,0.05)', border: 'rgba(255,255,255,0.1)' };
const statusLabel = (s) => ({ COMPLETED: 'Завершена', PROCESSING: 'В работе', QUEUED: 'В очереди', FAILED: 'Ошибка' })[(s || '').toUpperCase()] || s;
const statusIcon = s => ({
COMPLETED: 'pi pi-check-circle',
PROCESSING: 'pi pi-spin pi-spinner',
QUEUED: 'pi pi-clock',
FAILED: 'pi pi-times-circle'
})[(s||'').toUpperCase()] || 'pi pi-circle';
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';
const getPlatformIcon = p => {
const pl = (p||'').toLowerCase();
if (pl.includes('insta')) return 'pi pi-instagram';
if (pl.includes('tik')) return 'pi pi-tiktok';
if (pl.includes('you')) return 'pi pi-youtube';
if (pl.includes('tg') || pl.includes('tele')) return 'pi pi-telegram';
return 'pi pi-share-alt';
};
const getModelInfo = (modelName) => {
const getModelInfo = modelName => {
if (!modelName) return DEFAULT_STRATEGY_MODEL;
return STRATEGY_MODELS[modelName.toUpperCase()] || DEFAULT_STRATEGY_MODEL;
};
const getLatestStatusMessage = (item) => {
if (item.statusHistory && item.statusHistory.length > 0) {
return item.statusHistory[item.statusHistory.length - 1].message;
}
const getStrategyName = item =>
item.analysisTitle ||
(item.analysisData?.requestData?.productName
? `${item.analysisData.requestData.productName}${item.analysisData.requestData.businessNiche}`
: (item.analysisData?.requestData?.businessNiche || 'Стратегия #' + (item.strategyId || item.id || '').slice(-6)));
const getLatestMsg = item => {
if (item.statusHistory?.length > 0) return item.statusHistory[item.statusHistory.length - 1].message;
return item.status === 'QUEUED' ? 'В очереди...' : 'Фабрика генерирует контент...';
};
// Actions
const openStrategy = (item) => {
const openStrategy = item => {
const s = (item.status || '').toUpperCase();
if (s === 'COMPLETED' || s === 'PROCESSING' || s === 'QUEUED') {
if (['COMPLETED', 'PROCESSING', 'QUEUED'].includes(s)) {
const id = item.strategyId || item.id;
router.push(`/marketing-analysis/v3/strategy?strategyId=${id}&analysisId=${item.analysisId}`);
}
@@ -122,172 +108,223 @@ onMounted(load);
</script>
<template>
<div class="bg-surface-50 dark:bg-surface-900 min-h-screen p-4 md:p-6">
<div class="sl-page">
<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>
<div class="sl-inner">
<!-- 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>
<!-- HEADER -->
<div class="sl-header">
<div class="sl-header__left">
<button class="sl-back-btn" @click="$router.push('/marketing-analysis')">
<i class="pi pi-arrow-left"></i>
</button>
<Button icon="pi pi-refresh" severity="secondary" text rounded size="small" :loading="loading" @click="load" v-tooltip.top="'Обновить'" />
<div>
<div class="sl-eyebrow">Content Factory · v3</div>
<h1 class="sl-title">Мои стратегии</h1>
</div>
</div>
<button class="sl-btn sl-btn--ghost" @click="$router.push('/marketing-analysis/v3')">
<i class="pi pi-chart-bar"></i> Мои анализы
</button>
</div>
<!-- STATS -->
<div class="sl-stats">
<div class="sl-stat" v-for="f in STATUS_FILTERS.filter(f=>f.value)" :key="f.value">
<span class="sl-stat__num" :style="{ color: statusStyle(f.value).color }">{{ countByStatus(f.value) }}</span>
<span class="sl-stat__label">{{ f.label }}</span>
</div>
<div class="sl-stat">
<span class="sl-stat__num" style="color:var(--kai-txt);">{{ strategies.length }}</span>
<span class="sl-stat__label">Всего</span>
</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" />
<!-- FILTERS -->
<div class="sl-filters">
<div class="sl-search">
<i class="pi pi-search sl-search__icon"></i>
<input v-model="searchQuery" type="text" placeholder="Поиск (ниша, ID)..." class="sl-search__input" />
</div>
<div class="sl-pills">
<button v-for="f in STATUS_FILTERS" :key="f.value"
@click="filterStatus = f.value"
class="sl-pill" :class="{ 'sl-pill--active': filterStatus === f.value }">
{{ f.label }}
<span v-if="f.value" class="sl-pill__count">{{ countByStatus(f.value) }}</span>
</button>
<button class="sl-icon-btn" @click="load" title="Обновить">
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }"></i>
</button>
</div>
</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" />
<!-- LOADING -->
<div v-if="loading" class="sl-skeleton-list">
<div v-for="i in 4" :key="i" class="sl-skeleton-item">
<div class="sl-skeleton-icon"></div>
<div class="sl-skeleton-lines">
<div class="sl-skeleton-line sl-skeleton-line--w40"></div>
<div class="sl-skeleton-line sl-skeleton-line--w60"></div>
</div>
</div>
</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 group relative overflow-hidden cursor-pointer hover:-translate-y-1 hover:shadow-2xl hover:shadow-emerald-500/10 transition-all duration-300 border border-surface-200 dark:border-surface-800 bg-white/50 dark:bg-surface-900/50 backdrop-blur-sm"
@click="openStrategy(item)"
>
<!-- Background Glow on Hover -->
<div class="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Status badge -->
<div class="shrink-0">
<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>
<!-- EMPTY -->
<div v-else-if="!filtered.length" class="sl-empty">
<div class="sl-empty__icon"><i class="pi pi-sparkles"></i></div>
<h2 class="sl-empty__title">{{ filterStatus ? 'Нет стратегий с таким статусом' : 'Стратегий пока нет' }}</h2>
<p class="sl-empty__desc">Сгенерируйте первую стратегию ИИ-маркетологом на основе готового анализа.</p>
<button class="sl-btn sl-btn--primary" @click="$router.push('/marketing-analysis/v3')">
<i class="pi pi-arrow-right"></i> К анализам
</button>
</div>
<!-- Main info -->
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<h3 class="text-base font-extrabold text-surface-900 dark:text-surface-0 truncate group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
{{ item.analysisTitle || (item.analysisData?.requestData?.productName ? `${item.analysisData.requestData.productName}${item.analysisData.requestData.businessNiche}` : (item.analysisData?.requestData?.businessNiche || 'Стратегия #' + (item.strategyId || item.id || '').slice(-6))) }}
</h3>
<div class="flex items-center gap-2">
<span :class="['inline-flex items-center px-1.5 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-wider', statusTagClass(item.status)]">
{{ statusLabel(item.status) }}
</span>
<!-- Strategy Model Badge -->
<div
:style="{ backgroundColor: getModelInfo(item.scoringModelName).colorLight, color: getModelInfo(item.scoringModelName).color, borderColor: getModelInfo(item.scoringModelName).color + '20' }"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border shadow-sm transition-transform group-hover:scale-105"
>
<span class="text-xs">{{ getModelInfo(item.scoringModelName).icon }}</span>
{{ getModelInfo(item.scoringModelName).badge }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-surface-500 dark:text-surface-400 mt-2">
<span v-if="item.durationWeeks || item.strategyData?.durationWeeks" class="flex items-center gap-1 font-medium"> <i class="pi pi-calendar" /> {{ item.durationWeeks || item.strategyData?.durationWeeks }} недель </span>
<!-- LIST -->
<TransitionGroup v-else name="sl-list" tag="div" class="sl-list">
<div v-for="item in paginated" :key="item.strategyId || item.id"
class="sl-item"
:class="{ 'sl-item--clickable': ['COMPLETED','PROCESSING','QUEUED'].includes((item.status||'').toUpperCase()) }"
@click="openStrategy(item)">
<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>
<!-- Status -->
<div class="sl-item__status"
:style="{ background: statusStyle(item.status).bg, border: '1px solid ' + statusStyle(item.status).border }">
<i :class="statusIcon(item.status)" :style="{ color: statusStyle(item.status).color }"></i>
</div>
<!-- Content -->
<div class="sl-item__content">
<div class="sl-item__row1">
<h3 class="sl-item__name">{{ getStrategyName(item) }}</h3>
<div class="sl-item__badges">
<span class="sl-item__badge"
:style="{ color: statusStyle(item.status).color, background: statusStyle(item.status).bg, borderColor: statusStyle(item.status).border }">
{{ statusLabel(item.status) }}
</span>
<span class="sl-item__model"
:style="{ color: getModelInfo(item.scoringModelName).color, background: getModelInfo(item.scoringModelName).colorLight, borderColor: getModelInfo(item.scoringModelName).color + '30' }">
{{ getModelInfo(item.scoringModelName).icon }} {{ getModelInfo(item.scoringModelName).badge }}
</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 class="sl-item__meta">
<span v-if="item.durationWeeks || item.strategyData?.durationWeeks">
<i class="pi pi-calendar"></i> {{ item.durationWeeks || item.strategyData?.durationWeeks }} нед.
</span>
<span v-if="(item.priorityPlatforms || item.strategyData?.priorityPlatforms)?.length" class="sl-platforms">
<i class="pi pi-sitemap"></i>
<span v-for="p in (item.priorityPlatforms || item.strategyData?.priorityPlatforms)" :key="p" class="sl-plat-chip">
<i :class="getPlatformIcon(p)"></i> {{ p }}
</span>
</span>
<span><i class="pi pi-clock"></i> {{ formatDate(item.createdAt) }}</span>
</div>
<!-- Progress -->
<div v-if="['PROCESSING','QUEUED'].includes((item.status||'').toUpperCase())" class="sl-item__progress">
<span class="sl-item__progress-msg">{{ getLatestMsg(item) }}</span>
<div class="sl-item__progress-track">
<div class="sl-item__progress-bar" :style="{ width: item.status === 'QUEUED' ? '8%' : '60%' }"></div>
</div>
</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>{{ getLatestStatusMessage(item) }}</span>
</div>
<div class="h-1.5 bg-surface-200 dark:bg-surface-700 rounded-full overflow-hidden">
<div class="h-1.5 rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 animate-pulse" :style="{ width: item.status === 'QUEUED' ? '10%' : '60%' }" />
</div>
<!-- Action -->
<div class="sl-item__action" @click.stop>
<button v-if="['COMPLETED','PROCESSING'].includes((item.status||'').toUpperCase())"
class="sl-icon-btn sl-icon-btn--view" @click="openStrategy(item)" title="Открыть">
<i class="pi pi-eye"></i>
</button>
</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" />
<!-- PAGINATION -->
<div v-if="filtered.length > pageSize" class="sl-pagination">
<span class="sl-pagination__info">Показано {{ (currentPage-1)*pageSize+1 }}{{ Math.min(currentPage*pageSize, filtered.length) }} из {{ filtered.length }}</span>
<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);
}
.sl-page { background: var(--kai-bg); min-height: 100vh; font-family: 'Onest', sans-serif; padding: 32px 24px 80px; }
.sl-inner { max-width: 1100px; margin: 0 auto; }
.sl-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; flex-wrap: wrap; }
.sl-header__left { display: flex; align-items: center; gap: 16px; }
.sl-back-btn { width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--kai-border); background: var(--kai-card); color: var(--kai-txt2); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.sl-back-btn:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
.sl-eyebrow { font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.08em; color: var(--kai-green); margin-bottom: 4px; }
.sl-title { font-family: 'Unbounded', sans-serif; font-size: 24px; font-weight: 900; color: var(--kai-txt); }
.sl-btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 18px; border-radius: 10px; font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; border: 1px solid; font-family: 'Onest', sans-serif; }
.sl-btn--primary { background: var(--kai-green); color: #0A0E1A; border-color: var(--kai-green); }
.sl-btn--primary:hover { opacity: 0.9; transform: translateY(-1px); }
.sl-btn--ghost { background: var(--kai-card); color: var(--kai-txt2); border-color: var(--kai-border); }
.sl-btn--ghost:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
.sl-stats { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.sl-stat { display: flex; flex-direction: column; gap: 2px; }
.sl-stat__num { font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 900; line-height: 1; }
.sl-stat__label { font-size: 11px; color: var(--kai-txt3); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.sl-filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.sl-search { position: relative; flex: 1; min-width: 200px; max-width: 300px; }
.sl-search__icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--kai-txt3); font-size: 13px; }
.sl-search__input { width: 100%; padding: 9px 12px 9px 36px; border-radius: 10px; background: var(--kai-card); border: 1px solid var(--kai-border); color: var(--kai-txt); font-size: 13px; font-family: 'Onest', sans-serif; outline: none; transition: border-color 0.2s; }
.sl-search__input:focus { border-color: var(--kai-border-hover); }
.sl-search__input::placeholder { color: var(--kai-txt3); }
.sl-pills { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.sl-pill { padding: 6px 14px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--kai-border); background: var(--kai-card); color: var(--kai-txt2); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; gap: 5px; }
.sl-pill:hover { border-color: var(--kai-border-hover); color: var(--kai-txt); }
.sl-pill--active { background: rgba(0,212,139,0.1); border-color: rgba(0,212,139,0.4); color: var(--kai-green); }
.sl-pill__count { opacity: 0.7; font-size: 10px; }
.sl-icon-btn { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--kai-border); background: var(--kai-card); color: var(--kai-txt2); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; font-size: 13px; }
.sl-icon-btn:hover { background: var(--kai-card-hover); color: var(--kai-txt); border-color: var(--kai-border-hover); }
.sl-icon-btn--view:hover { background: rgba(45,123,255,0.1); color: var(--kai-blue); border-color: rgba(45,123,255,0.3); }
.sl-skeleton-list { display: flex; flex-direction: column; gap: 8px; }
.sl-skeleton-item { display: flex; gap: 14px; align-items: center; padding: 18px 20px; background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 14px; }
.sl-skeleton-icon { width: 44px; height: 44px; border-radius: 12px; background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sl-skeleton-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.sl-skeleton-line { height: 12px; border-radius: 6px; background: rgba(255,255,255,0.06); }
.sl-skeleton-line--w40 { width: 40%; }
.sl-skeleton-line--w60 { width: 65%; }
.sl-empty { text-align: center; padding: 80px 24px; background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 20px; }
.sl-empty__icon { width: 72px; height: 72px; border-radius: 20px; background: rgba(0,212,139,0.1); border: 1px solid rgba(0,212,139,0.2); display: flex; align-items: center; justify-content: center; font-size: 30px; color: var(--kai-green); margin: 0 auto 20px; }
.sl-empty__title { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 700; color: var(--kai-txt); margin-bottom: 10px; }
.sl-empty__desc { font-size: 14px; color: var(--kai-txt2); max-width: 380px; margin: 0 auto 24px; line-height: 1.6; }
.sl-list { display: flex; flex-direction: column; gap: 8px; }
.sl-item { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; border-radius: 14px; background: var(--kai-card); border: 1px solid var(--kai-border); transition: all 0.25s; }
.sl-item--clickable { cursor: pointer; }
.sl-item--clickable:hover { background: var(--kai-card-hover); border-color: var(--kai-border-hover); transform: translateY(-2px); box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
.sl-item__status { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.sl-item__content { flex: 1; min-width: 0; }
.sl-item__row1 { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.sl-item__name { font-weight: 700; font-size: 14px; color: var(--kai-txt); line-height: 1.3; flex: 1; min-width: 0; }
.sl-item__badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
.sl-item__badge { padding: 2px 10px; border-radius: 999px; font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid; }
.sl-item__model { padding: 2px 10px; border-radius: 999px; font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid; }
.sl-item__meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--kai-txt3); }
.sl-item__meta i { margin-right: 3px; font-size: 11px; }
.sl-platforms { display: flex; align-items: center; gap: 6px; }
.sl-plat-chip { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); padding: 1px 7px; border-radius: 6px; font-size: 10px; color: var(--kai-txt2); }
.sl-item__progress { margin-top: 10px; }
.sl-item__progress-msg { font-size: 11px; color: var(--kai-txt3); margin-bottom: 6px; display: block; }
.sl-item__progress-track { height: 3px; background: rgba(255,255,255,0.06); border-radius: 999px; overflow: hidden; }
.sl-item__progress-bar { height: 100%; background: linear-gradient(90deg, var(--kai-green), rgba(0,212,139,0.4)); border-radius: 999px; animation: sl-pulse 2s ease-in-out infinite; }
@keyframes sl-pulse { 0%,100%{opacity:1}50%{opacity:0.6} }
.sl-item__action { flex-shrink: 0; }
.sl-pagination { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; margin-top: 8px; flex-wrap: wrap; gap: 12px; }
.sl-pagination__info { font-size: 12px; color: var(--kai-txt3); }
.sl-list-enter-active, .sl-list-leave-active { transition: all 0.25s ease; }
.sl-list-enter-from, .sl-list-leave-to { opacity: 0; transform: translateY(-6px); }
</style>
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import CampaignAdSets from './components/CampaignAdSets.vue';
import CampaignInsights from './components/CampaignInsights.vue';
import CampaignMetrics from './components/CampaignMetrics.vue';
import CampaignPrediction from './components/CampaignPrediction.vue';
import { formatArrayField, formatGeo, formatMetricValue } from '@/composables/useCampaignMetrics';
import { useCampaignBudget } from '@/composables/useCampaignBudget';
import { useCampaignStatusPoller } from '@/composables/useCampaignStatusPoller';
import { useEnumLabels } from '@/composables/useEnumLabels';
import { useTargetingApi } from '@/composables/useTargetingApi';
import { useCampaignStore } from '@/stores/campaign.store';
import TargetingService from '@/service/TargetingService';
import type { CampaignStatus } from '@/types/campaign.types';
import Toast from 'primevue/toast';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Skeleton from 'primevue/skeleton';
interface PipelineStep {
status: CampaignStatus;
title: string;
}
const route = useRoute();
const router = useRouter();
const toast = useToast();
const campaignStore = useCampaignStore();
const { launchFromStrategy } = useTargetingApi();
const { campaignStatusLabel, objectiveLabel, platformLabel, targetingTypeLabel } = useEnumLabels();
const activeTab = ref(0);
const statusRef = ref<CampaignStatus | null>(null);
const launchingTarget = ref(false);
const publishingCampaign = ref(false);
const campaignId = computed(() => String(route.params.id ?? ''));
const campaign = computed(() => campaignStore.campaign);
const loadingCampaign = computed(() => campaignStore.loading.campaign);
const actionLoading = computed(() => campaignStore.loading.action);
const budget = useCampaignBudget(campaign);
const predictionMetrics = computed(() => campaignStore.prediction?.predictedMetrics ?? campaign.value?.predictedMetrics ?? null);
const recommendation = computed(() => campaign.value?.aiRecommendations?.targetingRecommendation ?? null);
const audienceProfile = computed(() => recommendation.value?.audienceProfile ?? null);
const strategyId = computed(() => String(campaign.value?.strategyId ?? '').trim());
const pipelineSteps: PipelineStep[] = [
{ status: 'CREATED', title: 'Создана' },
{ status: 'ORCHESTRATING', title: 'ИИ анализирует' },
{ status: 'MAPPING', title: 'Генерация' },
{ status: 'PUBLISHING', title: 'Публикация' },
{ status: 'ACTIVE', title: 'Активна' }
];
const statusOrder: Record<CampaignStatus, number> = {
CREATED: 0,
ORCHESTRATING: 1,
MAPPING: 2,
PUBLISHING: 3,
ACTIVE: 4,
PAUSED: 4,
COMPLETED: 4,
FAILED: 3
};
const pipelineVisible = computed(() => {
const status = statusRef.value;
return status !== 'ACTIVE' && status !== 'COMPLETED' && status !== 'PAUSED';
});
const isLaunchingStatus = computed(() => {
const status = statusRef.value;
return status === 'CREATED' || status === 'ORCHESTRATING' || status === 'MAPPING' || status === 'PUBLISHING';
});
const sm = (status: any) => {
const s = String(status || '').toUpperCase();
if (s === 'ACTIVE') return { label: 'Активна', color: '#00D48B', bg: 'rgba(0,212,139,0.1)', border: 'rgba(0,212,139,0.2)', icon: 'pi pi-play-circle' };
if (s === 'PAUSED') return { label: 'Пауза', color: '#FF7A2D', bg: 'rgba(255,122,45,0.1)', border: 'rgba(255,122,45,0.2)', icon: 'pi pi-pause-circle' };
if (s === 'FAILED' || s === 'ERROR') return { label: 'Ошибка', color: '#EF4444', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.2)', icon: 'pi pi-exclamation-circle' };
if (s === 'COMPLETED') return { label: 'Завершена', color: '#A855F7', bg: 'rgba(168,85,247,0.1)', border: 'rgba(168,85,247,0.2)', icon: 'pi pi-check-circle' };
return { label: 'Запуск...', color: '#2D7BFF', bg: 'rgba(45,123,255,0.1)', border: 'rgba(45,123,255,0.2)', icon: 'pi pi-spin pi-spinner' };
};
const platformIcon = (platform: string): string => {
const value = platform.toUpperCase();
if (value === 'INSTAGRAM') return 'pi pi-instagram';
if (value === 'FACEBOOK') return 'pi pi-facebook';
if (value === 'TIKTOK') return 'pi pi-tiktok';
return 'pi pi-globe';
};
const formatDateTime = (value: string | null | undefined): string => {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return format(date, 'dd.MM.yyyy HH:mm', { locale: ru });
};
const formatKzt = (value: number | null | undefined): string => formatMetricValue(value, 'KZT');
const formatLargeNumber = (value: number | null | undefined): string => {
if (value == null) return '—';
return new Intl.NumberFormat('ru-RU').format(value);
};
const formatPlatformValues = (value: string[] | string | null | undefined): string => {
if (!value) return '—';
const list = Array.isArray(value) ? value : [value];
return list.map((item) => platformLabel(item)).join(', ');
};
const launchTimestamp = computed(() => {
const activeEvent = campaign.value?.statusHistory?.find((item) => item.status === 'ACTIVE');
return activeEvent?.createdAt ?? activeEvent?.timestamp ?? campaign.value?.startedAt ?? null;
});
const currentPipelineIndex = computed(() => {
const status = statusRef.value;
if (!status) return 0;
return statusOrder[status] ?? 0;
});
const pipelineIcon = (index: number): string => {
const status = statusRef.value;
if (status === 'FAILED' && index === currentPipelineIndex.value) return 'pi pi-times';
if (index < currentPipelineIndex.value || status === 'ACTIVE') return 'pi pi-check';
if (index === currentPipelineIndex.value) return 'pi pi-spin pi-spinner';
return 'pi pi-circle';
};
const pipelineClass = (index: number): string => {
const status = statusRef.value;
if (status === 'FAILED' && index === currentPipelineIndex.value) return 'wiz-pipe--error';
if (index < currentPipelineIndex.value || status === 'ACTIVE') return 'wiz-pipe--done';
if (index === currentPipelineIndex.value) return 'wiz-pipe--active';
return 'wiz-pipe--pending';
};
const showLaunchTarget = computed(() => {
const status = statusRef.value;
return status === 'CREATED' || status === 'FAILED' || status === 'PAUSED';
});
const launchTargetLabel = computed(() => {
const s = String(statusRef.value ?? '').toUpperCase();
if (s === 'ACTIVE' || s === 'COMPLETED') return 'Таргетинг запущен';
return 'Запустить таргетинг';
});
const launchTargetDisabled = computed(() => {
const s = String(statusRef.value ?? '').toUpperCase();
if (launchingTarget.value) return true;
if (s === 'ACTIVE' || s === 'COMPLETED') return true;
// If strategyId is missing we still keep the button clickable to show a clear toast with next steps.
return false;
});
const showActionDetails = (error: unknown, fallback: string): void => {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error instanceof Error ? error.message : fallback,
life: 4500
});
};
const launchTarget = async (): Promise<void> => {
if (launchingTarget.value) return;
if (!strategyId.value) {
toast.add({
severity: 'warn',
summary: 'Запуск недоступен',
detail: 'Не найден strategyId для этой кампании. Запустите таргетинг из мастера стратегии.',
life: 4500
});
return;
}
launchingTarget.value = true;
try {
await launchFromStrategy(strategyId.value);
toast.add({
severity: 'success',
summary: 'Запуск',
detail: 'Кампания запущена и пост отправлен в Facebook.',
life: 3500
});
await loadCampaign();
} catch (error) {
showActionDetails(error, 'Не удалось запустить таргет. Попробуйте снова.');
} finally {
launchingTarget.value = false;
}
};
const publishCampaignToFacebook = async (): Promise<void> => {
if (publishingCampaign.value) return;
if (!strategyId.value) {
toast.add({
severity: 'warn',
summary: 'Нет стратегии',
detail: 'Не найден ID стратегии для публикации. Запустите таргетинг из мастера стратегии.',
life: 4500
});
return;
}
publishingCampaign.value = true;
try {
const result = await TargetingService.publishStrategyToFacebook(strategyId.value);
toast.add({
severity: 'success',
summary: 'Опубликовано',
detail: result?.message || 'Пост успешно опубликован в Facebook!',
life: 4000
});
} catch (error: any) {
toast.add({
severity: 'error',
summary: 'Ошибка публикации',
detail: error?.message || 'Не удалось опубликовать пост. Попробуйте позже.',
life: 5000
});
} finally {
publishingCampaign.value = false;
}
};
const loadCampaign = async (): Promise<void> => {
if (!campaignId.value) return;
try {
const payload = await campaignStore.fetchCampaign(campaignId.value);
statusRef.value = payload.status;
} catch (error) {
showActionDetails(error, 'Не удалось загрузить кампанию');
router.push('/marketing-analysis/v3/targetologist/dashboard');
}
};
const pauseCampaign = async (): Promise<void> => {
try {
await campaignStore.pauseCampaign(campaignId.value);
statusRef.value = 'PAUSED';
toast.add({ severity: 'info', summary: 'Кампания', detail: 'Кампания поставлена на паузу', life: 3000 });
} catch (error) {
showActionDetails(error, 'Не удалось поставить кампанию на паузу');
}
};
const resumeCampaign = async (): Promise<void> => {
try {
await campaignStore.resumeCampaign(campaignId.value);
statusRef.value = 'ACTIVE';
toast.add({ severity: 'success', summary: 'Кампания', detail: 'Кампания возобновлена', life: 3000 });
} catch (error) {
showActionDetails(error, 'Не удалось возобновить кампанию');
}
};
const retryCampaign = async (): Promise<void> => {
try {
await campaignStore.retryCampaignLaunch(campaignId.value);
await loadCampaign();
toast.add({ severity: 'success', summary: 'Кампания', detail: 'Повторный запуск отправлен', life: 3000 });
} catch (error) {
showActionDetails(error, 'Не удалось повторить запуск кампании');
}
};
const syncInsights = async (): Promise<void> => {
try {
await campaignStore.syncInsights(campaignId.value);
toast.add({ severity: 'success', summary: 'Обновление', detail: 'Данные успешно синхронизированы', life: 2000 });
} catch (error) {
showActionDetails(error, 'Не удалось синхронизировать данные');
}
};
const openInMeta = (): void => {
const url = campaign.value?.metaCampaignUrl;
if (!url) {
toast.add({
severity: 'warn',
summary: 'Ссылка недоступна',
detail: 'Ссылка на Meta появится после полной публикации.',
life: 3500
});
return;
}
window.open(url, '_blank', 'noopener');
};
watch(
() => campaign.value?.status,
(status) => {
statusRef.value = status ?? null;
},
{ immediate: true }
);
useCampaignStatusPoller({
campaignId,
status: statusRef,
onStatusUpdate: (payload) => {
campaignStore.applyStatusPayload(payload);
},
onTerminal: async () => {
// Prefetch prediction right after terminal status, so the Prediction tab opens instantly.
try {
await campaignStore.waitForPrediction(campaignId.value, 10, 5000);
} catch {
// ignore
}
}
});
onMounted(() => {
void loadCampaign();
});
</script>
<template>
<div class="mkt-hub app-dark">
<Toast />
<!-- AMBIENT GLOW -->
<div class="mkt-hub__glow1"></div>
<div class="mkt-hub__glow2"></div>
<div class="mkt-hub__inner">
<div v-if="loadingCampaign" class="space-y-6">
<div class="mkt-card" style="height: 120px;"></div>
<div class="grid grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="mkt-card" style="height: 100px;"></div>
</div>
<div class="mkt-card" style="height: 400px;"></div>
</div>
<template v-else-if="campaign">
<!-- HEADER -->
<div class="mkt-hub__header" style="text-align: left; margin-bottom: 32px; display: flex; align-items: flex-start; justify-content: space-between;">
<div class="space-y-3">
<div class="mkt-hub__badge">
<span class="mkt-hub__badge-dot"></span>
AI Targetologist · Кампания
</div>
<div class="flex items-center gap-3">
<button class="td-back-mini" @click="router.push('/marketing-analysis/v3/targetologist/dashboard')">
<i class="pi pi-arrow-left"></i>
</button>
<h1 class="mkt-hub__title">{{ campaign.name }}</h1>
<div class="td-campaign__status" :style="{ color: sm(statusRef).color, background: sm(statusRef).bg, borderColor: sm(statusRef).border }">
<i :class="sm(statusRef).icon"></i>
{{ sm(statusRef).label }}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm" style="color: var(--kai-txt2);">
<span><i class="pi pi-calendar mr-1"></i> {{ formatDateTime(campaign.createdAt) }}</span>
<span v-if="campaign.objective"><i class="pi pi-bullseye mr-1"></i> {{ objectiveLabel(campaign.objective) }}</span>
<span class="flex items-center gap-2">
<i v-for="p in campaign.platforms" :key="p" :class="platformIcon(String(p))" :title="platformLabel(String(p))" style="opacity:0.7"></i>
</span>
</div>
</div>
<div class="flex gap-2 pt-6">
<button
class="mkt-banner__cta"
style="border:none; background: #1877F2; box-shadow: 0 4px 15px rgba(24, 119, 242, 0.4);"
:disabled="publishingCampaign"
@click="publishCampaignToFacebook"
>
<i :class="publishingCampaign ? 'pi pi-spin pi-spinner' : 'pi pi-facebook'"></i>
Запустить стратегию
</button>
<button
class="mkt-banner__cta"
style="border:none;"
:disabled="launchTargetDisabled"
:style="{ opacity: launchTargetDisabled ? 0.6 : 1 }"
@click="launchTarget"
>
<i :class="launchingTarget ? 'pi pi-spin pi-spinner' : (launchTargetDisabled ? 'pi pi-check' : 'pi pi-play')"></i>
{{ launchTargetLabel }}
</button>
<template v-if="statusRef === 'ACTIVE'">
<button class="wiz-btn-sec" style="padding: 10px 20px;" @click="pauseCampaign">
<i class="pi pi-pause"></i> Пауза
</button>
<button class="mkt-banner__cta" style="border:none;" @click="openInMeta">
Meta Ads <i class="pi pi-external-link"></i>
</button>
</template>
<button v-else-if="statusRef === 'PAUSED'" class="mkt-banner__cta" style="border:none; background: var(--kai-green);" @click="resumeCampaign">
<i class="pi pi-play"></i> Возобновить
</button>
<button v-else-if="statusRef === 'FAILED'" class="mkt-banner__cta" style="border:none; background: var(--kai-red);" @click="retryCampaign">
<i class="pi pi-refresh"></i> Повторить запуск
</button>
<button v-if="statusRef === 'ACTIVE'" class="wiz-btn-sec" style="padding: 10px 20px;" @click="syncInsights" :disabled="actionLoading">
<i :class="actionLoading ? 'pi pi-spin pi-spinner' : 'pi pi-sync'"></i>
</button>
</div>
</div>
<!-- PIPELINE -->
<div v-if="pipelineVisible" class="mkt-card" style="margin-bottom: 32px; padding: 20px;">
<div class="grid grid-cols-5 gap-3">
<div v-for="(step, index) in pipelineSteps" :key="index"
class="wiz-pipe-node" :class="pipelineClass(index)">
<div class="wiz-pipe-icon"><i :class="pipelineIcon(index)"></i></div>
<div class="wiz-pipe-label">{{ step.title }}</div>
</div>
</div>
</div>
<div v-else-if="statusRef === 'ACTIVE'" class="mkt-card" style="margin-bottom: 32px; padding: 12px 24px; border-color: rgba(0,212,139,0.3); background: rgba(0,212,139,0.05); display: flex; align-items: center; gap: 12px;">
<i class="pi pi-check-circle" style="color: var(--kai-green); font-size: 20px;"></i>
<span style="font-weight: 700; color: var(--kai-green);">Кампания успешно запущена {{ formatDateTime(launchTimestamp) }}</span>
</div>
<!-- KPI GRID -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="mkt-card td-kpi-card">
<div class="td-kpi-label">Общий бюджет</div>
<div class="td-kpi-val">{{ formatKzt(budget.totalBudget.value) }}</div>
</div>
<div class="mkt-card td-kpi-card">
<div class="td-kpi-label">Дневной расход</div>
<div class="td-kpi-val">{{ formatKzt(budget.dailyBudget.value) }}</div>
</div>
<div class="mkt-card td-kpi-card">
<div class="td-kpi-label">Длительность</div>
<div class="td-kpi-val">{{ budget.campaignDays.value ?? '—' }} дн.</div>
</div>
<div class="mkt-card td-kpi-card">
<div class="td-kpi-label">Целевой охват</div>
<div class="td-kpi-val">{{ formatLargeNumber(budget.targetReach.value) }}</div>
</div>
</div>
<!-- MAIN CONTENT -->
<div class="mkt-card td-main-tabs" style="padding: 12px;">
<TabView v-model:activeIndex="activeTab">
<TabPanel header="Метрики" value="0">
<CampaignMetrics :performance-metrics="campaign.performanceMetrics" :predicted-metrics="predictionMetrics" />
</TabPanel>
<TabPanel header="ИИ Предсказание" value="1">
<CampaignPrediction :campaign-id="campaignId" />
</TabPanel>
<TabPanel header="Инсайты" value="2">
<CampaignInsights :campaign-id="campaignId" />
</TabPanel>
<TabPanel header="Аудитория" value="3">
<div class="grid gap-6 md:grid-cols-2 p-4">
<div class="mkt-card" style="background: rgba(255,255,255,0.02);">
<h3 class="wiz-label" style="opacity:0.7"><i class="pi pi-users"></i> Профиль аудитории</h3>
<div class="space-y-3 pt-4 text-sm" style="color: var(--kai-txt2);">
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Сегмент</span> {{ audienceProfile?.primarySegment || '—' }}</div>
<div class="grid grid-cols-2 gap-4">
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Возраст</span> {{ audienceProfile?.ageRange || '—' }}</div>
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Гендер</span> {{ audienceProfile?.gender || '—' }}</div>
</div>
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">География</span> {{ audienceProfile?.geography ? formatGeo(audienceProfile.geography) : '—' }}</div>
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Интересы</span> {{ formatArrayField(audienceProfile?.interests ?? null) }}</div>
</div>
</div>
<div class="mkt-card" style="background: rgba(255,255,255,0.02);">
<h3 class="wiz-label" style="opacity:0.7"><i class="pi pi-th-large"></i> Плейсменты и форматы</h3>
<div class="space-y-3 pt-4 text-sm" style="color: var(--kai-txt2);">
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Рекумендованные площадки</span> {{ formatArrayField(recommendation?.recommendedPlacements ?? null) }}</div>
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Форматы</span> {{ formatArrayField(recommendation?.recommendedAdFormats ?? null) }}</div>
<div><span class="text-white opacity-40 uppercase text-[10px] block mb-1">Тип таргетинга</span> {{ targetingTypeLabel(recommendation?.recommendedType ?? null) }}</div>
</div>
</div>
</div>
</TabPanel>
<TabPanel header="Структура" value="4">
<CampaignAdSets :ad-sets="campaign.adSets" />
</TabPanel>
</TabView>
</div>
</template>
</div>
</div>
</template>
<style scoped>
:root {
--kai-bg: #0A0E1A;
--kai-card: rgba(255,255,255,0.04);
--kai-card-hover: rgba(255,255,255,0.07);
--kai-border: rgba(45,123,255,0.15);
--kai-border-hover: rgba(45,123,255,0.4);
--kai-blue: #2D7BFF;
--kai-green: #00D48B;
--kai-txt: rgba(255,255,255,0.92);
--kai-txt2: rgba(255,255,255,0.60);
--kai-txt3: rgba(255,255,255,0.35);
}
.mkt-hub {
background: #0A0E1A;
min-height: 100vh;
font-family: 'Onest', sans-serif;
position: relative;
overflow: hidden;
padding: 48px 24px 80px;
color: var(--kai-txt);
}
.mkt-hub__glow1 { position: absolute; top: -120px; right: -100px; width: 600px; height: 600px; background: radial-gradient(circle, rgba(45,123,255,0.08) 0%, transparent 70%); pointer-events: none; }
.mkt-hub__glow2 { position: absolute; bottom: -80px; left: -60px; width: 400px; height: 400px; background: radial-gradient(circle, rgba(0,212,139,0.06) 0%, transparent 70%); pointer-events: none; }
.mkt-hub__inner { max-width: 1200px; margin: 0 auto; position: relative; z-index: 1; }
.mkt-hub__badge { display: inline-flex; align-items: center; gap: 8px; padding: 5px 14px; border-radius: 999px; border: 1px solid rgba(45,123,255,0.3); background: rgba(45,123,255,0.08); font-size: 11px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-blue); margin-bottom: 20px; }
.mkt-hub__badge-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--kai-blue); box-shadow: 0 0 8px var(--kai-blue); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { 0%,100% { opacity:1; transform: scale(1); } 50% { opacity:0.5; transform: scale(0.8); } }
.mkt-hub__title { font-family: 'Unbounded', sans-serif; font-size: clamp(24px, 4vw, 36px); font-weight: 900; color: var(--kai-txt); letter-spacing: -0.02em; line-height: 1.1; }
.mkt-card { background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 20px; padding: 24px; transition: all 0.3s; position: relative; overflow: hidden; }
.mkt-card::before { content: ''; position: absolute; inset: 0; opacity: 0; transition: opacity 0.3s; background: radial-gradient(circle at top right, rgba(45,123,255,0.1), transparent 60%); pointer-events: none; }
.mkt-card:hover { transform: translateY(-2px); border-color: var(--kai-border-hover); background: var(--kai-card-hover); }
.mkt-card:hover::before { opacity: 1; }
.td-back-mini { width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--kai-border); background: rgba(255,255,255,0.05); color: var(--kai-txt2); cursor: pointer; transition: all 0.2s; }
.td-back-mini:hover { color: #fff; background: var(--kai-blue); border-color: var(--kai-blue); }
.td-campaign__status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 999px; border: 1px solid; font-size: 11px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.td-kpi-card { padding: 20px; text-align: center; }
.td-kpi-label { font-size: 10px; font-weight: 700; color: var(--kai-txt3); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 8px; }
.td-kpi-val { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 900; color: #fff; }
.wiz-pipe-node { flex: 1; padding: 12px; border-radius: 14px; border: 1px solid var(--kai-border); background: rgba(255,255,255,0.02); text-align: center; filter: grayscale(1); opacity: 0.4; transition: all 0.5s; }
.wiz-pipe-node.wiz-pipe--done { filter: grayscale(0); opacity: 1; border-color: var(--kai-green); background: rgba(0,212,139,0.05); color: var(--kai-green); }
.wiz-pipe-node.wiz-pipe--active { filter: grayscale(0); opacity: 1; border-color: var(--kai-blue); background: rgba(45,123,255,0.1); color: var(--kai-blue); box-shadow: 0 0 15px rgba(45,123,255,0.2); }
.wiz-pipe-icon { font-size: 18px; margin-bottom: 4px; }
.wiz-pipe-label { font-size: 10px; font-weight: 700; text-transform: uppercase; }
.wiz-label { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.1em; color: var(--kai-txt3); }
.wiz-btn-sec { background: transparent; border: 1px solid var(--kai-border); color: var(--kai-txt2); padding: 12px 24px; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.wiz-btn-sec:hover { background: var(--kai-card-hover); color: #fff; }
.mkt-banner__cta { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: 12px; background: var(--kai-blue); color: #fff; font-size: 13px; font-weight: 700; font-family: 'Unbounded', sans-serif; transition: all 0.3s; cursor: pointer; }
.mkt-banner__cta:hover { background: #1a6bff; box-shadow: 0 0 24px rgba(45,123,255,0.4); transform: translateY(-2px); }
:deep(.p-tabview-nav) { background: transparent !important; border: none !important; margin-bottom: 20px; }
:deep(.p-tabview-nav li .p-tabview-nav-link) { background: transparent !important; color: var(--kai-txt3) !important; border-bottom: 2px solid transparent; font-family: 'Unbounded', sans-serif; font-size: 12px; font-weight: 700; box-shadow: none !important; }
:deep(.p-tabview-nav li.p-highlight .p-tabview-nav-link) { color: var(--kai-blue) !important; border-color: var(--kai-blue) !important; }
:deep(.p-tabview-panels) { background: transparent !important; padding: 0 !important; }
@media (max-width: 768px) {
.grid-cols-4,
.md\:grid-cols-4 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
</style>
@@ -0,0 +1,545 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useTargeting } from './composables/useTargeting';
import TargetingService from '@/service/TargetingService';
import MarketingV3Service from '@/service/MarketingV3Service';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import Checkbox from 'primevue/checkbox';
import InputSwitch from 'primevue/inputswitch';
import Dropdown from 'primevue/dropdown';
import Toast from 'primevue/toast';
const router = useRouter();
const route = useRoute();
const { createAiCampaign } = useTargeting();
const currentStep = ref(1);
// Step 1: Context Selection
const strategies = ref([]);
const loadingContext = ref(true);
const selectedStrategy = ref(null);
const incomingStrategyId = computed(() => String(route.query.strategyId || '').trim());
const normalizeStrategyList = (raw) => {
const list =
Array.isArray(raw) ? raw :
Array.isArray(raw?.content) ? raw.content :
Array.isArray(raw?.items) ? raw.items :
[];
return list.map((item, idx) => {
const id = item?.strategyId ?? item?.id ?? item?.uuid ?? `strategy-${idx}`;
const product = item?.analysisData?.requestData?.productName;
const niche = item?.analysisData?.requestData?.businessNiche;
const displayTitle =
item?.strategyName ||
item?.title ||
item?.name ||
item?.analysisTitle ||
(product && niche ? `${product}${niche}` : niche) ||
`Стратегия #${String(id).slice(-6)}`;
return {
...item,
id,
strategyId: item?.strategyId ?? id,
displayTitle
};
});
};
const findStrategyById = (list, id) => {
const wanted = String(id || '').trim();
return list.find((s) => [s?.strategyId, s?.id, s?.uuid].some((candidate) => String(candidate || '') === wanted));
};
onMounted(async () => {
try {
const sRes = await MarketingV3Service.getUserStrategies().catch(() => []);
strategies.value = normalizeStrategyList(sRes);
if (incomingStrategyId.value) {
selectedStrategy.value = findStrategyById(strategies.value, incomingStrategyId.value) || {
id: incomingStrategyId.value,
strategyId: incomingStrategyId.value,
displayTitle: `Стратегия #${incomingStrategyId.value.slice(-6)}`
};
currentStep.value = 2;
}
} finally {
loadingContext.value = false;
}
});
const canProceedStep1 = computed(() => !!selectedStrategy.value);
// Step 2: Campaign Config
const campaignName = ref('Запуск ИИ-кампании');
const budgetTotal = ref(150000);
const selectedObjective = ref('CONVERSIONS');
const objectives = [
{ label: 'Продажи', value: 'CONVERSIONS' },
{ label: 'Трафик', value: 'TRAFFIC' },
{ label: 'Охват', value: 'REACH' },
{ label: 'Лиды', value: 'LEADS' }
];
const selectedPlatforms = ref(['FB_IG', 'TIKTOK']);
// Step 3: AI Params
const enableAbTesting = ref(true);
const useAiParsing = ref(true);
// Final: Orchestrator Polling
const isGenerating = ref(false);
const activeOrchestratorPhase = ref(-1);
const isError = ref(false);
const errorMessage = ref('');
const orchestratorPhases = [
{ code: 'ORCHESTRATING', label: 'ИИ рассчитывает сегменты таргета...', icon: 'pi pi-microchip-ai' },
{ code: 'MAPPING', label: 'Собираем группы объявлений по стратегии...', icon: 'pi pi-sitemap' },
{ code: 'PUBLISHING', label: 'Публикуем в Meta/TikTok...', icon: 'pi pi-send' },
{ code: 'ACTIVE', label: 'Готово: кампания запущена.', icon: 'pi pi-check-circle' }
];
const startGeneration = async () => {
isGenerating.value = true;
activeOrchestratorPhase.value = 0;
isError.value = false;
try {
const sId = selectedStrategy.value?.strategyId || selectedStrategy.value?.id || incomingStrategyId.value;
if (!sId) {
throw new Error('Не удалось определить стратегию для запуска кампании');
}
const newId = await createAiCampaign({
name: campaignName.value,
goal: selectedObjective.value,
budgetKzt: budgetTotal.value,
platforms: selectedPlatforms.value,
targetAudiences: { trustedAI: useAiParsing.value },
strategyId: sId
});
let isDone = false;
while (!isDone) {
await new Promise(r => setTimeout(r, 3500));
// Use the dedicated /status endpoint for efficient polling
const statusPayload = await TargetingService.getCampaignStatus ?
await TargetingService.getCampaignStatus(newId) :
await TargetingService.getCampaignById(newId);
const topStatus = ((statusPayload.status || statusPayload.status) || '').toUpperCase();
const statusHistory = statusPayload.statusHistory || [];
// Update pipeline phase based on history
for (const entry of statusHistory) {
const idx = orchestratorPhases.findIndex(p => p.code === (entry.status || '').toUpperCase());
if (idx > activeOrchestratorPhase.value) {
activeOrchestratorPhase.value = idx;
}
}
// Also check top-level status
const directIdx = orchestratorPhases.findIndex(p => p.code === topStatus);
if (directIdx > activeOrchestratorPhase.value) {
activeOrchestratorPhase.value = directIdx;
}
const isSuccess = topStatus === 'ACTIVE' || topStatus === 'COMPLETED' || topStatus === 'PUBLISHED';
const isFailure = topStatus === 'FAILED' || topStatus === 'ERROR' || topStatus === 'CANCELLED';
if (isSuccess || isFailure) {
isDone = true;
if (isSuccess) {
activeOrchestratorPhase.value = 3;
await new Promise(r => setTimeout(r, 1200));
router.push(`/marketing-analysis/v3/targetologist/campaign/${newId}`);
} else {
isError.value = true;
const lastMsg = statusHistory.length ? statusHistory[statusHistory.length - 1].message : '';
errorMessage.value = "Ошибка запуска (" + topStatus + (lastMsg ? ': ' + lastMsg : '') + "). Проверьте доступы.";
}
}
}
} catch (e) {
isError.value = true;
errorMessage.value = "Сбой соединения с сервисом таргетинга: " + e?.message;
}
};
const nextStep = () => {
if (currentStep.value === 1 && !canProceedStep1.value) return;
if (currentStep.value < 3) currentStep.value++;
else startGeneration();
};
const prevStep = () => {
if (currentStep.value > 1) currentStep.value--;
};
const togglePlat = (val) => {
const idx = selectedPlatforms.value.indexOf(val);
if (idx > -1) selectedPlatforms.value.splice(idx, 1);
else selectedPlatforms.value.push(val);
};
</script>
<template>
<div class="mkt-hub app-dark">
<Toast />
<!-- AMBIENT GLOW -->
<div class="mkt-hub__glow1"></div>
<div class="mkt-hub__glow2"></div>
<div class="mkt-hub__inner">
<!-- HEADER -->
<div class="mkt-hub__header" style="text-align: left; margin-bottom: 40px; display: flex; align-items: flex-start; justify-content: space-between;">
<div>
<div class="mkt-hub__badge">
<span class="mkt-hub__badge-dot"></span>
AI Targetologist · Wizard
</div>
<h1 class="mkt-hub__title">
<span v-if="currentStep === 1">Контекст кампании</span>
<span v-else-if="currentStep === 2">Настройки запуска</span>
<span v-else-if="currentStep === 3">Параметры ИИ</span>
<span v-else>Запуск Машины</span>
</h1>
<p class="mkt-hub__sub" style="margin: 8px 0 0 0;">
<span v-if="currentStep === 1">ИИ использует выбранную маркетинговую стратегию как основу.</span>
<span v-else-if="currentStep === 2">Определите бюджет и основные цели рекламы.</span>
<span v-else-if="currentStep === 3">Включите умные алгоритмы для максимальной эффективности.</span>
<span v-else>ИИ-Targetologist формирует рекламные структуры...</span>
</p>
</div>
<div v-if="!isGenerating" class="flex gap-2 pt-4">
<div v-for="step in 3" :key="step"
class="wiz-step-pill"
:class="{ 'wiz-step-pill--active': step <= currentStep }">
</div>
</div>
</div>
<transition name="fade-slide" mode="out-in">
<!-- STEP 1: CONTEXT -->
<div v-if="currentStep === 1 && !isGenerating" key="step1" class="wiz-container">
<div v-if="loadingContext" class="space-y-4">
<div class="wiz-skeleton" style="height: 120px;"></div>
<div class="wiz-skeleton" style="height: 120px;"></div>
</div>
<div v-else class="space-y-6">
<div class="mkt-card wiz-card">
<label class="wiz-label"><i class="pi pi-compass"></i> Маркетинговая стратегия</label>
<Dropdown v-model="selectedStrategy" :options="strategies" optionLabel="displayTitle"
placeholder="Выберите стратегию..."
class="wiz-dropdown"
panelClass="wiz-dropdown-panel"
style="width: 100%;">
<template #value="slotProps">
<div v-if="slotProps.value" class="flex items-center gap-3 font-bold text-white">
<div class="wiz-icon-mini"><i class="pi pi-map"></i></div>
{{ slotProps.value.displayTitle }}
</div>
<span v-else>{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="flex items-center gap-3 py-1">
<i class="pi pi-map" style="opacity: 0.5;"></i>
<div class="font-bold">{{ slotProps.option.displayTitle }}</div>
</div>
</template>
</Dropdown>
</div>
<div class="mkt-card" style="padding: 24px; background: rgba(45,123,255,0.05); border-color: rgba(45,123,255,0.2);">
<div class="flex gap-4">
<i class="pi pi-info-circle" style="color: var(--kai-blue); font-size: 20px;"></i>
<p class="mkt-card__desc" style="color: var(--kai-txt);">ИИ автоматически проанализирует рынок и сегменты из стратегии для настройки точного таргетинга.</p>
</div>
</div>
</div>
</div>
<!-- STEP 2: CONFIG -->
<div v-else-if="currentStep === 2 && !isGenerating" key="step2" class="wiz-container">
<div class="mkt-card wiz-card" style="margin-bottom: 24px;">
<label class="wiz-label">Название кампании</label>
<input v-model="campaignName" type="text" class="wiz-input" placeholder="Например: Осенний запуск..." />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="mkt-card wiz-card">
<label class="wiz-label"><i class="pi pi-wallet"></i> Бюджет (KZT)</label>
<InputNumber v-model="budgetTotal" mode="currency" currency="KZT" locale="ru-RU" :minFractionDigits="0" class="wiz-input-num" style="width: 100%;" />
</div>
<div class="mkt-card wiz-card">
<label class="wiz-label"><i class="pi pi-bullseye"></i> Цель</label>
<Dropdown v-model="selectedObjective" :options="objectives" optionLabel="label" optionValue="value" class="wiz-dropdown" style="width: 100%;" />
</div>
</div>
<div class="mkt-card wiz-card">
<label class="wiz-label" style="margin-bottom: 16px;"><i class="pi pi-share-alt"></i> Платформы размещения</label>
<div class="flex gap-4">
<div class="wiz-platform" :class="{ 'wiz-platform--active': selectedPlatforms.includes('FB_IG') }" @click="togglePlat('FB_IG')">
<i class="pi pi-facebook" style="color: #1877F2;"></i>
<span class="font-bold">Meta Ads</span>
<div class="wiz-check"><i class="pi pi-check" v-if="selectedPlatforms.includes('FB_IG')"></i></div>
</div>
<div class="wiz-platform" :class="{ 'wiz-platform--active': selectedPlatforms.includes('TIKTOK') }" @click="togglePlat('TIKTOK')">
<i class="pi pi-tiktok" style="color: #fff;"></i>
<span class="font-bold">TikTok</span>
<div class="wiz-check"><i class="pi pi-check" v-if="selectedPlatforms.includes('TIKTOK')"></i></div>
</div>
</div>
</div>
</div>
<!-- STEP 3: AI PARAMS -->
<div v-else-if="currentStep === 3 && !isGenerating" key="step3" class="wiz-container">
<div class="space-y-4">
<div class="mkt-card wiz-toggle-card" :class="{ 'wiz-toggle-card--active': enableAbTesting }" @click="enableAbTesting = !enableAbTesting">
<div class="wiz-icon-main" style="background: rgba(45,123,255,0.1); color: var(--kai-blue);"><i class="pi pi-copy"></i></div>
<div class="flex-1">
<h4 class="wiz-toggle-title">A/B Тестирование</h4>
<p class="wiz-toggle-sub">Автоматический сплит бюджета между вариантами креативов.</p>
</div>
<InputSwitch v-model="enableAbTesting" @click.stop />
</div>
<div class="mkt-card wiz-toggle-card" :class="{ 'wiz-toggle-card--active': useAiParsing }" @click="useAiParsing = !useAiParsing">
<div class="wiz-icon-main" style="background: rgba(168,85,247,0.1); color: var(--kai-purple);"><i class="pi pi-microchip-ai"></i></div>
<div class="flex-1">
<h4 class="wiz-toggle-title">ИИ-разбор концептов</h4>
<p class="wiz-toggle-sub">Генерация рекламных офферов на базе текстов стратегии.</p>
</div>
<InputSwitch v-model="useAiParsing" @click.stop />
</div>
</div>
</div>
<!-- GENERATING -->
<div v-else-if="isGenerating" key="generating" class="wiz-loading-view">
<div class="mkt-card wiz-loading-card">
<div class="wiz-loading-orb"></div>
<div class="text-center mb-10 relative z-10">
<div class="wiz-loading-icon"><i class="pi pi-bolt"></i></div>
<h2 class="wiz-loading-title">Запуск Машины</h2>
</div>
<div v-if="isError" class="wiz-error">
<i class="pi pi-exclamation-triangle"></i>
<h3 class="font-bold">Ошибка запуска</h3>
<p>{{ errorMessage }}</p>
<button class="mkt-banner__cta" style="margin: 16px auto 0; border:none;" @click="isGenerating = false">Вернуться</button>
</div>
<div v-else class="wiz-pipeline pl-6">
<div class="wiz-pipeline-line"></div>
<div v-for="(phase, idx) in orchestratorPhases" :key="idx"
class="wiz-pipeline-item"
:class="{ 'wiz-pipeline-item--active': idx <= activeOrchestratorPhase }">
<div class="wiz-pipeline-dot">
<i v-if="idx < activeOrchestratorPhase" class="pi pi-check"></i>
<i v-else-if="idx === activeOrchestratorPhase" :class="phase.icon" class="animate-spin-slow"></i>
</div>
<div class="wiz-pipeline-content">
<div class="wiz-pipeline-label">{{ phase.label }}</div>
<div v-if="idx === activeOrchestratorPhase" class="wiz-pipeline-status">Обработка...</div>
</div>
</div>
</div>
</div>
</div>
</transition>
<!-- FOOTER -->
<div v-if="!isGenerating" class="wiz-footer">
<button class="wiz-btn-sec" @click="prevStep" :disabled="currentStep === 1">
<i class="pi pi-arrow-left"></i> Назад
</button>
<button v-if="currentStep < 3" class="mkt-banner__cta" style="border:none; cursor: pointer;" @click="nextStep" :disabled="currentStep === 1 && !canProceedStep1">
Далее <i class="pi pi-arrow-right"></i>
</button>
<button v-else class="mkt-banner__cta" style="border:none; cursor: pointer; background: var(--kai-green);" @click="startGeneration">
<i class="pi pi-sparkles"></i> Запустить кампанию
</button>
</div>
</div>
</div>
</template>
<style scoped>
:root {
--kai-bg: #0A0E1A;
--kai-card: rgba(255,255,255,0.04);
--kai-card-hover: rgba(255,255,255,0.07);
--kai-border: rgba(45,123,255,0.15);
--kai-border-hover: rgba(45,123,255,0.4);
--kai-blue: #2D7BFF;
--kai-green: #00D48B;
--kai-purple: #A855F7;
--kai-txt: rgba(255,255,255,0.92);
--kai-txt2: rgba(255,255,255,0.60);
--kai-txt3: rgba(255,255,255,0.35);
}
.mkt-hub {
background: #0A0E1A;
min-height: 100vh;
font-family: 'Onest', sans-serif;
position: relative;
overflow: hidden;
padding: 48px 24px 80px;
color: var(--kai-txt);
}
.mkt-hub__glow1 {
position: absolute;
top: -120px;
right: -100px;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(45,123,255,0.08) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__glow2 {
position: absolute;
bottom: -80px;
left: -60px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0,212,139,0.06) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__inner {
max-width: 1000px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.mkt-hub__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(45,123,255,0.3);
background: rgba(45,123,255,0.08);
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--kai-blue);
margin-bottom: 20px;
}
.mkt-hub__badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--kai-blue);
box-shadow: 0 0 8px var(--kai-blue);
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot { 0%,100% { opacity:1; transform: scale(1); } 50% { opacity:0.5; transform: scale(0.8); } }
.mkt-hub__title { font-family: 'Unbounded', sans-serif; font-size: clamp(24px, 4vw, 42px); font-weight: 900; color: var(--kai-txt); letter-spacing: -0.02em; line-height: 1.1; }
.mkt-hub__sub { font-size: 15px; color: var(--kai-txt2); line-height: 1.6; font-weight: 400; }
.mkt-card {
background: var(--kai-card);
border: 1px solid var(--kai-border);
border-radius: 20px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
position: relative;
overflow: hidden;
}
.mkt-card::before { content: ''; position: absolute; inset: 0; opacity: 0; transition: opacity 0.3s; background: radial-gradient(circle at top right, rgba(45,123,255,0.1), transparent 60%); pointer-events: none; }
.mkt-card:hover { transform: translateY(-4px); border-color: var(--kai-border-hover); background: var(--kai-card-hover); }
.mkt-card:hover::before { opacity: 1; }
.wiz-step-pill { width: 40px; height: 4px; border-radius: 2px; background: var(--kai-border); transition: all 0.4s; }
.wiz-step-pill--active { background: var(--kai-blue); box-shadow: 0 0 10px var(--kai-blue); width: 60px; }
.wiz-container { width: 100%; animation: wiz-fade-in 0.6s ease-out; }
@keyframes wiz-fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.wiz-card { padding: 32px; }
.wiz-label { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; letter-spacing: 0.1em; color: var(--kai-txt3); margin-bottom: 12px; }
.wiz-dropdown :deep(.p-dropdown) { background: rgba(255,255,255,0.03); border: 1px solid var(--kai-border); border-radius: 12px; padding: 6px 12px; width: 100%; }
.wiz-dropdown :deep(.p-dropdown-label) { font-family: 'Onest', sans-serif; font-weight: 600; color: var(--kai-txt); }
.wiz-icon-mini { width: 32px; height: 32px; border-radius: 8px; background: rgba(45,123,255,0.15); color: var(--kai-blue); display: flex; align-items: center; justify-content: center; }
.wiz-input { width: 100%; background: rgba(255,255,255,0.03); border: 1px solid var(--kai-border); border-radius: 12px; padding: 14px 20px; color: #fff; font-family: 'Onest', sans-serif; font-size: 18px; font-weight: 600; outline: none; transition: all 0.3s; }
.wiz-input:focus { border-color: var(--kai-blue); background: rgba(255,255,255,0.06); box-shadow: 0 0 20px rgba(45,123,255,0.1); }
.wiz-input-num :deep(.p-inputtext) { background: transparent; border: none; font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 900; color: #fff; padding: 0; }
.wiz-platform { flex: 1; padding: 24px; background: rgba(255,255,255,0.03); border: 1px solid var(--kai-border); border-radius: 20px; display: flex; flex-direction: column; align-items: center; gap: 12px; cursor: pointer; transition: all 0.3s; position: relative; }
.wiz-platform:hover { border-color: var(--kai-border-hover); transform: translateY(-2px); }
.wiz-platform--active { background: rgba(45,123,255,0.1); border-color: var(--kai-blue); box-shadow: 0 0 20px rgba(45,123,255,0.15); }
.wiz-platform i { font-size: 32px; }
.wiz-check { position: absolute; top: 16px; right: 16px; width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--kai-border); display: flex; align-items: center; justify-content: center; }
.wiz-platform--active .wiz-check { background: var(--kai-blue); border-color: var(--kai-blue); color: #fff; }
.wiz-toggle-card { display: flex; align-items: center; gap: 20px; padding: 24px; cursor: pointer; }
.wiz-toggle-card--active { border-color: var(--kai-blue); background: rgba(45,123,255,0.06); }
.wiz-icon-main { width: 56px; height: 56px; border-radius: 16px; display: flex; align-items: center; justify-content: center; font-size: 24px; }
.wiz-toggle-title { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 700; color: var(--kai-txt); margin-bottom: 4px; }
.wiz-toggle-sub { font-size: 13px; color: var(--kai-txt3); }
.wiz-footer { margin-top: 60px; padding-top: 40px; border-top: 1px solid var(--kai-border); display: flex; justify-content: space-between; align-items: center; }
.mkt-banner__cta { display: flex; align-items: center; gap: 8px; padding: 14px 32px; border-radius: 14px; background: var(--kai-blue); color: #fff; font-size: 14px; font-weight: 700; font-family: 'Unbounded', sans-serif; transition: all 0.3s; }
.mkt-banner__cta:hover { background: #1a6bff; box-shadow: 0 0 24px rgba(45,123,255,0.4); transform: translateY(-2px); }
.wiz-btn-sec { background: transparent; border: 1px solid var(--kai-border); color: var(--kai-txt2); padding: 12px 28px; border-radius: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
.wiz-btn-sec:hover:not(:disabled) { background: var(--kai-card-hover); color: #fff; }
.wiz-loading-view { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 0; }
.wiz-loading-card { background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 40px; padding: 60px; width: 100%; max-width: 540px; position: relative; overflow: hidden; }
.wiz-loading-orb { position: absolute; top: -50px; right: -50px; width: 250px; height: 250px; background: radial-gradient(circle, rgba(45,123,255,0.15) 0%, transparent 70%); filter: blur(40px); }
.wiz-loading-icon { font-size: 48px; color: var(--kai-blue); margin-bottom: 24px; filter: drop-shadow(0 0 15px var(--kai-blue)); }
.wiz-loading-title { font-family: 'Unbounded', sans-serif; font-size: 28px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.1em; color: #fff; }
.wiz-pipeline { position: relative; }
.wiz-pipeline-line { position: absolute; left: 17px; top: 10px; bottom: 10px; width: 2px; background: var(--kai-border); }
.wiz-pipeline-item { display: flex; gap: 20px; margin-bottom: 28px; opacity: 0.3; transition: all 0.5s; }
.wiz-pipeline-item--active { opacity: 1; }
.wiz-pipeline-dot { width: 36px; height: 36px; border-radius: 50%; background: var(--kai-bg); border: 2px solid var(--kai-border); display: flex; align-items: center; justify-content: center; z-index: 1; transition: all 0.5s; color: var(--kai-txt3); }
.wiz-pipeline-item--active .wiz-pipeline-dot { border-color: var(--kai-blue); color: var(--kai-blue); box-shadow: 0 0 20px rgba(45,123,255,0.3); }
.animate-spin-slow { animation: spin 3s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.wiz-error { text-align: center; padding: 32px; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); border-radius: 24px; color: #ef4444; }
.wiz-skeleton { background: rgba(255,255,255,0.03); border-radius: 20px; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.3; } }
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
.fade-slide-enter-from { opacity: 0; transform: translateX(20px); }
.fade-slide-leave-to { opacity: 0; transform: translateX(-20px); }
@media (max-width: 768px) { .mkt-hub { padding: 32px 16px 60px; } }
</style>
@@ -0,0 +1,615 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useTargeting } from './composables/useTargeting';
import TargetingService from '@/service/TargetingService';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import OAuthConnectionWidget from './components/OAuthConnectionWidget.vue';
const { state, fetchCampaigns, connectAccount, formatCurrency } = useTargeting();
const toast = useToast();
const campaigns = ref([]);
const loading = ref(true);
onMounted(async () => {
try { campaigns.value = await fetchCampaigns(); }
finally { loading.value = false; }
});
const STATUS_META = {
ACTIVE: { color: 'var(--kai-green)', bg: 'rgba(0,212,139,0.12)', border: 'rgba(0,212,139,0.3)', icon: 'pi pi-bolt', label: 'ACTIVE' },
PAUSED: { color: 'var(--kai-yellow)', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', icon: 'pi pi-pause', label: 'PAUSED' },
FAILED: { color: 'var(--kai-red)', bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.3)', icon: 'pi pi-exclamation-triangle', label: 'FAILED' },
GENERATING: { color: 'var(--kai-blue)', bg: 'rgba(45,123,255,0.12)', border: 'rgba(45,123,255,0.3)', icon: 'pi pi-spin pi-cog', label: 'GENERATING' },
};
const sm = s => STATUS_META[s] || { color: 'var(--kai-txt3)', bg: 'rgba(255,255,255,0.05)', border: 'rgba(255,255,255,0.1)', icon: 'pi pi-circle', label: s };
const totalBudget = () => campaigns.value.reduce((sum, c) => sum + (c.budgetKzt || 0), 0);
const totalSpent = () => campaigns.value.reduce((sum, c) => sum + (c.spentKzt || 0), 0);
const activeCnt = () => campaigns.value.filter(c => c.status === 'ACTIVE').length;
const handleConnect = async e => await connectAccount(e.platform, e.accountId, e.accountName);
const toggleStatus = async cmp => {
try {
if (cmp.status === 'ACTIVE') {
await TargetingService.pauseCampaign(cmp.id);
cmp.status = 'PAUSED';
toast.add({ severity: 'info', summary: 'На паузе', life: 3000 });
} else if (cmp.status === 'PAUSED') {
await TargetingService.resumeCampaign(cmp.id);
cmp.status = 'ACTIVE';
toast.add({ severity: 'success', summary: 'Возобновлена', life: 3000 });
}
} catch { toast.add({ severity: 'error', summary: 'Ошибка', life: 3000 }); }
};
const searchQ = ref('');
const filteredCampaigns = () => campaigns.value.filter(c =>
!searchQ.value || c.name?.toLowerCase().includes(searchQ.value.toLowerCase())
);
</script>
<template>
<div class="mkt-hub">
<Toast />
<!-- AMBIENT GLOW -->
<div class="mkt-hub__glow1"></div>
<div class="mkt-hub__glow2"></div>
<div class="mkt-hub__inner">
<!-- HEADER -->
<div class="mkt-hub__header" style="text-align: left; margin-bottom: 40px;">
<div class="flex items-center gap-4 mb-6">
<button class="td-back" @click="$router.push('/marketing-analysis')">
<i class="pi pi-arrow-left"></i>
</button>
<div>
<div class="mkt-hub__badge" style="margin-bottom: 0;">
<span class="mkt-hub__badge-dot"></span>
AI Targetologist · Beta
</div>
</div>
</div>
<div class="flex items-center justify-between gap-6 flex-wrap">
<div>
<h1 class="mkt-hub__title" style="margin-bottom: 8px;">Кабинет Таргетолога</h1>
<p class="mkt-hub__sub" style="margin: 0; text-align: left;">Управление рекламными кампаниями и AI-оптимизация бюджета</p>
</div>
<button class="td-launch-btn" @click="$router.push('/marketing-analysis/v3/targetologist/wizard')">
<i class="pi pi-sparkles"></i>
Создать AI кампанию
</button>
</div>
</div>
<!-- KPI ROW -->
<div class="mkt-hub__grid" style="grid-template-columns: repeat(4, 1fr); margin-bottom: 40px;">
<div class="mkt-card">
<div class="mkt-card__icon" style="background:rgba(45,123,255,0.15);color:var(--kai-blue);">
<i class="pi pi-list"></i>
</div>
<div class="td-kpi__num">{{ campaigns.length }}</div>
<div class="td-kpi__label">Кампаний всего</div>
</div>
<div class="mkt-card">
<div class="mkt-card__icon" style="background:rgba(0,212,139,0.15);color:var(--kai-green);">
<i class="pi pi-bolt"></i>
</div>
<div class="td-kpi__num" style="color:var(--kai-green);">{{ activeCnt() }}</div>
<div class="td-kpi__label">Активно сейчас</div>
</div>
<div class="mkt-card">
<div class="mkt-card__icon" style="background:rgba(255,122,45,0.15);color:var(--kai-orange);">
<i class="pi pi-dollar"></i>
</div>
<div class="td-kpi__num">{{ formatCurrency(totalSpent()) }}</div>
<div class="td-kpi__label">Всего потрачено</div>
</div>
<div class="mkt-card">
<div class="mkt-card__icon" style="background:rgba(168,85,247,0.15);color:var(--kai-purple);">
<i class="pi pi-wallet"></i>
</div>
<div class="td-kpi__num">{{ formatCurrency(totalBudget()) }}</div>
<div class="td-kpi__label">Общий лимит</div>
</div>
</div>
<!-- INTEGRATIONS -->
<div class="td-section-label">
<i class="pi pi-link" style="color:var(--kai-txt3)"></i>
Рекламные кабинеты
</div>
<div class="mkt-hub__grid" style="grid-template-columns: repeat(2, 1fr); margin-bottom: 40px;">
<OAuthConnectionWidget
platform="facebook"
title="Facebook & Instagram"
subtitle="Ads Manager API"
icon="pi pi-facebook"
themeClasses="bg-blue-500/10 blue"
:isConnected="state.accounts.facebook"
:accountName="state.accountNames.facebook"
@connect="handleConnect"
/>
<OAuthConnectionWidget
platform="tiktok"
title="TikTok for Business"
subtitle="Marketing API"
icon="pi pi-tiktok"
themeClasses="bg-rose-500/10 rose"
:isConnected="state.accounts.tiktok"
:accountName="state.accountNames.tiktok"
@connect="handleConnect"
/>
</div>
<!-- CAMPAIGNS -->
<div class="td-campaigns-header">
<div class="td-section-label" style="margin-bottom:0;">
<i class="pi pi-chart-bar" style="color:var(--kai-txt3)"></i>
Список кампаний
</div>
<div class="td-search">
<i class="pi pi-search td-search__icon"></i>
<input v-model="searchQ" type="text" placeholder="Найти кампанию..." class="td-search__input" />
</div>
</div>
<!-- SKELETON / EMPTY / LIST -->
<div v-if="loading" class="td-skeleton-list">
<div v-for="i in 3" :key="i" class="td-skeleton-item">
<div class="td-skeleton-bar"></div>
</div>
</div>
<div v-else-if="filteredCampaigns().length === 0" class="mkt-card" style="text-align: center; padding: 60px 24px;">
<div class="td-empty__icon"><i class="pi pi-chart-line"></i></div>
<h2 class="mkt-card__title" style="font-size: 20px;">Нет запущенных кампаний</h2>
<p class="mkt-card__desc" style="max-width: 420px; margin: 0 auto 24px;">ИИ-Таргетолог готов автоматизировать ваши расходы на рекламу, протестировать креативы и найти идеальную аудиторию.</p>
<button class="td-launch-btn" style="margin: 0 auto;" @click="$router.push('/marketing-analysis/v3/targetologist/wizard')">
<i class="pi pi-bolt"></i> Запустить первую кампанию
</button>
</div>
<div v-else class="td-campaign-list">
<div v-for="cmp in filteredCampaigns()" :key="cmp.id"
class="mkt-card td-campaign"
@click="$router.push(`/marketing-analysis/v3/targetologist/campaign/${cmp.id}`)">
<div class="td-campaign__bar" :style="{ background: sm(cmp.status).color }"></div>
<div class="td-campaign__body">
<div class="td-campaign__top">
<div class="td-campaign__info">
<h4 class="mkt-card__title" style="margin-bottom: 4px;">{{ cmp.name }}</h4>
<div class="td-campaign__meta">
<span><i class="pi pi-flag"></i> {{ cmp.goal }}</span>
<span>
<i v-if="cmp.platforms?.some(p=>p.includes('Insta'))" class="pi pi-instagram"></i>
<i v-if="cmp.platforms?.some(p=>p.includes('Face'))" class="pi pi-facebook"></i>
<i v-if="cmp.platforms?.some(p=>p.includes('Tik'))" class="pi pi-tiktok"></i>
</span>
</div>
</div>
<div class="td-campaign__status"
:style="{ color: sm(cmp.status).color, background: sm(cmp.status).bg, borderColor: sm(cmp.status).border }">
<i :class="sm(cmp.status).icon"></i>
{{ sm(cmp.status).label }}
</div>
</div>
<div class="td-campaign__bottom">
<div class="td-metric">
<div class="td-metric__label">Потрачено</div>
<div class="td-metric__val">{{ formatCurrency(cmp.spentKzt || 0) }}</div>
<div class="td-metric__bar">
<div class="td-metric__bar-fill" :style="{ width: `${Math.min(((cmp.spentKzt||0)/(cmp.budgetKzt||1))*100,100)}%` }"></div>
</div>
</div>
<div class="td-metric">
<div class="td-metric__label">CTR</div>
<div class="td-metric__val" style="color:var(--kai-green);">{{ cmp.ctr || '0.00' }}%</div>
</div>
<div class="td-metric">
<div class="td-metric__label">Охват</div>
<div class="td-metric__val">{{ new Intl.NumberFormat('ru-RU').format(cmp.reach || 0) }}</div>
</div>
<div class="td-campaign__actions" @click.stop>
<button v-if="cmp.status === 'ACTIVE'" class="td-act-btn td-act-btn--pause" @click="toggleStatus(cmp)" title="Пауза">
<i class="pi pi-pause"></i>
</button>
<button v-if="cmp.status === 'PAUSED'" class="td-act-btn td-act-btn--play" @click="toggleStatus(cmp)" title="Возобновить">
<i class="pi pi-play"></i>
</button>
<button class="td-act-btn" @click="$router.push(`/marketing-analysis/v3/targetologist/campaign/${cmp.id}`)" title="Аналитика">
<i class="pi pi-external-link"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ───── Page layout (from MarketingMain) ───── */
.mkt-hub {
background: var(--kai-bg);
min-height: 100vh;
font-family: 'Onest', sans-serif;
position: relative;
overflow: hidden;
padding: 48px 24px 80px;
}
.mkt-hub__glow1 {
position: absolute;
top: -120px;
right: -100px;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(45,123,255,0.08) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__glow2 {
position: absolute;
bottom: -80px;
left: -60px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0,212,139,0.06) 0%, transparent 70%);
pointer-events: none;
}
.mkt-hub__inner {
max-width: 1100px;
margin: 0 auto;
position: relative;
z-index: 1;
}
/* ───── Header (V4 Style) ───── */
.mkt-hub__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(45,123,255,0.3);
background: rgba(45,123,255,0.08);
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--kai-blue);
margin-bottom: 20px;
}
.mkt-hub__badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--kai-blue);
box-shadow: 0 0 8px var(--kai-blue);
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%,100% { opacity:1; transform: scale(1); }
50% { opacity:0.5; transform: scale(0.8); }
}
.mkt-hub__title {
font-family: 'Unbounded', sans-serif;
font-size: clamp(24px, 4vw, 36px);
font-weight: 900;
color: var(--kai-txt);
letter-spacing: -0.02em;
line-height: 1.1;
}
.mkt-hub__sub {
font-size: 15px;
color: var(--kai-txt2);
line-height: 1.6;
font-weight: 400;
}
/* ───── Grid ───── */
.mkt-hub__grid {
display: grid;
gap: 16px;
}
@media (max-width: 768px) {
.mkt-hub__grid { grid-template-columns: 1fr !important; }
}
/* ───── Cards (V4 Style) ───── */
.mkt-card {
background: var(--kai-card);
border: 1px solid var(--kai-border);
border-radius: 20px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
position: relative;
overflow: hidden;
}
.mkt-card:hover {
transform: translateY(-4px);
border-color: var(--kai-border-hover);
background: var(--kai-card-hover);
box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 40px rgba(45,123,255,0.08);
}
.mkt-card__icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 16px;
transition: transform 0.3s;
}
.mkt-card:hover .mkt-card__icon { transform: scale(1.1) rotate(3deg); }
.mkt-card__title {
font-family: 'Unbounded', sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--kai-txt);
line-height: 1.25;
}
.mkt-card__desc {
font-size: 13px;
color: var(--kai-txt2);
line-height: 1.6;
font-weight: 400;
}
/* ───── Targetologist Specifics ───── */
.td-back {
width: 36px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--kai-border);
background: var(--kai-card);
color: var(--kai-txt2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.td-back:hover {
background: var(--kai-card-hover);
color: var(--kai-txt);
border-color: var(--kai-border-hover);
transform: translateX(-2px);
}
.td-launch-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 14px;
background: var(--kai-blue);
color: #fff;
font-size: 13px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
border: none;
cursor: pointer;
transition: all 0.3s;
}
.td-launch-btn:hover {
background: #1a6bff;
box-shadow: 0 0 28px rgba(45,123,255,0.45);
transform: translateY(-2px);
}
.td-kpi__num {
font-family: 'Unbounded', sans-serif;
font-size: 22px;
font-weight: 900;
color: var(--kai-txt);
line-height: 1;
margin-bottom: 6px;
}
.td-kpi__label {
font-size: 11px;
color: var(--kai-txt3);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.td-section-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--kai-txt2);
margin-bottom: 20px;
}
.td-campaigns-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.td-search {
position: relative;
}
.td-search__icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--kai-txt3);
font-size: 14px;
}
.td-search__input {
padding: 10px 16px 10px 44px;
border-radius: 12px;
background: var(--kai-card);
border: 1px solid var(--kai-border);
color: var(--kai-txt);
font-size: 14px;
font-family: 'Onest', sans-serif;
outline: none;
width: 280px;
transition: all 0.2s;
}
.td-search__input:focus {
border-color: var(--kai-blue);
background: var(--kai-card-hover);
width: 320px;
}
.td-campaign-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.td-campaign {
padding: 0;
display: flex;
cursor: pointer;
}
.td-campaign__bar {
width: 4px;
flex-shrink: 0;
}
.td-campaign__body {
flex: 1;
padding: 24px;
}
.td-campaign__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.td-campaign__meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--kai-txt3);
align-items: center;
}
.td-campaign__status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 14px;
border-radius: 999px;
border: 1px solid;
font-size: 10px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.td-campaign__bottom {
display: flex;
align-items: center;
gap: 32px;
}
.td-metric {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.td-metric__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--kai-txt3);
}
.td-metric__val {
font-family: 'Unbounded', sans-serif;
font-size: 18px;
font-weight: 900;
color: var(--kai-txt);
line-height: 1;
}
.td-metric__bar {
height: 4px;
background: rgba(255,255,255,0.06);
border-radius: 999px;
overflow: hidden;
margin-top: 6px;
width: 100%;
}
.td-metric__bar-fill {
height: 100%;
background: var(--kai-blue);
border-radius: 999px;
transition: width 0.8s cubic-bezier(0.4,0,0.2,1);
}
.td-campaign__actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.td-act-btn {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--kai-border);
background: rgba(255,255,255,0.04);
color: var(--kai-txt2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.td-act-btn:hover {
background: var(--kai-card-hover);
color: var(--kai-txt);
border-color: var(--kai-border-hover);
transform: scale(1.05);
}
.td-empty__icon {
width: 72px;
height: 72px;
border-radius: 20px;
background: rgba(45,123,255,0.1);
border: 1px solid rgba(45,123,255,0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
color: var(--kai-blue);
margin: 0 auto 20px;
}
.td-skeleton-list { display: flex; flex-direction: column; gap: 12px; }
.td-skeleton-item { height: 140px; background: var(--kai-card); border: 1px solid var(--kai-border); border-radius: 20px; padding: 24px; }
.td-skeleton-bar { height: 20px; background: rgba(255,255,255,0.04); border-radius: 10px; width: 60%; }
</style>
@@ -0,0 +1,506 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue';
import { useEnumLabels } from '@/composables/useEnumLabels';
import { resolveMarketingMediaUrl } from '@/composables/useMarketingMedia';
import MarketingV3Service from '@/service/MarketingV3Service';
import type { AdCreative, CampaignAdSet } from '@/types/campaign.types';
const props = defineProps<{
adSets: CampaignAdSet[] | null | undefined;
}>();
const { platformLabel } = useEnumLabels();
const expandedText = ref<Record<string, boolean>>({});
const blobUrls = ref<Record<string, string>>({});
const normalizeMediaKey = (value: string): string => value.split('?')[0]?.trim() ?? value.trim();
const extractFilename = (value: string): string => {
const key = normalizeMediaKey(value);
const parts = key.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : key;
};
const currencyFormatter = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT',
maximumFractionDigits: 0
});
const cleanAdSetName = (name: string | null | undefined): string => {
if (!name) {
return 'Без названия';
}
const cleaned = name
.replace(/[_#{}[\]()/\\]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return cleaned || 'Без названия';
};
const platformIcon = (platform: string | null | undefined): string => {
const value = (platform ?? '').toUpperCase();
if (value === 'INSTAGRAM') {
return 'pi pi-instagram';
}
if (value === 'FACEBOOK') {
return 'pi pi-facebook';
}
if (value === 'TIKTOK') {
return 'pi pi-tiktok';
}
if (value === 'YOUTUBE') {
return 'pi pi-youtube';
}
return 'pi pi-globe';
};
const platformClass = (platform: string | null | undefined): string => {
const value = (platform ?? '').toUpperCase();
if (value === 'INSTAGRAM') {
return 'bg-pink-100 text-pink-700';
}
if (value === 'FACEBOOK') {
return 'bg-blue-100 text-blue-700';
}
if (value === 'TIKTOK') {
return 'bg-slate-200 text-slate-700';
}
if (value === 'YOUTUBE') {
return 'bg-red-100 text-red-700';
}
return 'bg-slate-100 text-slate-700';
};
const statusSeverity = (status: string | null | undefined): 'success' | 'warn' | 'secondary' => {
const value = (status ?? '').toUpperCase();
if (value === 'ACTIVE') {
return 'success';
}
if (value === 'PAUSED') {
return 'warn';
}
return 'secondary';
};
const formatBudget = (value: number | null | undefined): string => {
if (value == null) {
return '—';
}
return currencyFormatter.format(value);
};
const getAdKey = (ad: AdCreative, adIndex: number): string => ad.adId ?? ad.id ?? `ad-${adIndex}`;
const isVideoMedia = (ad: AdCreative): boolean => {
const url = ad.mediaUrl?.toLowerCase() ?? '';
const type = ad.contentType?.toLowerCase() ?? '';
return type.includes('video') || url.endsWith('.mp4') || url.endsWith('.mov');
};
const resolveMediaUrl = (value: string | null | undefined): string => {
if (!value) return '';
const key = normalizeMediaKey(value);
return blobUrls.value[key] || resolveMarketingMediaUrl(key);
};
const mediaTypeLabel = (ad: AdCreative): string => (isVideoMedia(ad) ? 'Видео' : 'Изображение');
const mediaTypeIcon = (ad: AdCreative): string => (isVideoMedia(ad) ? 'pi pi-video' : 'pi pi-image');
const truncateText = (text: string | null | undefined, key: string): string => {
if (!text) {
return '';
}
if (expandedText.value[key] || text.length <= 150) {
return text;
}
return `${text.slice(0, 150)}...`;
};
const toggleText = (key: string): void => {
expandedText.value = {
...expandedText.value,
[key]: !expandedText.value[key]
};
};
const ensureBlobUrl = async (value: string, asVideo: boolean): Promise<void> => {
const key = normalizeMediaKey(value);
if (!key || blobUrls.value[key]) return;
if (key.startsWith('data:') || /^https?:\/\//i.test(key)) return;
const filename = extractFilename(key);
const url = asVideo
? await MarketingV3Service.loadVideoAsBlobUrl(filename)
: await MarketingV3Service.loadImageAsBlobUrl(filename);
if (url) {
blobUrls.value = { ...blobUrls.value, [key]: url };
}
};
const prefetchMedia = async (): Promise<void> => {
const sets = props.adSets ?? [];
const tasks: Promise<void>[] = [];
for (const adSet of sets) {
for (const ad of adSet.ads ?? []) {
if (!ad.mediaUrl) continue;
const url = String(ad.mediaUrl).trim();
if (!url) continue;
tasks.push(ensureBlobUrl(url, isVideoMedia(ad)));
}
}
// Best-effort: do not block UI on media loads.
void Promise.allSettled(tasks);
};
watch(
() => props.adSets,
() => {
void prefetchMedia();
},
{ immediate: true }
);
onBeforeUnmount(() => {
for (const url of Object.values(blobUrls.value)) {
try {
window.URL.revokeObjectURL(url);
} catch {
// ignore
}
}
});
</script>
<template>
<section>
<div
v-if="!adSets || adSets.length === 0"
class="ad-empty"
>
Структура кампании пока формируется. AdSet-ы появятся после публикации.
</div>
<Accordion v-else value="0">
<AccordionPanel v-for="(adSet, index) in adSets" :key="adSet.adSetId ?? adSet.id ?? index" :value="String(index)">
<AccordionHeader>
<div class="ads-acc-header">
<div class="ads-acc-left">
<i :class="platformIcon(adSet.platform)" class="ads-platform-icon"></i>
<span class="ads-adset-name">{{ cleanAdSetName(adSet.name) }}</span>
</div>
<div class="ads-acc-right">
<span class="ads-platform-badge">{{ platformLabel(adSet.platform ?? null) }}</span>
<span class="ads-status-badge" :class="`ads-status--${(adSet.status ?? 'DRAFT').toLowerCase()}`">
{{ (adSet.status ?? 'DRAFT').toUpperCase() }}
</span>
<span class="ads-budget-badge">{{ formatBudget(adSet.budgetKzt) }}</span>
</div>
</div>
</AccordionHeader>
<AccordionContent>
<div class="ads-cards-grid">
<article
v-for="(ad, adIndex) in adSet.ads ?? []"
:key="getAdKey(ad, adIndex)"
class="ad-card"
>
<div class="ad-card__meta">
<span class="ad-card__type"><i :class="mediaTypeIcon(ad)" class="ad-type-icon"></i> {{ mediaTypeLabel(ad) }}</span>
<span v-if="ad.mediaSize" class="ad-card__size">{{ ad.mediaSize }}</span>
</div>
<div class="ad-media">
<video
v-if="isVideoMedia(ad) && resolveMediaUrl(ad.mediaUrl)"
class="ad-media__content"
:src="resolveMediaUrl(ad.mediaUrl)"
muted
controls
></video>
<img
v-else-if="resolveMediaUrl(ad.mediaUrl)"
class="ad-media__content ad-media__img"
:src="resolveMediaUrl(ad.mediaUrl)"
alt="Креатив объявления"
/>
<div v-else class="ad-media__empty"><i class="pi pi-image"></i> Нет медиа</div>
</div>
<h4 v-if="ad.headline" class="ad-card__headline">{{ ad.headline }}</h4>
<p v-if="ad.primaryText" class="ad-card__text">
{{ truncateText(ad.primaryText, getAdKey(ad, adIndex)) }}
</p>
<button
v-if="ad.primaryText && ad.primaryText.length > 150"
class="ad-card__expand"
@click.prevent="toggleText(getAdKey(ad, adIndex))"
>
{{ expandedText[getAdKey(ad, adIndex)] ? 'Свернуть' : 'Читать полностью' }}
</button>
<div class="ad-card__footer">
<span v-if="ad.callToAction" class="ad-card__cta">{{ ad.callToAction }}</span>
</div>
</article>
</div>
<details class="ad-details">
<summary class="ad-details__summary">Детали</summary>
<div class="mt-2 space-y-1">
<div v-if="adSet.adSetId || adSet.id">adSetId: {{ adSet.adSetId ?? adSet.id }}</div>
<div v-for="(ad, adIndex) in adSet.ads ?? []" :key="`details-${getAdKey(ad, adIndex)}`">
adId: {{ ad.adId ?? ad.id ?? `ad-${adIndex + 1}` }}
</div>
</div>
</details>
</AccordionContent>
</AccordionPanel>
</Accordion>
</section>
</template>
<style scoped>
/* ─── Accordion header ─── */
.ads-acc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
flex-wrap: wrap;
padding-right: 8px;
}
.ads-acc-left {
display: flex;
align-items: center;
gap: 10px;
}
.ads-platform-icon {
font-size: 18px;
color: rgba(255, 255, 255, 0.7);
}
.ads-adset-name {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.ads-acc-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ads-platform-badge {
font-size: 10px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 3px 12px;
border-radius: 999px;
background: rgba(45, 123, 255, 0.12);
border: 1px solid rgba(45, 123, 255, 0.25);
color: #2D7BFF;
}
.ads-status-badge {
font-size: 9px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.5);
background: rgba(255,255,255,0.04);
}
.ads-status--active { color: #00D48B; background: rgba(0,212,139,0.1); border-color: rgba(0,212,139,0.25); }
.ads-status--paused { color: #F59E0B; background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.25); }
.ads-status--failed { color: #EF4444; background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.25); }
.ads-budget-badge {
font-family: 'Unbounded', sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.6);
padding: 3px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ─── Cards grid ─── */
.ads-cards-grid {
display: grid;
gap: 16px;
grid-template-columns: 1fr;
padding: 12px 0;
}
@media (min-width: 768px) {
.ads-cards-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1280px) {
.ads-cards-grid { grid-template-columns: repeat(3, 1fr); }
}
/* ─── Ad card ─── */
.ad-card {
display: flex;
flex-direction: column;
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(45, 123, 255, 0.2);
box-shadow: 0 16px 32px rgba(6, 12, 28, 0.35);
backdrop-filter: blur(6px);
transition: border-color 0.2s, transform 0.2s;
}
.ad-card:hover {
border-color: rgba(45, 123, 255, 0.4);
transform: translateY(-2px);
}
.ad-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ad-card__type {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
display: inline-flex;
align-items: center;
gap: 8px;
}
.ad-type-icon {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
.ad-card__size {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
}
.ad-card__headline {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 8px;
line-height: 1.4;
}
.ad-card__text {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
margin-bottom: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.ad-card__expand {
background: none;
border: none;
color: #2D7BFF;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 12px;
text-decoration: underline;
text-underline-offset: 3px;
}
.ad-card__footer {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.ad-card__cta {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
background: rgba(45, 123, 255, 0.12);
border: 1px solid rgba(45, 123, 255, 0.25);
color: #2D7BFF;
}
.ad-media {
height: 240px;
border-radius: 14px;
overflow: hidden;
background: radial-gradient(circle at top, rgba(45, 123, 255, 0.12), rgba(8, 12, 26, 0.9));
border: 1px solid rgba(45, 123, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.ad-media__content {
width: 100%;
height: 100%;
object-fit: contain;
}
.ad-media__img {
object-fit: contain;
background: transparent;
}
.ad-media__empty {
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
}
.ad-empty {
border-radius: 16px;
border: 1px solid rgba(45, 123, 255, 0.18);
background: rgba(255, 255, 255, 0.03);
padding: 20px 16px;
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.ad-details {
margin-top: 16px;
border-radius: 12px;
border: 1px solid rgba(45, 123, 255, 0.18);
background: rgba(6, 10, 20, 0.6);
padding: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.ad-details__summary {
cursor: pointer;
font-weight: 600;
color: rgba(255, 255, 255, 0.75);
}
</style>
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useCampaignStore } from '@/stores/campaign.store';
import type { CampaignInsight } from '@/types/campaign.types';
import Dialog from 'primevue/dialog';
const props = defineProps<{
campaignId: string;
}>();
const campaignStore = useCampaignStore();
const toast = useToast();
const selectedInsight = ref<CampaignInsight | null>(null);
const dialogVisible = ref(false);
const insights = computed(() => campaignStore.insights);
const loading = computed(() => campaignStore.loading.insights);
const insightMeta = (type: CampaignInsight['type']) => {
if (type === 'SUCCESS') return { icon: 'pi pi-check-circle', color: '#00D48B', bg: 'rgba(0,212,139,0.08)', border: 'rgba(0,212,139,0.25)' };
if (type === 'WARNING') return { icon: 'pi pi-exclamation-triangle', color: '#F59E0B', bg: 'rgba(245,158,11,0.08)', border: 'rgba(245,158,11,0.25)' };
if (type === 'ALERT') return { icon: 'pi pi-exclamation-circle', color: '#EF4444', bg: 'rgba(239,68,68,0.08)', border: 'rgba(239,68,68,0.25)' };
return { icon: 'pi pi-lightbulb', color: '#2D7BFF', bg: 'rgba(45,123,255,0.08)', border: 'rgba(45,123,255,0.25)' };
};
const openInsightModal = (insight: CampaignInsight): void => {
selectedInsight.value = insight;
dialogVisible.value = true;
};
const loadInsights = async (): Promise<void> => {
try {
await campaignStore.fetchInsights(props.campaignId);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить инсайты';
toast.add({ severity: 'error', summary: 'Ошибка', detail: message, life: 4000 });
}
};
const syncInsights = async (): Promise<void> => {
try {
await campaignStore.syncInsights(props.campaignId);
toast.add({ severity: 'success', summary: 'Инсайты', detail: 'Данные обновлены', life: 2500 });
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось синхронизировать инсайты';
toast.add({ severity: 'error', summary: 'Ошибка', detail: message, life: 4000 });
}
};
watch(() => props.campaignId, () => { void loadInsights(); });
onMounted(() => { void loadInsights(); });
</script>
<template>
<section class="ci-section">
<div v-if="loading" class="ci-grid">
<div v-for="i in 4" :key="i" class="ci-skeleton"></div>
</div>
<div v-else-if="insights.length === 0" class="ci-empty">
<i class="pi pi-microchip-ai ci-empty__icon"></i>
<p>ИИ анализирует кампанию. Первые инсайты появятся через 2448 часов после запуска.</p>
<button class="ci-empty__btn" @click="syncInsights" :disabled="loading">
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-sync'"></i>
Синхронизировать сейчас
</button>
</div>
<div v-else class="ci-grid">
<article
v-for="insight in insights"
:key="insight.id"
class="ci-card"
:style="{ background: insightMeta(insight.type).bg, borderColor: insightMeta(insight.type).border }"
>
<div class="ci-card__stripe" :style="{ background: insightMeta(insight.type).color }"></div>
<div class="ci-card__body">
<div class="ci-card__header">
<div class="ci-card__icon" :style="{ color: insightMeta(insight.type).color }">
<i :class="insightMeta(insight.type).icon"></i>
</div>
<h3 class="ci-card__title">{{ insight.title }}</h3>
<span class="ci-card__type" :style="{ color: insightMeta(insight.type).color, borderColor: insightMeta(insight.type).border }">
{{ insight.type }}
</span>
</div>
<p class="ci-card__desc">{{ insight.description }}</p>
<button
v-if="insight.actionRequired"
class="ci-card__btn"
:style="{ color: insightMeta(insight.type).color, borderColor: insightMeta(insight.type).border }"
@click="openInsightModal(insight)"
>
<i class="pi pi-arrow-right"></i> Принять меры
</button>
</div>
</article>
</div>
<Dialog
v-model:visible="dialogVisible"
modal
header="Действие по инсайту"
:style="{ width: '32rem' }"
:pt="{
root: { style: 'background:#0F1628;border:1px solid rgba(45,123,255,0.2);border-radius:20px;' },
header: { style: 'background:#0F1628;border-bottom:1px solid rgba(45,123,255,0.15);color:rgba(255,255,255,0.9);font-family:Unbounded,sans-serif;' },
content: { style: 'background:#0F1628;' }
}"
>
<div v-if="selectedInsight" class="ci-modal">
<h4 class="ci-modal__title">{{ selectedInsight.title }}</h4>
<p class="ci-modal__desc">{{ selectedInsight.description }}</p>
<div class="ci-modal__action">
<i class="pi pi-check-circle" style="color:#00D48B; flex-shrink:0;"></i>
{{ selectedInsight.suggestedAction || 'Рекомендация будет добавлена позже.' }}
</div>
</div>
</Dialog>
</section>
</template>
<style scoped>
.ci-section {
padding: 8px 0;
}
.ci-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.ci-grid { grid-template-columns: repeat(2, 1fr); }
}
.ci-card {
border-radius: 18px;
border: 1px solid;
display: flex;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.ci-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.ci-card__stripe {
width: 4px;
flex-shrink: 0;
}
.ci-card__body {
flex: 1;
padding: 20px;
}
.ci-card__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.ci-card__icon {
font-size: 18px;
flex-shrink: 0;
}
.ci-card__title {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
flex: 1;
line-height: 1.3;
}
.ci-card__type {
font-size: 9px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid;
flex-shrink: 0;
}
.ci-card__desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
margin-bottom: 14px;
}
.ci-card__btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
background: transparent;
border: 1px solid;
border-radius: 10px;
padding: 6px 16px;
cursor: pointer;
transition: all 0.2s;
}
.ci-card__btn:hover {
opacity: 0.8;
transform: translateX(2px);
}
.ci-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 24px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(45, 123, 255, 0.12);
text-align: center;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
line-height: 1.6;
}
.ci-empty__icon {
font-size: 32px;
color: rgba(45, 123, 255, 0.5);
}
.ci-empty__btn {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(45, 123, 255, 0.25);
background: rgba(45, 123, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-weight: 700;
font-family: 'Unbounded', sans-serif;
cursor: pointer;
transition: transform 0.2s, opacity 0.2s;
}
.ci-empty__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ci-empty__btn:not(:disabled):hover {
transform: translateY(-1px);
}
.ci-skeleton {
height: 100px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(45, 123, 255, 0.1);
animation: ci-pulse 2s infinite;
}
@keyframes ci-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.3; }
}
.ci-modal__title {
font-family: 'Unbounded', sans-serif;
font-size: 16px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 10px;
}
.ci-modal__desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
margin-bottom: 16px;
}
.ci-modal__action {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px;
border-radius: 12px;
background: rgba(0, 212, 139, 0.07);
border: 1px solid rgba(0, 212, 139, 0.2);
font-size: 13px;
color: rgba(0, 212, 139, 0.9);
line-height: 1.5;
}
</style>
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
compareMetricWithRange,
formatRangeLabel,
getMetricDisplayState,
type MetricTrend
} from '@/composables/useCampaignMetrics';
import type { PerformanceMetrics, PredictedMetricMap } from '@/types/campaign.types';
type MetricKey = 'reach' | 'impressions' | 'clicks' | 'ctr' | 'conversions' | 'cpl' | 'spend' | 'roas';
interface MetricConfig {
key: MetricKey;
label: string;
icon: string;
unit?: string;
}
interface MetricCardView {
key: MetricKey;
icon: string;
label: string;
value: string;
isNoData: boolean;
isZero: boolean;
tooltip: string | null;
helperText: string | null;
rangeLabel: string;
trend: MetricTrend;
}
const props = defineProps<{
performanceMetrics: PerformanceMetrics | null | undefined;
predictedMetrics: PredictedMetricMap | null | undefined;
}>();
const configs: MetricConfig[] = [
{ key: 'reach', label: 'Охват', icon: '👥' },
{ key: 'impressions', label: 'Показы', icon: '👁️' },
{ key: 'clicks', label: 'Клики', icon: '🖱️' },
{ key: 'ctr', label: 'CTR', icon: '📊', unit: '%' },
{ key: 'conversions', label: 'Конверсии', icon: '🎯' },
{ key: 'cpl', label: 'CPL', icon: '💰', unit: 'KZT' },
{ key: 'spend', label: 'Потрачено', icon: '💳', unit: 'KZT' },
{ key: 'roas', label: 'ROAS', icon: '📈' }
];
const hasData = computed(() => props.performanceMetrics?.dataAvailable === true);
const cards = computed<MetricCardView[]>(() =>
configs.map((config) => {
const actual = props.performanceMetrics?.[config.key] ?? null;
const range = props.predictedMetrics?.[config.key];
const display = getMetricDisplayState(actual, config.unit ?? range?.unit ?? undefined);
return {
key: config.key,
icon: config.icon,
label: config.label,
value: display.value,
isNoData: display.isNoData,
isZero: display.isZero,
tooltip: display.tooltip,
helperText: display.helperText,
rangeLabel: formatRangeLabel(range),
trend: compareMetricWithRange(actual, range)
};
})
);
const trendColor = (trend: MetricTrend): string => {
if (trend === 'up') return '#00D48B';
if (trend === 'down') return '#EF4444';
return 'rgba(255,255,255,0.35)';
};
const trendLabel = (trend: MetricTrend): string => {
if (trend === 'up') return '↑ Выше прогноза';
if (trend === 'down') return '↓ Ниже прогноза';
if (trend === 'neutral') return '→ В рамках';
return '—';
};
</script>
<template>
<section class="cm-section">
<div v-if="!hasData" class="cm-notice">
<i class="pi pi-clock cm-notice__icon"></i>
Данные появятся через 2448 часов после запуска кампании
</div>
<div class="cm-grid">
<div v-for="card in cards" :key="card.key" class="cm-card">
<div class="cm-card__header">
<span class="cm-card__name">{{ card.icon }} {{ card.label }}</span>
<span class="cm-card__trend" :style="{ color: trendColor(card.trend) }">
{{ trendLabel(card.trend) }}
</span>
</div>
<div v-if="!hasData" class="cm-card__skeleton">
<div class="cm-skel cm-skel--val"></div>
<div class="cm-skel cm-skel--sub"></div>
</div>
<div v-else class="cm-card__body">
<div class="cm-card__val" :class="{ 'cm-card__val--empty': card.isNoData }" :title="card.tooltip ?? undefined">
{{ card.value }}
</div>
<div class="cm-card__range">{{ card.rangeLabel }}</div>
<div v-if="card.isZero && card.helperText" class="cm-card__helper">
{{ card.helperText }}
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.cm-section {
padding: 8px 0;
}
.cm-notice {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-radius: 14px;
background: rgba(45, 123, 255, 0.07);
border: 1px solid rgba(45, 123, 255, 0.2);
font-size: 13px;
font-weight: 500;
color: rgba(45, 123, 255, 0.9);
margin-bottom: 20px;
}
.cm-notice__icon {
font-size: 16px;
flex-shrink: 0;
}
.cm-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.cm-grid { grid-template-columns: repeat(4, 1fr); }
}
.cm-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(45, 123, 255, 0.15);
border-radius: 18px;
padding: 20px;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.cm-card::before {
content: '';
position: absolute;
inset: 0;
opacity: 0;
background: radial-gradient(circle at top right, rgba(45, 123, 255, 0.08), transparent 60%);
pointer-events: none;
transition: opacity 0.3s;
}
.cm-card:hover {
border-color: rgba(45, 123, 255, 0.35);
background: rgba(255, 255, 255, 0.06);
transform: translateY(-2px);
}
.cm-card:hover::before {
opacity: 1;
}
.cm-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.cm-card__name {
font-size: 11px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.45);
}
.cm-card__trend {
font-size: 10px;
font-weight: 600;
}
.cm-card__val {
font-family: 'Unbounded', sans-serif;
font-size: 24px;
font-weight: 900;
color: rgba(255, 255, 255, 0.92);
line-height: 1;
margin-bottom: 6px;
}
.cm-card__val--empty {
color: rgba(255, 255, 255, 0.25);
}
.cm-card__range {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 4px;
}
.cm-card__helper {
font-size: 11px;
color: #F59E0B;
}
.cm-skel {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
margin-bottom: 8px;
animation: cm-pulse 2s infinite;
}
.cm-skel--val {
height: 30px;
width: 70%;
}
.cm-skel--sub {
height: 12px;
width: 50%;
}
@keyframes cm-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
</style>
@@ -0,0 +1,930 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { useToast } from 'primevue/usetoast';
import { formatMetricValue } from '@/composables/useCampaignMetrics';
import { useCampaignStore } from '@/stores/campaign.store';
import type { PredictedMetricMap, TopRecommendation } from '@/types/campaign.types';
import Chart from 'primevue/chart';
import ProgressBar from 'primevue/progressbar';
interface MetricCard {
key: string;
icon: string;
label: string;
rangeLabel: string;
barValue: number;
}
const props = defineProps<{
campaignId: string;
}>();
const campaignStore = useCampaignStore();
const toast = useToast();
const awaitingPrediction = ref(false);
const showManualTrigger = ref(false);
const generationRunning = ref(false);
const prediction = computed(() => campaignStore.prediction);
const loading = computed(() => campaignStore.loading.prediction);
const score = computed(() => prediction.value?.predictionScore ?? 0);
const confidence = computed(() => prediction.value?.confidence ?? 0);
const predictionLabel = computed(() => prediction.value?.predictionLabel || '—');
const lastUpdated = computed(() => (prediction.value as any)?.createdAt ?? prediction.value?.updatedAt ?? null);
const scoreColor = computed(() => {
if (score.value <= 40) return '#EF4444';
if (score.value <= 70) return '#F59E0B';
return '#00D48B';
});
const scoreStrokeColor = computed(() => scoreColor.value);
const gaugeRadius = 70;
const gaugeCircumference = 2 * Math.PI * gaugeRadius;
const gaugeOffset = computed(() => gaugeCircumference - (Math.max(0, Math.min(100, score.value)) / 100) * gaugeCircumference);
const metricConfigs: Array<{ key: keyof PredictedMetricMap; label: string; icon: string }> = [
{ key: 'ctr', label: 'CTR', icon: 'pi pi-chart-line' },
{ key: 'cpl', label: 'CPL', icon: 'pi pi-wallet' },
{ key: 'reach', label: 'Охват', icon: 'pi pi-users' },
{ key: 'conversions', label: 'Конверсии', icon: 'pi pi-bullseye' },
{ key: 'roas', label: 'ROAS', icon: 'pi pi-chart-bar' }
];
const metricCards = computed<MetricCard[]>(() => {
const predicted = prediction.value?.predictedMetrics ?? {};
return metricConfigs.map((config) => {
const range = predicted[config.key];
const rangeLabel = range && range.min != null && range.max != null
? `${formatMetricValue(range.min, range.unit ?? undefined)} ${formatMetricValue(range.max, range.unit ?? undefined)}`
: '—';
const barValue = (() => {
if (!range || range.min == null || range.max == null || range.max === 0) return 0;
const normalized = Math.min(100, Math.max(0, ((range.min + range.max) / 2 / range.max) * 100));
return Number.isFinite(normalized) ? normalized : 0;
})();
return { key: String(config.key), icon: config.icon, label: config.label, rangeLabel, barValue };
});
});
const topRecommendations = computed<TopRecommendation[]>(() => prediction.value?.topRecommendations ?? []);
const weeklyLabels = computed(() => (prediction.value?.weeklyForecast ?? []).map((item) => `Нед. ${item.week}`));
const leadsValues = computed(() => (prediction.value?.weeklyForecast ?? []).map((item) => item.expectedLeads));
const spendValues = computed(() => (prediction.value?.weeklyForecast ?? []).map((item) => item.expectedSpend ?? 0));
const weeklyChartData = computed(() => ({
labels: weeklyLabels.value,
datasets: [
{
label: 'Ожидаемые лиды',
data: leadsValues.value,
yAxisID: 'y',
borderRadius: 8,
backgroundColor: 'rgba(45,123,255,0.72)'
},
{
label: 'Ожидаемые расходы',
data: spendValues.value,
yAxisID: 'y1',
borderRadius: 8,
backgroundColor: 'rgba(0,212,139,0.52)'
}
]
}));
const weeklyChartOptions = {
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
position: 'left',
beginAtZero: true,
ticks: { color: 'rgba(255,255,255,0.4)', font: { family: 'Onest' } },
grid: { color: 'rgba(255,255,255,0.06)' }
},
y1: {
type: 'linear',
position: 'right',
beginAtZero: true,
ticks: { color: 'rgba(255,255,255,0.4)', font: { family: 'Onest' } },
grid: { drawOnChartArea: false }
},
x: {
ticks: { color: 'rgba(255,255,255,0.4)', font: { family: 'Onest' } },
grid: { color: 'rgba(255,255,255,0.04)' }
}
},
plugins: {
legend: { labels: { color: 'rgba(255,255,255,0.6)', font: { family: 'Onest', size: 12 } } }
}
};
const weeklyPhases = computed(() =>
(prediction.value?.weeklyForecast ?? []).map((item) => ({
id: `${item.week}-${item.phaseName ?? item.phase ?? 'phase'}`,
name: item.phaseName ?? item.phase ?? `Фаза ${item.week}`,
spend: item.expectedSpend
}))
);
const audienceInsights = computed(() => ({
bestPerformingSegment:
prediction.value?.audienceInsights?.bestPerformingSegment ?? prediction.value?.bestPerformingSegment ?? null,
recommendedExpansion:
prediction.value?.audienceInsights?.recommendedExpansion ?? prediction.value?.recommendedExpansion ?? null,
exclusionAdvice: prediction.value?.audienceInsights?.exclusionAdvice ?? prediction.value?.exclusionAdvice ?? null
}));
const budgetOptimization = computed(() => ({
currentAllocation: prediction.value?.budgetOptimization?.currentAllocation ?? prediction.value?.currentAllocation ?? null,
recommendedReallocation:
prediction.value?.budgetOptimization?.recommendedReallocation ?? prediction.value?.recommendedReallocation ?? null,
potentialCplReduction:
prediction.value?.budgetOptimization?.potentialCplReduction ?? prediction.value?.potentialCplReduction ?? null
}));
const categoryIcon = (category: string): string => {
const key = category.toUpperCase();
if (key === 'CREATIVE') return 'pi pi-image';
if (key === 'AUDIENCE') return 'pi pi-users';
if (key === 'BUDGET') return 'pi pi-wallet';
if (key === 'PLACEMENT') return 'pi pi-map';
return 'pi pi-sparkles';
};
const priorityMeta = (priority: string): { color: string; bg: string; border: string } => {
const v = priority.toUpperCase();
if (v === 'HIGH') return { color: '#EF4444', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.25)' };
if (v === 'MEDIUM') return { color: '#F59E0B', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.25)' };
return { color: 'rgba(255,255,255,0.4)', bg: 'rgba(255,255,255,0.04)', border: 'rgba(255,255,255,0.1)' };
};
const marketSaturationMeta = computed(() => {
const level = (
prediction.value?.competitiveAnalysis?.marketSaturation ?? prediction.value?.marketSaturation ?? ''
).toUpperCase();
if (level === 'HIGH') return { color: '#EF4444', label: 'Высокая' };
if (level === 'MEDIUM') return { color: '#F59E0B', label: 'Средняя' };
return { color: '#00D48B', label: 'Низкая' };
});
const differentiationScore = computed(
() => prediction.value?.competitiveAnalysis?.differentiationScore ?? prediction.value?.differentiationScore ?? 0
);
const competitiveAdvantages = computed(
() => prediction.value?.competitiveAnalysis?.competitiveAdvantages ?? prediction.value?.competitiveAdvantages ?? []
);
const differentiationOffset = computed(
() => gaugeCircumference - (Math.max(0, Math.min(100, differentiationScore.value)) / 100) * gaugeCircumference
);
const hydratePrediction = async (): Promise<void> => {
awaitingPrediction.value = true;
showManualTrigger.value = false;
try {
const ready = await campaignStore.waitForPrediction(props.campaignId, 10, 5000);
showManualTrigger.value = !ready;
} catch (error) {
showManualTrigger.value = true;
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error instanceof Error ? error.message : 'Не удалось загрузить прогноз',
life: 4500
});
} finally {
awaitingPrediction.value = false;
}
};
const refreshPrediction = async (): Promise<void> => {
generationRunning.value = true;
try {
await campaignStore.triggerPredictionGeneration(props.campaignId);
toast.add({
severity: 'info',
summary: 'Генерация запущена',
detail: 'Прогноз формируется. Подождите 15-30 секунд.',
life: 3500
});
await hydratePrediction();
if (campaignStore.prediction) {
toast.add({
severity: 'success',
summary: 'Прогноз обновлен',
detail: 'AI успешно пересчитал предсказания.',
life: 3000
});
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: error instanceof Error ? error.message : 'Не удалось обновить прогноз',
life: 4500
});
} finally {
generationRunning.value = false;
}
};
const formatDateTime = (value: string | null): string => {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return format(date, 'dd.MM.yyyy HH:mm', { locale: ru });
};
watch(() => props.campaignId, () => { void hydratePrediction(); });
onMounted(() => { void hydratePrediction(); });
</script>
<template>
<section class="cp-section">
<!-- SKELETON -->
<template v-if="loading || awaitingPrediction || generationRunning">
<div class="cp-grid-2 mb-4">
<div v-for="i in 2" :key="i" class="cp-card cp-skel-block" style="height:180px;"></div>
</div>
<div class="cp-card cp-skel-block" style="height:120px;"></div>
</template>
<!-- NO DATA -->
<div v-else-if="!prediction" class="cp-empty">
<i class="pi pi-microchip-ai cp-empty__icon"></i>
<h3 class="cp-empty__title">AI-прогноз недоступен</h3>
<p class="cp-empty__sub" v-if="showManualTrigger">Прогноз пока не готов. Запустите генерацию вручную.</p>
<p class="cp-empty__sub" v-else>Сервис готовит прогноз. Обычно это занимает 15-30 секунд.</p>
<button class="cp-btn-primary" @click="refreshPrediction" :disabled="loading || generationRunning || !showManualTrigger">
<i class="pi pi-sparkles"></i> Запустить расчет
</button>
</div>
<!-- CONTENT -->
<template v-else>
<!-- TOP ROW: Score + Metrics -->
<div class="cp-grid-2 mb-4">
<!-- SCORE CARD -->
<div class="cp-card cp-score-card">
<div class="cp-card__label">Оценка прогноза</div>
<div class="cp-score-wrap">
<svg width="160" height="160" viewBox="0 0 190 190" class="cp-gauge">
<g transform="translate(95,95)">
<circle r="70" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="12" />
<circle
r="70"
fill="none"
:stroke="scoreStrokeColor"
stroke-width="12"
stroke-linecap="round"
:stroke-dasharray="gaugeCircumference"
:stroke-dashoffset="gaugeOffset"
transform="rotate(-90)"
style="transition: stroke-dashoffset 1s ease;"
/>
</g>
</svg>
<div class="cp-score-inner">
<div class="cp-score-val" :style="{ color: scoreColor }">{{ score }}</div>
<div class="cp-score-sub">/100</div>
</div>
</div>
<div class="cp-score-label" :style="{ color: scoreColor }">{{ predictionLabel }}</div>
<div class="cp-score-conf">Уверенность: <b>{{ confidence }}%</b></div>
<div class="cp-score-date">Обновлено: {{ formatDateTime(lastUpdated) }}</div>
<button class="cp-btn-secondary mt-3" @click="refreshPrediction" :disabled="loading || generationRunning">
<i :class="generationRunning ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
Обновить
</button>
</div>
<!-- PREDICTED METRICS -->
<div class="cp-card">
<div class="cp-card__label">Прогнозируемые метрики</div>
<div class="cp-metrics-grid">
<div v-for="metric in metricCards" :key="metric.key" class="cp-metric-item">
<div class="cp-metric-header">
<span class="cp-metric-name"><i :class="metric.icon" class="cp-metric-icon"></i> {{ metric.label }}</span>
<span class="cp-metric-range">{{ metric.rangeLabel }}</span>
</div>
<ProgressBar :value="metric.barValue" :showValue="false" class="cp-progress" />
</div>
</div>
</div>
</div>
<!-- WEEKLY CHART -->
<div class="cp-card mb-4">
<div class="cp-card__label">Прогноз по неделям</div>
<div class="cp-chart-wrap">
<Chart type="bar" :data="weeklyChartData" :options="weeklyChartOptions" style="height:100%;" />
</div>
<div v-if="weeklyPhases.length" class="cp-phases-grid mt-4">
<div v-for="phase in weeklyPhases" :key="phase.id" class="cp-phase-item">
<div class="cp-phase-name">{{ phase.name }}</div>
<div class="cp-phase-val">{{ phase.spend != null ? formatMetricValue(phase.spend, 'KZT') : '—' }}</div>
</div>
</div>
</div>
<!-- STRENGTHS & RISKS -->
<div class="cp-grid-2 mb-4">
<div class="cp-card cp-card--green">
<div class="cp-card__label" style="color:#00D48B;">Сильные стороны</div>
<ul class="cp-factor-list">
<li v-for="(factor, idx) in prediction?.strengthFactors ?? []" :key="`s-${idx}`">
<i class="pi pi-check" style="color:#00D48B;"></i> {{ factor }}
</li>
<li v-if="!(prediction?.strengthFactors?.length)" class="cp-factor-empty"></li>
</ul>
</div>
<div class="cp-card cp-card--amber">
<div class="cp-card__label" style="color:#F59E0B;">Риски</div>
<ul class="cp-factor-list">
<li v-for="(factor, idx) in prediction?.riskFactors ?? []" :key="`r-${idx}`">
<i class="pi pi-exclamation-triangle" style="color:#F59E0B;"></i> {{ factor }}
</li>
<li v-if="!(prediction?.riskFactors?.length)" class="cp-factor-empty"></li>
</ul>
</div>
</div>
<!-- AI RECOMMENDATIONS -->
<div class="cp-card mb-4">
<div class="cp-card__label">Топ рекомендации от ИИ</div>
<div class="cp-rec-list">
<div
v-for="(rec, index) in topRecommendations"
:key="rec.id ?? `${rec.title}-${index}`"
class="cp-rec-item"
>
<div class="cp-rec-badges">
<span class="cp-rec-priority" :style="{ color: priorityMeta(rec.priority).color, background: priorityMeta(rec.priority).bg, borderColor: priorityMeta(rec.priority).border }">
{{ rec.priority }}
</span>
<span class="cp-rec-category">
<i :class="categoryIcon(rec.category)"></i> {{ rec.category }}
</span>
</div>
<h4 class="cp-rec-title">{{ rec.title }}</h4>
<p class="cp-rec-desc">{{ rec.description }}</p>
<div class="cp-rec-impact"><i class="pi pi-chart-line"></i> {{ rec.expectedImpact }}</div>
</div>
<div v-if="!topRecommendations.length" class="cp-factor-empty" style="padding:16px;">Рекомендации отсутствуют</div>
</div>
</div>
<!-- AUDIENCE + BUDGET INSIGHTS ROW -->
<div class="cp-grid-3 mb-4">
<div class="cp-card">
<div class="cp-card__label">Лучший сегмент</div>
<p class="cp-info-text">{{ audienceInsights.bestPerformingSegment || '—' }}</p>
</div>
<div class="cp-card">
<div class="cp-card__label">Расширение аудитории</div>
<p class="cp-info-text">{{ audienceInsights.recommendedExpansion || '—' }}</p>
</div>
<div class="cp-card">
<div class="cp-card__label">Кого исключить</div>
<p class="cp-info-text">{{ audienceInsights.exclusionAdvice || '—' }}</p>
</div>
</div>
<!-- BUDGET OPTIMIZATION -->
<div class="cp-card mb-4">
<div class="cp-card__label">Оптимизация бюджета</div>
<div class="cp-grid-2 mt-3">
<div class="cp-info-block">
<div class="cp-info-block__key">Текущее распределение</div>
<div class="cp-info-block__val">{{ budgetOptimization.currentAllocation || '—' }}</div>
</div>
<div class="cp-info-block">
<div class="cp-info-block__key">Рекомендация</div>
<div class="cp-info-block__val">{{ budgetOptimization.recommendedReallocation || '—' }}</div>
</div>
</div>
<div class="cp-cpl-reduction">
<i class="pi pi-arrow-down-right" style="color:#00D48B;"></i>
Потенциальное снижение CPL: <b>{{ budgetOptimization.potentialCplReduction || '—' }}</b>
</div>
</div>
<!-- COMPETITIVE ANALYSIS -->
<div class="cp-card">
<div class="cp-card__label">Конкурентный анализ</div>
<div class="cp-comp-row">
<div class="cp-comp-gauge">
<svg width="144" height="144" viewBox="0 0 190 190">
<g transform="translate(95,95)">
<circle r="70" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="12" />
<circle
r="70"
fill="none"
stroke="#2D7BFF"
stroke-width="12"
stroke-linecap="round"
:stroke-dasharray="gaugeCircumference"
:stroke-dashoffset="differentiationOffset"
transform="rotate(-90)"
style="transition: stroke-dashoffset 1s ease;"
/>
</g>
</svg>
<div class="cp-comp-gauge__val">{{ differentiationScore }}/100</div>
</div>
<div class="cp-comp-info">
<div class="cp-comp-saturation">
Насыщенность рынка:
<span :style="{ color: marketSaturationMeta.color, fontWeight: '700' }">{{ marketSaturationMeta.label }}</span>
</div>
<div class="cp-card__label mt-3" style="margin-bottom:8px;">Конкурентные преимущества</div>
<ul class="cp-factor-list">
<li v-for="(advantage, index) in competitiveAdvantages" :key="`adv-${index}`">
<i class="pi pi-check" style="color:#2D7BFF;"></i> {{ advantage }}
</li>
<li v-if="!competitiveAdvantages.length" class="cp-factor-empty"></li>
</ul>
</div>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.cp-section {
padding: 8px 0;
}
/* ─── Cards ─── */
.cp-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(45, 123, 255, 0.15);
border-radius: 20px;
padding: 24px;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
}
.cp-card--green { border-color: rgba(0, 212, 139, 0.2); background: rgba(0, 212, 139, 0.04); }
.cp-card--amber { border-color: rgba(245, 158, 11, 0.2); background: rgba(245, 158, 11, 0.04); }
.cp-card__label {
font-family: 'Unbounded', sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 16px;
}
/* ─── Layout grids ─── */
.cp-grid-2 {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.cp-grid-2 { grid-template-columns: repeat(2, 1fr); }
}
.cp-grid-3 {
display: grid;
gap: 12px;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.cp-grid-3 { grid-template-columns: repeat(3, 1fr); }
}
.mb-4 { margin-bottom: 16px; }
.mt-3 { margin-top: 12px; }
.mt-4 { margin-top: 16px; }
/* ─── Score ─── */
.cp-score-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.cp-score-wrap {
position: relative;
width: 160px;
height: 160px;
margin: 0 auto 8px;
}
.cp-gauge {
position: absolute;
inset: 0;
}
.cp-score-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.cp-score-val {
font-family: 'Unbounded', sans-serif;
font-size: 36px;
font-weight: 900;
line-height: 1;
}
.cp-score-sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.35);
font-weight: 600;
}
.cp-score-label {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
margin-bottom: 4px;
}
.cp-score-conf, .cp-score-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 2px;
}
/* ─── Metrics ─── */
.cp-metrics-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.cp-metric-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cp-metric-name {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.65);
}
.cp-metric-icon {
font-size: 12px;
margin-right: 8px;
opacity: 0.85;
}
.cp-metric-range {
font-family: 'Unbounded', sans-serif;
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
:deep(.cp-progress .p-progressbar) {
height: 6px !important;
background: rgba(255, 255, 255, 0.06) !important;
border-radius: 999px !important;
border: none !important;
}
:deep(.cp-progress .p-progressbar-value) {
background: linear-gradient(90deg, #2D7BFF, #00D48B) !important;
border-radius: 999px !important;
}
/* ─── Chart ─── */
.cp-chart-wrap {
height: 260px;
background: rgba(255, 255, 255, 0.02);
border-radius: 14px;
padding: 12px;
}
.cp-phases-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.cp-phases-grid { grid-template-columns: repeat(4, 1fr); }
}
.cp-phase-item {
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(45, 123, 255, 0.12);
border-radius: 12px;
}
.cp-phase-name {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 4px;
}
.cp-phase-val {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
/* ─── Factors ─── */
.cp-factor-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cp-factor-list li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
line-height: 1.5;
}
.cp-factor-empty {
font-size: 12px;
color: rgba(255, 255, 255, 0.25);
}
/* ─── Recommendations ─── */
.cp-rec-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.cp-rec-item {
padding: 18px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(45, 123, 255, 0.12);
border-radius: 16px;
transition: border-color 0.2s;
}
.cp-rec-item:hover {
border-color: rgba(45, 123, 255, 0.3);
}
.cp-rec-badges {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.cp-rec-priority {
font-size: 9px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid;
}
.cp-rec-category {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: rgba(45, 123, 255, 0.8);
background: rgba(45, 123, 255, 0.1);
border-radius: 999px;
padding: 3px 10px;
}
.cp-rec-title {
font-family: 'Unbounded', sans-serif;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
line-height: 1.4;
}
.cp-rec-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.6;
margin-bottom: 8px;
}
.cp-rec-impact {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #00D48B;
}
/* ─── Info ─── */
.cp-info-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
line-height: 1.6;
}
.cp-info-block {
padding: 14px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(45, 123, 255, 0.1);
border-radius: 12px;
}
.cp-info-block__key {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 6px;
font-weight: 700;
}
.cp-info-block__val {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
}
.cp-cpl-reduction {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 16px;
background: rgba(0, 212, 139, 0.07);
border: 1px solid rgba(0, 212, 139, 0.2);
border-radius: 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.cp-cpl-reduction b {
color: #00D48B;
}
/* ─── Competitive ─── */
.cp-comp-row {
display: flex;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
}
.cp-comp-gauge {
position: relative;
width: 144px;
height: 144px;
flex-shrink: 0;
}
.cp-comp-gauge__val {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Unbounded', sans-serif;
font-size: 18px;
font-weight: 900;
color: rgba(255, 255, 255, 0.9);
}
.cp-comp-info {
flex: 1;
min-width: 200px;
}
.cp-comp-saturation {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
/* ─── Buttons ─── */
.cp-btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
background: #2D7BFF;
color: #fff;
font-size: 13px;
font-weight: 700;
font-family: 'Unbounded', sans-serif;
border: none;
cursor: pointer;
transition: all 0.3s;
}
.cp-btn-primary:hover:not(:disabled) {
background: #1a6bff;
box-shadow: 0 0 24px rgba(45, 123, 255, 0.4);
transform: translateY(-2px);
}
.cp-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.cp-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 10px;
background: transparent;
border: 1px solid rgba(45, 123, 255, 0.3);
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cp-btn-secondary:hover:not(:disabled) {
border-color: #2D7BFF;
color: #fff;
background: rgba(45, 123, 255, 0.1);
}
.cp-btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
/* ─── Empty ─── */
.cp-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 24px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(45, 123, 255, 0.12);
text-align: center;
}
.cp-empty__icon {
font-size: 40px;
color: rgba(45, 123, 255, 0.5);
}
.cp-empty__title {
font-family: 'Unbounded', sans-serif;
font-size: 18px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
.cp-empty__sub {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
line-height: 1.6;
}
/* ─── Skeleton ─── */
.cp-skel-block {
animation: cp-pulse 2s infinite;
}
@keyframes cp-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.25; }
}
</style>
@@ -0,0 +1,289 @@
<script setup>
import { ref, computed } from 'vue';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import RadioButton from 'primevue/radiobutton';
import TargetingService from '@/service/TargetingService';
const props = defineProps({
platform: { type: String, required: true }, // 'facebook' | 'tiktok'
title: { type: String, required: true },
subtitle: { type: String, required: true },
icon: { type: String, required: true },
themeClasses: { type: String, default: '' },
isConnected: { type: Boolean, default: false },
accountName: { type: String, default: '' }
});
const emit = defineEmits(['connect']);
// Modal state
const isConnecting = ref(false);
const showAccountsModal = ref(false);
const selectedAccountId = ref('');
// Mock accounts fetched after OAuth
const mockAccounts = ref([]);
const handleAuth = async () => {
isConnecting.value = true;
try {
// 1. Get OAuth URL
let url = '';
if (props.platform === 'facebook') {
url = await TargetingService.getFacebookOAuthUrl();
} else {
url = await TargetingService.getTikTokOAuthUrl();
}
// 2. Open popup
const width = 600, height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const authWindow = window.open(url, '_blank', `width=${width},height=${height},top=${top},left=${left}`);
// Ideally we listen to postMessage from the popup when backend redirects to callback
// For now, we simulate user clicking through popup
await new Promise(r => setTimeout(r, 4000));
// 3. Close popup & Fetch Accounts
if (authWindow && !authWindow.closed) {
authWindow.close();
}
let accountsData = [];
if (props.platform === 'facebook') {
accountsData = await TargetingService.getFacebookAdAccounts();
} else {
// Mock TT for now
accountsData = []; // will use fallback
}
mockAccounts.value = accountsData && accountsData.length > 0 ? accountsData : [
{ id: '12345', name: 'ИП Иванов (Основной)' },
{ id: '67890', name: 'Ivanov Marketing (USD)' },
{ id: '99999', name: 'Test Agency Account' },
];
showAccountsModal.value = true;
} catch (e) {
console.error("Error connecting to platform:", e);
} finally {
isConnecting.value = false;
}
};
const handleAccountSelection = async () => {
if (!selectedAccountId.value) return;
// 4. "POST /api/v1/targeting/account/select"
// Mock selection
const acc = mockAccounts.value.find(a => a.id === selectedAccountId.value);
showAccountsModal.value = false;
emit('connect', { platform: props.platform, accountId: acc.id, accountName: acc.name });
};
</script>
<template>
<div class="mkt-card oauth-card group">
<!-- Blur Backdrop Decor -->
<div class="oauth-glow" :class="themeClasses"></div>
<div class="flex flex-col sm:flex-row sm:items-center gap-6 relative z-10">
<div class="oauth-icon" :class="themeClasses">
<i :class="icon"></i>
</div>
<div class="flex-1">
<h4 class="oauth-title">{{ title }}</h4>
<p class="oauth-sub">{{ subtitle }}</p>
</div>
<div class="shrink-0 mt-4 sm:mt-0">
<div v-if="isConnected" class="oauth-connected">
<i class="pi pi-check-circle"></i>
<div class="flex flex-col">
<span class="text-[10px] uppercase font-bold opacity-60">Подключено</span>
<span class="text-xs font-bold">{{ accountName || 'Аккаунт' }}</span>
</div>
</div>
<button v-else
class="td-launch-btn"
:disabled="isConnecting"
@click="handleAuth">
<i :class="isConnecting ? 'pi pi-spin pi-spinner' : 'pi pi-key'"></i>
{{ isConnecting ? 'Ожидание...' : 'Авторизовать' }}
</button>
</div>
</div>
<!-- Accounts Selection Modal -->
<Dialog v-model:visible="showAccountsModal"
header="Выберите рекламный кабинет"
:modal="true"
class="oauth-dialog"
:closable="false"
:draggable="false">
<p class="oauth-dialog-sub">
Мы нашли несколько рекламных аккаунтов. Выберите тот, который будет использоваться ИИ для запуска рекламы.
</p>
<div class="space-y-3 mb-8">
<div v-for="acc in mockAccounts" :key="acc.id"
class="oauth-acc-item"
:class="{ 'oauth-acc-item--active': selectedAccountId === acc.id }"
@click="selectedAccountId = acc.id">
<RadioButton v-model="selectedAccountId" :inputId="acc.id" name="account" :value="acc.id" />
<label :for="acc.id" class="flex-1 font-bold">{{ acc.name }}</label>
<span class="oauth-acc-id">ID: {{ acc.id }}</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3 w-full">
<button class="wiz-btn-sec" style="padding: 8px 16px; font-size: 12px;" @click="showAccountsModal = false">Отмена</button>
<button class="wiz-btn-pri" style="padding: 8px 16px; font-size: 12px;" @click="handleAccountSelection" :disabled="!selectedAccountId">Подтвердить</button>
</div>
</template>
</Dialog>
</div>
</template>
<style scoped>
.oauth-card {
padding: 32px;
overflow: hidden;
}
.oauth-glow {
position: absolute;
top: -40px;
right: -40px;
width: 140px;
height: 140px;
filter: blur(40px);
opacity: 0.3;
transition: opacity 0.5s;
pointer-events: none;
}
.group:hover .oauth-glow { opacity: 0.5; }
.oauth-glow.blue-glow { background: var(--kai-blue); }
.oauth-glow.rose-glow { background: #ff2d55; }
.oauth-icon {
width: 64px;
height: 64px;
border-radius: 20px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--kai-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
transition: all 0.3s;
}
.oauth-icon.blue-glow { color: #1877F2; }
.oauth-icon.rose-glow { color: #fff; }
.oauth-title {
font-family: 'Unbounded', sans-serif;
font-size: 20px;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
}
.oauth-sub {
font-size: 13px;
color: var(--kai-txt3);
font-weight: 500;
}
.oauth-connected {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: rgba(0,212,139,0.08);
border: 1px solid rgba(0,212,139,0.2);
border-radius: 14px;
color: var(--kai-green);
}
.oauth-connected i { font-size: 18px; }
.oauth-dialog :deep(.p-dialog-header) {
background: var(--kai-card);
color: #fff;
font-family: 'Unbounded', sans-serif;
font-size: 18px;
padding: 24px;
}
.oauth-dialog :deep(.p-dialog-content) {
background: var(--kai-card);
padding: 0 24px 24px;
}
.oauth-dialog :deep(.p-dialog-footer) {
background: var(--kai-card);
border-top: 1px solid var(--kai-border);
padding: 16px 24px;
}
.oauth-dialog-sub {
font-size: 13px;
color: var(--kai-txt3);
margin-bottom: 20px;
line-height: 1.5;
}
.oauth-acc-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--kai-border);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s;
}
.oauth-acc-item:hover {
background: rgba(255,255,255,0.04);
border-color: var(--kai-border-hover);
}
.oauth-acc-item--active {
background: rgba(45,123,255,0.08);
border-color: var(--kai-blue);
}
.oauth-acc-id {
font-size: 10px;
background: rgba(255,255,255,0.05);
padding: 2px 6px;
border-radius: 4px;
color: var(--kai-txt3);
}
/* Reuse from global/others */
.td-launch-btn {
background: #fff;
color: #000;
font-family: 'Unbounded', sans-serif;
font-weight: 700;
font-size: 12px;
padding: 12px 24px;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s;
}
.td-launch-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0 20px rgba(255,255,255,0.2);
}
.td-launch-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wiz-btn-pri { background: #fff; color: #000; border: none; font-family: 'Unbounded', sans-serif; font-weight: 700; border-radius: 8px; cursor: pointer; }
.wiz-btn-sec { background: transparent; border: 1px solid var(--kai-border); color: var(--kai-txt2); font-weight: 600; border-radius: 8px; cursor: pointer; }
</style>
@@ -0,0 +1,110 @@
<script setup>
const props = defineProps({
statusHistory: { type: Array, default: () => [] },
currentStatus: { type: String, default: '' }
});
const STEPS = [
{ code: 'CREATED', label: 'Кампания создана', icon: 'pi pi-plus-circle', color: 'var(--kai-txt3)' },
{ code: 'ORCHESTRATING', label: 'ИИ строит сегменты', icon: 'pi pi-microchip-ai', color: 'var(--kai-blue)' },
{ code: 'MAPPING', label: 'Маппинг адсетов и креативов', icon: 'pi pi-sitemap', color: 'var(--kai-purple)' },
{ code: 'PUBLISHING', label: 'Публикация в Meta/TikTok', icon: 'pi pi-send', color: 'var(--kai-orange)' },
{ code: 'ACTIVE', label: 'Кампания запущена', icon: 'pi pi-bolt', color: 'var(--kai-green)' },
];
const order = STEPS.map(s => s.code);
const stepState = code => {
const cur = order.indexOf(props.currentStatus);
const idx = order.indexOf(code);
if (props.currentStatus === 'FAILED') return idx <= cur ? 'failed' : 'pending';
if (idx < cur) return 'done';
if (idx === cur) return 'active';
return 'pending';
};
const historyEntry = code => (props.statusHistory || []).find(h => h.status === code);
const fmtTime = ts => {
if (!ts) return '';
try { return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }
catch { return ''; }
};
</script>
<template>
<div class="pt-timeline">
<div v-for="(step, idx) in STEPS" :key="step.code" class="pt-row">
<!-- Left column: line + dot -->
<div class="pt-connector">
<div class="pt-line pt-line--top" :class="{ 'pt-line--lit': idx > 0 && stepState(STEPS[idx-1].code) !== 'pending' }"></div>
<div class="pt-dot"
:class="{
'pt-dot--done': stepState(step.code) === 'done',
'pt-dot--active': stepState(step.code) === 'active',
'pt-dot--failed': stepState(step.code) === 'failed',
'pt-dot--pending': stepState(step.code) === 'pending',
}">
<i v-if="stepState(step.code) === 'done'" class="pi pi-check"></i>
<i v-else-if="stepState(step.code) === 'failed'" class="pi pi-times"></i>
<i v-else :class="step.icon"></i>
</div>
<div class="pt-line pt-line--bottom" :class="{ 'pt-line--lit': stepState(step.code) === 'done' }"></div>
</div>
<!-- Right: content -->
<div class="pt-content" :class="{ 'pt-content--dim': stepState(step.code) === 'pending' }">
<div class="pt-content__row">
<span class="pt-content__label">
<i :class="step.icon" :style="{ color: stepState(step.code) !== 'pending' ? step.color : 'var(--kai-txt3)' }"></i>
{{ step.label }}
</span>
<span v-if="historyEntry(step.code)?.timestamp" class="pt-content__time">
{{ fmtTime(historyEntry(step.code).timestamp) }}
</span>
</div>
<div v-if="historyEntry(step.code)?.message" class="pt-content__msg">
{{ historyEntry(step.code).message }}
</div>
<div v-if="stepState(step.code) === 'active'" class="pt-content__active-indicator">
<span class="pt-pulse"></span> В процессе...
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pt-timeline { display: flex; flex-direction: column; }
.pt-row { display: flex; gap: 16px; min-height: 52px; }
.pt-connector { display: flex; flex-direction: column; align-items: center; width: 28px; flex-shrink: 0; }
.pt-line { width: 2px; flex: 1; min-height: 10px; background: rgba(255,255,255,0.07); border-radius: 999px; transition: background 0.4s; }
.pt-line--lit { background: var(--kai-blue); }
.pt-dot {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 11px; transition: all 0.4s; border: 2px solid;
}
.pt-dot--done { background: var(--kai-blue); border-color: var(--kai-blue); color: #fff; box-shadow: 0 0 12px rgba(45,123,255,0.5); }
.pt-dot--active { background: rgba(45,123,255,0.15); border-color: var(--kai-blue); color: var(--kai-blue); animation: pt-active 1.5s ease-in-out infinite; }
.pt-dot--failed { background: rgba(239,68,68,0.15); border-color: var(--kai-red); color: var(--kai-red); }
.pt-dot--pending { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.1); color: rgba(255,255,255,0.2); }
@keyframes pt-active { 0%,100%{ box-shadow: 0 0 0 0 rgba(45,123,255,0.4); } 50%{ box-shadow: 0 0 0 6px rgba(45,123,255,0); } }
.pt-content { flex: 1; padding: 6px 0 16px; transition: opacity 0.3s; }
.pt-content--dim { opacity: 0.4; }
.pt-content__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pt-content__label { font-weight: 600; font-size: 13px; color: var(--kai-txt); display: flex; align-items: center; gap: 7px; }
.pt-content__label i { font-size: 12px; }
.pt-content__time { font-size: 11px; font-family: monospace; color: var(--kai-txt3); flex-shrink: 0; }
.pt-content__msg { font-size: 12px; color: var(--kai-txt3); margin-top: 4px; line-height: 1.4; }
.pt-content__active-indicator { display: flex; align-items: center; gap: 7px; margin-top: 5px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--kai-blue); font-family: 'Unbounded', sans-serif; }
.pt-pulse { width: 6px; height: 6px; border-radius: 50%; background: var(--kai-blue); animation: pulse 1.2s ease-in-out infinite; display: inline-block; }
@keyframes pulse { 0%,100%{ transform:scale(1); opacity:1; } 50%{ transform:scale(0.7); opacity:0.5; } }
</style>
@@ -0,0 +1,315 @@
<script setup>
const props = defineProps({
recommendation: { type: Object, default: null },
loading: { type: Boolean, default: false }
});
const safeArr = v => Array.isArray(v) && v.length > 0 ? v : null;
const safeStr = v => (v && String(v).trim()) ? v : null;
const fmt = v => v ?? '—';
const fmtKzt = v => {
if (v == null || isNaN(v)) return '—';
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(v);
};
const PHASE_ACCENTS = ['var(--kai-blue)', 'var(--kai-purple)', 'var(--kai-green)'];
const PLATFORM_ICONS = { INSTAGRAM: 'pi pi-instagram', FACEBOOK: 'pi pi-facebook', TIKTOK: 'pi pi-tiktok', YOUTUBE: 'pi pi-youtube' };
const pIcon = p => PLATFORM_ICONS[String(p).toUpperCase()] || 'pi pi-globe';
</script>
<template>
<!-- SKELETON -->
<div v-if="loading || !recommendation" class="trp-skeleton">
<div class="trp-sk-row">
<div v-for="i in 2" :key="i" class="trp-sk-card trp-sk-card--half"></div>
</div>
<div class="trp-sk-row">
<div v-for="i in 5" :key="i" class="trp-sk-card trp-sk-card--kpi"></div>
</div>
<div class="trp-sk-card trp-sk-card--full"></div>
<div class="trp-sk-card trp-sk-card--full" style="height:100px"></div>
</div>
<!-- FULL CONTENT -->
<div v-else class="trp">
<!-- TYPE + OBJECTIVE -->
<div class="trp-grid-2">
<div class="trp-card">
<div class="trp-label"><i class="pi pi-bullseye" style="color:var(--kai-blue)"></i> Тип таргетинга</div>
<div class="trp-val">{{ fmt(recommendation.recommendedTypeLabel || recommendation.recommendedType) }}</div>
<p v-if="safeStr(recommendation.typeRationale)" class="trp-rationale">{{ recommendation.typeRationale }}</p>
</div>
<div class="trp-card">
<div class="trp-label"><i class="pi pi-flag" style="color:var(--kai-purple)"></i> Цель кампании</div>
<div class="trp-val">{{ fmt(recommendation.campaignObjectiveLabel || recommendation.campaignObjective) }}</div>
<p v-if="safeStr(recommendation.campaignObjectiveRationale)" class="trp-rationale">{{ recommendation.campaignObjectiveRationale }}</p>
</div>
</div>
<!-- KPI ROW -->
<div class="trp-kpi-row">
<div class="trp-kpi"><div class="trp-kpi__l">CTR</div><div class="trp-kpi__v" style="color:var(--kai-green)">{{ fmt(recommendation.estimatedCtr) }}%</div></div>
<div class="trp-kpi"><div class="trp-kpi__l">CPL</div><div class="trp-kpi__v">{{ fmtKzt(recommendation.estimatedCpl) }}</div></div>
<div class="trp-kpi"><div class="trp-kpi__l">Охват</div><div class="trp-kpi__v">{{ fmt(recommendation.estimatedReach) }}</div></div>
<div class="trp-kpi"><div class="trp-kpi__l">Частота</div><div class="trp-kpi__v">{{ fmt(recommendation.estimatedFrequency) }}</div></div>
<div class="trp-kpi"><div class="trp-kpi__l">Конверсии</div><div class="trp-kpi__v">{{ fmt(recommendation.estimatedConversions) }}</div></div>
</div>
<!-- AUDIENCE PROFILE -->
<div v-if="recommendation.audienceProfile" class="trp-card">
<div class="trp-section-title"><i class="pi pi-users" style="color:var(--kai-blue)"></i> Портрет аудитории</div>
<div class="trp-grid-3" style="margin-bottom:16px;">
<div v-if="safeStr(recommendation.audienceProfile.primarySegment)" class="trp-field">
<div class="trp-field__l">Основной сегмент</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.primarySegment }}</div>
</div>
<div v-if="safeStr(recommendation.audienceProfile.ageRange)" class="trp-field">
<div class="trp-field__l">Возраст</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.ageRange }}</div>
</div>
<div v-if="safeStr(recommendation.audienceProfile.gender)" class="trp-field">
<div class="trp-field__l">Гендер</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.gender }}</div>
</div>
<div v-if="safeStr(recommendation.audienceProfile.geography)" class="trp-field">
<div class="trp-field__l">География</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.geography }}</div>
</div>
<div v-if="safeStr(recommendation.audienceProfile.secondarySegment)" class="trp-field">
<div class="trp-field__l">Вторичный сегмент</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.secondarySegment }}</div>
</div>
<div v-if="safeStr(recommendation.audienceProfile.language)" class="trp-field">
<div class="trp-field__l">Язык</div>
<div class="trp-field__v">{{ recommendation.audienceProfile.language }}</div>
</div>
</div>
<div v-if="safeArr(recommendation.audienceProfile.interests)" class="trp-tags-group">
<div class="trp-tags-group__label">Интересы</div>
<div class="trp-tags">
<span v-for="t in recommendation.audienceProfile.interests" :key="t" class="trp-tag trp-tag--blue">{{ t }}</span>
</div>
</div>
<div v-if="safeArr(recommendation.audienceProfile.behaviors)" class="trp-tags-group">
<div class="trp-tags-group__label">Поведение</div>
<div class="trp-tags">
<span v-for="t in recommendation.audienceProfile.behaviors" :key="t" class="trp-tag trp-tag--purple">{{ t }}</span>
</div>
</div>
<div v-if="safeArr(recommendation.audienceProfile.exclusions)" class="trp-tags-group">
<div class="trp-tags-group__label">Исключения</div>
<div class="trp-tags">
<span v-for="t in recommendation.audienceProfile.exclusions" :key="t" class="trp-tag">{{ t }}</span>
</div>
</div>
</div>
<!-- PLATFORMS + FORMATS + PLACEMENTS -->
<div class="trp-grid-3">
<div v-if="safeArr(recommendation.recommendedPlatforms)" class="trp-card">
<div class="trp-label">Платформы</div>
<div class="trp-platform-list">
<div v-for="p in recommendation.recommendedPlatforms" :key="p" class="trp-platform">
<i :class="pIcon(p)" style="color:var(--kai-blue)"></i> {{ p }}
</div>
</div>
</div>
<div v-if="safeArr(recommendation.recommendedAdFormats)" class="trp-card">
<div class="trp-label">Форматы</div>
<div class="trp-tags" style="margin-top:10px;">
<span v-for="f in recommendation.recommendedAdFormats" :key="f" class="trp-tag">{{ f }}</span>
</div>
</div>
<div v-if="safeArr(recommendation.recommendedPlacements)" class="trp-card">
<div class="trp-label">Плейсменты</div>
<div class="trp-tags" style="margin-top:10px;">
<span v-for="pl in recommendation.recommendedPlacements" :key="pl" class="trp-tag">{{ pl }}</span>
</div>
</div>
</div>
<!-- BUDGET -->
<div v-if="recommendation.budgetRecommendation" class="trp-card">
<div class="trp-section-title"><i class="pi pi-wallet" style="color:var(--kai-green)"></i> Бюджетная рекомендация</div>
<div class="trp-grid-4" style="margin-bottom:12px;">
<div class="trp-field">
<div class="trp-field__l">Дневной бюджет</div>
<div class="trp-field__v trp-field__v--big">{{ fmtKzt(recommendation.budgetRecommendation.dailyBudget) }}</div>
</div>
<div class="trp-field">
<div class="trp-field__l">Месячный бюджет</div>
<div class="trp-field__v trp-field__v--big">{{ fmtKzt(recommendation.budgetRecommendation.monthlyBudget) }}</div>
</div>
<div class="trp-field">
<div class="trp-field__l">Стратегия ставок</div>
<div class="trp-field__v">{{ fmt(recommendation.budgetRecommendation.bidStrategy) }}</div>
</div>
<div class="trp-field">
<div class="trp-field__l">Дневной охват</div>
<div class="trp-field__v">{{ fmt(recommendation.budgetRecommendation.estimatedDailyReach) }}</div>
</div>
</div>
<p v-if="safeStr(recommendation.budgetRecommendation.rationale)" class="trp-rationale" style="border-top:1px solid rgba(255,255,255,0.06);padding-top:12px;margin-top:4px;">
{{ recommendation.budgetRecommendation.rationale }}
</p>
</div>
<!-- PHASES -->
<div v-if="safeArr(recommendation.phases)" class="trp-phases">
<div class="trp-section-title" style="margin-bottom:12px;"><i class="pi pi-chart-bar" style="color:var(--kai-purple)"></i> Фазы кампании</div>
<div v-for="(phase, idx) in recommendation.phases" :key="idx" class="trp-phase">
<div class="trp-phase__accent" :style="{ background: PHASE_ACCENTS[idx % 3] }"></div>
<div class="trp-phase__body">
<div class="trp-phase__head">
<div>
<div class="trp-phase__num">Фаза {{ phase.phase }}</div>
<div class="trp-phase__name">{{ fmt(phase.name) }}</div>
</div>
<div class="trp-phase__dur">{{ fmt(phase.duration) }}</div>
</div>
<div class="trp-grid-4" style="margin:12px 0;">
<div v-if="safeStr(phase.objective)" class="trp-field"><div class="trp-field__l">Цель</div><div class="trp-field__v">{{ phase.objective }}</div></div>
<div v-if="safeStr(phase.budget)" class="trp-field"><div class="trp-field__l">Бюджет</div><div class="trp-field__v">{{ phase.budget }}</div></div>
<div v-if="safeStr(phase.targetingType)" class="trp-field"><div class="trp-field__l">Тип</div><div class="trp-field__v">{{ phase.targetingType }}</div></div>
<div v-if="safeStr(phase.kpi)" class="trp-field"><div class="trp-field__l">KPI</div><div class="trp-field__v">{{ phase.kpi }}</div></div>
</div>
<div v-if="safeArr(phase.tactics)" class="trp-tags">
<span v-for="t in phase.tactics" :key="t" class="trp-tag">{{ t }}</span>
</div>
</div>
</div>
</div>
<!-- QUICK WINS + WARNINGS -->
<div class="trp-grid-2">
<div v-if="safeArr(recommendation.quickWins)" class="trp-alert trp-alert--green">
<div class="trp-alert__title"><i class="pi pi-bolt"></i> Quick Wins</div>
<ul class="trp-alert__list">
<li v-for="w in recommendation.quickWins" :key="w"><i class="pi pi-check-circle"></i> {{ w }}</li>
</ul>
</div>
<div v-if="safeArr(recommendation.warnings)" class="trp-alert trp-alert--yellow">
<div class="trp-alert__title"><i class="pi pi-exclamation-triangle"></i> Предупреждения</div>
<ul class="trp-alert__list">
<li v-for="w in recommendation.warnings" :key="w"><i class="pi pi-info-circle"></i> {{ w }}</li>
</ul>
</div>
</div>
<!-- COMPETITIVE -->
<div v-if="safeStr(recommendation.competitiveContext) || safeStr(recommendation.differentiationAdvice)" class="trp-card">
<div class="trp-section-title">
<i class="pi pi-shield" style="color:var(--kai-orange)"></i> Конкурентный контекст
<span v-if="safeStr(recommendation.ciiLevel)" class="trp-cii-badge"
:style="recommendation.ciiLevel === 'HIGH' ? 'color:var(--kai-red);background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.25)' : recommendation.ciiLevel === 'MEDIUM' ? 'color:var(--kai-yellow);background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.25)' : 'color:var(--kai-green);background:rgba(0,212,139,0.1);border-color:rgba(0,212,139,0.25)'">
CII: {{ recommendation.ciiLevel }}
</span>
</div>
<p v-if="safeStr(recommendation.competitiveContext)" class="trp-rationale">{{ recommendation.competitiveContext }}</p>
<p v-if="safeStr(recommendation.differentiationAdvice)" class="trp-rationale" style="border-top:1px solid rgba(255,255,255,0.06);padding-top:10px;margin-top:8px;">
<strong style="color:var(--kai-txt)">Дифференциация:</strong> {{ recommendation.differentiationAdvice }}
</p>
</div>
<!-- CTA + FUNNEL -->
<div v-if="safeStr(recommendation.primaryCta) || safeStr(recommendation.funnelStage)" class="trp-card">
<div class="trp-section-title"><i class="pi pi-send" style="color:var(--kai-blue)"></i> Воронка и CTA</div>
<div class="trp-grid-3">
<div v-if="safeStr(recommendation.primaryCta)" class="trp-field">
<div class="trp-field__l">Основной CTA</div>
<div class="trp-field__v" style="color:var(--kai-blue);font-weight:800;">{{ recommendation.primaryCta }}</div>
</div>
<div v-if="safeStr(recommendation.funnelStage)" class="trp-field">
<div class="trp-field__l">Стадия воронки</div>
<div class="trp-field__v">{{ recommendation.funnelStage }}</div>
</div>
<div v-if="safeStr(recommendation.conversionMechanism)" class="trp-field">
<div class="trp-field__l">Механизм конверсии</div>
<div class="trp-field__v">{{ recommendation.conversionMechanism }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Skeleton */
.trp-skeleton { display: flex; flex-direction: column; gap: 12px; }
.trp-sk-row { display: flex; gap: 12px; }
.trp-sk-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.07); border-radius: 12px; position: relative; overflow: hidden; }
.trp-sk-card::after { content:''; position:absolute; inset:0; background:linear-gradient(90deg,transparent,rgba(255,255,255,0.04),transparent); animation:shimmer 1.5s infinite; }
@keyframes shimmer { 0%{transform:translateX(-100%)} 100%{transform:translateX(100%)} }
.trp-sk-card--half { flex:1; height:80px; }
.trp-sk-card--kpi { flex:1; height:70px; }
.trp-sk-card--full { width:100%; height:140px; }
/* Base */
.trp { display: flex; flex-direction: column; gap: 12px; }
.trp-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 14px; padding: 18px 20px; }
.trp-section-title { display: flex; align-items: center; gap: 8px; font-family: 'Unbounded', sans-serif; font-size: 13px; font-weight: 700; color: var(--kai-txt); margin-bottom: 16px; }
.trp-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-txt3); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.trp-val { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 700; color: var(--kai-txt); line-height: 1.3; }
.trp-rationale { font-size: 12px; color: var(--kai-txt2); line-height: 1.6; margin-top: 8px; }
/* Grids */
.trp-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.trp-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.trp-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 900px) { .trp-grid-3 { grid-template-columns: repeat(2,1fr); } .trp-grid-4 { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 640px) { .trp-grid-2, .trp-grid-3, .trp-grid-4 { grid-template-columns: 1fr; } }
/* KPI row */
.trp-kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
@media (max-width: 900px) { .trp-kpi-row { grid-template-columns: repeat(3,1fr); } }
@media (max-width: 600px) { .trp-kpi-row { grid-template-columns: repeat(2,1fr); } }
.trp-kpi { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 12px; padding: 14px 16px; text-align: center; }
.trp-kpi__l { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-txt3); margin-bottom: 6px; }
.trp-kpi__v { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 900; color: var(--kai-txt); }
/* Fields */
.trp-field { display: flex; flex-direction: column; gap: 4px; }
.trp-field__l { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-txt3); }
.trp-field__v { font-size: 13px; color: var(--kai-txt); font-weight: 500; }
.trp-field__v--big { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 900; color: var(--kai-txt); }
/* Tags */
.trp-tags-group { margin-top: 10px; }
.trp-tags-group__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-txt3); margin-bottom: 7px; }
.trp-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.trp-tag { padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: var(--kai-txt2); }
.trp-tag--blue { background: rgba(45,123,255,0.1); border-color: rgba(45,123,255,0.25); color: rgba(120,175,255,0.9); }
.trp-tag--purple { background: rgba(168,85,247,0.1); border-color: rgba(168,85,247,0.25); color: rgba(196,151,255,0.9); }
/* Platform */
.trp-platform-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.trp-platform { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--kai-txt); }
/* Phases */
.trp-phases { display: flex; flex-direction: column; gap: 10px; }
.trp-phase { display: flex; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 14px; overflow: hidden; }
.trp-phase__accent { width: 4px; flex-shrink: 0; }
.trp-phase__body { flex: 1; padding: 18px 20px; }
.trp-phase__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
.trp-phase__num { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--kai-txt3); margin-bottom: 3px; }
.trp-phase__name { font-family: 'Unbounded', sans-serif; font-size: 14px; font-weight: 700; color: var(--kai-txt); }
.trp-phase__dur { font-size: 12px; color: var(--kai-txt3); flex-shrink: 0; }
/* Alerts */
.trp-alert { border-radius: 14px; padding: 18px 20px; border: 1px solid; }
.trp-alert--green { background: rgba(0,212,139,0.07); border-color: rgba(0,212,139,0.2); }
.trp-alert--yellow { background: rgba(245,158,11,0.07); border-color: rgba(245,158,11,0.2); }
.trp-alert__title { display: flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; margin-bottom: 12px; }
.trp-alert--green .trp-alert__title { color: var(--kai-green); }
.trp-alert--yellow .trp-alert__title { color: var(--kai-yellow); }
.trp-alert__list { display: flex; flex-direction: column; gap: 7px; list-style: none; padding: 0; margin: 0; }
.trp-alert__list li { display: flex; align-items: flex-start; gap: 7px; font-size: 13px; }
.trp-alert--green .trp-alert__list li { color: rgba(0,212,139,0.8); }
.trp-alert--yellow .trp-alert__list li { color: rgba(245,158,11,0.8); }
.trp-alert__list i { flex-shrink: 0; margin-top: 1px; }
/* CII badge */
.trp-cii-badge { margin-left: auto; padding: 3px 10px; border-radius: 999px; font-size: 10px; font-weight: 700; font-family: 'Unbounded', sans-serif; text-transform: uppercase; border: 1px solid; }
</style>
@@ -0,0 +1,199 @@
import { reactive } from 'vue';
import { useToast } from 'primevue/usetoast';
import TargetingService from '@/service/TargetingService';
// --- Types Mocks (will be matched with API) ---
export interface AdAccountStatus {
facebook: boolean;
tiktok: boolean;
}
export interface Campaign {
id: string;
name: string;
goal: string;
status: 'ACTIVE' | 'PAUSED' | 'FAILED' | 'GENERATING' | 'DRAFT' | 'COMPLETED';
budgetKzt: number;
spentKzt: number;
reach: number;
cpm: number;
ctr: number;
cpc: number;
roas?: number;
conversions?: number;
platforms: string[];
createdAt: string;
}
export interface AdCreative {
id: string;
headline: string;
primaryText: string;
mediaUrl: string;
contentType: 'image' | 'video';
}
export interface AdSet {
id: string;
name: string;
targetAudience: string;
ads: AdCreative[];
}
export interface CampaignDetails extends Campaign {
adSets: AdSet[];
statusHistory?: Array<{ status: string; message?: string }>;
}
export interface TargetAudience {
trustedAI: boolean;
ageMin?: number;
ageMax?: number;
locations?: string[];
}
// --- Centralized State ---
const state = reactive({
accounts: {
facebook: false,
tiktok: false,
} as AdAccountStatus,
accountNames: {
facebook: '',
tiktok: '',
},
campaigns: [] as Campaign[],
currentCampaignDetails: null as any,
isGenerating: false,
});
// --- Actions (Composables) ---
export function useTargeting() {
const toast = useToast();
const connectAccount = async (platform: 'facebook' | 'tiktok', accountId: string, accountName: string) => {
try {
await TargetingService.selectAdAccount(platform, accountId);
state.accounts[platform] = true;
state.accountNames[platform] = accountName || `Account ${accountId}`;
toast.add({ severity: 'success', summary: 'Синхронизация завершена', detail: `Успешно подключен кабинет: ${state.accountNames[platform]}`, life: 4000 });
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Ошибка привязки', detail: error.message || 'Не удалось привязать кабинет', life: 5000 });
}
};
const fetchCampaigns = async () => {
try {
const campaignsData = await TargetingService.getCampaigns(0, 50);
state.campaigns = Array.isArray(campaignsData) ? campaignsData : [];
return state.campaigns;
} catch (error) {
console.error(error);
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось загрузить кампании', life: 5000 });
return [];
}
};
const getCampaignById = async (id: string, noStateUpdate = false) => {
try {
const details = await TargetingService.getCampaignById(id);
// mock ad structure if backend lacks it for now
if (!details.adSets) {
details.adSets = [
{
id: 'set-1',
name: 'IT-Специалисты 18-34',
targetAudience: 'Lookalike, AI-matched',
ads: [
{ id: 'ad1', headline: 'Оффер для Tech', primaryText: 'Текст', mediaUrl: 'https://images.unsplash.com/photo-1558002038-1055907df827', contentType: 'image' }
]
}
];
}
if (!noStateUpdate) {
state.currentCampaignDetails = details;
}
return details;
} catch (error) {
console.error(error);
throw error;
}
};
const createAiCampaign = async (payload: { name: string, goal: string, budgetKzt: number, platforms: string[], targetAudiences?: TargetAudience, strategyId?: string }) => {
state.isGenerating = true;
try {
// Mapping frontend model to backend DTO Request
// analysisId намеренно не передаём — бэкенд подтягивает его из стратегии сам
// Map frontend platform keys → backend enum values
const mapPlatforms = (platforms: string[]): string[] => {
const result: string[] = [];
for (const p of platforms) {
const up = p.toUpperCase();
if (up === 'FB_IG') { result.push('FACEBOOK', 'INSTAGRAM'); }
else if (up === 'FACEBOOK') { result.push('FACEBOOK'); }
else if (up === 'INSTAGRAM') { result.push('INSTAGRAM'); }
else if (up === 'TIKTOK') { result.push('TIKTOK'); }
else { result.push(up); }
}
return result;
};
const apiPayload = {
strategyId: payload.strategyId,
campaignName: payload.name || 'Новая AI Кампания',
objective: getObjectiveEnum(payload.goal),
platforms: mapPlatforms(payload.platforms),
totalBudgetKzt: payload.budgetKzt,
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 86400000).toISOString(),
};
const response = await TargetingService.createCampaign(apiPayload);
// Response typically returns { id, status, statusHistory }
// Fetch immediately to put into state
if (response && response.id) {
return response.id;
} else {
throw new Error("Невалидный ответ от сервера при создании кампании");
}
} catch (error: any) {
toast.add({ severity: 'error', summary: 'Сбой оркестратора', detail: error.message || 'Критическая ошибка запуска', life: 8000 });
throw error;
} finally {
state.isGenerating = false;
}
};
// Map Russian UI labels OR direct enum values → backend objective enum
const getObjectiveEnum = (goalRu: string) => {
const v = goalRu.toUpperCase();
// Direct enum pass-through
if (['CONVERSIONS', 'TRAFFIC', 'REACH', 'LEADS', 'AWARENESS', 'ENGAGEMENT', 'SALES'].includes(v)) return v;
// Russian UI labels
switch(goalRu.toLowerCase()) {
case 'продажи': return 'CONVERSIONS';
case 'трафик': return 'TRAFFIC';
case 'охват': return 'REACH';
case 'лиды': return 'LEADS';
default: return 'CONVERSIONS';
}
}
const formatCurrency = (amount: number) => {
if (!amount || isNaN(amount)) amount = 0;
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount);
};
return {
state,
connectAccount,
fetchCampaigns,
getCampaignById,
createAiCampaign,
formatCurrency,
TargetingService // Expose raw service if needed directly
};
}
@@ -0,0 +1,214 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import TargetingPageHeader from '@/components/targeting/TargetingPageHeader.vue';
import TargetingPanel from '@/components/targeting/TargetingPanel.vue';
import TargetingStateCard from '@/components/targeting/TargetingStateCard.vue';
import { useTargetingApi } from '@/composables/useTargetingApi';
import { useTargetingStore } from '@/stores/targeting';
const router = useRouter();
const store = useTargetingStore();
const { generateStrategy } = useTargetingApi();
const loading = ref(false);
const errorMessage = ref('');
const analysis = computed(() => store.analysis.value);
function getPriorityClass(priority) {
if (priority === 'Высокий') return 'bg-green-100 text-green-700';
if (priority === 'Средний') return 'bg-yellow-100 text-yellow-700';
return 'bg-gray-100 text-gray-500';
}
function extractError(error) {
return error?.data?.message || error?.message || 'Не удалось построить стратегию. Попробуйте снова.';
}
async function handleBuildStrategy() {
if (!analysis.value) return;
loading.value = true;
errorMessage.value = '';
try {
const result = await generateStrategy(analysis.value);
store.strategy.value = result;
store.creative.value = null;
store.selectedCreative.value = null;
store.launchResult.value = null;
await router.push({ name: 'targeting-strategy' });
} catch (error) {
errorMessage.value = extractError(error);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-6xl mx-auto py-2 lg:py-4">
<TargetingPageHeader
back-label="Назад"
eyebrow="Шаг 1"
title="Маркетинговый анализ"
subtitle="Собрали основу для рекламного запуска: рынок, аудитория, каналы, сообщения и бюджет."
:badge="analysis?.topic || ''"
@back="router.push({ name: 'targeting-input' })"
/>
<TargetingStateCard v-if="loading" mode="loading" message="Разрабатываем стратегию кампании..." />
<template v-else-if="analysis">
<div class="space-y-4 lg:space-y-6">
<TargetingPanel title="Обзор рынка">
<p class="text-gray-700 leading-relaxed">{{ analysis.marketOverview }}</p>
</TargetingPanel>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<TargetingPanel title="Целевая аудитория">
<div class="space-y-4">
<div>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-1">Основная</p>
<p class="text-gray-700">{{ analysis.targetAudience?.primary }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-1">Вторичная</p>
<p class="text-gray-700">{{ analysis.targetAudience?.secondary }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">Возраст</p>
<p class="font-medium text-gray-800">{{ analysis.targetAudience?.ageRange }}</p>
</div>
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">Пол</p>
<p class="font-medium text-gray-800">{{ analysis.targetAudience?.gender }}</p>
</div>
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">Доход</p>
<p class="font-medium text-gray-800">{{ analysis.targetAudience?.income }}</p>
</div>
</div>
<div>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-2">Интересы</p>
<div class="flex flex-wrap gap-2">
<span
v-for="interest in analysis.targetAudience?.interests || []"
:key="interest"
class="rounded-full bg-blue-50 text-blue-700 px-3 py-1 text-sm"
>
{{ interest }}
</span>
</div>
</div>
</div>
</TargetingPanel>
<div class="space-y-4 lg:space-y-6">
<TargetingPanel title="Платформы">
<div class="space-y-3">
<div
v-for="platform in analysis.platforms || []"
:key="platform.name"
class="flex items-start justify-between gap-3 rounded-xl border border-gray-100 bg-gray-50 p-3"
>
<div>
<p class="font-medium text-gray-800">{{ platform.name }}</p>
<p class="text-sm text-gray-500 mt-1">{{ platform.reason }}</p>
</div>
<span class="text-xs px-2.5 py-1 rounded-full" :class="getPriorityClass(platform.priority)">
{{ platform.priority }}
</span>
</div>
</div>
</TargetingPanel>
<TargetingPanel title="Бюджет">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">Мин/день</p>
<p class="font-medium text-gray-800">{{ analysis.budgetRecommendation?.minDaily }}</p>
</div>
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">Оптимум/день</p>
<p class="font-medium text-gray-800">{{ analysis.budgetRecommendation?.optimalDaily }}</p>
</div>
<div class="rounded-xl bg-gray-50 border border-gray-200 p-3">
<p class="text-gray-500 mb-1">В месяц</p>
<p class="font-medium text-gray-800">{{ analysis.budgetRecommendation?.monthly }}</p>
</div>
</div>
</TargetingPanel>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<TargetingPanel title="Боли аудитории">
<ul class="space-y-3 text-gray-700">
<li v-for="point in analysis.targetAudience?.painPoints || []" :key="point" class="flex items-start gap-3">
<span class="text-red-500 mt-0.5"></span>
<span>{{ point }}</span>
</li>
</ul>
</TargetingPanel>
<TargetingPanel title="Мотивации">
<ul class="space-y-3 text-gray-700">
<li v-for="point in analysis.targetAudience?.motivations || []" :key="point" class="flex items-start gap-3">
<span class="text-green-500 mt-0.5"></span>
<span>{{ point }}</span>
</li>
</ul>
</TargetingPanel>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<TargetingPanel title="Ключевые сообщения">
<ol class="space-y-3">
<li v-for="(message, index) in analysis.keyMessages || []" :key="message" class="flex items-start gap-3 text-gray-700">
<span class="w-7 h-7 rounded-full bg-primary-50 text-primary flex items-center justify-center text-sm font-semibold shrink-0">
{{ index + 1 }}
</span>
<span class="pt-1">{{ message }}</span>
</li>
</ol>
</TargetingPanel>
<TargetingPanel title="Лучшее время постинга">
<ul class="space-y-2 text-gray-700 mb-4">
<li v-for="time in analysis.postingTimes || []" :key="time"> {{ time }}</li>
</ul>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-2">Форматы</p>
<div class="flex flex-wrap gap-2">
<span
v-for="format in analysis.bestAdFormats || []"
:key="format"
class="px-3 py-1 rounded-full border border-gray-300 text-sm text-gray-600 bg-white"
>
{{ format }}
</span>
</div>
</TargetingPanel>
</div>
<TargetingPanel title="Анализ конкурентов">
<p class="text-gray-700 leading-relaxed">{{ analysis.competitorsInsight }}</p>
</TargetingPanel>
<TargetingPanel title="Рекомендации по визуалам">
<p class="text-gray-700 leading-relaxed">{{ analysis.recommendedVisuals }}</p>
</TargetingPanel>
<TargetingStateCard v-if="errorMessage" mode="error" :message="errorMessage" @retry="handleBuildStrategy" />
<div class="flex justify-end">
<Button label="Построить стратегию" icon="pi pi-arrow-right" icon-pos="right" @click="handleBuildStrategy" />
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,291 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Button from 'primevue/button';
import { useToast } from 'primevue/usetoast';
import PostPreviewCard from '@/components/targeting/PostPreviewCard.vue';
import TargetingPageHeader from '@/components/targeting/TargetingPageHeader.vue';
import TargetingPanel from '@/components/targeting/TargetingPanel.vue';
import TargetingStateCard from '@/components/targeting/TargetingStateCard.vue';
import { useTargetingApi } from '@/composables/useTargetingApi';
import { useTargetingStore } from '@/stores/targeting';
import { API_CONFIG } from '@/config/api';
const router = useRouter();
const route = useRoute();
const store = useTargetingStore();
const toast = useToast();
const { launchFromStrategy } = useTargetingApi();
const launching = ref(false);
const errorMessage = ref('');
const copiedState = ref('');
const autoLaunchStarted = ref(false);
const creative = computed(() => store.creative.value);
const selectedCreative = computed(() => store.selectedCreative.value);
const launchResult = computed(() => store.launchResult.value);
const directStrategyId = computed(() => route.query.strategyId || store.sourceStrategyId.value || null);
const directStrategyName = computed(() => route.query.strategyName || store.sourceStrategyName.value || 'Существующая стратегия');
const isDirectLaunchMode = computed(() => !creative.value && !!directStrategyId.value);
const statusBadge = computed(() => {
const status = launchResult.value?.facebookStatus;
if (status === 'PUBLISHED') return { text: 'Опубликовано с фото', cls: 'bg-green-100 text-green-700' };
if (status === 'TEXT_ONLY') return { text: 'Опубликовано без фото', cls: 'bg-yellow-100 text-yellow-700' };
if (status === 'FAILED') return { text: 'Ошибка публикации', cls: 'bg-red-100 text-red-700' };
return null;
});
function extractError(error) {
return error?.data?.message || error?.message || 'Не удалось запустить рекламу. Попробуйте снова.';
}
function isTokenExpired(error) {
const status = error?.status;
const code = error?.data?.error || error?.data?.code || error?.data?.exception;
const message = (error?.data?.message || error?.message || '').toLowerCase();
return (
status === 400 ||
status === 401 ||
code === 'FacebookTokenExpiredException' ||
message.includes('token') ||
message.includes('expired')
);
}
function resolveImageUrl(url) {
if (!url) return '';
if (url.startsWith('data:')) return url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith('/')) return `${API_CONFIG.BASE_URL}${url}`;
return `${API_CONFIG.BASE_URL}/${url}`;
}
const previewImageUrl = computed(() => {
const direct = creative.value?.imageUrl || creative.value?.imageFilename || creative.value?.mediaUrl;
return resolveImageUrl(direct || '');
});
const launchImageUrl = computed(() => resolveImageUrl(launchResult.value?.imageUrl || ''));
async function copyText(value, key) {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
copiedState.value = key;
window.setTimeout(() => {
if (copiedState.value === key) copiedState.value = '';
}, 1500);
} catch (_) {
copiedState.value = '';
}
}
async function handleLaunch() {
launching.value = true;
errorMessage.value = '';
try {
if (directStrategyId.value) {
const result = await launchFromStrategy(directStrategyId.value);
store.launchResult.value = result;
await router.replace({
name: 'targeting-creative',
query: {
...route.query,
strategyId: directStrategyId.value,
strategyName: directStrategyName.value,
completed: '1'
}
});
} else {
await new Promise((resolve) => setTimeout(resolve, 2000));
store.launchResult.value = {
facebookStatus: 'PUBLISHED',
facebookPostId: 'simulated-post-id',
platform: 'Instagram',
targetingSettings: creative.value?.targetingSettings,
message: 'Реклама запущена в демонстрационном режиме'
};
}
} catch (error) {
if (isTokenExpired(error)) {
errorMessage.value = 'Требуется обновление доступа социальной сети';
toast.add({
severity: 'warn',
summary: 'Авторизация',
detail: 'Требуется обновление доступа социальной сети',
life: 4000
});
} else {
errorMessage.value = extractError(error);
}
} finally {
launching.value = false;
}
}
function openSourceStrategy() {
if (store.sourceContext.value === 'marketing-legacy') {
router.push({ name: 'marketing-promotion', query: { strategyId: directStrategyId.value } });
return;
}
if (store.sourceContext.value === 'marketing-v3' || (isDirectLaunchMode.value && directStrategyId.value)) {
router.push({ name: 'marketing-v3-strategy', query: { strategyId: directStrategyId.value } });
return;
}
router.push({ name: 'targeting-strategy' });
}
function startNewCampaign() {
store.reset();
router.push({ name: 'targeting-input' });
}
onMounted(() => {
if (route.query.autostart === '1' && isDirectLaunchMode.value && !launchResult.value && !autoLaunchStarted.value) {
autoLaunchStarted.value = true;
handleLaunch();
}
});
</script>
<template>
<div class="max-w-6xl mx-auto py-2 lg:py-4">
<TargetingPageHeader
:back-label="isDirectLaunchMode ? 'К стратегии' : 'Назад к стратегии'"
eyebrow="Шаг 3"
:title="launchResult ? 'Реклама запущена' : isDirectLaunchMode ? 'Запуск таргета из стратегии' : 'Рекламный пост готов'"
:subtitle="isDirectLaunchMode ? 'Используем новую targeting API логику и запускаем рекламу напрямую из готовой V3 стратегии.' : 'Пост, настройки таргетинга и запуск рекламы собраны в одном финальном экране.'"
@back="openSourceStrategy"
/>
<TargetingStateCard v-if="launching" mode="loading" message="Запускаем рекламу в Facebook..." />
<template v-else-if="launchResult">
<TargetingPanel>
<div class="text-center py-4">
<div class="text-5xl mb-4"></div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">Реклама запущена в Facebook</h2>
<p class="text-gray-600 mb-6">Первый пост из стратегии опубликован. Ожидайте первые результаты через 24-48 часов.</p>
<div class="max-w-2xl mx-auto rounded-xl border border-gray-200 bg-gray-50 p-4 text-left mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-gray-700">
<p><span class="text-gray-500">ID публикации:</span> {{ launchResult.facebookPostId || '—' }}</p>
<p><span class="text-gray-500">Платформа:</span> {{ launchResult.platform || 'Instagram' }}</p>
<p><span class="text-gray-500">Бюджет/день:</span> {{ launchResult.targetingSettings?.budgetPerDay || '—' }}</p>
<p><span class="text-gray-500">Охват/день:</span> {{ launchResult.targetingSettings?.estimatedReach || '—' }}</p>
</div>
</div>
<div v-if="launchImageUrl" class="max-w-sm mx-auto mb-6">
<img :src="launchImageUrl" alt="Facebook preview" class="w-full rounded-xl border border-gray-200 object-cover" />
</div>
<div v-if="statusBadge" class="inline-flex px-3 py-1 rounded-full text-sm font-medium mb-6" :class="statusBadge.cls">
{{ statusBadge.text }}
</div>
<div class="flex flex-wrap justify-center gap-3">
<Button label="Новая кампания" outlined @click="startNewCampaign" />
<Button label="Перейти к стратегии" @click="openSourceStrategy" />
</div>
</div>
</TargetingPanel>
</template>
<template v-else-if="isDirectLaunchMode">
<div class="space-y-4 lg:space-y-6">
<TargetingPanel title="Источник запуска" :subtitle="directStrategyName">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-gray-700 max-w-3xl">
Стратегия уже готова, поэтому мы пропускаем генерацию анализа и креатива и сразу запускаем таргет через `POST /api/targeting/launch/{strategyId}`.
</p>
<Button label="Запустить сейчас" icon="pi pi-play" @click="handleLaunch" />
</div>
</TargetingPanel>
<TargetingStateCard v-if="errorMessage" mode="error" :message="errorMessage" @retry="handleLaunch" />
</div>
</template>
<template v-else-if="creative">
<div class="space-y-4 lg:space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
<TargetingPanel title="Превью поста">
<PostPreviewCard
:topic="store.topic.value"
:post-type="selectedCreative?.type"
:cta="creative.ctaButton"
:image-url="previewImageUrl"
/>
</TargetingPanel>
<TargetingPanel title="Подпись к посту">
<h3 class="text-xl font-bold text-gray-900 mb-3">{{ creative.headline }}</h3>
<p class="text-gray-700 whitespace-pre-line mb-4">{{ creative.caption }}</p>
<p class="text-sm text-primary mb-4">{{ (creative.hashtags || []).join(' ') }}</p>
<div class="flex flex-wrap gap-2">
<Button
:label="copiedState === 'caption' ? 'Скопировано' : 'Скопировать подпись'"
icon="pi pi-copy"
size="small"
outlined
@click="copyText(creative.caption, 'caption')"
/>
<Button
:label="copiedState === 'all' ? 'Скопировано' : 'Скопировать все'"
icon="pi pi-copy"
size="small"
outlined
@click="copyText(`${creative.headline}\n\n${creative.caption}\n\n${(creative.hashtags || []).join(' ')}`, 'all')"
/>
</div>
</TargetingPanel>
</div>
<TargetingPanel title="Настройки таргетинга">
<div class="space-y-3 text-gray-700">
<p><span class="text-gray-500">Аудитория:</span> {{ creative.targetingSettings?.audience }}</p>
<p><span class="text-gray-500">Возраст:</span> {{ creative.targetingSettings?.age }}</p>
<p><span class="text-gray-500">Размещение:</span> {{ creative.targetingSettings?.placement }}</p>
<p><span class="text-gray-500">Бюджет/день:</span> {{ creative.targetingSettings?.budgetPerDay }}</p>
<p><span class="text-gray-500">Охват/день:</span> {{ creative.targetingSettings?.estimatedReach }}</p>
</div>
<div class="flex flex-wrap gap-2 mt-4">
<span
v-for="interest in creative.targetingSettings?.interests || []"
:key="interest"
class="rounded-full bg-blue-50 text-blue-700 px-3 py-1 text-sm"
>
{{ interest }}
</span>
</div>
</TargetingPanel>
<TargetingPanel title="CTA кнопка">
<Button :label="creative.ctaButton" outlined />
</TargetingPanel>
<TargetingStateCard v-if="errorMessage" mode="error" :message="errorMessage" @retry="handleLaunch" />
<div class="flex justify-center">
<Button
label="Запустить рекламу"
icon="pi pi-rocket"
size="large"
class="w-full lg:w-auto"
@click="handleLaunch"
/>
</div>
</div>
</template>
</div>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import TargetingPageHeader from '@/components/targeting/TargetingPageHeader.vue';
import TargetingPanel from '@/components/targeting/TargetingPanel.vue';
import TargetingStateCard from '@/components/targeting/TargetingStateCard.vue';
import { useTargetingApi } from '@/composables/useTargetingApi';
import { useTargetingStore } from '@/stores/targeting';
const router = useRouter();
const store = useTargetingStore();
const { generateAnalysis } = useTargetingApi();
const topicInput = ref(store.topic.value || '');
const loading = ref(false);
const errorMessage = ref('');
const examples = ['Детский сад в Алматы', 'Фитнес-студия в Алматы', 'Кафе семейной кухни', 'Онлайн курсы английского', 'Юридические услуги'];
const canSubmit = computed(() => topicInput.value.trim().length >= 3 && !loading.value);
function extractError(error) {
if (error?.data?.fields?.topic) return error.data.fields.topic;
return error?.data?.message || error?.message || 'Произошла ошибка. Попробуйте еще раз.';
}
async function handleStart() {
if (!canSubmit.value) return;
loading.value = true;
errorMessage.value = '';
try {
const topic = topicInput.value.trim();
const result = await generateAnalysis(topic);
store.topic.value = topic;
store.analysis.value = result;
store.strategy.value = null;
store.creative.value = null;
store.selectedCreative.value = null;
store.launchResult.value = null;
store.sourceStrategyId.value = null;
store.sourceStrategyName.value = '';
store.sourceContext.value = 'targeting';
await router.push({ name: 'targeting-analysis' });
} catch (error) {
errorMessage.value = extractError(error);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-3xl mx-auto py-2 lg:py-4">
<TargetingPageHeader
eyebrow="Новый запуск"
title="AI Targeting System"
subtitle="Введите тему бизнеса, и система соберет маркетинговый анализ, стратегию продвижения и готовый рекламный запуск в едином потоке."
/>
<TargetingPanel>
<TargetingStateCard v-if="loading" mode="loading" message="Анализируем рынок и аудиторию..." />
<template v-else>
<div class="grid grid-cols-1 lg:grid-cols-[1.3fr_0.7fr] gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Тема бизнеса <span class="text-red-500">*</span></label>
<textarea
v-model="topicInput"
rows="4"
class="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none transition"
placeholder="Например: фитнес-студия в Алматы"
/>
<div class="mt-5">
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-3">Быстрые примеры</p>
<div class="flex flex-wrap gap-2">
<button
v-for="example in examples"
:key="example"
type="button"
class="text-sm px-3 py-2 rounded-full border border-gray-300 text-gray-600 hover:border-primary hover:text-primary hover:bg-primary-50 transition"
@click="topicInput = example"
>
{{ example }}
</button>
</div>
</div>
</div>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-3">Что получим</p>
<div class="space-y-3 text-sm text-gray-700">
<div class="flex items-start gap-3">
<span class="mt-0.5 text-primary">01</span>
<p>Анализ рынка, аудитории, платформ и бюджета.</p>
</div>
<div class="flex items-start gap-3">
<span class="mt-0.5 text-primary">02</span>
<p>Стратегию по фазам с KPI и набором креативов.</p>
</div>
<div class="flex items-start gap-3">
<span class="mt-0.5 text-primary">03</span>
<p>Готовый рекламный пост и запуск по новой targeting API логике.</p>
</div>
</div>
</div>
</div>
<div v-if="errorMessage" class="mt-5">
<TargetingStateCard mode="error" :message="errorMessage" @retry="handleStart" />
</div>
<div class="mt-6 flex justify-end">
<Button label="Начать анализ" icon="pi pi-arrow-right" icon-pos="right" class="px-5" :disabled="!canSubmit" @click="handleStart" />
</div>
</template>
</TargetingPanel>
</div>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const steps = [
{ key: 'analysis', label: 'Анализ' },
{ key: 'strategy', label: 'Стратегия' },
{ key: 'creative', label: 'Креатив' },
{ key: 'launch', label: 'Запуск' }
];
const currentStepIndex = computed(() => {
if (route.name === 'targeting-input') return 0;
if (route.name === 'targeting-analysis') return 0;
if (route.name === 'targeting-strategy') return 1;
if (route.name === 'targeting-creative' && route.query.strategyId) {
return route.query.completed === '1' ? 3 : 3;
}
if (route.name === 'targeting-creative') {
return route.query.completed === '1' ? 3 : 2;
}
return 0;
});
function isCompleted(index) {
return index < currentStepIndex.value;
}
function isActive(index) {
return index === currentStepIndex.value;
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-b from-gray-50 via-white to-gray-50">
<div class="max-w-6xl mx-auto px-4 py-6 lg:px-8 lg:py-8">
<div class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden mb-6">
<div class="px-5 py-4 lg:px-6 border-b border-gray-100 bg-[linear-gradient(135deg,rgba(59,130,246,0.08),rgba(16,185,129,0.04))]">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em]">AI Targeting System</p>
<p class="text-gray-600 mt-1">Единый поток: анализ, стратегия, креатив и запуск рекламы.</p>
</div>
<div class="rounded-full bg-gray-50 border border-gray-200 px-3 py-1 text-sm text-gray-500">
Шаг {{ currentStepIndex + 1 }} из 4
</div>
</div>
</div>
<div class="px-5 py-5 lg:px-6">
<div class="flex items-center gap-2 overflow-x-auto">
<template v-for="(step, index) in steps" :key="step.key">
<div class="flex items-center min-w-fit">
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-full border flex items-center justify-center text-sm font-semibold transition-colors"
:class="{
'bg-primary border-primary text-white shadow-sm': isActive(index),
'bg-green-500 border-green-500 text-white': isCompleted(index),
'bg-white border-gray-300 text-gray-400': !isActive(index) && !isCompleted(index)
}"
>
<span v-if="isCompleted(index)"></span>
<span v-else>{{ index + 1 }}</span>
</div>
<span
class="text-sm font-medium whitespace-nowrap transition-colors"
:class="{
'text-primary underline underline-offset-4': isActive(index),
'text-gray-500': isCompleted(index),
'text-gray-400': !isActive(index) && !isCompleted(index)
}"
>
{{ step.label }}
</span>
</div>
<div
v-if="index < steps.length - 1"
class="h-0.5 w-8 sm:w-14 mx-3 rounded-full"
:class="isCompleted(index + 1) || isActive(index + 1) ? 'bg-primary/50' : 'bg-gray-200'"
></div>
</div>
</template>
</div>
</div>
</div>
<router-view />
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import TargetingPageHeader from '@/components/targeting/TargetingPageHeader.vue';
import TargetingPanel from '@/components/targeting/TargetingPanel.vue';
import TargetingStateCard from '@/components/targeting/TargetingStateCard.vue';
import { useTargetingApi } from '@/composables/useTargetingApi';
import { useTargetingStore } from '@/stores/targeting';
const router = useRouter();
const store = useTargetingStore();
const { generateCreative } = useTargetingApi();
const tabs = [
{ key: 'overview', label: 'Обзор' },
{ key: 'phases', label: 'Фазы' },
{ key: 'creatives', label: 'Креативы' },
{ key: 'kpi', label: 'Бюджет / KPI' }
];
const activeTab = ref('creatives');
const creatingCreativeId = ref(null);
const errorMessage = ref('');
const lastCreativeAttempt = ref(null);
const strategy = computed(() => store.strategy.value);
function extractError(error) {
return error?.data?.message || error?.message || 'Не удалось создать креатив. Попробуйте снова.';
}
async function handleLaunchCreative(creativeNeed) {
if (!store.analysis.value || !strategy.value || !creativeNeed) return;
creatingCreativeId.value = creativeNeed.id;
errorMessage.value = '';
lastCreativeAttempt.value = creativeNeed;
try {
store.selectedCreative.value = creativeNeed;
store.sourceStrategyId.value = strategy.value.strategyId || strategy.value.id || null;
store.sourceStrategyName.value = strategy.value.strategyName || '';
store.sourceContext.value = 'targeting';
const result = await generateCreative(creativeNeed.captionIdea, store.analysis.value, strategy.value);
store.creative.value = result;
store.launchResult.value = null;
await router.push({ name: 'targeting-creative' });
} catch (error) {
errorMessage.value = extractError(error);
} finally {
creatingCreativeId.value = null;
}
}
</script>
<template>
<div class="max-w-6xl mx-auto py-2 lg:py-4">
<TargetingPageHeader
back-label="Назад"
eyebrow="Шаг 2"
title="Маркетинговая стратегия"
:subtitle="strategy?.goal || 'Разбили запуск на этапы, KPI и набор рекламных креативов.'"
@back="router.push({ name: 'targeting-analysis' })"
/>
<template v-if="strategy">
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-4 items-start mb-6">
<TargetingPanel compact>
<div class="flex flex-wrap items-center gap-3">
<span class="text-lg font-semibold text-gray-900">{{ strategy.strategyName }}</span>
<span class="text-sm text-gray-400"></span>
<span class="text-sm text-gray-600">{{ strategy.duration }}</span>
<span class="text-sm text-gray-400"></span>
<span class="text-sm text-gray-600">{{ strategy.totalBudget }}</span>
</div>
</TargetingPanel>
<TargetingPanel compact>
<div class="flex gap-2 flex-wrap">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="px-4 py-2 rounded-lg text-sm font-medium transition"
:class="activeTab === tab.key ? 'bg-primary text-white shadow-sm' : 'bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200'"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
</TargetingPanel>
</div>
<div v-if="activeTab === 'overview'" class="space-y-4 lg:space-y-6">
<TargetingPanel title="Главная цель">
<p class="text-gray-700">{{ strategy.goal }}</p>
</TargetingPanel>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<TargetingPanel
v-for="phase in strategy.phases || []"
:key="phase.phase"
:title="`Фаза ${phase.phase}`"
:subtitle="phase.duration"
compact
>
<p class="font-semibold text-gray-900">{{ phase.name }}</p>
</TargetingPanel>
</div>
</div>
<div v-else-if="activeTab === 'phases'" class="space-y-4 lg:space-y-6">
<TargetingPanel
v-for="phase in strategy.phases || []"
:key="phase.phase"
:title="`Фаза ${phase.phase} — ${phase.name}`"
:subtitle="phase.duration"
>
<p class="text-gray-700 mb-4"><span class="font-medium">Цель:</span> {{ phase.objective }}</p>
<div class="border-t border-gray-100 pt-4 mb-4">
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-2">Тактики</p>
<ul class="space-y-2 text-gray-700">
<li v-for="tactic in phase.tactics || []" :key="tactic"> {{ tactic }}</li>
</ul>
</div>
<p class="text-gray-700"><span class="font-medium">KPI:</span> {{ phase.kpi }}</p>
</TargetingPanel>
</div>
<div v-else-if="activeTab === 'creatives'" class="space-y-4 lg:space-y-6">
<TargetingPanel compact>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em]">Главный раздел</p>
<p class="text-gray-600 mt-1">Выберите нужный креатив, и мы сразу сгенерируем готовый рекламный пост с настройками таргетинга.</p>
</div>
</div>
</TargetingPanel>
<TargetingPanel
v-for="creativeNeed in strategy.adCreativesNeeded || []"
:key="creativeNeed.id"
:title="`[${creativeNeed.id}] ${creativeNeed.type}`"
:subtitle="creativeNeed.theme"
>
<div class="space-y-4">
<div class="flex flex-wrap justify-between items-start gap-3">
<div class="max-w-3xl">
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-1">Идея подписи</p>
<p class="text-gray-700">{{ creativeNeed.captionIdea }}</p>
</div>
<span class="px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 text-xs">
{{ creativeNeed.targetSegment }}
</span>
</div>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<p class="text-sm font-medium text-gray-500 uppercase tracking-[0.18em] mb-2">Описание изображения</p>
<p class="text-gray-700 leading-relaxed">{{ creativeNeed.imageDescription }}</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-gray-700"><span class="font-medium">CTA:</span> {{ creativeNeed.cta }}</p>
<Button
:label="creatingCreativeId === creativeNeed.id ? 'Создаем рекламный пост...' : 'Запустить таргет'"
icon="pi pi-bullseye"
:loading="creatingCreativeId === creativeNeed.id"
:disabled="creatingCreativeId !== null"
@click="handleLaunchCreative(creativeNeed)"
/>
</div>
</div>
</TargetingPanel>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6">
<TargetingPanel title="Бюджет">
<p class="text-3xl font-bold text-gray-900">{{ strategy.totalBudget }}</p>
</TargetingPanel>
<TargetingPanel title="Ожидаемые результаты">
<div class="space-y-2 text-gray-700">
<p><span class="text-gray-500">Охват:</span> {{ strategy.expectedResults?.reach }}</p>
<p><span class="text-gray-500">Клики:</span> {{ strategy.expectedResults?.clicks }}</p>
<p><span class="text-gray-500">Лиды:</span> {{ strategy.expectedResults?.conversions }}</p>
<p><span class="text-gray-500">CPL:</span> {{ strategy.expectedResults?.cpl }}</p>
</div>
</TargetingPanel>
</div>
<div v-if="errorMessage" class="mt-6">
<TargetingStateCard mode="error" :message="errorMessage" @retry="lastCreativeAttempt && handleLaunchCreative(lastCreativeAttempt)" />
</div>
</template>
</div>
</template>
@@ -0,0 +1,47 @@
// vite.config.mjs
import { fileURLToPath, URL } from "node:url";
import { PrimeVueResolver } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@primevue/auto-import-resolver/index.mjs";
import vue from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import Components from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/unplugin-vue-components/dist/vite.js";
import { defineConfig } from "file:///C:/Users/777/IdeaProjects/marketing/node_modules/vite/dist/node/index.js";
var __vite_injected_original_import_meta_url = "file:///C:/Users/777/IdeaProjects/marketing/vite.config.mjs";
var vite_config_default = defineConfig({
optimizeDeps: {
noDiscovery: true
},
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()]
})
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", __vite_injected_original_import_meta_url))
}
},
server: {
proxy: {
"/api": {
target: "https://api.konturai.kz",
changeOrigin: true,
secure: true,
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err);
});
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url);
});
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log("Received Response from the Target:", proxyRes.statusCode, req.url);
});
}
}
}
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcNzc3XFxcXElkZWFQcm9qZWN0c1xcXFxtYXJrZXRpbmdcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIkM6XFxcXFVzZXJzXFxcXDc3N1xcXFxJZGVhUHJvamVjdHNcXFxcbWFya2V0aW5nXFxcXHZpdGUuY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vQzovVXNlcnMvNzc3L0lkZWFQcm9qZWN0cy9tYXJrZXRpbmcvdml0ZS5jb25maWcubWpzXCI7aW1wb3J0IHsgZmlsZVVSTFRvUGF0aCwgVVJMIH0gZnJvbSAnbm9kZTp1cmwnO1xuXG5pbXBvcnQgeyBQcmltZVZ1ZVJlc29sdmVyIH0gZnJvbSAnQHByaW1ldnVlL2F1dG8taW1wb3J0LXJlc29sdmVyJztcbmltcG9ydCB2dWUgZnJvbSAnQHZpdGVqcy9wbHVnaW4tdnVlJztcbmltcG9ydCBDb21wb25lbnRzIGZyb20gJ3VucGx1Z2luLXZ1ZS1jb21wb25lbnRzL3ZpdGUnO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XG5cbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICAgIG9wdGltaXplRGVwczoge1xuICAgICAgICBub0Rpc2NvdmVyeTogdHJ1ZVxuICAgIH0sXG4gICAgcGx1Z2luczogW1xuICAgICAgICB2dWUoKSxcbiAgICAgICAgQ29tcG9uZW50cyh7XG4gICAgICAgICAgICByZXNvbHZlcnM6IFtQcmltZVZ1ZVJlc29sdmVyKCldXG4gICAgICAgIH0pXG4gICAgXSxcbiAgICByZXNvbHZlOiB7XG4gICAgICAgIGFsaWFzOiB7XG4gICAgICAgICAgICAnQCc6IGZpbGVVUkxUb1BhdGgobmV3IFVSTCgnLi9zcmMnLCBpbXBvcnQubWV0YS51cmwpKVxuICAgICAgICB9XG4gICAgfSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgICAgcHJveHk6IHtcbiAgICAgICAgICAgICcvYXBpJzoge1xuICAgICAgICAgICAgICAgIHRhcmdldDogJ2h0dHBzOi8vYXBpLmtvbnR1cmFpLmt6JyxcbiAgICAgICAgICAgICAgICBjaGFuZ2VPcmlnaW46IHRydWUsXG4gICAgICAgICAgICAgICAgc2VjdXJlOiB0cnVlLFxuICAgICAgICAgICAgICAgIGNvbmZpZ3VyZTogKHByb3h5LCBfb3B0aW9ucykgPT4ge1xuICAgICAgICAgICAgICAgICAgICBwcm94eS5vbignZXJyb3InLCAoZXJyLCBfcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygncHJveHkgZXJyb3InLCBlcnIpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICAgICAgcHJveHkub24oJ3Byb3h5UmVxJywgKHByb3h5UmVxLCByZXEsIF9yZXMpID0+IHtcbiAgICAgICAgICAgICAgICAgICAgICAgIGNvbnNvbGUubG9nKCdTZW5kaW5nIFJlcXVlc3QgdG8gdGhlIFRhcmdldDonLCByZXEubWV0aG9kLCByZXEudXJsKTtcbiAgICAgICAgICAgICAgICAgICAgfSk7XG4gICAgICAgICAgICAgICAgICAgIHByb3h5Lm9uKCdwcm94eVJlcycsIChwcm94eVJlcywgcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnUmVjZWl2ZWQgUmVzcG9uc2UgZnJvbSB0aGUgVGFyZ2V0OicsIHByb3h5UmVzLnN0YXR1c0NvZGUsIHJlcS51cmwpO1xuICAgICAgICAgICAgICAgICAgICB9KTtcbiAgICAgICAgICAgICAgICB9XG4gICAgICAgICAgICB9XG4gICAgICAgIH1cbiAgICB9XG59KTtcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBdVMsU0FBUyxlQUFlLFdBQVc7QUFFMVUsU0FBUyx3QkFBd0I7QUFDakMsT0FBTyxTQUFTO0FBQ2hCLE9BQU8sZ0JBQWdCO0FBQ3ZCLFNBQVMsb0JBQW9CO0FBTDJKLElBQU0sMkNBQTJDO0FBUXpPLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQ3hCLGNBQWM7QUFBQSxJQUNWLGFBQWE7QUFBQSxFQUNqQjtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ0wsSUFBSTtBQUFBLElBQ0osV0FBVztBQUFBLE1BQ1AsV0FBVyxDQUFDLGlCQUFpQixDQUFDO0FBQUEsSUFDbEMsQ0FBQztBQUFBLEVBQ0w7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNMLE9BQU87QUFBQSxNQUNILEtBQUssY0FBYyxJQUFJLElBQUksU0FBUyx3Q0FBZSxDQUFDO0FBQUEsSUFDeEQ7QUFBQSxFQUNKO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDSixPQUFPO0FBQUEsTUFDSCxRQUFRO0FBQUEsUUFDSixRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxRQUFRO0FBQUEsUUFDUixXQUFXLENBQUMsT0FBTyxhQUFhO0FBQzVCLGdCQUFNLEdBQUcsU0FBUyxDQUFDLEtBQUssTUFBTSxTQUFTO0FBQ25DLG9CQUFRLElBQUksZUFBZSxHQUFHO0FBQUEsVUFDbEMsQ0FBQztBQUNELGdCQUFNLEdBQUcsWUFBWSxDQUFDLFVBQVUsS0FBSyxTQUFTO0FBQzFDLG9CQUFRLElBQUksa0NBQWtDLElBQUksUUFBUSxJQUFJLEdBQUc7QUFBQSxVQUNyRSxDQUFDO0FBQ0QsZ0JBQU0sR0FBRyxZQUFZLENBQUMsVUFBVSxLQUFLLFNBQVM7QUFDMUMsb0JBQVEsSUFBSSxzQ0FBc0MsU0FBUyxZQUFZLElBQUksR0FBRztBQUFBLFVBQ2xGLENBQUM7QUFBQSxRQUNMO0FBQUEsTUFDSjtBQUFBLElBQ0o7QUFBQSxFQUNKO0FBQ0osQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K