fix
This commit is contained in:
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
Generated
+147
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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: 'Установки приложения'
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>ИИ анализирует кампанию. Первые инсайты появятся через 24–48 часов после запуска.</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>
|
||||
Данные появятся через 24–48 часов после запуска кампании
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user