This commit is contained in:
arys
2026-02-25 10:33:51 +05:00
parent b517ee4ba0
commit 413e05fa8a
59 changed files with 9067 additions and 5437 deletions
+183
View File
@@ -0,0 +1,183 @@
# Интеграция маркетингового анализа v4.0 в существующую архитектуру
## ✅ Что было сделано
### 1. Восстановлена старая архитектура Vue.js
- Восстановлены все файлы из `origin/main`
- Сохранена структура проекта (Vue 3 + PrimeVue + Vite)
### 2. Добавлен новый функционал v4.0
#### Типы и сервисы
- `src/types/marketing-v3.js` — все enum'ы и маппинги для UI
- `src/service/MarketingV3Service.js` — API сервис для работы с v4.0
#### Новый многошаговый опросник
- `src/components/marketing/MarketingBriefWizardV3.vue` — главный компонент опросника
- `src/components/marketing/wizard/StepSection.vue` — секция шага
- `src/components/marketing/wizard/RadioGroupField.vue` — радио-группа с карточками
- `src/components/marketing/wizard/CheckboxCardsLimitedField.vue` — чекбоксы с ограничением (max 2)
- `src/components/marketing/wizard/CheckboxCardsField.vue` — обычные чекбоксы
- `src/components/marketing/wizard/TagArrayField.vue` — поле для массивов (города)
- `src/components/marketing/wizard/DynamicLinksField.vue` — динамические ссылки
#### Дашборд результатов
- `src/views/pages/marketing/MarketingAnalysisV3.vue` — страница результатов с поллингом
- `src/components/marketing/MetricCard.vue` — карточка метрики
### 3. Обновлён дизайн существующих компонентов
- `src/views/pages/auth/Login.vue` — обновлён под новый стиль (градиенты, современные карточки)
- `src/views/pages/marketing/MarketingMain.vue` — добавлена ссылка на новый опросник v4.0, улучшен hover-эффект
### 4. Добавлены новые роуты
- `/marketing-analysis/v3` — новый опросник v4.0
- `/marketing-analysis/v3/:id` — страница результатов анализа v4.0
---
## 🚀 Как запустить и протестировать
### 1. Установка зависимостей
```bash
npm install
```
### 2. Запуск проекта
```bash
npm run dev
```
Открой в браузере: **http://localhost:5173** (или другой порт, который покажет Vite)
### 3. Тестирование нового функционала
#### Шаг 1: Авторизация
1. Перейди на `/auth/login`
2. Войди в систему (используй существующие credentials)
#### Шаг 2: Новый опросник v4.0
1. Перейди в раздел "Маркетинг" (`/marketing-analysis`)
2. Выбери **"Маркетинговый анализ v4.0 (новый)"** — первый пункт в меню
3. Или перейди напрямую на `/marketing-analysis/v3`
#### Шаг 3: Заполнение опросника
- Пройди все 5 шагов:
1. **Базовая информация** — ниша, название, цель, этап бизнеса
2. **География и чек** — города, средний чек
3. **Продукт и поведение** — описание, частота покупок, приоритеты
4. **Текущая ситуация** — SMM статус, заявки, ограничения
5. **Ссылки и настройки** — соцсети, конкуренты, уровень детализации
**Особенности:**
- Ограничение выбора: некоторые поля позволяют выбрать только 1–2 варианта (карточки серые, если достигнут лимит)
- Динамические поля: города добавляются по Enter, ссылки — кнопкой "Добавить ссылку"
- Казахстанские примеры в placeholder'ах (Алматы, Астана, Шымкент и т.д.)
#### Шаг 4: Результаты анализа
- После отправки формы тебя перенаправит на `/marketing-analysis/v3/{analysisId}`
- Страница автоматически делает поллинг каждые 5 секунд
- Пока статус `QUEUED` или `PROCESSING` — показывается красивый лоадер с меняющимися сообщениями
- Когда статус `COMPLETED` — отображается дашборд с:
- Hero-карточками (Executive Summary)
- Графиками (сезонность, платформы конкурентов)
- Таблицей конкурентов с аккордеоном
- Выводами и стратегией
---
## 📋 Структура файлов
```
src/
├── components/
│ └── marketing/
│ ├── MarketingBriefWizardV3.vue # Главный опросник v4.0
│ ├── MetricCard.vue # Карточка метрики
│ └── wizard/ # Компоненты для опросника
│ ├── StepSection.vue
│ ├── RadioGroupField.vue
│ ├── CheckboxCardsLimitedField.vue
│ ├── CheckboxCardsField.vue
│ ├── TagArrayField.vue
│ └── DynamicLinksField.vue
├── views/
│ └── pages/
│ ├── auth/
│ │ └── Login.vue # Обновлённый дизайн
│ └── marketing/
│ ├── MarketingMain.vue # Обновлённое меню
│ └── MarketingAnalysisV3.vue # Дашборд результатов v4.0
├── service/
│ └── MarketingV3Service.js # API для v4.0
└── types/
└── marketing-v3.js # Типы и enum'ы
```
---
## 🔧 API эндпоинты
Новый функционал использует:
- `POST /api/v3/marketing-analysis/start` — запуск анализа v4.0
- `GET /api/v3/marketing-analysis/{id}` — получение статуса и результатов
**Важно:** Убедись, что на бэкенде эти эндпоинты реализованы и возвращают данные в формате:
- `POST``{ analysisId: string }`
- `GET``MarketingAnalysisV3Document` с полями `status`, `resultData`, `errorMessage`
---
## 🎨 Особенности дизайна
### Новый стиль опросника
- Премиальные карточки для выбора (Radio Cards)
- Плавные анимации переходов между шагами
- Прогресс-бар с градиентом
- Индикаторы шагов с галочками для завершённых
### Обновлённый Login
- Градиентный фон
- Современная карточка с тенью
- Иконка замка в градиентном круге
- Улучшенная типографика
### Дашборд результатов
- Карточки метрик с иконками
- Графики Chart.js (линейный и doughnut)
- Таблица конкурентов с аккордеоном
- Адаптивная сетка
---
## ⚠️ Важные замечания
1. **Поллинг:** Дашборд автоматически опрашивает статус каждые 5 секунд. Поллинг останавливается при статусе `COMPLETED` или `FAILED`.
2. **Валидация:** Форма валидируется перед отправкой. Обязательные поля и ограничения на массивы проверяются.
3. **Казахстанский контекст:** Все примеры адаптированы под казахстанский рынок (города, примеры ниш).
4. **Совместимость:** Новый функционал не ломает существующий опросник — он доступен по старому роуту `/marketing-analysis/analysis`.
---
## 🐛 Если что-то не работает
1. **Ошибки компиляции:**
- Проверь, что все зависимости установлены: `npm install`
- Убедись, что Vue 3 и PrimeVue установлены
2. **Ошибки API:**
- Проверь, что бэкенд запущен и доступен
- Проверь URL в `src/config/api.js`
- Убедись, что эндпоинты `/api/v3/marketing-analysis/*` реализованы на бэкенде
3. **Компоненты не отображаются:**
- Проверь консоль браузера на ошибки
- Убедись, что все компоненты правильно импортированы
---
Готово! Теперь можно тестировать новый функционал. 🎉
+155
View File
@@ -0,0 +1,155 @@
# 🚀 Быстрый старт — Маркетинговый анализ v4.0
## Что было сделано
**Восстановлена старая архитектура Vue.js** из Git
**Добавлен новый многошаговый опросник v4.0** с премиальным дизайном
**Создан дашборд результатов** с графиками и таблицами
**Обновлён дизайн Login.vue** под новый стиль
**Обновлён дизайн маркетинговых страниц**
**Интегрированы новые роуты** в существующий роутер
---
## 📦 Запуск проекта
### 1. Установка зависимостей
```bash
npm install
```
### 2. Запуск dev-сервера
```bash
npm run dev
```
Открой в браузере: **http://localhost:5173** (или порт, который покажет Vite)
---
## 🧪 Как протестировать
### Шаг 1: Авторизация
1. Перейди на `/auth/login`
2. Войди в систему
### Шаг 2: Новый опросник v4.0
1. Перейди в **"Маркетинг"** (`/marketing-analysis`)
2. Выбери **"Маркетинговый анализ v4.0 (новый)"** — первый пункт в меню
3. Или перейди напрямую на `/marketing-analysis/v3`
### Шаг 3: Заполнение опросника
Пройди все 5 шагов:
- **Шаг 1:** Базовая информация (ниша, название, цель, этап бизнеса)
- **Шаг 2:** География и чек (города, средний чек)
- **Шаг 3:** Продукт и поведение (описание, частота покупок, приоритеты клиентов)
- **Шаг 4:** Текущая ситуация (SMM статус, заявки, ограничения)
- **Шаг 5:** Ссылки и настройки (соцсети, конкуренты, уровень детализации)
**Особенности:**
- ✨ Красивые карточки для выбора (Radio Cards)
- 🔢 Ограничение выбора: некоторые поля позволяют выбрать только 1–2 варианта
- 🏙️ Динамические поля для городов (добавляются по Enter)
- 🔗 Динамические ссылки (кнопка "Добавить ссылку")
- 🇰🇿 Казахстанские примеры (Алматы, Астана, Шымкент и т.д.)
### Шаг 4: Результаты анализа
- После отправки формы тебя перенаправит на `/marketing-analysis/v3/{analysisId}`
- Страница автоматически делает **поллинг каждые 5 секунд**
- Пока статус `QUEUED` или `PROCESSING` — показывается **красивый лоадер** с меняющимися сообщениями
- Когда статус `COMPLETED` — отображается **дашборд** с:
- 📊 Hero-карточками (Executive Summary)
- 📈 Графиками (сезонность, платформы конкурентов)
- 📋 Таблицей конкурентов с аккордеоном (strengths/weaknesses)
- ✅ Выводами и стратегией
---
## 🎨 Новые роуты
- `/marketing-analysis/v3` — новый опросник v4.0
- `/marketing-analysis/v3/:id` — страница результатов анализа v4.0
**Старый опросник остался доступен:**
- `/marketing-analysis/analysis` — старый опросник (не изменён)
---
## 📋 Структура новых файлов
```
src/
├── components/marketing/
│ ├── MarketingBriefWizardV3.vue # Новый опросник v4.0
│ ├── MetricCard.vue # Карточка метрики
│ └── wizard/ # Компоненты для опросника
│ ├── StepSection.vue
│ ├── RadioGroupField.vue
│ ├── CheckboxCardsLimitedField.vue
│ ├── CheckboxCardsField.vue
│ ├── TagArrayField.vue
│ └── DynamicLinksField.vue
├── views/pages/marketing/
│ └── MarketingAnalysisV3.vue # Дашборд результатов v4.0
├── service/
│ └── MarketingV3Service.js # API для v4.0
└── types/
└── marketing-v3.js # Типы и enum'ы
```
---
## ⚙️ API эндпоинты
Новый функционал использует:
- `POST /api/v3/marketing-analysis/start` — запуск анализа v4.0
- `GET /api/v3/marketing-analysis/{id}` — получение статуса и результатов
**Важно:** Убедись, что на бэкенде эти эндпоинты реализованы и возвращают:
- `POST``{ analysisId: string }`
- `GET``MarketingAnalysisV3Document` с полями `status`, `resultData`, `errorMessage`
---
## 🎯 Особенности
### Дизайн
- ✨ Премиальные карточки для выбора (Radio Cards)
- 🎭 Плавные анимации переходов между шагами
- 📊 Прогресс-бар с градиентом
- ✅ Индикаторы шагов с галочками
### Функционал
- 🔄 Автоматический поллинг статуса анализа
- ✅ Валидация формы перед отправкой
- 🏙️ Динамические поля для городов и ссылок
- 🔢 Ограничения на выбор (max 2 для некоторых полей)
### Казахстанский контекст
- 🇰🇿 Примеры городов: Алматы, Астана, Шымкент, Караганда, Актобе
- 📱 Примеры ниш: стоматология в Алматы, клиники
- 🔗 Примеры ссылок: Instagram профили, 2ГИС
---
## 🐛 Если что-то не работает
1. **Ошибки компиляции:**
```bash
npm install
```
2. **Ошибки API:**
- Проверь, что бэкенд запущен
- Проверь URL в `src/config/api.js`
- Убедись, что эндпоинты `/api/v3/marketing-analysis/*` реализованы
3. **Компоненты не отображаются:**
- Проверь консоль браузера
- Убедись, что все компоненты правильно импортированы
---
Готово! Теперь можно тестировать. 🎉
-351
View File
@@ -1,351 +0,0 @@
# Руководство по API для фронтенда
## Обзор
Это руководство описывает API сервиса `Parser`, который собирает и предоставляет новостные данные. API спроектировано для эффективной работы с клиентскими приложениями и поддерживает пагинацию, сортировку и фильтрацию.
## Реализация в UI: что мы должны показать пользователю
Ниже описаны ключевые элементы интерфейса, которые необходимо создать, используя эндпоинты из этого руководства.
### 1. Основной экран: Лента новостей 📰
Это главный экран приложения, где пользователь видит все собранные новости.
- **Функционал:**
- **Отображение новостей:** Используйте `GET /api/parser/items` для загрузки и отображения списка новостей. Каждая новость в списке должна показывать заголовок, источник, дату публикации и краткий текст (`rawText` или `analytics.summary`, когда он будет готов).
- **Пагинация:** Так как новостей может быть много, реализуйте "бесконечную прокрутку" (подгрузка новых статей при скролле вниз) или классические кнопки пагинации («1, 2, 3...»). API полностью это поддерживает.
- **Сортировка:** По умолчанию новости должны быть отсортированы по дате публикации (`sort=publishedAt,desc`), чтобы пользователь видел сначала самое свежее.
### 2. Панель фильтров 🔎
Сбоку или сверху от ленты новостей должна быть панель, позволяющая пользователю отфильтровать данные.
- **Функционал:**
- **Фильтр по источнику:** Добавьте выпадающий список или чекбоксы с названиями источников (например, "Kursiv", "Kapital.kz"). При выборе источника отправляйте его в параметре `sourceName` в запросе к `GET /api/parser/items`.
- **Фильтр по дате:** Добавьте два поля для выбора даты ("от" и "до") с календарем. Это позволит пользователю смотреть новости за конкретный период. Используйте параметры `startDate` и `endDate`.
### 3. Дашборд/Статистика 📊
Нужно создать отдельную страницу "Дашборд" или "Статистика", где будет отображаться общая информация о системе.
- **Функционал:**
- **Общее количество новостей:** Используйте `GET /api/parser/stats` для получения и отображения общего числа статей в базе (`totalItems`).
- **Разбивка по источникам:** На основе поля `sourceStatistics` постройте круговую диаграмму (pie chart), которая наглядно покажет, сколько новостей пришло с каждого источника.
- **Статус системы:** Используйте `GET /api/parser/health`, чтобы показать пользователю (скорее, администратору) текущий статус сервиса и планировщика. Можно добавить зелёный/красный индикатор.
### 4. Админ-панель: Управление парсером (опционально, для администраторов) ⚙️
Эта часть интерфейса может быть скрыта от обычных пользователей и доступна только администраторам.
- **Функционал:**
- **Ручной запуск парсинга:** Добавьте кнопки "Запустить парсинг Kursiv", "Запустить парсинг Kapital" и "Запустить все". Эти кнопки будут отправлять запросы на эндпоинты `POST /api/parser/parse/{source}` и `POST /api/parser/parse/all`.
- **Мониторинг состояния БД:** Используйте `GET /api/parser/health/mongodb` для проверки статуса подключения к базе данных и выводите сообщение об успехе или ошибке.
---
## API Endpoints
### 1. Получение записей с фильтрацией и пагинацией
```http
GET /api/parser/items?page=0&size=10&sort=publishedAt,desc&sourceName=Kursiv&startDate=2025-01-01&endDate=2025-01-31
```
**Параметры:**
- `page` (optional, default: 0) - номер страницы
- `size` (optional, default: 20) - размер страницы
- `sort` (optional, default: "publishedAt,desc") - сортировка
- `sourceName` (optional) - фильтр по источнику
- `startDate` (optional) - начальная дата (ISO format)
- `endDate` (optional) - конечная дата (ISO format)
**Ответ:**
```json
{
"success": true,
"message": "Записи получены успешно",
"data": {
"content": [
{
"id": "...",
"sourceName": "Kursiv (Бизнес/экономика)",
"title": "Заголовок новости",
"url": "https://kursiv.media/...",
"publishedAt": "2025-01-14T10:30:00",
"addedAt": "2025-01-14T12:00:00",
"rawText": "Текст статьи...",
"hash": "...",
"category": "Бизнес",
"analytics": {
"summary": null,
"sentiment": null,
"tags": [],
"entities": {}
}
}
],
"pageable": {
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"pageNumber": 0,
"pageSize": 10,
"offset": 0,
"paged": true,
"unpaged": false
},
"totalElements": 25,
"totalPages": 3,
"last": false,
"first": true,
"numberOfElements": 10,
"size": 10,
"number": 0,
"sort": {
"sorted": true,
"unsorted": false,
"empty": false
},
"empty": false
}
}
```
### 2. Статистика системы
```http
GET /api/parser/stats
```
**Ответ:**
```json
{
"success": true,
"message": "Статистика получена успешно",
"data": {
"totalItems": 150,
"sourceStatistics": {
"Kursiv (Бизнес/экономика)": 75,
"Kapital.kz (Бизнес)": 75
},
"lastUpdate": "2025-01-14T12:00:00"
}
}
```
### 3. Запуск парсинга
#### Парсинг всех источников
```http
POST /api/parser/parse/all
```
**Ответ:**
```json
{
"success": true,
"message": "Парсинг всех источников завершен успешно",
"data": [
{
"source": "Kursiv",
"processedItems": 15,
"totalItemsInDb": 90,
"status": "completed"
},
{
"source": "Kapital",
"processedItems": 12,
"totalItemsInDb": 90,
"status": "completed"
}
]
}
```
#### Парсинг конкретного источника
```http
POST /api/parser/parse/kursiv
POST /api/parser/parse/kapital
```
### 4. Проверка состояния
#### Общее состояние
```http
GET /api/parser/health
```
**Ответ:**
```json
{
"success": true,
"message": "Сервис работает",
"data": {
"status": "UP",
"service": "RSS Parser System",
"timestamp": 1705123456789,
"scheduler": "Enabled - runs every 30 minutes"
}
}
```
#### Состояние MongoDB
```http
GET /api/parser/health/mongodb
```
**Ответ при успехе:**
```json
{
"success": true,
"message": "MongoDB подключение успешно",
"data": {
"status": "UP",
"message": "MongoDB подключение успешно",
"timestamp": 1705123456789
}
}
```
**Ответ при ошибке:**
```json
{
"success": false,
"message": "Ошибка подключения к MongoDB",
"data": {
"status": "DOWN",
"message": "Ошибка подключения к MongoDB: Command failed with error 13 (Unauthorized)",
"timestamp": 1705123456789,
"suggestion": "Проверьте учетные данные в application.properties"
}
}
```
## Примеры использования
### 1. Получение последних новостей с пагинацией
```javascript
// Получить первую страницу с 10 записями, отсортированными по дате публикации
fetch('/api/parser/items?page=0&size=10&sort=publishedAt,desc')
.then((response) => response.json())
.then((data) => {
if (data.success) {
const items = data.data.content;
const totalPages = data.data.totalPages;
// Обработка данных
}
});
```
### 2. Фильтрация по источнику
```javascript
// Получить только новости от Kursiv
fetch('/api/parser/items?sourceName=Kursiv (Бизнес/экономика)&page=0&size=20')
.then((response) => response.json())
.then((data) => {
if (data.success) {
const kursivNews = data.data.content;
// Обработка данных
}
});
```
### 3. Фильтрация по датам
```javascript
// Получить новости за последнюю неделю
const endDate = new Date().toISOString();
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
fetch(`/api/parser/items?startDate=${startDate}&endDate=${endDate}`)
.then((response) => response.json())
.then((data) => {
if (data.success) {
const recentNews = data.data.content;
// Обработка данных
}
});
```
### 4. Комплексная фильтрация
```javascript
// Получить новости Kapital за последний месяц, отсортированные по дате
const endDate = new Date().toISOString();
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
fetch(`/api/parser/items?sourceName=Kapital.kz (Бизнес)&startDate=${startDate}&endDate=${endDate}&sort=publishedAt,desc&page=0&size=50`)
.then((response) => response.json())
.then((data) => {
if (data.success) {
const kapitalNews = data.data.content;
const totalCount = data.data.totalElements;
// Обработка данных
}
});
```
### 5. Получение статистики
```javascript
fetch('/api/parser/stats')
.then((response) => response.json())
.then((data) => {
if (data.success) {
const stats = data.data;
console.log(`Всего новостей: ${stats.totalItems}`);
console.log('По источникам:', stats.sourceStatistics);
}
});
```
## Поддерживаемые форматы дат
API поддерживает следующие форматы дат:
- `yyyy-MM-dd` (например: 2025-01-14)
- `yyyy-MM-dd'T'HH:mm:ss` (например: 2025-01-14T10:30:00)
- `yyyy-MM-dd'T'HH:mm:ss.SSS` (например: 2025-01-14T10:30:00.000)
- `yyyy-MM-dd HH:mm:ss` (например: 2025-01-14 10:30:00)
## Обработка ошибок
Все ответы API следуют единому формату:
```json
{
"success": false,
"message": "Описание ошибки",
"error": "Детальная информация об ошибке"
}
```
HTTP статус коды:
- `200` - Успешный запрос
- `500` - Внутренняя ошибка сервера
- `503` - Сервис недоступен (например, MongoDB)
## Рекомендации для фронтенда
1. **Кэширование**: Используйте кэширование для статистики и часто запрашиваемых данных
2. **Пагинация**: Реализуйте бесконечную прокрутку или традиционную пагинацию
3. **Фильтры**: Предоставьте пользователю удобные фильтры по источнику и датам
4. **Обновление**: Используйте WebSocket или polling для обновления данных в реальном времени
5. **Обработка ошибок**: Всегда проверяйте поле `success` в ответе
-52
View File
@@ -1,52 +0,0 @@
### Инструкция (Промпт) для AI Агента
Ты — Senior Vue.js разработчик. Мне нужно исправить рендеринг Markdown в компоненте.
**Проблема:**
Компонент отображает сырой Markdown (символы #, \*, таблицы в виде текста), вместо HTML разметки. Стили CSS есть, но они не применяются, так как HTML не генерируется.
**Анализ кода:**
1. В секции imports из 'vue' забыли добавить `computed`.
2. Функция `renderMarkdown` скорее всего падает в `catch` и возвращает исходную строку.
3. Библиотека `marked` в последних версиях изменила API (удален `setOptions`, используется `marked.parse`).
**Задача:**
Перепиши секцию `<script setup>` и исправь логику рендеринга.
**Требования к исправлению:**
1. Добавь `computed` в импорты из 'vue'.
2. Обнови использование библиотеки `marked`. Убедись, что используется `marked.parse(text)`. Вместо устаревшего `setOptions` используй современный подход (или `marked.use()`, или просто дефолтный парсинг, так как `gfm` и `breaks` обычно включены по умолчанию).
3. Убедись, что переменная `renderedSummary` корректно обрабатывает `report.summary` и возвращает безопасный HTML (если summary нет, возвращать пустую строку).
4. (Опционально) Добавь импорт `DomPurify` (если он есть в проекте) для очистки HTML перед выводом в `v-html`, но если нет — оставь просто парсинг.
**Ожидаемый результат:**
Код компонента, где `renderedSummary` возвращает корректный HTML, и заголовки/таблицы отображаются красиво согласно CSS стилям `.markdown-content`.
````
### Что именно должен изменить агент (для вашего понимания):
Агент должен изменить блок скрипта примерно так:
```javascript
// Было
import { onBeforeUnmount, onMounted, ref } from 'vue';
// Станет (добавится computed)
import { onBeforeUnmount, onMounted, ref, computed } from 'vue';
// Было (конфигурация marked)
if (marked.setOptions) { ... }
// Станет (упрощенно и надежно)
const renderMarkdown = (markdown) => {
if (!markdown) return '';
try {
// Просто парсим, так как в новых версиях gfm включен по умолчанию
return marked.parse(markdown, { breaks: true });
} catch (e) {
console.error('Markdown parsing error:', e);
return markdown;
}
};
````
+12
View File
@@ -1152,6 +1152,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1333,6 +1334,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001640",
"electron-to-chromium": "^1.4.820",
@@ -1639,6 +1641,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -1694,6 +1697,7 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -2394,6 +2398,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -2739,6 +2744,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -2882,6 +2888,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3026,6 +3033,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.1.tgz",
"integrity": "sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==",
"dev": true,
"peer": true,
"dependencies": {
"@types/estree": "1.0.5"
},
@@ -3083,6 +3091,7 @@
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
"dev": true,
"peer": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -3345,6 +3354,7 @@
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
"integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -3587,6 +3597,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
"integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.39",
@@ -3641,6 +3652,7 @@
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
+33 -32
View File
@@ -1,36 +1,5 @@
<template>
<div v-if="normalized && normalized.data.length" class="heatmap-table mt-6">
<div v-if="showHeader" class="flex align-items-center mb-3">
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="heatmap-table__table w-full border-collapse">
<thead>
<tr>
<th class="heatmap-table__th">Сегмент</th>
<th v-for="col in normalized.columns" :key="col" class="heatmap-table__th text-center">
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in normalized.data" :key="idx" class="heatmap-table__tr">
<td class="heatmap-table__td font-semibold">{{ getRowName(row) }}</td>
<td v-for="col in normalized.columns" :key="col" class="heatmap-table__td text-center">
<span class="heatmap-table__badge" :style="getCellStyle(row?.[col])">
{{ formatCell(row?.[col]) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import { computed } from 'vue';
const props = defineProps({
@@ -94,6 +63,38 @@ const formatCell = (value) => {
};
</script>
<template>
<div v-if="normalized && normalized.data.length" class="heatmap-table mt-6">
<div v-if="showHeader" class="flex align-items-center mb-3">
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="heatmap-table__table w-full border-collapse">
<thead>
<tr>
<th class="heatmap-table__th">Сегмент</th>
<th v-for="col in normalized.columns" :key="col" class="heatmap-table__th text-center">
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in normalized.data" :key="idx" class="heatmap-table__tr">
<td class="heatmap-table__td font-semibold">{{ getRowName(row) }}</td>
<td v-for="col in normalized.columns" :key="col" class="heatmap-table__td text-center">
<span class="heatmap-table__badge" :style="getCellStyle(row?.[col])">
{{ formatCell(row?.[col]) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.heatmap-table__table {
border: 1px solid var(--surface-border);
@@ -0,0 +1,654 @@
<script setup>
import MarketingV3Service from '@/service/MarketingV3Service';
import {
AverageCheckLabels,
AverageChecks,
BusinessConstraintLabels,
BusinessConstraints,
BusinessStageLabels,
BusinessStages,
ClientTargetLabels,
ClientTargets,
CustomerBehaviorLabels,
CustomerBehaviors,
DecisionPriorities,
DecisionPriorityLabels,
DiscoveryMethodLabels,
DiscoveryMethods,
GeoScopeLabels,
GeoScopes,
LeadVolumeLabels,
LeadVolumes,
OfferTypeLabels,
OfferTypes,
PriceFeedbackLabels,
PriceFeedbacks,
PurchaseFrequencies,
PurchaseFrequencyLabels,
ResponseHandlerLabels,
ResponseHandlers,
SmmStatuses,
SmmStatusLabels,
VisualFactorLabels,
VisualFactors
} from '@/types/marketing-v3';
import { useToast } from 'primevue/usetoast';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
const STEPS = ['Базовая информация', 'География и чек', 'Продукт и поведение', 'Текущая ситуация', 'Ссылки и настройки'];
const currentStep = ref(0);
const submitting = ref(false);
const errors = ref({});
// Form Data
const formData = ref({
businessNiche: '',
productName: '',
goal: '',
businessStage: 'JUST_STARTING',
clientTarget: 'MIXED',
offerType: 'SERVICES',
averageCheck: 'MEDIUM',
customerBehaviors: [],
geoScope: 'SINGLE_CITY',
mainCity: '',
presenceCities: [],
promotionCities: [],
productDescription: '',
purchaseFrequency: 'ONCE',
visualFactor: 'VISUALS_IMPORTANT',
decisionPriorities: [],
discoveryMethods: [],
priceFeedback: 'AS_EXPECTED',
smmStatus: 'NONE',
leadVolume: 'NONE',
responseHandler: 'MYSELF',
constraints: [],
userSocialLinks: [],
knownCompetitorLinks: [],
detailLevel: 'СТАНДАРТНО',
analysisType: []
});
// Options для Radio Groups
const businessStageOptions = computed(() =>
BusinessStages.map((value) => ({
value,
label: BusinessStageLabels[value]
}))
);
const clientTargetOptions = computed(() =>
ClientTargets.map((value) => ({
value,
label: ClientTargetLabels[value]
}))
);
const offerTypeOptions = computed(() =>
OfferTypes.map((value) => ({
value,
label: OfferTypeLabels[value]
}))
);
const geoScopeOptions = computed(() =>
GeoScopes.map((value) => ({
value,
label: GeoScopeLabels[value]
}))
);
const averageCheckOptions = computed(() =>
AverageChecks.map((value) => ({
value,
label: AverageCheckLabels[value]
}))
);
const purchaseFrequencyOptions = computed(() =>
PurchaseFrequencies.map((value) => ({
value,
label: PurchaseFrequencyLabels[value]
}))
);
const visualFactorOptions = computed(() =>
VisualFactors.map((value) => ({
value,
label: VisualFactorLabels[value]
}))
);
const customerBehaviorOptions = computed(() =>
CustomerBehaviors.map((value) => ({
value,
label: CustomerBehaviorLabels[value]
}))
);
const decisionPriorityOptions = computed(() =>
DecisionPriorities.map((value) => ({
value,
label: DecisionPriorityLabels[value]
}))
);
const discoveryMethodOptions = computed(() =>
DiscoveryMethods.map((value) => ({
value,
label: DiscoveryMethodLabels[value]
}))
);
const priceFeedbackOptions = computed(() =>
PriceFeedbacks.map((value) => ({
value,
label: PriceFeedbackLabels[value]
}))
);
const smmStatusOptions = computed(() =>
SmmStatuses.map((value) => ({
value,
label: SmmStatusLabels[value]
}))
);
const leadVolumeOptions = computed(() =>
LeadVolumes.map((value) => ({
value,
label: LeadVolumeLabels[value]
}))
);
const responseHandlerOptions = computed(() =>
ResponseHandlers.map((value) => ({
value,
label: ResponseHandlerLabels[value]
}))
);
const businessConstraintOptions = computed(() =>
BusinessConstraints.map((value) => ({
value,
label: BusinessConstraintLabels[value]
}))
);
const detailLevelOptions = [
{ value: 'КРАТКО', label: 'Кратко — только главное' },
{ value: 'СТАНДАРТНО', label: 'Стандартно — баланс деталей и скорости' },
{ value: 'ПОДРОБНО', label: 'Максимально подробно — глубокий разбор' }
];
const analysisTypeOptions = [
{ value: 'РЫНОК', label: 'Рынок и спрос' },
{ value: 'КОНКУРЕНТЫ', label: 'Конкуренты и позиционирование' },
{ value: 'ЦА', label: 'Целевая аудитория' },
{ value: 'КАНАЛЫ', label: 'Каналы продвижения' }
];
// Валидация конкретного шага (запускается перед переходом)
const validateStep = (stepIndex) => {
let isValid = true;
if (stepIndex === 0) {
if (!formData.value.businessNiche?.trim() || formData.value.businessNiche.trim().length < 2) {
errors.value.businessNiche = 'Введите не менее 2 символов';
isValid = false;
} else delete errors.value.businessNiche;
if (!formData.value.productName?.trim() || formData.value.productName.trim().length < 2) {
errors.value.productName = 'Введите не менее 2 символов';
isValid = false;
} else delete errors.value.productName;
if (!formData.value.goal?.trim() || formData.value.goal.trim().length < 5) {
errors.value.goal = 'Введите не менее 5 символов';
isValid = false;
} else delete errors.value.goal;
}
if (stepIndex === 1) {
if (formData.value.geoScope === 'SINGLE_CITY') {
if (!formData.value.mainCity?.trim()) {
errors.value.mainCity = 'Укажите основной город';
isValid = false;
} else {
delete errors.value.mainCity;
}
}
if (formData.value.geoScope === 'MULTI_CITY') {
if (!formData.value.presenceCities?.length) {
errors.value.presenceCities = 'Укажите хотя бы один город присутствия';
isValid = false;
} else {
delete errors.value.presenceCities;
}
}
if (!formData.value.promotionCities?.length) {
errors.value.promotionCities = 'Укажите хотя бы один город для продвижения';
isValid = false;
} else {
delete errors.value.promotionCities;
}
}
if (stepIndex === 2) {
if (!formData.value.productDescription?.trim() || formData.value.productDescription.trim().length < 10) {
errors.value.productDescription = 'Введите подробное описание (не менее 10 символов)';
isValid = false;
} else delete errors.value.productDescription;
if (!formData.value.customerBehaviors?.length) {
errors.value.customerBehaviors = 'Выберите хотя бы один вариант';
isValid = false;
} else if (formData.value.customerBehaviors.length > 2) {
errors.value.customerBehaviors = 'Не более двух вариантов';
isValid = false;
} else delete errors.value.customerBehaviors;
if (!formData.value.decisionPriorities?.length) {
errors.value.decisionPriorities = 'Выберите хотя бы один приоритет';
isValid = false;
} else if (formData.value.decisionPriorities.length > 2) {
errors.value.decisionPriorities = 'Не более двух приоритетов';
isValid = false;
} else delete errors.value.decisionPriorities;
if (!formData.value.discoveryMethods?.length) {
errors.value.discoveryMethods = 'Выберите хотя бы один вариант';
isValid = false;
} else if (formData.value.discoveryMethods.length > 2) {
errors.value.discoveryMethods = 'Не более двух вариантов';
isValid = false;
} else delete errors.value.discoveryMethods;
}
if (stepIndex === 4) {
if (!formData.value.analysisType?.length) {
errors.value.analysisType = 'Выберите хотя бы один тип анализа';
isValid = false;
} else delete errors.value.analysisType;
}
return isValid;
};
// Navigation
const goNext = () => {
// ИСПРАВЛЕНИЕ: Блокируем переход, если шаг не заполнен
if (validateStep(currentStep.value)) {
if (currentStep.value < STEPS.length - 1) {
currentStep.value++;
}
} else {
toast.add({
severity: 'warn',
summary: 'Заполните поля',
detail: 'Пожалуйста, заполните все обязательные поля на этом шаге',
life: 3000
});
}
};
const goBack = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
// Submit
const handleSubmit = async () => {
// Финальная проверка всех шагов перед отправкой на всякий случай
let isAllValid = true;
for (let i = 0; i < STEPS.length; i++) {
if (!validateStep(i)) {
currentStep.value = i; // Возвращаем пользователя на шаг с ошибкой
isAllValid = false;
toast.add({
severity: 'error',
summary: 'Ошибка валидации',
detail: `Пожалуйста, проверьте заполнение полей на шаге "${STEPS[i]}"`,
life: 5000
});
break;
}
}
if (!isAllValid) return;
submitting.value = true;
try {
const payload = {
...formData.value,
discoveryMethods: formData.value.discoveryMethods || []
};
const result = await MarketingV3Service.startAnalysisV3(payload);
const analysisId = result.analysisId || result.id;
if (analysisId) {
toast.add({
severity: 'success',
summary: 'Анализ запущен',
detail: 'Маркетинговый анализ успешно запущен',
life: 3000
});
router.push(`/marketing-analysis/v3/${analysisId}`);
} else {
throw new Error('Не получен ID анализа');
}
} catch (error) {
// ИСПРАВЛЕНИЕ: Теперь мы вытягиваем конкретную ошибку от бекенда (Axios)
const errorMessage = error.response?.data?.message || error.response?.data?.detail || error.message || 'Не удалось запустить анализ';
toast.add({
severity: 'error',
summary: 'Ошибка при отправке',
detail: errorMessage,
life: 6000
});
} finally {
submitting.value = false;
}
};
// Watch geoScope для очистки зависимых полей
watch(
() => formData.value.geoScope,
(newVal) => {
if (newVal === 'FULL_COUNTRY') {
formData.value.promotionCities = ['Алматы', 'Астана', 'Шымкент', 'Караганда', 'Актобе'];
} else if (newVal === 'ONLINE') {
formData.value.promotionCities = ['Remote'];
}
if (newVal !== 'SINGLE_CITY') {
formData.value.mainCity = '';
}
if (newVal !== 'MULTI_CITY') {
formData.value.presenceCities = [];
}
}
);
</script>
<script>
import CheckboxCardsField from './wizard/CheckboxCardsField.vue';
import CheckboxCardsLimitedField from './wizard/CheckboxCardsLimitedField.vue';
import DynamicLinksField from './wizard/DynamicLinksField.vue';
import RadioGroupField from './wizard/RadioGroupField.vue';
import StepSection from './wizard/StepSection.vue';
import TagArrayField from './wizard/TagArrayField.vue';
export default {
components: {
StepSection,
RadioGroupField,
CheckboxCardsLimitedField,
CheckboxCardsField,
TagArrayField,
DynamicLinksField
}
};
</script>
<template>
<div class="marketing-brief-wizard-v3 bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-4xl mx-auto">
<div class="card">
<div class="card-header mb-6">
<div class="flex items-center justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Бриф для маркетингового анализа v4.0</h1>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-1">{{ STEPS[currentStep] }} · шаг {{ currentStep + 1 }} из {{ STEPS.length }}</p>
</div>
<div class="hidden sm:block text-right text-xs text-surface-500 dark:text-surface-400">
<p>Можно пройти за 57 минут.</p>
<p>Данные нужны, чтобы ИИ дал максимально точные выводы.</p>
</div>
</div>
<div class="relative h-2 overflow-hidden rounded-full bg-surface-200 dark:bg-surface-800 mb-4">
<div class="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-primary-500 via-purple-500 to-pink-500 transition-all duration-300" :style="{ width: `${((currentStep + 1) / STEPS.length) * 100}%` }" />
</div>
<div class="flex flex-wrap gap-2 text-xs text-surface-500 dark:text-surface-400">
<div
v-for="(label, index) in STEPS"
:key="label"
class="flex items-center gap-1 rounded-full px-2 py-1"
:class="{
'bg-primary-50 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200': index === currentStep,
'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200': index < currentStep,
'bg-surface-100 dark:bg-surface-800': index > currentStep
}"
>
<i v-if="index < currentStep" class="pi pi-check-circle text-xs" />
<span v-else class="h-3 w-3 rounded-full bg-surface-300 dark:bg-surface-600" />
<span class="hidden sm:inline">{{ label }}</span>
<span class="sm:hidden">{{ index + 1 }}</span>
</div>
</div>
</div>
<div class="card-content min-h-[400px]">
<Transition name="slide-fade" mode="out-in">
<div v-if="currentStep === 0" key="step-0" class="space-y-5">
<StepSection title="Базовая информация">
<div class="field md:col-span-2">
<label for="businessNiche" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Ниша бизнеса * </label>
<InputText id="businessNiche" v-model="formData.businessNiche" placeholder="Например: стоматологическая клиника в Алматы" class="w-full" :class="{ 'p-invalid': errors.businessNiche }" />
<small v-if="errors.businessNiche" class="p-error">{{ errors.businessNiche }}</small>
</div>
<div class="field md:col-span-2">
<label for="productName" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Название продукта или бренда * </label>
<InputText id="productName" v-model="formData.productName" placeholder="Например: DentArt Clinic или ТОО 'Стоматология Плюс'" class="w-full" :class="{ 'p-invalid': errors.productName }" />
<small v-if="errors.productName" class="p-error">{{ errors.productName }}</small>
</div>
<div class="field md:col-span-2">
<label for="goal" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Главная цель маркетинга сейчас * </label>
<InputText id="goal" v-model="formData.goal" placeholder="Например: увеличить поток заявок из Instagram и 2ГИС" class="w-full" :class="{ 'p-invalid': errors.goal }" />
<small v-if="errors.goal" class="p-error">{{ errors.goal }}</small>
</div>
</StepSection>
<StepSection title="Этап и формат бизнеса">
<RadioGroupField v-model="formData.businessStage" label="Этап развития бизнеса" :options="businessStageOptions" :error="errors.businessStage" @update:modelValue="formData.businessStage = $event" />
<RadioGroupField v-model="formData.clientTarget" label="Для кого вы работаете в первую очередь?" :options="clientTargetOptions" :error="errors.clientTarget" @update:modelValue="formData.clientTarget = $event" />
<RadioGroupField v-model="formData.offerType" label="Что вы продаёте?" :options="offerTypeOptions" :error="errors.offerType" @update:modelValue="formData.offerType = $event" />
</StepSection>
</div>
<div v-else-if="currentStep === 1" key="step-1" class="space-y-5">
<StepSection title="География и чек">
<RadioGroupField v-model="formData.geoScope" label="Где вы работаете?" :options="geoScopeOptions" :error="errors.geoScope" @update:modelValue="formData.geoScope = $event" />
<div v-if="formData.geoScope === 'SINGLE_CITY'" class="field md:col-span-2">
<label for="mainCity" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Основной город * </label>
<InputText id="mainCity" v-model="formData.mainCity" placeholder="Например: Алматы, Астана, Шымкент, Караганда" class="w-full" :class="{ 'p-invalid': errors.mainCity }" />
<small v-if="errors.mainCity" class="p-error">{{ errors.mainCity }}</small>
</div>
<div v-if="formData.geoScope === 'MULTI_CITY'" class="field md:col-span-2">
<TagArrayField
v-model="formData.presenceCities"
label="Города присутствия *"
placeholder="Например: Алматы, Астана, Актобе"
:max="5"
:error="errors.presenceCities"
@update:modelValue="formData.presenceCities = $event"
/>
<small v-if="errors.presenceCities" class="p-error">{{ errors.presenceCities }}</small>
</div>
<div class="field md:col-span-2">
<TagArrayField
v-model="formData.promotionCities"
label="Города, где хотите усиливать продвижение *"
placeholder="Например: Алматы, Астана, Шымкент"
:max="5"
:error="errors.promotionCities"
@update:modelValue="formData.promotionCities = $event"
/>
<small v-if="errors.promotionCities" class="p-error">{{ errors.promotionCities }}</small>
</div>
<RadioGroupField v-model="formData.averageCheck" label="Средний чек" :options="averageCheckOptions" :error="errors.averageCheck" @update:modelValue="formData.averageCheck = $event" />
</StepSection>
</div>
<div v-else-if="currentStep === 2" key="step-2" class="space-y-5">
<StepSection title="Продукт и поведение клиентов">
<div class="field md:col-span-2">
<label for="productDescription" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Опишите продукт или услугу простыми словами * </label>
<Textarea
id="productDescription"
v-model="formData.productDescription"
placeholder="Например: Лечение зубов премиум-класса с использованием немецкого оборудования. Работаем на казахском, русском и английском языках."
rows="4"
class="w-full"
:class="{ 'p-invalid': errors.productDescription }"
/>
<small v-if="errors.productDescription" class="p-error">{{ errors.productDescription }}</small>
</div>
<RadioGroupField
v-model="formData.purchaseFrequency"
label="Как часто клиенты обычно покупают?"
:options="purchaseFrequencyOptions"
:error="errors.purchaseFrequency"
@update:modelValue="formData.purchaseFrequency = $event"
/>
<RadioGroupField v-model="formData.visualFactor" label="Насколько для вашей ниши важен визуал?" :options="visualFactorOptions" :error="errors.visualFactor" @update:modelValue="formData.visualFactor = $event" />
<CheckboxCardsLimitedField
v-model="formData.customerBehaviors"
label="Как клиенты обычно принимают решение?"
:options="customerBehaviorOptions"
:max="2"
:error="errors.customerBehaviors"
@update:modelValue="formData.customerBehaviors = $event"
/>
<small v-if="errors.customerBehaviors" class="p-error">{{ errors.customerBehaviors }}</small>
<CheckboxCardsLimitedField
v-model="formData.decisionPriorities"
label="Что для клиентов важнее всего при выборе?"
:options="decisionPriorityOptions"
:max="2"
:error="errors.decisionPriorities"
@update:modelValue="formData.decisionPriorities = $event"
/>
<small v-if="errors.decisionPriorities" class="p-error">{{ errors.decisionPriorities }}</small>
<CheckboxCardsLimitedField
v-model="formData.discoveryMethods"
label="Как клиенты обычно находят вас?"
:options="discoveryMethodOptions"
:max="2"
:error="errors.discoveryMethods"
@update:modelValue="formData.discoveryMethods = $event"
/>
<small v-if="errors.discoveryMethods" class="p-error">{{ errors.discoveryMethods }}</small>
</StepSection>
</div>
<div v-else-if="currentStep === 3" key="step-3" class="space-y-5">
<StepSection title="Текущая ситуация и ресурсы">
<RadioGroupField v-model="formData.smmStatus" label="Как сейчас обстоят дела с соцсетями?" :options="smmStatusOptions" :error="errors.smmStatus" @update:modelValue="formData.smmStatus = $event" />
<RadioGroupField v-model="formData.leadVolume" label="Приходят ли заявки из онлайн-каналов?" :options="leadVolumeOptions" :error="errors.leadVolume" @update:modelValue="formData.leadVolume = $event" />
<RadioGroupField v-model="formData.responseHandler" label="Кто чаще всего отвечает на заявки?" :options="responseHandlerOptions" :error="errors.responseHandler" @update:modelValue="formData.responseHandler = $event" />
<RadioGroupField v-model="formData.priceFeedback" label="Как клиенты чаще всего реагируют на цену?" :options="priceFeedbackOptions" :error="errors.priceFeedback" @update:modelValue="formData.priceFeedback = $event" />
<CheckboxCardsField
v-model="formData.constraints"
label="Есть ли у бизнеса ограничения, которые нужно учесть?"
:options="businessConstraintOptions"
:error="errors.constraints"
@update:modelValue="formData.constraints = $event"
/>
</StepSection>
</div>
<div v-else-if="currentStep === 4" key="step-4" class="space-y-5">
<StepSection title="Ссылки и настройки отчёта">
<DynamicLinksField
v-model="formData.userSocialLinks"
label="Ссылки на ваши соцсети"
placeholder="Например: https://instagram.com/dentart_kz или https://vk.com/clinic_almaty"
:max="5"
:error="errors.userSocialLinks"
@update:modelValue="formData.userSocialLinks = $event"
/>
<DynamicLinksField
v-model="formData.knownCompetitorLinks"
label="Ссылки на конкурентов (если знаете)"
placeholder="Например: https://instagram.com/competitor_clinic или профиль в 2ГИС"
:max="5"
:error="errors.knownCompetitorLinks"
@update:modelValue="formData.knownCompetitorLinks = $event"
/>
<RadioGroupField v-model="formData.detailLevel" label="Насколько детальным должен быть отчёт?" :options="detailLevelOptions" :error="errors.detailLevel" @update:modelValue="formData.detailLevel = $event" />
<CheckboxCardsField
v-model="formData.analysisType"
label="Что важно проанализировать в первую очередь?"
:options="analysisTypeOptions"
:error="errors.analysisType"
@update:modelValue="formData.analysisType = $event"
/>
<small v-if="errors.analysisType" class="p-error block mt-1">{{ errors.analysisType }}</small>
</StepSection>
</div>
</Transition>
</div>
<div class="card-footer flex items-center justify-between gap-4 pt-6 border-t border-surface-200 dark:border-surface-700">
<Button label="Назад" icon="pi pi-arrow-left" severity="secondary" outlined :disabled="currentStep === 0 || submitting" @click="goBack" />
<Button v-if="currentStep < STEPS.length - 1" label="Далее" icon="pi pi-arrow-right" icon-pos="right" @click="goNext" />
<Button v-else label="Запустить маркетинговый анализ" icon="pi pi-send" icon-pos="right" :loading="submitting" :disabled="submitting" @click="handleSubmit" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.25s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(20px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
defineProps({
icon: {
type: String,
required: true
},
label: {
type: String,
required: true
},
value: {
type: [String, Number],
required: true
},
helper: {
type: String,
default: null
}
});
</script>
<template>
<div class="card flex flex-col gap-3 transition hover:-translate-y-0.5 hover:shadow-md">
<div class="flex items-center justify-between">
<p class="text-xs font-medium uppercase tracking-widest text-surface-400">
{{ label }}
</p>
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/40">
<i :class="[icon, 'text-primary-600 dark:text-primary-300']" />
</span>
</div>
<p class="text-2xl font-semibold text-surface-900 dark:text-surface-0">
{{ value }}
</p>
<p v-if="helper" class="text-xs text-surface-500 dark:text-surface-400">
{{ helper }}
</p>
</div>
</template>
@@ -1,3 +1,10 @@
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
defineProps({
text: { type: String, required: true }
});
</script>
<template>
<div class="rounded-2xl bg-slate-100/70 border border-slate-200/60 px-5 py-4">
<div class="flex items-start gap-3">
@@ -13,10 +20,3 @@
</div>
</div>
</template>
<script setup>
defineProps({
text: { type: String, required: true }
});
</script>
+37 -36
View File
@@ -1,40 +1,5 @@
<template>
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5">
<div class="text-[15px] font-semibold text-slate-800">{{ title }}</div>
<i v-if="description" class="pi pi-question-circle text-[12px] text-slate-400 cursor-help" v-tooltip.top="description"></i>
</div>
<div v-if="subtitle" class="text-[12px] text-slate-500">{{ subtitle }}</div>
</div>
<div class="relative w-full">
<svg :viewBox="`0 0 ${viewW} ${viewH}`" class="w-full h-auto">
<g v-for="(s, idx) in normalizedSteps" :key="s.key">
<polygon :points="polygonPoints(idx)" :fill="s.color" opacity="0.95"></polygon>
<polygon :points="polygonPoints(idx)" fill="none" stroke="rgba(255,255,255,0.65)" stroke-width="2"></polygon>
<text :x="viewW / 2" :y="stageTextY(idx) - 6" text-anchor="middle" fill="rgba(15,23,42,0.85)" font-size="14" font-weight="600">
{{ s.label }}
</text>
<text :x="viewW / 2" :y="stageTextY(idx) + 14" text-anchor="middle" fill="rgba(15,23,42,0.75)" font-size="13">
{{ s.value }}
</text>
<!-- conversion badge between stages -->
<g v-if="s.rateToNext && idx < normalizedSteps.length - 1">
<rect :x="viewW / 2 - 34" :y="badgeY(idx)" rx="10" ry="10" width="68" height="22" fill="white" stroke="rgba(15,23,42,0.12)"></rect>
<text :x="viewW / 2" :y="badgeY(idx) + 15" text-anchor="middle" fill="#52C41A" font-size="12" font-weight="700">
{{ s.rateToNext }}
</text>
</g>
</g>
</svg>
</div>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import { computed } from 'vue';
const props = defineProps({
@@ -101,3 +66,39 @@ function badgeY(index) {
return y + stageH + (gap - 22) / 2;
}
</script>
<template>
<div class="rounded-2xl bg-white shadow-sm border border-slate-200/60 p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5">
<div class="text-[15px] font-semibold text-slate-800">{{ title }}</div>
<i v-if="description" class="pi pi-question-circle text-[12px] text-slate-400 cursor-help" v-tooltip.top="description"></i>
</div>
<div v-if="subtitle" class="text-[12px] text-slate-500">{{ subtitle }}</div>
</div>
<div class="relative w-full">
<svg :viewBox="`0 0 ${viewW} ${viewH}`" class="w-full h-auto">
<g v-for="(s, idx) in normalizedSteps" :key="s.key">
<polygon :points="polygonPoints(idx)" :fill="s.color" opacity="0.95"></polygon>
<polygon :points="polygonPoints(idx)" fill="none" stroke="rgba(255,255,255,0.65)" stroke-width="2"></polygon>
<text :x="viewW / 2" :y="stageTextY(idx) - 6" text-anchor="middle" fill="rgba(15,23,42,0.85)" font-size="14" font-weight="600">
{{ s.label }}
</text>
<text :x="viewW / 2" :y="stageTextY(idx) + 14" text-anchor="middle" fill="rgba(15,23,42,0.75)" font-size="13">
{{ s.value }}
</text>
<!-- conversion badge between stages -->
<g v-if="s.rateToNext && idx < normalizedSteps.length - 1">
<rect :x="viewW / 2 - 34" :y="badgeY(idx)" rx="10" ry="10" width="68" height="22" fill="white" stroke="rgba(15,23,42,0.12)"></rect>
<text :x="viewW / 2" :y="badgeY(idx) + 15" text-anchor="middle" fill="#52C41A" font-size="12" font-weight="700">
{{ s.rateToNext }}
</text>
</g>
</g>
</svg>
</div>
</div>
</template>
+36 -35
View File
@@ -1,3 +1,39 @@
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import { computed } from 'vue';
const props = defineProps({
title: { type: String, required: true },
value: { type: [String, Number], required: true },
subtitle: { type: String, default: '' },
icon: { type: String, default: 'pi-chart-line' },
accentColor: { type: String, default: '#5B89FF' },
trendText: { type: String, default: '' },
description: { type: String, default: '' }
});
function hexToRgba(hex, alpha) {
const h = String(hex || '')
.replace('#', '')
.trim();
const full =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
if (full.length !== 6) return `rgba(91, 137, 255, ${alpha})`;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const headerBg = computed(() => `linear-gradient(90deg, ${hexToRgba(props.accentColor, 0.2)}, ${hexToRgba(props.accentColor, 0.06)})`);
const iconBg = computed(() => hexToRgba(props.accentColor, 0.16));
</script>
<template>
<div class="relative overflow-hidden rounded-2xl bg-white shadow-sm border border-slate-200/60">
<!-- subtle top tint like PPT -->
@@ -38,38 +74,3 @@
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: { type: String, required: true },
value: { type: [String, Number], required: true },
subtitle: { type: String, default: '' },
icon: { type: String, default: 'pi-chart-line' },
accentColor: { type: String, default: '#5B89FF' },
trendText: { type: String, default: '' },
description: { type: String, default: '' }
});
function hexToRgba(hex, alpha) {
const h = String(hex || '')
.replace('#', '')
.trim();
const full =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
if (full.length !== 6) return `rgba(91, 137, 255, ${alpha})`;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const headerBg = computed(() => `linear-gradient(90deg, ${hexToRgba(props.accentColor, 0.2)}, ${hexToRgba(props.accentColor, 0.06)})`);
const iconBg = computed(() => hexToRgba(props.accentColor, 0.16));
</script>
@@ -1,36 +1,5 @@
<template>
<div v-if="normalized && normalized.data.length" class="scm mt-6">
<div v-if="showHeader" class="flex align-items-center mb-3">
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="scm__table w-full border-collapse">
<thead>
<tr>
<th class="scm__th">Сегмент</th>
<th v-for="channel in normalized.channels" :key="channel" class="scm__th text-center">
{{ channel }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in normalized.data" :key="idx" class="scm__tr">
<td class="scm__td font-semibold">{{ getSegmentName(row) }}</td>
<td v-for="channel in normalized.channels" :key="channel" class="scm__td text-center">
<span class="scm__badge" :style="getCellStyle(row?.[channel])">
{{ formatCell(row?.[channel]) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import { computed } from 'vue';
const props = defineProps({
@@ -100,6 +69,38 @@ const formatCell = (value) => {
};
</script>
<template>
<div v-if="normalized && normalized.data.length" class="scm mt-6">
<div v-if="showHeader" class="flex align-items-center mb-3">
<i v-if="icon" :class="icon" class="text-primary-500 mr-2"></i>
<h4 class="text-lg font-semibold text-surface-700 dark:text-surface-300 m-0">{{ title }}</h4>
</div>
<div class="overflow-x-auto border-round" style="border: 1px solid var(--surface-border)">
<table class="scm__table w-full border-collapse">
<thead>
<tr>
<th class="scm__th">Сегмент</th>
<th v-for="channel in normalized.channels" :key="channel" class="scm__th text-center">
{{ channel }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in normalized.data" :key="idx" class="scm__tr">
<td class="scm__td font-semibold">{{ getSegmentName(row) }}</td>
<td v-for="channel in normalized.channels" :key="channel" class="scm__td text-center">
<span class="scm__badge" :style="getCellStyle(row?.[channel])">
{{ formatCell(row?.[channel]) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.scm__table {
border: 1px solid var(--surface-border);
@@ -0,0 +1,54 @@
<script setup>
const props = defineProps({
data: { type: Object, default: null }
// data: { min, q1, median, q3, max }
});
const scaleX = (val) => {
if (!props.data) return 0;
const { min, max } = props.data;
const range = max - min || 1;
const padding = 20;
const width = 280 - padding * 2;
return padding + ((val - min) / range) * width;
};
</script>
<template>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<template v-if="data">
<!-- Simplified SVG boxplot -->
<svg viewBox="0 0 280 80" class="w-full h-20">
<!-- whisker line -->
<line :x1="scaleX(data.min)" y1="40" :x2="scaleX(data.max)" y2="40" stroke="currentColor" stroke-width="1.5" class="text-surface-400" />
<!-- left whisker cap -->
<line :x1="scaleX(data.min)" y1="28" :x2="scaleX(data.min)" y2="52" stroke="currentColor" stroke-width="1.5" class="text-surface-400" />
<!-- right whisker cap -->
<line :x1="scaleX(data.max)" y1="28" :x2="scaleX(data.max)" y2="52" stroke="currentColor" stroke-width="1.5" class="text-surface-400" />
<!-- IQR box -->
<rect :x="scaleX(data.q1)" y="22" :width="scaleX(data.q3) - scaleX(data.q1)" height="36" rx="4" fill="rgba(79,70,229,0.15)" stroke="#4F46E5" stroke-width="1.5" />
<!-- median line -->
<line :x1="scaleX(data.median)" y1="22" :x2="scaleX(data.median)" y2="58" stroke="#4F46E5" stroke-width="2.5" />
<!-- labels -->
<text :x="scaleX(data.min)" y="72" class="text-surface-400 text-[8px]" fill="#9CA3AF" text-anchor="middle" font-size="8">{{ data.min }}</text>
<text :x="scaleX(data.q1)" y="72" class="text-surface-400 text-[8px]" fill="#9CA3AF" text-anchor="middle" font-size="8">Q1</text>
<text :x="scaleX(data.median)" y="72" class="text-primary-500 text-[8px]" fill="#4F46E5" text-anchor="middle" font-size="8" font-weight="600">M</text>
<text :x="scaleX(data.q3)" y="72" class="text-surface-400 text-[8px]" fill="#9CA3AF" text-anchor="middle" font-size="8">Q3</text>
<text :x="scaleX(data.max)" y="72" class="text-surface-400 text-[8px]" fill="#9CA3AF" text-anchor="middle" font-size="8">{{ data.max }}</text>
</svg>
<div class="mt-2 flex justify-between text-xs text-surface-500 dark:text-surface-400">
<span
>Медиана: <strong class="text-surface-700 dark:text-surface-300">{{ data.median }}</strong></span
>
<span
>IQR: <strong class="text-surface-700 dark:text-surface-300">{{ data.q1 }}{{ data.q3 }}</strong></span
>
</div>
</template>
<template v-else>
<div class="flex items-center justify-center h-20 rounded-lg border-2 border-dashed border-surface-300 dark:border-surface-600">
<p class="text-xs text-surface-400 dark:text-surface-500">Нет данных по распределению отзывов</p>
</div>
</template>
</div>
</template>
@@ -0,0 +1,12 @@
<script setup>
defineProps({ text: { type: String, default: 'Нет данных' } });
</script>
<template>
<div class="flex items-center justify-center h-full min-h-[120px] rounded-xl border-2 border-dashed border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800">
<div class="text-center px-4">
<i class="pi pi-chart-bar text-2xl text-surface-400 dark:text-surface-500 mb-2" />
<p class="text-xs text-surface-400 dark:text-surface-500">{{ text }}</p>
</div>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup>
defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
icon: { type: String, default: 'pi pi-chart-bar' },
color: { type: String, default: 'indigo' },
trend: { type: String, default: null } // 'up' | 'down' | 'neutral'
});
const colorMap = {
indigo: {
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
border: 'border-indigo-200 dark:border-indigo-800',
label: 'text-indigo-500 dark:text-indigo-400',
value: 'text-indigo-800 dark:text-indigo-200',
iconBg: 'bg-indigo-100 dark:bg-indigo-900/40',
iconColor: 'text-indigo-600 dark:text-indigo-300'
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-800',
label: 'text-blue-500 dark:text-blue-400',
value: 'text-blue-800 dark:text-blue-200',
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
iconColor: 'text-blue-600 dark:text-blue-300'
},
violet: {
bg: 'bg-violet-50 dark:bg-violet-900/20',
border: 'border-violet-200 dark:border-violet-800',
label: 'text-violet-500 dark:text-violet-400',
value: 'text-violet-800 dark:text-violet-200',
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
iconColor: 'text-violet-600 dark:text-violet-300'
},
amber: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
border: 'border-amber-200 dark:border-amber-800',
label: 'text-amber-500 dark:text-amber-400',
value: 'text-amber-800 dark:text-amber-200',
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
iconColor: 'text-amber-600 dark:text-amber-300'
},
emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-800',
label: 'text-emerald-500 dark:text-emerald-400',
value: 'text-emerald-800 dark:text-emerald-200',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
iconColor: 'text-emerald-600 dark:text-emerald-300'
},
orange: {
bg: 'bg-orange-50 dark:bg-orange-900/20',
border: 'border-orange-200 dark:border-orange-800',
label: 'text-orange-500 dark:text-orange-400',
value: 'text-orange-800 dark:text-orange-200',
iconBg: 'bg-orange-100 dark:bg-orange-900/40',
iconColor: 'text-orange-600 dark:text-orange-300'
}
};
</script>
<template>
<div :class="['exec-card rounded-xl border p-4 flex flex-col gap-2 transition hover:-translate-y-0.5 hover:shadow-md', colorMap[color].border, colorMap[color].bg]">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-widest truncate" :class="colorMap[color].label">{{ label }}</p>
<span class="inline-flex h-8 w-8 items-center justify-center rounded-full shrink-0" :class="colorMap[color].iconBg">
<i :class="[icon, 'text-sm', colorMap[color].iconColor]" />
</span>
</div>
<p class="text-xl font-black leading-none" :class="colorMap[color].value">{{ value }}</p>
<div v-if="trend" class="flex items-center gap-1 text-xs font-medium">
<i :class="['pi', trend === 'up' ? 'pi-arrow-up text-emerald-500' : trend === 'down' ? 'pi-arrow-down text-rose-500' : 'pi-minus text-surface-400']" />
<span :class="trend === 'up' ? 'text-emerald-600' : trend === 'down' ? 'text-rose-600' : 'text-surface-400'">
{{ trend === 'up' ? 'Выше среднего' : trend === 'down' ? 'Ниже среднего' : 'Нейтрально' }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
value: { type: Number, default: 0 }, // 0100
label: { type: String, default: null } // Пользовательский лейбл
});
const clampedValue = computed(() => Math.min(100, Math.max(0, Math.round(props.value))));
const ARC_LENGTH = Math.PI * 80; // ≈ 251.3
const trackPath = 'M 20 100 A 80 80 0 0 1 180 100';
const arcDash = computed(() => {
const filled = (clampedValue.value / 100) * ARC_LENGTH;
return `${filled} ${ARC_LENGTH}`;
});
const arcColor = computed(() => {
const v = clampedValue.value;
if (v >= 70) return '#EF4444';
if (v >= 40) return '#F97316';
return '#22C55E';
});
const displayLabel = computed(() => {
if (props.label) return props.label;
const v = clampedValue.value;
if (v >= 70) return 'Высокая интенсивность';
if (v >= 40) return 'Средняя интенсивность';
return 'Низкая интенсивность';
});
</script>
<template>
<div class="flex flex-col items-center">
<svg viewBox="0 0 200 130" class="w-56 h-36">
<!-- Background arc (track) -->
<path :d="trackPath" fill="none" stroke="#E5E7EB" stroke-width="18" stroke-linecap="round" class="dark:stroke-surface-700" />
<!-- Colored fill arc -->
<path :d="trackPath" fill="none" :stroke="arcColor" stroke-width="18" stroke-linecap="round" :stroke-dasharray="arcDash" stroke-dashoffset="0" style="transition: stroke-dasharray 0.9s ease" />
<!-- Center value -->
<text x="100" y="105" text-anchor="middle" font-size="28" font-weight="800" :fill="arcColor">{{ clampedValue }}</text>
<text x="100" y="122" text-anchor="middle" font-size="10" fill="#9CA3AF">из 100</text>
<!-- Min / Max labels -->
<text x="22" y="118" text-anchor="middle" font-size="9" fill="#9CA3AF">0</text>
<text x="178" y="118" text-anchor="middle" font-size="9" fill="#9CA3AF">100</text>
</svg>
<p v-if="displayLabel" class="mt-1 text-xs font-semibold uppercase tracking-widest" :style="{ color: arcColor }">
{{ displayLabel }}
</p>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
competitors: { type: Array, default: () => [] }
});
const COLORS = ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#22C55E', '#06B6D4', '#EAB308', '#EF4444'];
const plotData = computed(() => {
const arr = props.competitors.slice(0, 12);
if (!arr.length) return [];
const maxPosts = Math.max(...arr.map((c) => c.postsPerMonth ?? 0), 1);
const maxEr = Math.max(...arr.map((c) => parseFloat(c.er) || 0), 1);
const maxFoll = Math.max(...arr.map((c) => c.followers ?? 0), 1);
const PAD = 50,
W = 280,
H = 150;
return arr.map((c) => ({
name: (c.name || '').slice(0, 12),
cx: PAD + ((c.postsPerMonth ?? 0) / maxPosts) * W,
cy: H - ((parseFloat(c.er) || 0) / maxEr) * H + 10,
r: Math.max(5, Math.min(18, ((c.followers ?? 0) / maxFoll) * 18)),
original: c
}));
});
</script>
<template>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<template v-if="competitors.length >= 2">
<svg viewBox="0 0 340 200" class="w-full h-48">
<!-- Grid lines -->
<line v-for="i in 4" :key="'gy' + i" x1="40" :y1="40 * i" x2="330" :y2="40 * i" stroke="currentColor" stroke-width="0.5" class="text-surface-200 dark:text-surface-700" opacity="0.5" />
<line v-for="i in 6" :key="'gx' + i" :x1="40 + i * 48" y1="0" :x2="40 + i * 48" y2="168" stroke="currentColor" stroke-width="0.5" class="text-surface-200 dark:text-surface-700" opacity="0.5" />
<!-- Axes -->
<line x1="40" y1="0" x2="40" y2="170" stroke="#9CA3AF" stroke-width="1" />
<line x1="38" y1="168" x2="335" y2="168" stroke="#9CA3AF" stroke-width="1" />
<!-- Axis labels -->
<text x="185" y="195" fill="#9CA3AF" font-size="9" text-anchor="middle">Активность (постов/мес)</text>
<text x="8" y="90" fill="#9CA3AF" font-size="9" text-anchor="middle" transform="rotate(-90,8,90)">Вовлечённость (ER%)</text>
<!-- Bubbles -->
<g v-for="(c, i) in plotData" :key="i">
<circle
:cx="c.cx"
:cy="c.cy"
:r="c.r"
:fill="COLORS[i % COLORS.length]"
opacity="0.7"
class="transition-all duration-300"
v-tooltip.top="`${c.name}\nПостов: ${c.original.postsPerMonth}, ER: ${c.original.er}%\nПодписчиков: ${c.original.followers}`"
/>
<text :x="c.cx" :y="c.cy - c.r - 3" fill="#4B5563" font-size="7" text-anchor="middle" class="dark:fill-gray-300">{{ c.name }}</text>
</g>
</svg>
<div class="mt-2 flex justify-between text-xs text-surface-500 dark:text-surface-400">
<span>Размер пузыря = аудитория</span>
<span>{{ plotData.length }} игроков</span>
</div>
</template>
<template v-else>
<div class="flex items-center justify-center h-48 rounded-lg border-2 border-dashed border-surface-300 dark:border-surface-600">
<div class="text-center">
<i class="pi pi-chart-scatter text-2xl text-surface-400 mb-2" />
<p class="text-xs text-surface-400">Нет данных для Scatter Plot</p>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
defineProps({
id: { type: String, default: '' },
title: { type: String, required: true },
subtitle: { type: String, default: '' }
});
</script>
<template>
<div :id="id" class="card scroll-mt-4">
<div class="flex items-start justify-between mb-4 gap-2">
<div>
<h2 class="text-base font-bold text-surface-900 dark:text-surface-0 tracking-tight">{{ title }}</h2>
<p v-if="subtitle" class="text-xs text-surface-500 dark:text-surface-400 mt-0.5">{{ subtitle }}</p>
</div>
<slot name="actions" />
</div>
<div class="border-t border-surface-200 dark:border-surface-700 pt-4">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup>
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
label: {
type: String,
required: true
},
options: {
type: Array,
required: true
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const isSelected = (value) => {
return props.modelValue.includes(value);
};
const toggle = (value) => {
const newValue = [...props.modelValue];
if (isSelected(value)) {
const index = newValue.indexOf(value);
newValue.splice(index, 1);
} else {
newValue.push(value);
}
emit('update:modelValue', newValue);
};
</script>
<template>
<div class="field md:col-span-2 space-y-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-300">{{ label }}</p>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div
v-for="option in options"
:key="option.value"
class="flex items-start gap-2 rounded-xl border px-4 py-3 text-left text-sm transition cursor-pointer"
:class="{
'border-primary-500 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-400 dark:bg-primary-900/30 dark:text-primary-50': isSelected(option.value),
'border-surface-200 bg-surface-0 text-surface-900 hover:border-primary-200 hover:bg-primary-50/40 dark:border-surface-700 dark:bg-surface-900 dark:text-surface-100 dark:hover:border-primary-500/60': !isSelected(option.value)
}"
@click="toggle(option.value)"
>
<span class="font-medium">{{ option.label }}</span>
</div>
</div>
<small v-if="error" class="p-error">{{ error }}</small>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
label: {
type: String,
required: true
},
options: {
type: Array,
required: true
},
max: {
type: Number,
default: 2
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const isSelected = (value) => {
return props.modelValue.includes(value);
};
const isDisabled = (value) => {
return !isSelected(value) && props.modelValue.length >= props.max;
};
const toggle = (value) => {
if (isDisabled(value)) return;
const newValue = [...props.modelValue];
if (isSelected(value)) {
const index = newValue.indexOf(value);
newValue.splice(index, 1);
} else {
newValue.push(value);
}
emit('update:modelValue', newValue);
};
</script>
<template>
<div class="field md:col-span-2 space-y-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-300">
{{ label }}
<span class="font-normal text-surface-400">(можно выбрать до {{ max }})</span>
</p>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div
v-for="option in options"
:key="option.value"
class="flex items-start gap-2 rounded-xl border px-4 py-3 text-left text-sm transition cursor-pointer"
:class="{
'border-primary-500 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-400 dark:bg-primary-900/30 dark:text-primary-50': isSelected(option.value),
'border-surface-200 bg-surface-0 text-surface-900 hover:border-primary-200 hover:bg-primary-50/40 dark:border-surface-700 dark:bg-surface-900 dark:text-surface-100 dark:hover:border-primary-500/60':
!isSelected(option.value) && !isDisabled(option.value),
'opacity-50 cursor-not-allowed': isDisabled(option.value)
}"
@click="toggle(option.value)"
>
<span class="font-medium">{{ option.label }}</span>
</div>
</div>
<small v-if="error" class="p-error">{{ error }}</small>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { watch } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
label: {
type: String,
required: true
},
placeholder: {
type: String,
default: 'https://...'
},
max: {
type: Number,
default: 5
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const addLink = () => {
if (props.modelValue.length >= props.max) return;
emit('update:modelValue', [...props.modelValue, '']);
};
const removeLink = (index) => {
const newValue = [...props.modelValue];
newValue.splice(index, 1);
emit('update:modelValue', newValue);
};
const updateLink = (index, value) => {
const newValue = [...props.modelValue];
newValue[index] = value;
emit('update:modelValue', newValue);
};
</script>
<template>
<div class="field md:col-span-2 space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-surface-600 dark:text-surface-300">{{ label }}</p>
<Button label="Добавить ссылку" icon="pi pi-plus" size="small" text severity="secondary" @click="addLink" />
</div>
<div class="space-y-2">
<div v-for="(link, index) in modelValue" :key="index" class="flex items-center gap-2">
<InputText :modelValue="link" :placeholder="placeholder" class="flex-1 text-xs" @update:modelValue="updateLink(index, $event)" />
<Button icon="pi pi-times" severity="danger" text rounded size="small" @click="removeLink(index)" />
</div>
</div>
<small v-if="error" class="p-error">{{ error }}</small>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
defineProps({
modelValue: {
type: String,
required: true
},
label: {
type: String,
required: true
},
options: {
type: Array,
required: true
},
error: {
type: String,
default: null
}
});
defineEmits(['update:modelValue']);
</script>
<template>
<div class="field md:col-span-2 space-y-2">
<p class="text-xs font-medium text-surface-600 dark:text-surface-300">{{ label }}</p>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div
v-for="option in options"
:key="option.value"
class="flex items-start gap-2 rounded-xl border px-4 py-3 text-left text-sm transition cursor-pointer"
:class="{
'border-primary-500 bg-primary-50 text-primary-900 shadow-sm dark:border-primary-400 dark:bg-primary-900/30 dark:text-primary-50': modelValue === option.value,
'border-surface-200 bg-surface-0 text-surface-900 hover:border-primary-200 hover:bg-primary-50/40 dark:border-surface-700 dark:bg-surface-900 dark:text-surface-100 dark:hover:border-primary-500/60': modelValue !== option.value
}"
@click="$emit('update:modelValue', option.value)"
>
<span class="font-medium">{{ option.label }}</span>
</div>
</div>
<small v-if="error" class="p-error">{{ error }}</small>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
defineProps({
title: {
type: String,
required: true
}
});
</script>
<template>
<section class="space-y-4">
<h2 class="text-sm font-semibold uppercase tracking-wider text-surface-500 dark:text-surface-400">
{{ title }}
</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<slot />
</div>
</section>
</template>
@@ -0,0 +1,68 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Введите значение и нажмите Enter'
},
max: {
type: Number,
default: 5
},
error: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const inputValue = ref('');
const addTag = () => {
const value = inputValue.value.trim();
if (!value) return;
if (props.modelValue.includes(value)) return;
if (props.modelValue.length >= props.max) return;
emit('update:modelValue', [...props.modelValue, value]);
inputValue.value = '';
};
const remove = (index) => {
const newValue = [...props.modelValue];
newValue.splice(index, 1);
emit('update:modelValue', newValue);
};
</script>
<template>
<div class="field md:col-span-2 space-y-1">
<label class="text-xs font-medium text-surface-600 dark:text-surface-300">{{ label }}</label>
<div class="rounded-lg border border-surface-200 bg-surface-0 px-3 py-2 shadow-sm dark:border-surface-700 dark:bg-surface-950">
<div class="flex flex-wrap gap-1">
<Tag v-for="(item, index) in modelValue" :key="index" :value="item" severity="info" class="cursor-pointer" @click="remove(index)" />
<div class="flex items-center gap-1 flex-1">
<input
v-model="inputValue"
type="text"
:placeholder="placeholder"
class="min-w-[120px] flex-1 border-0 bg-transparent text-xs text-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-0 dark:text-surface-100"
@keydown.enter.prevent="addTag"
/>
<Button icon="pi pi-plus" size="small" text rounded :disabled="!inputValue.trim() || modelValue.length >= max" @click="addTag" v-tooltip.top="'Добавить'" />
</div>
</div>
</div>
<small v-if="error" class="p-error">{{ error }}</small>
</div>
</template>
+80 -80
View File
@@ -1,83 +1,3 @@
<template>
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-filter mr-2"></i>
Фильтры
</div>
</template>
<template #content>
<div class="grid">
<!-- Фильтр по источнику -->
<div class="col-12 md:col-4">
<div class="field">
<label for="sourceFilter" class="font-medium block mb-2">Источник</label>
<Dropdown id="sourceFilter" v-model="localFilters.sourceName" :options="sourceOptions" optionLabel="label" optionValue="value" placeholder="Все источники" :loading="loading" showClear class="w-full" />
</div>
</div>
<!-- Фильтр по дате начала -->
<div class="col-12 md:col-4">
<div class="field">
<label for="startDate" class="font-medium block mb-2">Дата от</label>
<Calendar id="startDate" v-model="localFilters.startDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
</div>
</div>
<!-- Фильтр по дате окончания -->
<div class="col-12 md:col-4">
<div class="field">
<label for="endDate" class="font-medium block mb-2">Дата до</label>
<Calendar id="endDate" v-model="localFilters.endDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
</div>
</div>
<!-- Сортировка -->
<div class="col-12 md:col-6">
<div class="field">
<label for="sortField" class="font-medium block mb-2">Сортировка по</label>
<Dropdown id="sortField" v-model="sortField" :options="sortFieldOptions" optionLabel="label" optionValue="value" placeholder="Выберите поле" class="w-full" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="sortOrder" class="font-medium block mb-2">Порядок</label>
<Dropdown id="sortOrder" v-model="sortOrder" :options="sortOrderOptions" optionLabel="label" optionValue="value" placeholder="Выберите порядок" class="w-full" />
</div>
</div>
<!-- Размер страницы -->
<div class="col-12 md:col-6">
<div class="field">
<label for="pageSize" class="font-medium block mb-2">Новостей на странице</label>
<Dropdown id="pageSize" v-model="localFilters.size" :options="pageSizeOptions" optionLabel="label" optionValue="value" placeholder="Выберите размер" class="w-full" />
</div>
</div>
<!-- Быстрые фильтры -->
<div class="col-12 md:col-6">
<div class="field">
<label class="font-medium block mb-2">Быстрые фильтры</label>
<div class="flex flex-wrap gap-2">
<Button label="Сегодня" size="small" severity="secondary" @click="setTodayFilter" />
<Button label="Неделя" size="small" severity="secondary" @click="setWeekFilter" />
<Button label="Месяц" size="small" severity="secondary" @click="setMonthFilter" />
<Button label="Очистить" size="small" severity="danger" @click="clearFilters" />
</div>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="flex justify-content-end gap-2 mt-3">
<Button label="Сбросить" icon="pi pi-times" severity="secondary" @click="resetFilters" />
<Button label="Применить" icon="pi pi-check" @click="applyFilters" :loading="loading" />
</div>
</template>
</Card>
</template>
<script setup>
import Button from 'primevue/button';
import Calendar from 'primevue/calendar';
@@ -213,6 +133,86 @@ const initializeFromProps = () => {
watch(() => props.filters, initializeFromProps, { immediate: true, deep: true });
</script>
<template>
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-filter mr-2"></i>
Фильтры
</div>
</template>
<template #content>
<div class="grid">
<!-- Фильтр по источнику -->
<div class="col-12 md:col-4">
<div class="field">
<label for="sourceFilter" class="font-medium block mb-2">Источник</label>
<Dropdown id="sourceFilter" v-model="localFilters.sourceName" :options="sourceOptions" optionLabel="label" optionValue="value" placeholder="Все источники" :loading="loading" showClear class="w-full" />
</div>
</div>
<!-- Фильтр по дате начала -->
<div class="col-12 md:col-4">
<div class="field">
<label for="startDate" class="font-medium block mb-2">Дата от</label>
<Calendar id="startDate" v-model="localFilters.startDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
</div>
</div>
<!-- Фильтр по дате окончания -->
<div class="col-12 md:col-4">
<div class="field">
<label for="endDate" class="font-medium block mb-2">Дата до</label>
<Calendar id="endDate" v-model="localFilters.endDate" placeholder="Выберите дату" dateFormat="yy-mm-dd" showIcon class="w-full" />
</div>
</div>
<!-- Сортировка -->
<div class="col-12 md:col-6">
<div class="field">
<label for="sortField" class="font-medium block mb-2">Сортировка по</label>
<Dropdown id="sortField" v-model="sortField" :options="sortFieldOptions" optionLabel="label" optionValue="value" placeholder="Выберите поле" class="w-full" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="sortOrder" class="font-medium block mb-2">Порядок</label>
<Dropdown id="sortOrder" v-model="sortOrder" :options="sortOrderOptions" optionLabel="label" optionValue="value" placeholder="Выберите порядок" class="w-full" />
</div>
</div>
<!-- Размер страницы -->
<div class="col-12 md:col-6">
<div class="field">
<label for="pageSize" class="font-medium block mb-2">Новостей на странице</label>
<Dropdown id="pageSize" v-model="localFilters.size" :options="pageSizeOptions" optionLabel="label" optionValue="value" placeholder="Выберите размер" class="w-full" />
</div>
</div>
<!-- Быстрые фильтры -->
<div class="col-12 md:col-6">
<div class="field">
<label class="font-medium block mb-2">Быстрые фильтры</label>
<div class="flex flex-wrap gap-2">
<Button label="Сегодня" size="small" severity="secondary" @click="setTodayFilter" />
<Button label="Неделя" size="small" severity="secondary" @click="setWeekFilter" />
<Button label="Месяц" size="small" severity="secondary" @click="setMonthFilter" />
<Button label="Очистить" size="small" severity="danger" @click="clearFilters" />
</div>
</div>
</div>
</div>
<!-- Кнопки действий -->
<div class="flex justify-content-end gap-2 mt-3">
<Button label="Сбросить" icon="pi pi-times" severity="secondary" @click="resetFilters" />
<Button label="Применить" icon="pi pi-check" @click="applyFilters" :loading="loading" />
</div>
</template>
</Card>
</template>
<style scoped>
.field {
margin-bottom: 1rem;
+24
View File
@@ -271,6 +271,30 @@ const router = createRouter({
component: () => import('@/views/pages/marketing/MarketingCredentials.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3',
name: 'marketing-v3-list',
component: () => import('@/views/pages/marketing/MarketingAnalysisV3List.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/new',
name: 'marketing-v3-wizard',
component: () => import('@/components/marketing/MarketingBriefWizardV3.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/:id',
name: 'marketing-v3-analysis',
component: () => import('@/views/pages/marketing/MarketingAnalysisV3.vue'),
meta: { requiresAuth: true }
},
{
path: '/marketing-analysis/v3/strategy',
name: 'marketing-v3-strategy',
component: () => import('@/views/pages/marketing/MarketingStrategyV3.vue'),
meta: { requiresAuth: true }
},
{
path: '/pages/notfound',
name: 'notfound',
+11 -3
View File
@@ -1,7 +1,7 @@
// Lightweight auth service to manage tokens and API calls
// Use proxy in development, direct API in production
const API_URL = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? '' : 'https://api.konturai.kz');
const API_URL = import.meta.env.VITE_API_URL || 'https://api.konturai.kz';
const TOKEN_STORAGE_KEY = 'auth.accessToken';
const REFRESH_STORAGE_KEY = 'auth.refreshToken';
@@ -82,7 +82,11 @@ async function authFetch(input, init = {}) {
const headers = new Headers(restInit.headers || {});
const token = getAccessToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.set('Content-Type', headers.get('Content-Type') || 'application/json');
// If body is FormData, don't set Content-Type header - browser will set it with boundary
const isFormData = typeof FormData !== 'undefined' && restInit.body instanceof FormData;
if (!isFormData) {
headers.set('Content-Type', headers.get('Content-Type') || 'application/json');
}
let timeoutId = null;
let controller = null;
@@ -132,7 +136,11 @@ async function authFetch(input, init = {}) {
const newAccess = await refreshToken();
const retryHeaders = new Headers(restInit.headers || {});
retryHeaders.set('Authorization', `Bearer ${newAccess}`);
if (!retryHeaders.get('Content-Type')) retryHeaders.set('Content-Type', 'application/json');
const isRetryFormData = typeof FormData !== 'undefined' && restInit.body instanceof FormData;
if (!isRetryFormData && !retryHeaders.get('Content-Type')) {
retryHeaders.set('Content-Type', 'application/json');
}
// Retry uses the same timeout/signal settings
let retryTimeoutId = null;
+3 -2
View File
@@ -3,6 +3,7 @@ import AuthService from './AuthService';
const API_BASE_URL = API_CONFIG.BASE_URL;
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
class MarketingService {
/**
* Запуск маркетингового анализа
@@ -141,7 +142,7 @@ class MarketingService {
// v2: извлекаем data из обёртки {success, message, data}, если она есть
// для обратной совместимости поддерживаем оба формата
let extracted = result.data || result;
// Убеждаемся, что sources сохраняются, если они есть в исходных данных
if (extracted && !extracted.sources) {
if (result.data?.sources) {
@@ -150,7 +151,7 @@ class MarketingService {
extracted.sources = result.sources;
}
}
return extracted;
} catch (error) {
console.error('Ошибка при запуске маркетингового анализа v2:', error);
+136
View File
@@ -0,0 +1,136 @@
import { API_CONFIG, DEFAULT_REQUEST_CONFIG } from '@/config/api';
import AuthService from './AuthService';
const API_BASE_URL = API_CONFIG.BASE_URL;
class MarketingV3Service {
/**
* Запуск маркетингового анализа v4.0
* POST /api/marketing/v3/start
* @param {Object} data - MarketingAnalysisV3Request
* @returns {Promise<Object>} { analysisId: string }
*/
async startAnalysisV3(data) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/start`, {
method: 'POST',
...DEFAULT_REQUEST_CONFIG,
body: JSON.stringify(data)
});
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при запуске анализа v4.0');
}
return result?.data ?? result;
} catch (error) {
console.error('Ошибка при запуске маркетингового анализа v4.0:', error);
throw error;
}
}
/**
* Получение анализа v4.0 по ID
* GET /api/marketing/v3/{id}
* @param {string} id - Идентификатор анализа
* @returns {Promise<Object>} MarketingAnalysisV3Document
*/
async getAnalysisV3ById(id) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/${id}`);
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении анализа v4.0');
}
return result?.data ?? result;
} catch (error) {
console.error('Ошибка при получении анализа v4.0:', error);
throw error;
}
}
async getUserAnalyses() {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/my`);
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении списка анализов');
}
// ApiResponse wrapper: { data: [...] } или прямой массив
return result?.data ?? result ?? [];
} catch (error) {
console.error('Ошибка при получении списка анализов v4.0:', error);
throw error;
}
}
/**
* ЭТАП 2: Предпросмотр стратегии
* GET /api/marketing/v3/{analysisId}/strategy-preview
* @param {string} analysisId - Идентификатор анализа
* @returns {Promise<Object>} Описание рекомендуемой стратегии
*/
async getStrategyPreview(analysisId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/${analysisId}/strategy-preview`);
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при получении предпросмотра стратегии');
}
return result?.data ?? result;
} catch (error) {
console.error('Ошибка при предпросмотре стратегии v4.0:', error);
throw error;
}
}
/**
* ЭТАП 3: Запуск генерации стратегии (с логотипом)
* POST /api/marketing/v3/{analysisId}/strategy/generate
* @param {string} analysisId - Идентификатор анализа
* @param {FormData} formData - Объект FormData (содержит поля request Blob и необязательно logo File)
* @returns {Promise<Object>} { strategyId: string }
*/
async generateStrategy(analysisId, formData) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/${analysisId}/strategy/generate`, {
method: 'POST',
body: formData // Не указываем Content-Type, браузер сам поставит multipart/form-data с правильным boundary
});
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при запуске генерации стратегии');
}
return result?.data ?? result;
} catch (error) {
console.error('Ошибка при запуске генерации стратегии v4.0:', error);
throw error;
}
}
/**
* ЭТАП 4: Поллинг статуса стратегии
* GET /api/marketing/v3/strategy/{strategyId}
* @param {string} strategyId - Идентификатор стратегии
* @returns {Promise<Object>} Данные стратегии, включая статус и результаты, когда готово
*/
async getStrategyStatus(strategyId) {
try {
const response = await AuthService.authFetch(`${API_BASE_URL}/api/marketing/v3/strategy/${strategyId}`);
const result = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(result?.message || result?.error?.message || 'Ошибка при проверке статуса стратегии');
}
return result?.data ?? result;
} catch (error) {
console.error('Ошибка при проверке статуса стратегии v4.0:', error);
throw error;
}
}
}
export default new MarketingV3Service();
+129
View File
@@ -0,0 +1,129 @@
// Типы для маркетингового анализа v4.0
// Адаптировано из TypeScript версии для Vue.js
export const MARKETING_ANALYSIS_STATUS = {
QUEUED: 'QUEUED',
PROCESSING: 'PROCESSING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED'
};
// Enum маппинги для UI
export const BusinessStageLabels = {
JUST_STARTING: 'Только запускаемся',
LESS_THAN_ONE_YEAR: 'Работаем до 1 года',
ONE_TO_THREE_YEARS: 'Работаем 1–3 года',
MORE_THAN_THREE_YEARS: 'Работаем более 3 лет'
};
export const ClientTargetLabels = {
PRIVATE_CLIENTS: 'Работаем с конечными клиентами (B2C)',
BUSINESS: 'Работаем с бизнесом (B2B)',
MIXED: 'Смешанная модель (B2B + B2C)'
};
export const OfferTypeLabels = {
SERVICES: 'Услуги',
GOODS: 'Товары',
SUBSCRIPTION: 'Подписка / доступ'
};
export const AverageCheckLabels = {
LOW: 'Низкий чек',
MEDIUM: 'Средний чек',
HIGH: 'Высокий чек'
};
export const CustomerBehaviorLabels = {
BUY_QUICKLY: 'Покупают быстро, без долгих раздумий',
COMPARE_OPTIONS: 'Сравнивают варианты и предложения',
REQUEST_PROPOSAL: 'Запрашивают индивидуальное коммерческое предложение',
CHECK_REVIEWS: 'Внимательно читают отзывы и рейтинги',
NEED_CONSULTATION: 'Нуждаются в консультации перед покупкой'
};
export const GeoScopeLabels = {
SINGLE_CITY: 'Один город',
MULTI_CITY: 'Несколько городов',
FULL_COUNTRY: 'Вся страна',
ONLINE: 'Онлайн по всему миру'
};
export const PurchaseFrequencyLabels = {
ONCE: 'Одна покупка',
MULTIPLE_PER_YEAR: 'Несколько раз в год',
REGULARLY: 'Регулярно (ежемесячно / чаще)'
};
export const VisualFactorLabels = {
VISUALS_IMPORTANT: 'Визуал критически важен (эстетика, фото, видео)',
PARTIALLY: 'Важно, но не критично',
EXPLANATION_IMPORTANT: 'Тексты и экспертность важнее визуала для доверия'
};
export const DecisionPriorityLabels = {
PRICE: 'Цена',
QUALITY: 'Качество',
SPEED: 'Скорость',
TRUST: 'Доверие к бренду',
SERVICE: 'Сервис и сопровождение'
};
export const DiscoveryMethodLabels = {
SEARCH_WEB: 'Ищут в поиске и сравнивают',
RECOMMENDATIONS: 'Приходят по рекомендациям / сарафанке',
PRICE_SELECTION: 'Выбирают по цене / акциям',
REVIEWS: 'Ориентируются на отзывы и рейтинги',
CONVENIENCE: 'Выбирают по удобству (сайт, UX, доставка)'
};
export const PriceFeedbackLabels = {
LOWER_THAN_EXPECTED: 'Часто говорят, что «дёшево / ниже ожиданий»',
AS_EXPECTED: 'Цена воспринимается как ожидаемая',
HIGHER_THAN_EXPECTED: 'Считают, что «дорого / выше ожиданий»',
NOT_DISCUSSED: 'Цену обычно подробно не обсуждают'
};
export const SmmStatusLabels = {
NONE: 'Соцсетей нет',
DORMANT: 'Есть, но почти не ведём',
IRREGULAR: 'Ведём нерегулярно',
ACTIVE: 'Активно ведём и развиваем'
};
export const LeadVolumeLabels = {
NONE: 'Заявок практически нет',
SOMETIMES: 'Иногда приходят заявки',
REGULARLY: 'Заявки приходят регулярно'
};
export const ResponseHandlerLabels = {
MYSELF: 'Отвечает лично основатель / владелец',
EMPLOYEE: 'Есть выделенный сотрудник',
DELAYED: 'Отвечаем не сразу, с задержками'
};
export const BusinessConstraintLabels = {
NONE: 'Серьезных ограничений нет',
SEASONALITY: 'Ярко выраженная сезонность',
SMALL_TEAM: 'Маленькая команда',
LIMITED_BUDGET: 'Ограниченный маркетинговый бюджет',
LONG_DEAL_CYCLE: 'Длинный цикл сделки'
};
// Значения enum'ов
export const BusinessStages = Object.keys(BusinessStageLabels);
export const ClientTargets = Object.keys(ClientTargetLabels);
export const OfferTypes = Object.keys(OfferTypeLabels);
export const AverageChecks = Object.keys(AverageCheckLabels);
export const CustomerBehaviors = Object.keys(CustomerBehaviorLabels);
export const GeoScopes = Object.keys(GeoScopeLabels);
export const PurchaseFrequencies = Object.keys(PurchaseFrequencyLabels);
export const VisualFactors = Object.keys(VisualFactorLabels);
export const DecisionPriorities = Object.keys(DecisionPriorityLabels);
export const DiscoveryMethods = Object.keys(DiscoveryMethodLabels);
export const PriceFeedbacks = Object.keys(PriceFeedbackLabels);
export const SmmStatuses = Object.keys(SmmStatusLabels);
export const LeadVolumes = Object.keys(LeadVolumeLabels);
export const ResponseHandlers = Object.keys(ResponseHandlerLabels);
export const BusinessConstraints = Object.keys(BusinessConstraintLabels);
+6 -6
View File
@@ -1,3 +1,9 @@
<script>
export default {
name: 'DashboardNew'
};
</script>
<template>
<div class="card">
<h1 class="text-2xl font-semibold">New Dashboard</h1>
@@ -5,10 +11,4 @@
</div>
</template>
<script>
export default {
name: 'DashboardNew'
};
</script>
<style scoped></style>
+217 -217
View File
@@ -1,220 +1,3 @@
<template>
<div class="card">
<Toast />
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Управление парсером</h1>
<Button icon="pi pi-refresh" label="Обновить статус" @click="loadStatus" :loading="loading" severity="secondary" />
</div>
<!-- Статус системы -->
<div class="grid mb-4">
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-cog mr-2"></i>
Статус сервиса
</div>
</template>
<template #content>
<div v-if="healthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="health" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="health.status" :severity="health.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сервис:</span>
<span>{{ health.service }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Планировщик:</span>
<span>{{ health.scheduler }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(health.timestamp) }}</span>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус сервиса</p>
</div>
</template>
</Card>
</div>
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-database mr-2"></i>
Статус базы данных
</div>
</template>
<template #content>
<div v-if="mongoHealthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="mongoHealth" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="mongoHealth.status" :severity="mongoHealth.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сообщение:</span>
<span class="text-sm">{{ mongoHealth.message }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(mongoHealth.timestamp) }}</span>
</div>
<div v-if="mongoHealth.suggestion" class="mt-3 p-2 bg-orange-50 border-orange-200 rounded">
<div class="text-sm text-orange-800">
<i class="pi pi-info-circle mr-1"></i>
{{ mongoHealth.suggestion }}
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус базы данных</p>
</div>
</template>
</Card>
</div>
</div>
<!-- Управление парсингом -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-play mr-2"></i>
Запуск парсинга
</div>
</template>
<template #content>
<div class="grid">
<!-- Кнопка для всех источников -->
<div class="col-12 md:col-4">
<div class="text-center p-4 border-round bg-blue-50">
<i class="pi pi-globe text-blue-500 text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">Все источники</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг всех доступных источников</p>
<Button label="Запустить все" icon="pi pi-play" @click="parseAll" :loading="parsingAll" severity="info" class="w-full" />
</div>
</div>
<!-- Динамические кнопки для каждого источника -->
<div v-if="sourcesLoading" class="col-12">
<div class="text-center py-4">
<ProgressSpinner size="30" />
<p class="mt-2 text-color-secondary">Загрузка источников...</p>
</div>
</div>
<div v-else-if="sources.length === 0" class="col-12">
<div class="text-center py-4">
<i class="pi pi-exclamation-triangle text-orange-500 text-4xl mb-3"></i>
<p class="text-color-secondary">Источники не найдены</p>
</div>
</div>
<div v-else v-for="(source, index) in sources" :key="source" class="col-12 md:col-4">
<div class="text-center p-4 border-round" :class="getSourceCardClass(index)">
<i :class="getSourceIcon(index)" class="text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">{{ source }}</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг только {{ source }}</p>
<Button :label="`Запустить ${source}`" icon="pi pi-play" @click="parseSource(getSourceKey(source))" :loading="parsingSources[getSourceKey(source)]" :severity="getSourceSeverity(index)" class="w-full" />
</div>
</div>
</div>
</template>
</Card>
<!-- Результаты последнего парсинга -->
<Card v-if="lastParseResult" class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-check-circle mr-2"></i>
Результаты последнего парсинга
</div>
</template>
<template #content>
<div class="grid">
<div v-for="result in lastParseResult" :key="result.source" class="col-12 md:col-6">
<div class="p-3 border-round" :class="getResultCardClass(result.status)">
<div class="flex justify-content-between align-items-center mb-2">
<h4 class="text-lg font-semibold m-0">{{ result.source }}</h4>
<Tag :value="result.status" :severity="getStatusSeverity(result.status)" />
</div>
<div class="space-y-2">
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Обработано:</span>
<span class="font-semibold">{{ result.processedItems }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Всего в БД:</span>
<span class="font-semibold">{{ result.totalItemsInDb }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Статистика -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-chart-bar mr-2"></i>
Текущая статистика
</div>
</template>
<template #content>
<div v-if="statsLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="stats" class="grid">
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-blue-50">
<div class="text-2xl font-bold text-blue-900">{{ stats.totalItems || 0 }}</div>
<div class="text-sm text-blue-600">Всего новостей</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-green-50">
<div class="text-2xl font-bold text-green-900">{{ sourceCount }}</div>
<div class="text-sm text-green-600">Источников</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-orange-50">
<div class="text-2xl font-bold text-orange-900">{{ averagePerSource }}</div>
<div class="text-sm text-orange-600">Среднее на источник</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-purple-50">
<div class="text-2xl font-bold text-purple-900">{{ formatDate(stats.lastUpdate) }}</div>
<div class="text-sm text-purple-600">Последнее обновление</div>
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<p>Не удалось загрузить статистику</p>
</div>
</template>
</Card>
<!-- Действия -->
<div class="flex justify-content-center gap-2">
<Button label="Перейти к новостям" icon="pi pi-arrow-right" @click="goToNews" severity="info" />
<Button label="Статистика" icon="pi pi-chart-bar" @click="goToDashboard" severity="secondary" />
</div>
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
@@ -487,6 +270,223 @@ onMounted(() => {
});
</script>
<template>
<div class="card">
<Toast />
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Управление парсером</h1>
<Button icon="pi pi-refresh" label="Обновить статус" @click="loadStatus" :loading="loading" severity="secondary" />
</div>
<!-- Статус системы -->
<div class="grid mb-4">
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-cog mr-2"></i>
Статус сервиса
</div>
</template>
<template #content>
<div v-if="healthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="health" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="health.status" :severity="health.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сервис:</span>
<span>{{ health.service }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Планировщик:</span>
<span>{{ health.scheduler }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(health.timestamp) }}</span>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус сервиса</p>
</div>
</template>
</Card>
</div>
<div class="col-12 md:col-6">
<Card>
<template #title>
<div class="flex align-items-center">
<i class="pi pi-database mr-2"></i>
Статус базы данных
</div>
</template>
<template #content>
<div v-if="mongoHealthLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="mongoHealth" class="space-y-3">
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Статус:</span>
<Tag :value="mongoHealth.status" :severity="mongoHealth.status === 'UP' ? 'success' : 'danger'" />
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Сообщение:</span>
<span class="text-sm">{{ mongoHealth.message }}</span>
</div>
<div class="flex justify-content-between align-items-center">
<span class="font-medium">Время:</span>
<span>{{ formatDate(mongoHealth.timestamp) }}</span>
</div>
<div v-if="mongoHealth.suggestion" class="mt-3 p-2 bg-orange-50 border-orange-200 rounded">
<div class="text-sm text-orange-800">
<i class="pi pi-info-circle mr-1"></i>
{{ mongoHealth.suggestion }}
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<i class="pi pi-exclamation-triangle text-2xl mb-2"></i>
<p>Не удалось загрузить статус базы данных</p>
</div>
</template>
</Card>
</div>
</div>
<!-- Управление парсингом -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-play mr-2"></i>
Запуск парсинга
</div>
</template>
<template #content>
<div class="grid">
<!-- Кнопка для всех источников -->
<div class="col-12 md:col-4">
<div class="text-center p-4 border-round bg-blue-50">
<i class="pi pi-globe text-blue-500 text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">Все источники</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг всех доступных источников</p>
<Button label="Запустить все" icon="pi pi-play" @click="parseAll" :loading="parsingAll" severity="info" class="w-full" />
</div>
</div>
<!-- Динамические кнопки для каждого источника -->
<div v-if="sourcesLoading" class="col-12">
<div class="text-center py-4">
<ProgressSpinner size="30" />
<p class="mt-2 text-color-secondary">Загрузка источников...</p>
</div>
</div>
<div v-else-if="sources.length === 0" class="col-12">
<div class="text-center py-4">
<i class="pi pi-exclamation-triangle text-orange-500 text-4xl mb-3"></i>
<p class="text-color-secondary">Источники не найдены</p>
</div>
</div>
<div v-else v-for="(source, index) in sources" :key="source" class="col-12 md:col-4">
<div class="text-center p-4 border-round" :class="getSourceCardClass(index)">
<i :class="getSourceIcon(index)" class="text-4xl mb-3"></i>
<h3 class="text-lg font-semibold mb-2">{{ source }}</h3>
<p class="text-sm text-color-secondary mb-3">Запустить парсинг только {{ source }}</p>
<Button :label="`Запустить ${source}`" icon="pi pi-play" @click="parseSource(getSourceKey(source))" :loading="parsingSources[getSourceKey(source)]" :severity="getSourceSeverity(index)" class="w-full" />
</div>
</div>
</div>
</template>
</Card>
<!-- Результаты последнего парсинга -->
<Card v-if="lastParseResult" class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-check-circle mr-2"></i>
Результаты последнего парсинга
</div>
</template>
<template #content>
<div class="grid">
<div v-for="result in lastParseResult" :key="result.source" class="col-12 md:col-6">
<div class="p-3 border-round" :class="getResultCardClass(result.status)">
<div class="flex justify-content-between align-items-center mb-2">
<h4 class="text-lg font-semibold m-0">{{ result.source }}</h4>
<Tag :value="result.status" :severity="getStatusSeverity(result.status)" />
</div>
<div class="space-y-2">
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Обработано:</span>
<span class="font-semibold">{{ result.processedItems }}</span>
</div>
<div class="flex justify-content-between">
<span class="text-sm text-color-secondary">Всего в БД:</span>
<span class="font-semibold">{{ result.totalItemsInDb }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Статистика -->
<Card class="mb-4">
<template #title>
<div class="flex align-items-center">
<i class="pi pi-chart-bar mr-2"></i>
Текущая статистика
</div>
</template>
<template #content>
<div v-if="statsLoading" class="text-center py-3">
<ProgressSpinner size="30" />
</div>
<div v-else-if="stats" class="grid">
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-blue-50">
<div class="text-2xl font-bold text-blue-900">{{ stats.totalItems || 0 }}</div>
<div class="text-sm text-blue-600">Всего новостей</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-green-50">
<div class="text-2xl font-bold text-green-900">{{ sourceCount }}</div>
<div class="text-sm text-green-600">Источников</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-orange-50">
<div class="text-2xl font-bold text-orange-900">{{ averagePerSource }}</div>
<div class="text-sm text-orange-600">Среднее на источник</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="text-center p-3 border-round bg-purple-50">
<div class="text-2xl font-bold text-purple-900">{{ formatDate(stats.lastUpdate) }}</div>
<div class="text-sm text-purple-600">Последнее обновление</div>
</div>
</div>
</div>
<div v-else class="text-center py-3 text-color-secondary">
<p>Не удалось загрузить статистику</p>
</div>
</template>
</Card>
<!-- Действия -->
<div class="flex justify-content-center gap-2">
<Button label="Перейти к новостям" icon="pi pi-arrow-right" @click="goToNews" severity="info" />
<Button label="Статистика" icon="pi pi-chart-bar" @click="goToDashboard" severity="secondary" />
</div>
</div>
</template>
<style scoped>
.space-y-2 > * + * {
margin-top: 0.5rem;
+157 -157
View File
@@ -1,3 +1,160 @@
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Chart from 'primevue/chart';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
// Реактивные данные
const stats = ref({});
const health = ref(null);
const mongoHealth = ref(null);
const loading = ref(false);
const healthLoading = ref(false);
const mongoHealthLoading = ref(false);
// Цвета для источников
const sourceColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6366F1'];
// Вычисляемые свойства
const sourceCount = computed(() => {
return stats.value.sourceStatistics ? Object.keys(stats.value.sourceStatistics).length : 0;
});
const averagePerSource = computed(() => {
if (!stats.value.sourceStatistics || sourceCount.value === 0) return 0;
const total = Object.values(stats.value.sourceStatistics).reduce((sum, count) => sum + count, 0);
return Math.round(total / sourceCount.value);
});
const chartData = computed(() => {
if (!stats.value.sourceStatistics) return { labels: [], datasets: [] };
const sources = Object.keys(stats.value.sourceStatistics);
const counts = Object.values(stats.value.sourceStatistics);
return {
labels: sources,
datasets: [
{
data: counts,
backgroundColor: sources.map((_, index) => getSourceColor(index)),
borderWidth: 2,
borderColor: '#ffffff'
}
]
};
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} новостей (${percentage}%)`;
}
}
}
}
};
// Методы
const loadData = async () => {
loading.value = true;
try {
await Promise.all([loadStats(), loadHealth(), loadMongoHealth()]);
} finally {
loading.value = false;
}
};
const loadStats = async () => {
try {
const response = await NewsService.getStats();
if (response.success) {
stats.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статистики:', error);
}
};
const loadHealth = async () => {
healthLoading.value = true;
try {
const response = await NewsService.getHealth();
if (response.success) {
health.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса сервиса:', error);
} finally {
healthLoading.value = false;
}
};
const loadMongoHealth = async () => {
mongoHealthLoading.value = true;
try {
const response = await NewsService.getMongoHealth();
if (response.success) {
mongoHealth.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса MongoDB:', error);
} finally {
mongoHealthLoading.value = false;
}
};
const getSourceColor = (source) => {
if (typeof source === 'number') {
return sourceColors[source % sourceColors.length];
}
const sources = Object.keys(stats.value.sourceStatistics || {});
const index = sources.indexOf(source);
return sourceColors[index % sourceColors.length];
};
const getPercentage = (count) => {
if (!stats.value.totalItems || stats.value.totalItems === 0) return 0;
return ((count / stats.value.totalItems) * 100).toFixed(1);
};
const formatDate = (timestamp) => {
if (!timestamp) return 'Неизвестно';
return NewsService.formatDate(new Date(timestamp).toISOString());
};
const goToNews = () => {
router.push('/news');
};
// Жизненный цикл
onMounted(() => {
loadData();
});
</script>
<template>
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
@@ -202,163 +359,6 @@
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Chart from 'primevue/chart';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
// Реактивные данные
const stats = ref({});
const health = ref(null);
const mongoHealth = ref(null);
const loading = ref(false);
const healthLoading = ref(false);
const mongoHealthLoading = ref(false);
// Цвета для источников
const sourceColors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4', '#84CC16', '#F97316', '#EC4899', '#6366F1'];
// Вычисляемые свойства
const sourceCount = computed(() => {
return stats.value.sourceStatistics ? Object.keys(stats.value.sourceStatistics).length : 0;
});
const averagePerSource = computed(() => {
if (!stats.value.sourceStatistics || sourceCount.value === 0) return 0;
const total = Object.values(stats.value.sourceStatistics).reduce((sum, count) => sum + count, 0);
return Math.round(total / sourceCount.value);
});
const chartData = computed(() => {
if (!stats.value.sourceStatistics) return { labels: [], datasets: [] };
const sources = Object.keys(stats.value.sourceStatistics);
const counts = Object.values(stats.value.sourceStatistics);
return {
labels: sources,
datasets: [
{
data: counts,
backgroundColor: sources.map((_, index) => getSourceColor(index)),
borderWidth: 2,
borderColor: '#ffffff'
}
]
};
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} новостей (${percentage}%)`;
}
}
}
}
};
// Методы
const loadData = async () => {
loading.value = true;
try {
await Promise.all([loadStats(), loadHealth(), loadMongoHealth()]);
} finally {
loading.value = false;
}
};
const loadStats = async () => {
try {
const response = await NewsService.getStats();
if (response.success) {
stats.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статистики:', error);
}
};
const loadHealth = async () => {
healthLoading.value = true;
try {
const response = await NewsService.getHealth();
if (response.success) {
health.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса сервиса:', error);
} finally {
healthLoading.value = false;
}
};
const loadMongoHealth = async () => {
mongoHealthLoading.value = true;
try {
const response = await NewsService.getMongoHealth();
if (response.success) {
mongoHealth.value = response.data;
}
} catch (error) {
console.error('Ошибка при загрузке статуса MongoDB:', error);
} finally {
mongoHealthLoading.value = false;
}
};
const getSourceColor = (source) => {
if (typeof source === 'number') {
return sourceColors[source % sourceColors.length];
}
const sources = Object.keys(stats.value.sourceStatistics || {});
const index = sources.indexOf(source);
return sourceColors[index % sourceColors.length];
};
const getPercentage = (count) => {
if (!stats.value.totalItems || stats.value.totalItems === 0) return 0;
return ((count / stats.value.totalItems) * 100).toFixed(1);
};
const formatDate = (timestamp) => {
if (!timestamp) return 'Неизвестно';
return NewsService.formatDate(new Date(timestamp).toISOString());
};
const goToNews = () => {
router.push('/news');
};
// Жизненный цикл
onMounted(() => {
loadData();
});
</script>
<style scoped>
.space-y-3 > * + * {
margin-top: 0.75rem;
+140 -140
View File
@@ -1,3 +1,143 @@
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
// Реактивные данные
const news = ref(null);
const loading = ref(false);
const error = ref(null);
// Вычисляемые свойства
const hasAnalytics = computed(() => {
return (
news.value?.analytics &&
(news.value.analytics.summary || news.value.analytics.sentiment || (news.value.analytics.tags && news.value.analytics.tags.length > 0) || (news.value.analytics.entities && Object.keys(news.value.analytics.entities).length > 0))
);
});
// Методы
const loadNews = async () => {
const newsId = route.params.id;
if (!newsId) {
error.value = 'ID новости не указан';
return;
}
loading.value = true;
error.value = null;
try {
// Поскольку у нас нет отдельного API для получения одной новости,
// мы будем использовать общий API с фильтром по ID
// В реальном приложении здесь должен быть отдельный эндпоинт
const response = await NewsService.getNewsItems({
page: 0,
size: 1
// Здесь можно добавить фильтр по ID, если API это поддерживает
});
if (response.success && response.data.content.length > 0) {
// Найдем новость по ID (временное решение)
const foundNews = response.data.content.find((item) => item.id === newsId);
if (foundNews) {
news.value = foundNews;
} else {
error.value = 'Новость не найдена';
}
} else {
error.value = 'Не удалось загрузить новость';
}
} catch (err) {
console.error('Ошибка при загрузке новости:', err);
error.value = 'Ошибка при загрузке новости';
} finally {
loading.value = false;
}
};
const goBack = () => {
router.push('/news');
};
const openOriginalUrl = () => {
if (news.value?.url) {
window.open(news.value.url, '_blank');
}
};
const shareNews = async () => {
if (navigator.share && news.value) {
try {
await navigator.share({
title: news.value.title,
text: news.value.rawText?.substring(0, 200) + '...',
url: news.value.url
});
} catch (err) {
console.log('Поделиться отменено или не поддерживается');
}
} else {
// Fallback - копирование URL в буфер обмена
if (news.value?.url) {
try {
await navigator.clipboard.writeText(news.value.url);
// Здесь можно показать уведомление об успешном копировании
console.log('URL скопирован в буфер обмена');
} catch (err) {
console.error('Ошибка при копировании URL:', err);
}
}
}
};
const getSentimentSeverity = (sentiment) => {
switch (sentiment?.toLowerCase()) {
case 'positive':
case 'позитивный':
return 'success';
case 'negative':
case 'негативный':
return 'danger';
case 'neutral':
case 'нейтральный':
return 'info';
default:
return 'secondary';
}
};
const formatDate = (dateString) => {
return NewsService.formatDate(dateString);
};
const formatDateShort = (dateString) => {
return NewsService.formatDateShort(dateString);
};
const truncateUrl = (url) => {
if (!url) return '';
return url.length > 50 ? url.substring(0, 50) + '...' : url;
};
const truncateHash = (hash) => {
if (!hash) return '';
return hash.length > 20 ? hash.substring(0, 20) + '...' : hash;
};
// Жизненный цикл
onMounted(() => {
loadNews();
});
</script>
<template>
<div class="card">
<!-- Загрузка -->
@@ -168,146 +308,6 @@
</div>
</template>
<script setup>
import NewsService from '@/service/NewsService';
import Button from 'primevue/button';
import Card from 'primevue/card';
import ProgressSpinner from 'primevue/progressspinner';
import Tag from 'primevue/tag';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
// Реактивные данные
const news = ref(null);
const loading = ref(false);
const error = ref(null);
// Вычисляемые свойства
const hasAnalytics = computed(() => {
return (
news.value?.analytics &&
(news.value.analytics.summary || news.value.analytics.sentiment || (news.value.analytics.tags && news.value.analytics.tags.length > 0) || (news.value.analytics.entities && Object.keys(news.value.analytics.entities).length > 0))
);
});
// Методы
const loadNews = async () => {
const newsId = route.params.id;
if (!newsId) {
error.value = 'ID новости не указан';
return;
}
loading.value = true;
error.value = null;
try {
// Поскольку у нас нет отдельного API для получения одной новости,
// мы будем использовать общий API с фильтром по ID
// В реальном приложении здесь должен быть отдельный эндпоинт
const response = await NewsService.getNewsItems({
page: 0,
size: 1
// Здесь можно добавить фильтр по ID, если API это поддерживает
});
if (response.success && response.data.content.length > 0) {
// Найдем новость по ID (временное решение)
const foundNews = response.data.content.find((item) => item.id === newsId);
if (foundNews) {
news.value = foundNews;
} else {
error.value = 'Новость не найдена';
}
} else {
error.value = 'Не удалось загрузить новость';
}
} catch (err) {
console.error('Ошибка при загрузке новости:', err);
error.value = 'Ошибка при загрузке новости';
} finally {
loading.value = false;
}
};
const goBack = () => {
router.push('/news');
};
const openOriginalUrl = () => {
if (news.value?.url) {
window.open(news.value.url, '_blank');
}
};
const shareNews = async () => {
if (navigator.share && news.value) {
try {
await navigator.share({
title: news.value.title,
text: news.value.rawText?.substring(0, 200) + '...',
url: news.value.url
});
} catch (err) {
console.log('Поделиться отменено или не поддерживается');
}
} else {
// Fallback - копирование URL в буфер обмена
if (news.value?.url) {
try {
await navigator.clipboard.writeText(news.value.url);
// Здесь можно показать уведомление об успешном копировании
console.log('URL скопирован в буфер обмена');
} catch (err) {
console.error('Ошибка при копировании URL:', err);
}
}
}
};
const getSentimentSeverity = (sentiment) => {
switch (sentiment?.toLowerCase()) {
case 'positive':
case 'позитивный':
return 'success';
case 'negative':
case 'негативный':
return 'danger';
case 'neutral':
case 'нейтральный':
return 'info';
default:
return 'secondary';
}
};
const formatDate = (dateString) => {
return NewsService.formatDate(dateString);
};
const formatDateShort = (dateString) => {
return NewsService.formatDateShort(dateString);
};
const truncateUrl = (url) => {
if (!url) return '';
return url.length > 50 ? url.substring(0, 50) + '...' : url;
};
const truncateHash = (hash) => {
if (!hash) return '';
return hash.length > 20 ? hash.substring(0, 20) + '...' : hash;
};
// Жизненный цикл
onMounted(() => {
loadNews();
});
</script>
<style scoped>
.prose {
max-width: none;
+130 -130
View File
@@ -1,133 +1,3 @@
<template>
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Лента новостей</h1>
<Button icon="pi pi-refresh" label="Обновить" @click="loadNews" :loading="loading" severity="secondary" />
</div>
<!-- Фильтры -->
<NewsFilters v-model:filters="filters" @filter-change="onFilterChange" :sources="sources" :loading="loading" />
<!-- Статистика -->
<div class="grid mb-4">
<div class="col-12 md:col-3">
<div class="card bg-blue-50 border-blue-200">
<div class="flex align-items-center">
<i class="pi pi-align-left text-blue-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-blue-600">Всего новостей</div>
<div class="text-xl font-semibold text-blue-900">{{ totalElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-green-50 border-green-200">
<div class="flex align-items-center">
<i class="pi pi-calendar text-green-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-green-600">На странице</div>
<div class="text-xl font-semibold text-green-900">{{ numberOfElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-orange-50 border-orange-200">
<div class="flex align-items-center">
<i class="pi pi-clock text-orange-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-orange-600">Последнее обновление</div>
<div class="text-sm font-semibold text-orange-900">{{ lastUpdateTime }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-purple-50 border-purple-200">
<div class="flex align-items-center">
<i class="pi pi-filter text-purple-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-purple-600">Активные фильтры</div>
<div class="text-xl font-semibold text-purple-900">{{ activeFiltersCount }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Список новостей -->
<div v-if="loading && news.length === 0" class="text-center py-6">
<ProgressSpinner />
<p class="mt-3 text-color-secondary">Загрузка новостей...</p>
</div>
<div v-else-if="news.length === 0" class="text-center py-6">
<i class="pi pi-inbox text-6xl text-color-secondary mb-3"></i>
<h3 class="text-xl font-semibold mb-2">Новости не найдены</h3>
<p class="text-color-secondary">Попробуйте изменить фильтры или обновить страницу</p>
</div>
<div v-else class="grid">
<div v-for="item in news" :key="item.id" class="col-12">
<Card class="mb-3 hover:shadow-2 transition-all transition-duration-200">
<template #header>
<div class="p-3 pb-0">
<div class="flex justify-content-between align-items-start mb-2">
<Tag :value="item.sourceName" severity="info" class="mr-2" />
<div class="flex align-items-center text-sm text-color-secondary">
<i class="pi pi-calendar mr-1"></i>
{{ formatDate(item.publishedAt) }}
</div>
</div>
<h3 class="text-xl font-semibold mb-2 line-height-3">
{{ item.title }}
</h3>
</div>
</template>
<template #content>
<div class="p-3 pt-0">
<p class="text-color-secondary line-height-3 mb-3">
{{ getPreviewText(item.rawText) }}
</p>
<div class="flex justify-content-between align-items-center">
<div class="flex align-items-center">
<Tag v-if="item.category" :value="item.category" severity="success" class="mr-2" />
<span class="text-sm text-color-secondary">
<i class="pi pi-clock mr-1"></i>
Добавлено: {{ formatDateShort(item.addedAt) }}
</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-external-link" label="Читать" size="small" @click="openNewsDetail(item.id)" severity="secondary" />
<Button icon="pi pi-external-link" size="small" @click="openOriginalUrl(item.url)" severity="info" v-tooltip.top="'Открыть оригинал'" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Пагинация -->
<div v-if="totalPages > 1" class="flex justify-content-center mt-4">
<Paginator
v-model:first="first"
:rows="filters.size"
:totalRecords="totalElements"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onPageChange"
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
/>
</div>
<!-- Кнопка "Загрузить еще" для бесконечной прокрутки -->
<div v-if="!last && news.length > 0" class="text-center mt-4">
<Button label="Загрузить еще" icon="pi pi-plus" @click="loadMore" :loading="loadingMore" severity="secondary" />
</div>
</div>
</template>
<script setup>
import NewsFilters from '@/components/news/NewsFilters.vue';
import NewsService from '@/service/NewsService';
@@ -297,6 +167,136 @@ watch(
);
</script>
<template>
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<h1 class="text-2xl font-semibold m-0">Лента новостей</h1>
<Button icon="pi pi-refresh" label="Обновить" @click="loadNews" :loading="loading" severity="secondary" />
</div>
<!-- Фильтры -->
<NewsFilters v-model:filters="filters" @filter-change="onFilterChange" :sources="sources" :loading="loading" />
<!-- Статистика -->
<div class="grid mb-4">
<div class="col-12 md:col-3">
<div class="card bg-blue-50 border-blue-200">
<div class="flex align-items-center">
<i class="pi pi-align-left text-blue-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-blue-600">Всего новостей</div>
<div class="text-xl font-semibold text-blue-900">{{ totalElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-green-50 border-green-200">
<div class="flex align-items-center">
<i class="pi pi-calendar text-green-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-green-600">На странице</div>
<div class="text-xl font-semibold text-green-900">{{ numberOfElements || 0 }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-orange-50 border-orange-200">
<div class="flex align-items-center">
<i class="pi pi-clock text-orange-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-orange-600">Последнее обновление</div>
<div class="text-sm font-semibold text-orange-900">{{ lastUpdateTime }}</div>
</div>
</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card bg-purple-50 border-purple-200">
<div class="flex align-items-center">
<i class="pi pi-filter text-purple-500 text-2xl mr-3"></i>
<div>
<div class="text-sm text-purple-600">Активные фильтры</div>
<div class="text-xl font-semibold text-purple-900">{{ activeFiltersCount }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Список новостей -->
<div v-if="loading && news.length === 0" class="text-center py-6">
<ProgressSpinner />
<p class="mt-3 text-color-secondary">Загрузка новостей...</p>
</div>
<div v-else-if="news.length === 0" class="text-center py-6">
<i class="pi pi-inbox text-6xl text-color-secondary mb-3"></i>
<h3 class="text-xl font-semibold mb-2">Новости не найдены</h3>
<p class="text-color-secondary">Попробуйте изменить фильтры или обновить страницу</p>
</div>
<div v-else class="grid">
<div v-for="item in news" :key="item.id" class="col-12">
<Card class="mb-3 hover:shadow-2 transition-all transition-duration-200">
<template #header>
<div class="p-3 pb-0">
<div class="flex justify-content-between align-items-start mb-2">
<Tag :value="item.sourceName" severity="info" class="mr-2" />
<div class="flex align-items-center text-sm text-color-secondary">
<i class="pi pi-calendar mr-1"></i>
{{ formatDate(item.publishedAt) }}
</div>
</div>
<h3 class="text-xl font-semibold mb-2 line-height-3">
{{ item.title }}
</h3>
</div>
</template>
<template #content>
<div class="p-3 pt-0">
<p class="text-color-secondary line-height-3 mb-3">
{{ getPreviewText(item.rawText) }}
</p>
<div class="flex justify-content-between align-items-center">
<div class="flex align-items-center">
<Tag v-if="item.category" :value="item.category" severity="success" class="mr-2" />
<span class="text-sm text-color-secondary">
<i class="pi pi-clock mr-1"></i>
Добавлено: {{ formatDateShort(item.addedAt) }}
</span>
</div>
<div class="flex gap-2">
<Button icon="pi pi-external-link" label="Читать" size="small" @click="openNewsDetail(item.id)" severity="secondary" />
<Button icon="pi pi-external-link" size="small" @click="openOriginalUrl(item.url)" severity="info" v-tooltip.top="'Открыть оригинал'" />
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Пагинация -->
<div v-if="totalPages > 1" class="flex justify-content-center mt-4">
<Paginator
v-model:first="first"
:rows="filters.size"
:totalRecords="totalElements"
:rowsPerPageOptions="[10, 20, 50, 100]"
@page="onPageChange"
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
/>
</div>
<!-- Кнопка "Загрузить еще" для бесконечной прокрутки -->
<div v-if="!last && news.length > 0" class="text-center mt-4">
<Button label="Загрузить еще" icon="pi pi-plus" @click="loadMore" :loading="loadingMore" severity="secondary" />
</div>
</div>
</template>
<style scoped>
.hover\:shadow-2:hover {
box-shadow:
File diff suppressed because it is too large Load Diff
+40 -40
View File
@@ -30,50 +30,50 @@ async function onSubmit() {
<template>
<FloatingConfigurator />
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
<div class="flex min-h-screen min-w-[100vw] items-center justify-center overflow-hidden bg-gradient-to-br from-primary-50 via-surface-0 to-purple-50 dark:from-surface-900 dark:via-surface-800 dark:to-surface-900">
<div class="flex flex-col items-center justify-center">
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, var(--primary-color) 10%, rgba(33, 150, 243, 0) 30%)">
<div class="w-full bg-surface-0 dark:bg-surface-900 py-20 px-8 sm:px-20" style="border-radius: 53px">
<div class="text-center mb-8">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="mb-8 w-16 shrink-0 mx-auto">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
fill="var(--primary-color)"
/>
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
</mask>
<g mask="url(#mask0_1413_1551)">
<path
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
fill="var(--primary-color)"
/>
</g>
</svg>
<div class="text-surface-900 dark:text-surface-0 text-3xl font-medium mb-4">Welcome to Konturai!</div>
<span class="text-muted-color font-medium">Sign in to continue</span>
</div>
<div>
<label for="email1" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-2">Email</label>
<InputText id="email1" type="text" placeholder="Email address" class="w-full md:w-[30rem] mb-8" v-model="email" />
<label for="password1" class="block text-surface-900 dark:text-surface-0 font-medium text-xl mb-2">Password</label>
<Password id="password1" v-model="password" placeholder="Password" :toggleMask="true" class="mb-4" fluid :feedback="false"></Password>
<div class="flex items-center justify-between mt-2 mb-8 gap-8">
<div class="flex items-center">
<Checkbox v-model="checked" id="rememberme1" binary class="mr-2"></Checkbox>
<label for="rememberme1">Remember me</label>
</div>
<span class="font-medium no-underline ml-2 text-right cursor-pointer text-primary">Forgot password?</span>
<div class="w-full max-w-md rounded-2xl bg-surface-0 p-8 shadow-xl ring-1 ring-surface-900/5 dark:bg-surface-800 dark:ring-surface-700">
<div class="mb-8 text-center">
<div class="mb-6 flex justify-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary-500 to-purple-500">
<i class="pi pi-lock text-2xl text-white" />
</div>
<div v-if="errorMessage" class="text-red-500 text-sm mb-4">{{ errorMessage }}</div>
<Button :loading="loading" label="Sign In" class="w-full" @click="onSubmit"></Button>
</div>
<h1 class="mb-2 text-3xl font-bold tracking-tight text-surface-900 dark:text-surface-0">Маркетинговый анализ v4.0</h1>
<p class="mt-2 text-sm text-surface-600 dark:text-surface-400">Войдите в систему для начала работы</p>
</div>
<form @submit.prevent="onSubmit" class="space-y-6">
<div class="space-y-1">
<label for="email1" class="flex items-center gap-2 text-sm font-medium text-surface-700 dark:text-surface-300">
<i class="pi pi-envelope text-xs" />
Email
</label>
<InputText id="email1" v-model="email" type="email" placeholder="example@mail.kz" class="w-full" :class="{ 'p-invalid': errorMessage }" />
</div>
<div class="space-y-1">
<label for="password1" class="flex items-center gap-2 text-sm font-medium text-surface-700 dark:text-surface-300">
<i class="pi pi-lock text-xs" />
Пароль
</label>
<Password id="password1" v-model="password" placeholder="Введите пароль" :toggleMask="true" :feedback="false" class="w-full" inputClass="w-full" :class="{ 'p-invalid': errorMessage }" />
</div>
<div v-if="errorMessage" class="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-200">
{{ errorMessage }}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Checkbox v-model="checked" id="rememberme1" binary inputId="rememberme1" />
<label for="rememberme1" class="ml-2 text-sm text-surface-600 dark:text-surface-400">Remember me</label>
</div>
<span class="cursor-pointer text-sm font-medium text-primary no-underline hover:text-primary-600 dark:text-primary-400"> Forgot password? </span>
</div>
<Button :loading="loading" label="Войти" icon="pi pi-sign-in" icon-pos="right" class="w-full" @click="onSubmit" />
</form>
</div>
</div>
</div>
@@ -1,142 +1,5 @@
<template>
<div class="marketing-analyses-list-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1400px] mx-auto">
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои анализы</h2>
<Button label="Создать новый анализ" icon="pi pi-plus" severity="success" @click="goToCreateAnalysis" />
</div>
</div>
<div class="card-content">
<DataTable
:value="analyses || []"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="single"
removableSort
:sortField="'createdAt'"
:sortOrder="-1"
filterDisplay="menu"
:globalFilterFields="['product', 'location', 'region', 'clientType', 'status', 'analysisType', 'businessNiche']"
v-model:filters="filters"
:filters="filters"
dataKey="analysisId"
>
<template #header>
<div class="flex justify-content-between align-items-center flex-wrap gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters.global.value" placeholder="Поиск анализов..." />
</span>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadAnalyses" :loading="loading" />
</div>
</template>
<Column field="product" header="Продукт" :sortable="true" style="min-width: 200px">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.product || '—' }}</span>
</template>
</Column>
<Column field="location" header="Локация" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<span>{{ slotProps.data.location || '—' }}</span>
</template>
</Column>
<Column field="clientType" header="Тип клиента" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<Tag v-if="slotProps.data.clientType && slotProps.data.clientType !== '—'" :value="slotProps.data.clientType" severity="info" />
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="analysisType" header="Тип анализа" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<div v-if="slotProps.data.analysisType" class="flex flex-wrap gap-1">
<Tag v-for="(type, index) in Array.isArray(slotProps.data.analysisType) ? slotProps.data.analysisType : [slotProps.data.analysisType]" :key="index" :value="getAnalysisTypeLabel(type)" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback && filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
</Dropdown>
</template>
</Column>
<Column field="createdAt" header="Создан" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
</Column>
<Column field="completedAt" header="Завершен" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column header="Действия" style="min-width: 250px">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewAnalysis(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.analysisId)" />
<Button icon="pi pi-download" severity="success" size="small" v-tooltip.top="'Скачать PDF'" @click="downloadPdf(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-magic" severity="warning" size="small" v-tooltip.top="'Создать стратегию'" @click="createStrategy(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог истории статусов -->
<Dialog v-model:visible="historyDialogVisible" :header="'История статусов анализа'" :style="{ width: '600px' }" :modal="true">
<div v-if="historyLoading" class="text-center py-4">
<ProgressSpinner />
</div>
<div v-else-if="statusHistory && statusHistory.length > 0" class="status-history">
<Timeline :value="statusHistory" align="left" class="w-full">
<template #marker="slotProps">
<span class="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1" :class="getStatusColorClass(slotProps.item.status)">
<i :class="getStatusIcon(slotProps.item.status)"></i>
</span>
</template>
<template #content="slotProps">
<div class="flex flex-column">
<div class="flex align-items-center gap-2 mb-2">
<Tag :value="getStatusLabel(slotProps.item.status)" :severity="getStatusSeverity(slotProps.item.status)" />
<span class="text-sm text-surface-500 dark:text-surface-400">{{ formatDateTime(slotProps.item.timestamp) }}</span>
</div>
<p class="text-sm text-surface-700 dark:text-surface-300 m-0">{{ slotProps.item.message }}</p>
</div>
</template>
</Timeline>
</div>
<div v-else class="text-center py-4 text-surface-500">История статусов недоступна</div>
<template #footer>
<Button label="Закрыть" icon="pi pi-times" @click="historyDialogVisible = false" severity="secondary" />
</template>
</Dialog>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import MarketingService from '@/service/MarketingService';
import { FilterMatchMode } from '@primevue/core/api';
import Button from 'primevue/button';
@@ -389,6 +252,144 @@ onMounted(() => {
});
</script>
<template>
<div class="marketing-analyses-list-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1400px] mx-auto">
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои анализы</h2>
<Button label="Создать новый анализ" icon="pi pi-plus" severity="success" @click="goToCreateAnalysis" />
</div>
</div>
<div class="card-content">
<DataTable
:value="analyses || []"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="single"
removableSort
:sortField="'createdAt'"
:sortOrder="-1"
filterDisplay="menu"
:globalFilterFields="['product', 'location', 'region', 'clientType', 'status', 'analysisType', 'businessNiche']"
v-model:filters="filters"
:filters="filters"
dataKey="analysisId"
>
<template #header>
<div class="flex justify-content-between align-items-center flex-wrap gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters.global.value" placeholder="Поиск анализов..." />
</span>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadAnalyses" :loading="loading" />
</div>
</template>
<Column field="product" header="Продукт" :sortable="true" style="min-width: 200px">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.product || '—' }}</span>
</template>
</Column>
<Column field="location" header="Локация" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<span>{{ slotProps.data.location || '—' }}</span>
</template>
</Column>
<Column field="clientType" header="Тип клиента" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<Tag v-if="slotProps.data.clientType && slotProps.data.clientType !== '—'" :value="slotProps.data.clientType" severity="info" />
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="analysisType" header="Тип анализа" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<div v-if="slotProps.data.analysisType" class="flex flex-wrap gap-1">
<Tag v-for="(type, index) in Array.isArray(slotProps.data.analysisType) ? slotProps.data.analysisType : [slotProps.data.analysisType]" :key="index" :value="getAnalysisTypeLabel(type)" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback && filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
</Dropdown>
</template>
</Column>
<Column field="createdAt" header="Создан" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
</Column>
<Column field="completedAt" header="Завершен" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column header="Действия" style="min-width: 250px">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewAnalysis(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.analysisId)" />
<Button icon="pi pi-download" severity="success" size="small" v-tooltip.top="'Скачать PDF'" @click="downloadPdf(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-magic" severity="warning" size="small" v-tooltip.top="'Создать стратегию'" @click="createStrategy(slotProps.data.analysisId)" :disabled="slotProps.data.status !== 'completed'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог истории статусов -->
<Dialog v-model:visible="historyDialogVisible" :header="'История статусов анализа'" :style="{ width: '600px' }" :modal="true">
<div v-if="historyLoading" class="text-center py-4">
<ProgressSpinner />
</div>
<div v-else-if="statusHistory && statusHistory.length > 0" class="status-history">
<Timeline :value="statusHistory" align="left" class="w-full">
<template #marker="slotProps">
<span class="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1" :class="getStatusColorClass(slotProps.item.status)">
<i :class="getStatusIcon(slotProps.item.status)"></i>
</span>
</template>
<template #content="slotProps">
<div class="flex flex-column">
<div class="flex align-items-center gap-2 mb-2">
<Tag :value="getStatusLabel(slotProps.item.status)" :severity="getStatusSeverity(slotProps.item.status)" />
<span class="text-sm text-surface-500 dark:text-surface-400">{{ formatDateTime(slotProps.item.timestamp) }}</span>
</div>
<p class="text-sm text-surface-700 dark:text-surface-300 m-0">{{ slotProps.item.message }}</p>
</div>
</template>
</Timeline>
</div>
<div v-else class="text-center py-4 text-surface-500">История статусов недоступна</div>
<template #footer>
<Button label="Закрыть" icon="pi pi-times" @click="historyDialogVisible = false" severity="secondary" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.marketing-analyses-list-page {
font-family: var(--font-family);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,885 @@
<script setup>
import MarketingV3Service from '@/service/MarketingV3Service';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const analysisId = computed(() => route.params.id);
const loading = ref(true);
const status = ref('IDLE');
const resultData = ref(null);
const errorMessage = ref('');
const expandedRows = ref([]);
// Loader
const LOADER_MESSAGES = ['Собираем данные конкурентов...', 'Анализируем отзывы и рейтинги...', 'Считаем ER и частоту публикаций...', 'Строим карту контент-стратегий...', 'Генерируем выводы и приоритеты...'];
const loaderMessage = ref(LOADER_MESSAGES[0]);
const loaderDot = ref(1);
let pollInterval = null;
let loaderTick = 0;
// Status helpers
const statusLabel = computed(() => {
const map = { COMPLETED: 'ГОТОВ', PROCESSING: 'В ОБРАБОТКЕ', QUEUED: 'В ОЧЕРЕДИ', FAILED: 'ОШИБКА' };
return map[status.value] || '—';
});
const statusClass = computed(() => {
const map = {
COMPLETED: 'text-emerald-600 dark:text-emerald-400',
PROCESSING: 'text-amber-600 dark:text-amber-400',
QUEUED: 'text-blue-600 dark:text-blue-400',
FAILED: 'text-red-600 dark:text-red-400'
};
return map[status.value] || 'text-surface-500';
});
// Data aliases
const exec = computed(() => resultData.value?.['0_executive_summary'] || {});
const landscape1 = computed(() => resultData.value?.['1_market_landscape'] || {});
const geo2 = computed(() => resultData.value?.['2_geo_structure'] || {});
const competitors = computed(() => resultData.value?.['3_competitor_map'] || []);
const content4 = computed(() => resultData.value?.['4_content_profile'] || {});
const cii = computed(() => resultData.value?.['5_competition_intensity'] || {});
const rep6 = computed(() => resultData.value?.['6_reputation_analysis'] || {});
const beh7 = computed(() => resultData.value?.['7_behavioral_pattern'] || {});
const demand8 = computed(() => resultData.value?.['8_search_demand'] || {});
const pos9 = computed(() => resultData.value?.['9_user_positioning'] || {});
const conclusions10 = computed(() => resultData.value?.['10_structured_conclusions'] || []);
const rationale11 = computed(() => resultData.value?.['11_smm_strategy_rationale'] || '');
// Section 2: Geo table
const geoSort = ref({ field: 'activePlayers', order: -1 });
const geoTable = computed(() => {
return geo2.value?.cityComparison || [];
});
const onGeoSort = (e) => {
geoSort.value = { field: e.sortField, order: e.sortOrder };
};
// Section 3: Row expand
const onRowExpand = (e) => {
expandedRows.value = [e.data];
};
const onRowCollapse = () => {
expandedRows.value = [];
};
// Section 5: CII
const ciiFactors = computed(() => {
const d = cii.value;
return d.contributingFactors || [];
});
// Section 1: City heatmap
const cityHeatmap = computed(() => {
const map = landscape1.value?.cityDistribution || {};
const entries = Object.entries(map);
if (!entries.length) return [];
const max = Math.max(...entries.map(([, v]) => v), 1);
return entries
.map(([city, count]) => ({
city,
count,
pct: Math.round((count / max) * 100)
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
});
const reviewBoxplot = computed(() => landscape1.value?.reviewDistribution || null);
// Section 9: Radar axes
const radarAxes = computed(() => {
const p = pos9.value;
const user = p.userMetrics || p.radarChart || {};
const market = p.marketMetrics || { activity: 50, engagement: 50, video: 50, reputation: 50, frequency: 50 };
const axes = ['activity', 'engagement', 'video', 'reputation', 'frequency'];
const labels = { activity: 'Активность', engagement: 'Вовлечённость', video: 'Видео', reputation: 'Репутация', frequency: 'Частота' };
return axes.map((k) => ({
label: labels[k],
user: user[k] ?? 0,
market: market[k] ?? 0
}));
});
// Chart Data
const PALETTE = ['#4F46E5', '#7C3AED', '#EC4899', '#F97316', '#22C55E', '#06B6D4', '#EAB308'];
const platformChartData = computed(() => {
const pl = landscape1.value?.activePlayersByPlatform || {};
const entries = Object.entries(pl);
return {
labels: entries.map(([k]) => k),
datasets: [{ data: entries.map(([, v]) => v), backgroundColor: entries.map((_, i) => PALETTE[i % PALETTE.length]) }]
};
});
const demandChartData = computed(() => {
const s = demand8.value?.seasonality || landscape1.value?.demandDynamics || landscape1.value?.demandTrend || [];
return {
labels: s.map((p) => p.period ?? p.month),
datasets: [{ label: 'Спрос', data: s.map((p) => p.value), borderColor: '#4F46E5', backgroundColor: 'rgba(79,70,229,0.08)', tension: 0.4, fill: true }]
};
});
const contentDistribution = computed(() => {
const d = content4.value;
if (d?.demoContentPercent == null && !d?.distribution) return null;
return {
Демо: d.demoContentPercent ?? d.distribution?.['Демо/Процесс'] ?? 0,
Экспертный: d.expertContentPercent ?? d.distribution?.['Экспертный'] ?? 0,
Продающий: d.salesContentPercent ?? d.distribution?.['Промо/Акции'] ?? 0,
Отзывы: d.reviewsContentPercent ?? d.distribution?.['Отзывы'] ?? 0,
Вовлекающий: d.engagementContentPercent ?? d.distribution?.['Вовлечение'] ?? 0
};
});
const contentChartData = computed(() => {
const d = contentDistribution.value || {};
const entries = Object.entries(d).filter(([, v]) => v > 0);
return {
labels: entries.map(([k]) => k),
datasets: [{ data: entries.map(([, v]) => v), backgroundColor: PALETTE }]
};
});
const starChartData = computed(() => {
const d = rep6.value?.starDistribution || {};
return {
labels: ['1★', '2★', '3★', '4★', '5★'],
datasets: [
{
label: 'Отзывов (%)',
data: [d['1'] ?? 0, d['2'] ?? 0, d['3'] ?? 0, d['4'] ?? 0, d['5'] ?? 0],
backgroundColor: ['#EF4444', '#F97316', '#EAB308', '#84CC16', '#22C55E']
}
]
};
});
const ctaChartData = computed(() => {
const d = {
'Промо и скидки': beh7.value?.promoUsagePercent ?? 0,
'Прямое бронирование': beh7.value?.directBookingPercent ?? 0,
'Открытые цены': beh7.value?.priceVisibilityPercent ?? 0
};
const entries = Object.entries(d);
return {
labels: entries.map(([k]) => k),
datasets: [{ label: 'Поведение', data: entries.map(([, v]) => v), backgroundColor: PALETTE.map((c) => c + 'CC') }]
};
});
const seasonChartData = computed(() => {
const s = demand8.value?.seasonality || landscape1.value?.demandDynamics || [];
const peaks = demand8.value?.peakPeriods || [];
return {
labels: s.map((p) => p.period ?? p.month),
datasets: [
{
label: 'Спрос',
data: s.map((p) => p.value),
borderColor: '#4F46E5',
backgroundColor: 'rgba(79,70,229,0.12)',
tension: 0.4,
fill: true,
pointRadius: s.map((p) => (peaks.includes(p.period ?? p.month) ? 7 : 3)),
pointBackgroundColor: s.map((p) => (peaks.includes(p.period ?? p.month) ? '#EC4899' : '#4F46E5'))
}
]
};
});
const radarChartData = computed(() => {
const a = radarAxes.value;
const isBenchmark = pos9.value?.mode === 'BENCHMARK';
const datasets = [{ label: 'Рынок (Ориентир)', data: a.map((x) => x.market), backgroundColor: 'rgba(234,179,8,0.2)', borderColor: '#EAB308', pointBackgroundColor: '#EAB308' }];
if (!isBenchmark) {
datasets.push({ label: 'Ваша позиция', data: a.map((x) => x.user), backgroundColor: 'rgba(79,70,229,0.2)', borderColor: '#4F46E5', pointBackgroundColor: '#4F46E5' });
}
return {
labels: a.map((x) => x.label),
datasets
};
});
// Chart Options
const tb = { mode: 'index', intersect: false }; // tooltip base
const basePlugin = { legend: { labels: { color: '#6B7280', font: { size: 11 } } }, tooltip: tb };
const doughnutOptions = { responsive: true, maintainAspectRatio: false, plugins: { ...basePlugin, legend: { position: 'right', labels: { color: '#6B7280', font: { size: 11 } } } } };
const lineOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF' } } } };
const pieOptions = { responsive: true, maintainAspectRatio: false, plugins: { ...basePlugin, legend: { position: 'right', labels: { color: '#6B7280', font: { size: 11 } } } } };
const barOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF' } } } };
const hbarOptions = { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { x: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, y: { ticks: { color: '#9CA3AF' } } } };
const areaOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: tb }, scales: { y: { beginAtZero: true, ticks: { color: '#9CA3AF' } }, x: { ticks: { color: '#9CA3AF', maxRotation: 45 } } } };
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: basePlugin,
scales: {
r: { min: 0, max: 100, ticks: { stepSize: 20, color: '#9CA3AF', backdropColor: 'transparent' }, pointLabels: { color: '#6B7280', font: { size: 11 } }, grid: { color: 'rgba(107,114,128,0.2)' }, angleLines: { color: 'rgba(107,114,128,0.2)' } }
}
};
// Helpers
const fmtNum = (n) => (n != null ? Number(n).toLocaleString('ru-RU') : '—');
const fmtPct = (n) => (n != null ? parseFloat(n).toFixed(2) + '%' : '—');
const erClass = (er) => {
const v = parseFloat(er);
if (v >= 6) return 'text-emerald-600 dark:text-emerald-400 font-semibold';
if (v >= 3) return 'text-amber-600 dark:text-amber-400 font-semibold';
return 'text-rose-600 dark:text-rose-400 font-semibold';
};
const heatColor = (pct) => {
if (pct >= 75) return '#4F46E5';
if (pct >= 50) return '#7C3AED';
if (pct >= 25) return '#A78BFA';
return '#C4B5FD';
};
const MOCK_RESULT = {
'0_executive_summary': {
businessStage: 'Рост',
geography: 'Алматы, Астана',
activeCompetitors: 24,
competitionLevel: 'Высокий',
averageNicheEr: 4.8,
averageRating: 4.3,
demandTrend: '▲ +12% за квартал',
keyFigures: [
'Рынок фитнес-услуг Алматы насчитывает 24 активных игрока в Instagram.',
'Средний ER по нише — 4.8%, что выше общего бенчмарка (3.2%).',
'Пик спроса приходится на январь и сентябрь (смена сезонов).',
'Лидер рынка — @championfitness.kz с 48 000 подписчиков и ER 7.2%.'
]
},
'1_market_landscape': {
activePlayersByPlatform: { Instagram: 18, '2GIS': 14, YouTube: 6, TikTok: 9 },
cityDistribution: { Алматы: 18, Астана: 12, Шымкент: 7, Қарағанды: 4, Актобе: 3 },
nicheReputationLevel: 4.2
},
'2_geo_structure': {
cityComparison: [
{ city: 'Алматы', activePlayers: 18, avgEr: 5.1, avgRating: 4.4, avgPostsPerMonth: 14 },
{ city: 'Астана', activePlayers: 12, avgEr: 4.3, avgRating: 4.2, avgPostsPerMonth: 11 },
{ city: 'Шымкент', activePlayers: 7, avgEr: 3.8, avgRating: 4.0, avgPostsPerMonth: 9 },
{ city: 'Қарағанды', activePlayers: 4, avgEr: 3.2, avgRating: 3.9, avgPostsPerMonth: 7 },
{ city: 'Актобе', activePlayers: 3, avgEr: 2.9, avgRating: 3.7, avgPostsPerMonth: 5 }
],
densityIndex: 78.5
},
'3_competitor_map': [
{
name: 'ChampionFitness',
platform: 'Instagram',
followers: 48000,
postsPerMonth: 22,
er: 7.2,
rating: 4.7,
reviews: 892,
strengths: ['Высокий ER', 'Регулярный постинг', 'Профессиональный визуал'],
weaknesses: ['Слабый 2GIS', 'Нет YouTube']
},
{ name: 'FitLife Almaty', platform: 'Instagram', followers: 31000, postsPerMonth: 15, er: 5.4, rating: 4.5, reviews: 541, strengths: ['Активные stories', 'Коллаборации'], weaknesses: ['Низкий охват Reels', 'Редкие публикации'] },
{ name: 'PowerGym KZ', platform: 'Instagram', followers: 22000, postsPerMonth: 18, er: 4.1, rating: 4.2, reviews: 310, strengths: ['Акции и скидки', 'Широкий ассортимент'], weaknesses: ['Слабый визуал', 'Нет Reels'] },
{ name: 'SportLife', platform: 'TikTok', followers: 19000, postsPerMonth: 30, er: 6.8, rating: 4.0, reviews: 280, strengths: ['Вирусный TikTok', 'Молодая аудитория'], weaknesses: ['Нет в 2GIS', 'Слабый Instagram'] },
{ name: 'Olymp Club', platform: 'Instagram', followers: 15000, postsPerMonth: 10, er: 3.2, rating: 4.3, reviews: 420, strengths: ['Хорошие отзывы', 'Лояльная ЦА'], weaknesses: ['Редкий постинг', 'Нет видео'] }
],
'4_content_profile': {
demoContentPercent: 28.0,
expertContentPercent: 22.0,
salesContentPercent: 20.0,
reviewsContentPercent: 18.0,
engagementContentPercent: 12.0,
videoShare: 38.0,
avgTextLengthCharacters: 284,
ctaUsagePercent: 33.3
},
'5_competition_intensity': {
ciiIndex: 72,
intensityLabel: 'Высокая интенсивность среды',
contributingFactors: ['Плотность конкурентов: высокая', 'Частота публикаций: 14 п/мес', 'Средний рейтинг рынка: 4.4 ★']
},
'6_reputation_analysis': {
avgNicheRating: 4.3,
medianReviews: 2443,
starDistribution: { 1: 4.3, 2: 6.2, 3: 15.0, 4: 52.0, 5: 22.5 },
highTrustBusinessShare: 0.88,
avgOwnerResponseTimeHours: 12
},
'7_behavioral_pattern': {
promoUsagePercent: 35.0,
directBookingPercent: 28.0,
priceVisibilityPercent: 15.0,
avgCycleDays: 2,
commonCta: ['Запишись на пробный', 'Напиши в Direct', 'Узнай цену', 'Смотри акцию']
},
'8_search_demand': {
avgMonthlySearches: 15400,
seasonality: [
{ period: 'Январь', value: 92 },
{ period: 'Февраль', value: 78 },
{ period: 'Март', value: 81 },
{ period: 'Апрель', value: 74 },
{ period: 'Май', value: 68 },
{ period: 'Июнь', value: 59 },
{ period: 'Июль', value: 55 },
{ period: 'Август', value: 63 },
{ period: 'Сентябрь', value: 89 },
{ period: 'Октябрь', value: 85 },
{ period: 'Ноябрь', value: 77 },
{ period: 'Декабрь', value: 70 }
],
peakPeriods: ['Январь', 'Сентябрь', 'Октябрь'],
relatedQueries: ['фитнес алматы', 'тренажёрный зал цена', 'записаться в зал', 'фитнес клуб рядом', 'персональный тренер', 'групповые тренировки']
},
'9_user_positioning': {
mode: 'COMPARISON',
marketMetrics: { activity: 7, engagement: 6, video: 5, reputation: 8, frequency: 7 },
userMetrics: { activity: 4, engagement: 3, video: 2, reputation: 6, frequency: 3 },
strategicFocus: ['Увеличить частоту публикаций', 'Добавить больше видео контента', 'Работать над повышением вовлеченности']
},
'10_structured_conclusions': [
'Рынок фитнес-услуг в Алматы высококонкурентный — 18 активных игроков в Instagram.',
'Средний ER 5.1% указывает на хорошую вовлечённость аудитории у топ-игроков.',
'Лидер рынка публикует 22 поста/мес — в 2.5x больше среднего по рынку.',
'Пиковый спрос в январе и сентябре — необходимо усиливать контент в эти периоды.',
'Слабое место конкурентов — YouTube и TikTok: только 25% имеют активное присутствие.',
'CII = 72 (высокий) — входной порог на рынок требует качественного контента и репутации.'
],
'11_smm_strategy_rationale':
'На основе анализа рекомендуется фокус на Instagram + 2GIS как основные каналы. Контент-стратегия должна включать не менее 14 публикаций в месяц с упором на видео (Reels) и экспертный контент. Это позволит конкурировать с лидером по ER при меньшем бюджете.\n\nScore Model приоритет: Репутация (2GIS-отзывы) → Engagement Rate → Частота постинга → CTA-конверсия.'
};
// API
const fetchAnalysis = async () => {
// PREVIEW MODE: ?preview=1 загружает mock без API
if (route.query.preview === '1') {
status.value = 'COMPLETED';
resultData.value = MOCK_RESULT;
loading.value = false;
return;
}
if (!analysisId.value) return;
try {
loading.value = true;
const doc = await MarketingV3Service.getAnalysisV3ById(analysisId.value);
status.value = doc.status || 'IDLE';
resultData.value = doc.resultData || null;
if (doc.status === 'FAILED' && doc.errorMessage) errorMessage.value = doc.errorMessage;
loaderTick++;
loaderMessage.value = LOADER_MESSAGES[loaderTick % LOADER_MESSAGES.length];
loaderDot.value = (loaderDot.value % 5) + 1;
} catch (err) {
status.value = 'FAILED';
errorMessage.value = err.message || 'Не удалось загрузить анализ.';
} finally {
loading.value = false;
}
};
const startPolling = () => {
if (pollInterval) return;
pollInterval = setInterval(() => {
if (status.value === 'COMPLETED' || status.value === 'FAILED') {
stopPolling();
return;
}
fetchAnalysis();
}, 5000);
};
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
};
const generateStrategy = () => {
router.push({
name: 'marketing-v3-strategy',
query: { analysisId: analysisId.value }
});
};
onMounted(() => {
fetchAnalysis();
startPolling();
});
onUnmounted(() => stopPolling());
</script>
<script>
import MetricCard from '@/components/marketing/MetricCard.vue';
import BoxplotPlaceholder from '@/components/marketing/analysis/BoxplotPlaceholder.vue';
import ChartPlaceholder from '@/components/marketing/analysis/ChartPlaceholder.vue';
import ExecCard from '@/components/marketing/analysis/ExecCard.vue';
import GaugeChart from '@/components/marketing/analysis/GaugeChart.vue';
import ScatterPlotSvg from '@/components/marketing/analysis/ScatterPlotSvg.vue';
import SectionBlock from '@/components/marketing/analysis/SectionBlock.vue';
export default {
components: {
MetricCard,
SectionBlock,
ExecCard,
ChartPlaceholder,
BoxplotPlaceholder,
ScatterPlotSvg,
GaugeChart
}
};
</script>
<template>
<div class="marketing-analysis-v3-page bg-surface-50 dark:bg-surface-900 min-h-screen p-4 md:p-6">
<Toast />
<div class="max-w-7xl mx-auto space-y-6">
<!-- ======================================================= -->
<!-- HEADER: статус + навигация -->
<!-- ======================================================= -->
<div class="card">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-bold tracking-widest uppercase text-primary-500">МАРКЕТИНГОВЫЙ АНАЛИЗ</span>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">v4.0 Data-Driven</span>
</div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Конкурентный разбор рынка</h1>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-0.5">
Статус:
<span :class="statusClass" class="font-semibold ml-1">{{ statusLabel }}</span>
</p>
</div>
<div class="flex items-center gap-2">
<Button label="Назад к анализам" icon="pi pi-arrow-left" severity="secondary" outlined size="small" @click="$router.push('/marketing-analysis/v3')" />
<Button v-if="status === 'COMPLETED'" label="SMM-Стратегия" icon="pi pi-chart-line" icon-pos="right" size="small" @click="generateStrategy" />
</div>
</div>
</div>
<!-- ======================================================= -->
<!-- LOADING STATE -->
<!-- ======================================================= -->
<div v-if="loading || status === 'QUEUED' || status === 'PROCESSING'" class="card">
<div class="text-center py-20">
<ProgressSpinner style="width: 72px; height: 72px" />
<p class="mt-5 text-lg font-semibold text-surface-900 dark:text-surface-0">{{ loaderMessage }}</p>
<p class="mt-2 text-sm text-surface-500 dark:text-surface-400 max-w-md mx-auto">Это может занять 13 минуты. Вкладку можно оставить открытой данные обновятся автоматически.</p>
<div class="mt-6 flex justify-center gap-2">
<span v-for="i in 5" :key="i" :class="['inline-block w-2 h-2 rounded-full transition-all duration-500', loaderDot === i ? 'bg-primary-500 scale-125' : 'bg-surface-300 dark:bg-surface-600']" />
</div>
</div>
</div>
<!-- ======================================================= -->
<!-- ERROR STATE -->
<!-- ======================================================= -->
<div v-else-if="status === 'FAILED'" class="card">
<div class="text-center py-20">
<i class="pi pi-exclamation-circle text-6xl text-red-500 mb-4" />
<p class="text-lg font-semibold text-surface-900 dark:text-surface-0 mb-2">Ошибка генерации анализа</p>
<p class="text-sm text-surface-500 dark:text-surface-400 max-w-sm mx-auto mb-6">
{{ errorMessage || 'Попробуйте повторить анализ или обратитесь в поддержку.' }}
</p>
<Button label="Запустить повторно" icon="pi pi-refresh" @click="$router.push('/marketing-analysis/v3')" />
</div>
</div>
<!-- ======================================================= -->
<!-- DASHBOARD (COMPLETED) -->
<!-- ======================================================= -->
<template v-else-if="status === 'COMPLETED' && resultData">
<SectionBlock id="s0" title="1. Краткий обзор" subtitle="Ключевые метрики рынка">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<ExecCard label="Стадия бизнеса" :value="exec.businessStage || '—'" icon="pi pi-briefcase" color="indigo" />
<ExecCard label="География" :value="exec.geography || '—'" icon="pi pi-map-marker" color="blue" />
<ExecCard label="Конкурентов" :value="String(exec.activeCompetitors ?? '—')" icon="pi pi-users" color="violet" :trend="exec.activeCompetitors > 10 ? 'up' : 'neutral'" />
<ExecCard label="Уровень конкурен." :value="exec.competitionLevel || '—'" icon="pi pi-bolt" color="amber" />
<ExecCard label="Средний ER" :value="fmtPct(exec.averageNicheEr)" icon="pi pi-percentage" color="emerald" :trend="parseFloat(exec.averageNicheEr) >= 5 ? 'up' : 'down'" />
<ExecCard label="Рейтинг ★" :value="exec.averageRating ? exec.averageRating.toFixed(1) : '—'" icon="pi pi-star-fill" color="orange" :trend="exec.averageRating >= 4 ? 'up' : 'down'" />
</div>
<!-- Key figures -->
<div v-if="exec.keyFigures?.length" class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
<div v-for="(item, idx) in exec.keyFigures" :key="idx" class="flex items-start gap-2 text-sm text-surface-700 dark:text-surface-300 bg-surface-100 dark:bg-surface-800 rounded-lg px-3 py-2">
<i class="pi pi-check-circle text-primary-500 mt-0.5 shrink-0" />
<span>{{ item }}</span>
</div>
</div>
</SectionBlock>
<!-- === 1. Рыночный ландшафт === -->
<SectionBlock id="s1" title="2. Рыночный ландшафт" subtitle="Структура платформ, спрос, динамика">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Donut: платформы -->
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Распределение по платформам</h3>
<div class="flex-1 flex items-center justify-center h-60">
<Chart v-if="platformChartData.labels.length" type="doughnut" :data="platformChartData" :options="doughnutOptions" class="max-h-60" />
<ChartPlaceholder v-else text="Нет данных по платформам" />
</div>
</div>
<!-- Line: спрос 12 мес -->
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Тренд спроса (12 месяцев)</h3>
<div class="flex-1 h-60">
<Chart v-if="demandChartData.labels.length" type="line" :data="demandChartData" :options="lineOptions" class="h-60" />
<ChartPlaceholder v-else text="Нет данных по сезонности" />
</div>
</div>
<!-- Heatmap города -->
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Распределение по городам</h3>
<div class="space-y-2">
<div v-for="(item, idx) in cityHeatmap" :key="idx" class="flex items-center gap-3">
<span class="w-28 text-xs text-surface-600 dark:text-surface-400 truncate">{{ item.city }}</span>
<div class="flex-1 bg-surface-200 dark:bg-surface-700 rounded-full h-3 overflow-hidden">
<div class="h-3 rounded-full transition-all duration-700" :style="{ width: (item.pct || 0) + '%', background: heatColor(item.pct || 0) }" />
</div>
<span class="text-xs font-semibold text-surface-700 dark:text-surface-300 w-8 text-right">{{ item.count || 0 }}</span>
</div>
<ChartPlaceholder v-if="!cityHeatmap?.length" text="Нет данных по городам" />
</div>
</div>
<!-- Metric: niche reputation -->
<div v-if="landscape1.nicheReputationLevel" class="flex flex-col justify-center">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Уровень репутации ниши</h3>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-6 flex items-center justify-between bg-emerald-50 dark:bg-emerald-900/20">
<div>
<p class="text-3xl font-bold text-emerald-600 dark:text-emerald-400">{{ landscape1.nicheReputationLevel }} </p>
<p class="text-xs text-emerald-700 dark:text-emerald-300 mt-1">в среднем по рынку</p>
</div>
<i class="pi pi-star-fill text-4xl text-amber-400" />
</div>
</div>
</div>
</SectionBlock>
<!-- === 2. Географическая структура === -->
<SectionBlock id="s2" title="3. Географическая структура конкуренции" subtitle="Активность игроков по городам">
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="xl:col-span-2">
<DataTable :value="geoTable" :sortField="geoSort.field" :sortOrder="geoSort.order" @sort="onGeoSort" class="p-datatable-sm text-sm" responsiveLayout="scroll">
<Column field="city" header="Город" sortable />
<Column field="activePlayers" header="Игроков" sortable />
<Column field="avgEr" header="Ср. ER" sortable>
<template #body="{ data }">
<span :class="erClass(data.avgEr)">{{ fmtPct(data.avgEr) }}</span>
</template>
</Column>
<Column field="avgRating" header="Рейтинг" sortable>
<template #body="{ data }">
<span class="flex items-center gap-1">
<i class="pi pi-star-fill text-amber-400 text-xs" />
{{ data.avgRating?.toFixed(1) || '—' }}
</span>
</template>
</Column>
<Column field="avgPostsPerMonth" header="Постов/мес" sortable />
</DataTable>
</div>
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Индекс плотности</h3>
<div class="flex flex-col items-center mt-6">
<GaugeChart :value="geo2.densityIndex ?? 0" />
<p class="text-sm text-surface-500 mt-2 text-center w-48">Индекс перегретости конкурентной среды</p>
</div>
</div>
</div>
</SectionBlock>
<!-- === 3. Конкурентная карта === -->
<SectionBlock id="s3" title="4. Конкурентная карта" subtitle="Топ-игроки ниши: масштаб, активность, репутация">
<DataTable
:value="competitors"
dataKey="name"
:paginator="competitors.length > 10"
:rows="10"
responsiveLayout="scroll"
class="p-datatable-sm text-sm"
:expandedRows="expandedRows"
@rowExpand="onRowExpand"
@rowCollapse="onRowCollapse"
>
<Column expander style="width: 3rem" />
<Column field="name" header="Бренд / Аккаунт" sortable>
<template #body="{ data }">
<span class="font-semibold text-surface-900 dark:text-surface-0">{{ data.name }}</span>
</template>
</Column>
<Column field="platform" header="Платформа" sortable />
<Column field="followers" header="Подписчики" sortable>
<template #body="{ data }">{{ fmtNum(data.followers) }}</template>
</Column>
<Column field="postsPerMonth" header="Постов/мес" sortable />
<Column field="er" header="ER" sortable>
<template #body="{ data }">
<span :class="erClass(data.er)" class="font-semibold">{{ fmtPct(data.er) }}</span>
</template>
</Column>
<Column field="rating" header="★ Рейтинг" sortable>
<template #body="{ data }">
<span class="flex items-center gap-1">
<i class="pi pi-star-fill text-amber-400 text-xs" />
{{ data.rating?.toFixed(1) || '—' }}
</span>
</template>
</Column>
<Column field="reviews" header="Отзывов" sortable>
<template #body="{ data }">{{ fmtNum(data.reviews) }}</template>
</Column>
<template #expansion="slotProps">
<div class="px-4 py-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-xs bg-surface-50 dark:bg-surface-800">
<div>
<p class="font-semibold text-emerald-600 dark:text-emerald-400 mb-2"> Сильные стороны</p>
<ul class="space-y-1 text-surface-700 dark:text-surface-300">
<li v-for="(s, i) in slotProps.data.strengths || []" :key="i" class="flex gap-2"><span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" />{{ s }}</li>
<li v-if="!slotProps.data.strengths?.length" class="italic text-surface-400">Нет данных</li>
</ul>
</div>
<div>
<p class="font-semibold text-rose-600 dark:text-rose-400 mb-2"> Слабые стороны</p>
<ul class="space-y-1 text-surface-700 dark:text-surface-300">
<li v-for="(w, i) in slotProps.data.weaknesses || []" :key="i" class="flex gap-2"><span class="mt-1 h-1.5 w-1.5 rounded-full bg-rose-500 shrink-0" />{{ w }}</li>
<li v-if="!slotProps.data.weaknesses?.length" class="italic text-surface-400">Нет данных</li>
</ul>
</div>
</div>
</template>
</DataTable>
<!-- Scatter Plot placeholder -->
<div class="mt-6">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Scatter Plot: Активность vs Вовлечённость</h3>
<ScatterPlotSvg :competitors="competitors" />
</div>
</SectionBlock>
<!-- === 4. Контент-профиль === -->
<SectionBlock id="s4" title="5. Контент-профиль рынка" subtitle="AI-классификация контента конкурентов">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Типы контента</h3>
<div class="h-64">
<Chart v-if="contentChartData.labels.length" type="pie" :data="contentChartData" :options="pieOptions" class="max-h-64" />
<ChartPlaceholder v-else text="Нет данных по контенту" />
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">Ключевые показатели</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-center">
<p class="text-xs text-surface-500 dark:text-surface-400 mb-1">Доля видео</p>
<p class="text-2xl font-bold text-primary-600 dark:text-primary-400">{{ fmtPct(content4.videoShare) }}</p>
</div>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-center">
<p class="text-xs text-surface-500 dark:text-surface-400 mb-1">Ср. длина текста</p>
<p class="text-2xl font-bold text-violet-600 dark:text-violet-400">{{ fmtNum(content4.avgTextLengthCharacters) }}</p>
<p class="text-xs text-surface-400">симв.</p>
</div>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-center">
<p class="text-xs text-surface-500 dark:text-surface-400 mb-1">Наличие CTA</p>
<p class="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{{ fmtPct(content4.ctaUsagePercent) }}</p>
</div>
</div>
<!-- Content type bars -->
<div v-if="contentDistribution" class="space-y-2 mt-2">
<div v-for="(val, key) in contentDistribution" :key="key" class="flex items-center gap-3">
<span class="text-xs w-24 text-surface-600 dark:text-surface-400 capitalize">{{ key }}</span>
<div class="flex-1 h-2 bg-surface-200 dark:bg-surface-700 rounded-full overflow-hidden">
<div class="h-2 rounded-full bg-gradient-to-r from-violet-400 to-primary-500" :style="{ width: val + '%' }" />
</div>
<span class="text-xs font-semibold w-10 text-right text-surface-700 dark:text-surface-300">{{ val }}%</span>
</div>
</div>
</div>
</div>
</SectionBlock>
<!-- === 5. Индекс интенсивности конкуренции === -->
<SectionBlock id="s5" title="6. Индекс интенсивности конкуренции" subtitle="CII: комплексная оценка давления конкурентной среды">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-center">
<div class="flex flex-col items-center justify-center">
<GaugeChart :value="cii.ciiIndex ?? 0" :label="cii.intensityLabel" />
</div>
<div class="grid grid-cols-1 gap-3 content-center">
<div v-for="factor in ciiFactors" :key="factor" class="rounded-xl border border-surface-200 dark:border-surface-700 px-4 py-3 flex items-center gap-3 bg-surface-50 dark:bg-surface-800">
<i class="pi pi-info-circle text-primary-500 shrink-0" />
<p class="text-sm font-medium text-surface-800 dark:text-surface-200">{{ factor }}</p>
</div>
</div>
</div>
</SectionBlock>
<!-- === 6. Репутационный анализ === -->
<SectionBlock id="s6" title="7. Репутационный анализ" subtitle="Local Trust Layer: распределение оценок и рейтинговые паттерны">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Распределение звёзд (15)</h3>
<div class="h-56">
<Chart v-if="starChartData.labels.length" type="bar" :data="starChartData" :options="barOptions" class="h-56" />
<ChartPlaceholder v-else text="Нет данных по рейтингам" />
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">Ключевые метрики</h3>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-4">
<p class="text-xs text-emerald-600 dark:text-emerald-400 mb-1">Средний рейтинг</p>
<p class="text-2xl font-bold text-emerald-700 dark:text-emerald-300">{{ rep6.avgNicheRating?.toFixed(1) || '—' }}</p>
</div>
<div class="rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4">
<p class="text-xs text-blue-600 dark:text-blue-400 mb-1">Медиана отзывов</p>
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ fmtNum(rep6.medianReviews) }}</p>
</div>
<div class="rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<p class="text-xs text-amber-600 dark:text-amber-400 mb-1">Ср. время ответа</p>
<p class="text-2xl font-bold text-amber-700 dark:text-amber-300 capitalize">{{ rep6.avgOwnerResponseTimeHours ? rep6.avgOwnerResponseTimeHours + ' ч.' : '—' }}</p>
</div>
<div class="rounded-xl bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800 p-4">
<p class="text-xs text-violet-600 dark:text-violet-400 mb-1">Доверие рынка (High Trust)</p>
<p class="text-2xl font-bold text-violet-700 dark:text-violet-300">{{ rep6.highTrustBusinessShare ? fmtPct(rep6.highTrustBusinessShare * 100) : '—' }}</p>
</div>
</div>
</div>
</div>
</SectionBlock>
<!-- === 7. Поведенческий паттерн === -->
<SectionBlock id="s7" title="8. Поведенческий паттерн рынка" subtitle="CTA-распределение и ритм публикаций">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-3 uppercase tracking-wider">Распределение CTA</h3>
<div class="h-56">
<Chart v-if="ctaChartData.labels.length" type="bar" :data="ctaChartData" :options="hbarOptions" class="h-56" />
<ChartPlaceholder v-else text="Нет данных по CTA" />
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">Цикл публикаций</h3>
<div class="rounded-xl border-2 border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-5 text-center">
<p class="text-xs text-primary-600 dark:text-primary-400 mb-1 uppercase tracking-wider">Средний цикл сделки</p>
<p class="text-4xl font-black text-primary-700 dark:text-primary-300 capitalize">{{ beh7.avgCycleDays || '—' }}</p>
<p class="text-xs text-primary-500 dark:text-primary-400 mt-1">дней</p>
</div>
<div v-if="(beh7.topCta || beh7.commonCta)?.length" class="space-y-2">
<p class="text-xs text-surface-500 dark:text-surface-400 font-medium uppercase tracking-wider">Топ CTA-призывы</p>
<div class="flex flex-wrap gap-2">
<span v-for="cta in beh7.topCta || beh7.commonCta" :key="cta" class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
{{ cta }}
</span>
</div>
</div>
</div>
</div>
</SectionBlock>
<!-- === 8. Поисковый спрос и сезонность === -->
<SectionBlock id="s8" title="9. Поисковый спрос и сезонность" subtitle="Помесячный тренд + связанные запросы">
<div>
<div class="h-72">
<Chart v-if="seasonChartData.labels.length" type="line" :data="seasonChartData" :options="areaOptions" class="h-72" />
<ChartPlaceholder v-else text="Нет данных по сезонности" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div v-if="demand8.avgMonthlySearches" class="flex items-center gap-4 rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<div class="h-12 w-12 rounded-full bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center shrink-0">
<i class="pi pi-search text-primary-600 dark:text-primary-400 text-xl" />
</div>
<div>
<p class="text-xs font-semibold text-surface-500 dark:text-surface-400 mb-0.5 uppercase tracking-wider">Частота запросов</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">{{ fmtNum(demand8.avgMonthlySearches) }} <span class="text-sm font-normal text-surface-500 lowercase">в месяц</span></p>
</div>
</div>
<div v-if="demand8.relatedQueries?.length" class="flex flex-col justify-center">
<p class="text-xs text-surface-500 dark:text-surface-400 font-medium uppercase tracking-wider mb-2">Связанные поисковые запросы</p>
<div class="flex flex-wrap gap-2">
<span
v-for="q in demand8.relatedQueries"
:key="q"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 border border-surface-200 dark:border-surface-700"
>
{{ q }}
</span>
</div>
</div>
</div>
</div>
</SectionBlock>
<!-- === 9. Позиционирование пользователя === -->
<SectionBlock id="s9" title="10. Позиционирование" subtitle="Вы vs Рынок: Radar-анализ по 5 осям">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-center">
<div class="h-80">
<Chart v-if="radarChartData.labels.length" type="radar" :data="radarChartData" :options="radarOptions" class="h-80" />
<ChartPlaceholder v-else text="Нет данных для радар-чарта" />
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider">{{ pos9.mode === 'BENCHMARK' ? 'Целевые ориентиры (Benchmark рынка)' : 'Конкурентный анализ: Ваша позиция' }}</h3>
<div v-for="(axis, idx) in radarAxes" :key="axis.label" class="flex items-center gap-3">
<span class="text-xs w-28 text-surface-600 dark:text-surface-400" v-tooltip.top="axis.label">{{ axis.label }}</span>
<div class="flex-1 space-y-1">
<div v-if="pos9.mode !== 'BENCHMARK'" class="flex items-center gap-2">
<span class="text-xs text-primary-600 dark:text-primary-400 w-6">Вы</span>
<div class="flex-1 h-2 bg-surface-200 dark:bg-surface-700 rounded-full overflow-hidden">
<div class="h-2 rounded-full bg-primary-500" :style="{ width: axis.user + '%' }" />
</div>
<span class="text-xs font-semibold text-surface-700 dark:text-surface-300">{{ axis.user }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-amber-500 dark:text-amber-400 w-6">Рын.</span>
<div class="flex-1 h-2 bg-surface-200 dark:bg-surface-700 rounded-full overflow-hidden">
<div class="h-2 rounded-full bg-amber-400" :style="{ width: axis.market + '%' }" />
</div>
<span class="text-xs font-semibold text-surface-700 dark:text-surface-300">{{ axis.market }}</span>
</div>
</div>
</div>
<div v-if="pos9.strategicFocus?.length" class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
<h4 class="text-xs font-semibold text-surface-700 dark:text-surface-300 uppercase tracking-wider mb-2">На чём сфокусироваться</h4>
<ul class="space-y-2">
<li v-for="(focus, i) in pos9.strategicFocus" :key="i" class="flex items-start gap-2 text-sm text-surface-800 dark:text-surface-200">
<i class="pi pi-angle-right text-primary-500 mt-1 shrink-0" />
<span>{{ focus }}</span>
</li>
</ul>
</div>
</div>
</div>
</SectionBlock>
<!-- === 10. Структурированные выводы === -->
<SectionBlock id="s10" title="11. Структурированные выводы" subtitle="Факты без воды — что это значит для вашего бизнеса">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div v-for="(item, idx) in conclusions10" :key="idx" class="flex items-start gap-3 rounded-xl border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 px-4 py-3">
<span class="flex-shrink-0 inline-flex items-center justify-center h-6 w-6 rounded-full bg-primary-100 dark:bg-primary-900/40 text-primary-600 dark:text-primary-300 text-xs font-bold mt-0.5">{{ idx + 1 }}</span>
<p class="text-sm text-surface-800 dark:text-surface-200 leading-relaxed">{{ item }}</p>
</div>
<p v-if="!conclusions10.length" class="text-sm text-surface-400 italic col-span-2">Данные отсутствуют</p>
</div>
</SectionBlock>
<!-- === 11. Обоснование канала === -->
<SectionBlock id="s11" title="12. Обоснование выбора канала" subtitle="Переход от аналитики к SMM-стратегии">
<div class="rounded-2xl border-2 border-primary-300 dark:border-primary-700 bg-gradient-to-br from-primary-50 via-white to-violet-50 dark:from-primary-900/30 dark:via-surface-800 dark:to-violet-900/20 p-6 md:p-8">
<div class="flex items-start gap-4">
<div class="flex-shrink-0 h-12 w-12 rounded-xl bg-primary-500 flex items-center justify-center">
<i class="pi pi-lightbulb text-white text-xl" />
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-surface-900 dark:text-surface-0 mb-3">Логика SMM-стратегии</h3>
<p class="text-sm text-surface-700 dark:text-surface-300 leading-relaxed whitespace-pre-line">{{ rationale11 || 'Данные не загружены' }}</p>
<div class="mt-6 pt-5 border-t border-primary-200 dark:border-primary-700">
<Button label="Сгенерировать SMM-Стратегию (Scoring Model)" icon="pi pi-chart-line" icon-pos="right" class="w-full sm:w-auto" @click="generateStrategy" />
</div>
</div>
</div>
</div>
</SectionBlock>
</template>
<!-- end COMPLETED -->
</div>
</div>
</template>
<style scoped>
.marketing-analysis-v3-page {
font-family: var(--font-family);
}
</style>
@@ -0,0 +1,271 @@
<script setup>
import MarketingV3Service from '@/service/MarketingV3Service';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const analyses = ref([]);
const loading = ref(false);
const searchQuery = ref('');
const filterStatus = ref('');
const currentPage = ref(1);
const pageSize = 10;
const STATUS_FILTERS = [
{ label: 'Все', value: '' },
{ label: 'Завершён', value: 'COMPLETED' },
{ label: 'В работе', value: 'PROCESSING' },
{ label: 'В очереди', value: 'QUEUED' },
{ label: 'Ошибка', value: 'FAILED' }
];
// Load
const load = async () => {
loading.value = true;
try {
const data = await MarketingV3Service.getUserAnalyses();
analyses.value = Array.isArray(data) ? data.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) : [];
} catch (err) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: err.message || 'Не удалось загрузить анализы', life: 5000 });
} finally {
loading.value = false;
}
};
// Filtered & paginated
const filtered = computed(() => {
let list = analyses.value;
if (filterStatus.value) list = list.filter((a) => a.status === filterStatus.value);
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase();
list = list.filter((a) => {
const niche = (a.requestData?.businessNiche || a.businessNiche || '').toLowerCase();
const city = cityLabel(a).toLowerCase();
return niche.includes(q) || city.includes(q);
});
}
return list;
});
const paginated = computed(() => {
const start = (currentPage.value - 1) * pageSize;
return filtered.value.slice(start, start + pageSize);
});
const countByStatus = (s) => analyses.value.filter((a) => a.status === s).length;
// Helpers
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 'География не указана';
};
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 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 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') {
router.push(`/marketing-analysis/v3/${item.id}`);
}
};
const retry = (item) => {
router.push('/marketing-analysis/v3/new');
};
const confirmDelete = (item) => {
confirm.require({
message: `Удалить анализ "${item.requestData?.businessNiche || 'без названия'}"?`,
header: 'Подтверждение удаления',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Удалить',
rejectLabel: 'Отмена',
acceptClass: 'p-button-danger',
accept: () => {
// TODO: добавить DELETE /api/v3/marketing-analysis/{id} когда появится в API
analyses.value = analyses.value.filter((a) => a.id !== item.id);
toast.add({ severity: 'success', summary: 'Удалено', detail: 'Анализ удалён', life: 3000 });
}
});
};
onMounted(load);
</script>
<template>
<div class="bg-surface-50 dark:bg-surface-900 min-h-screen p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-bold tracking-widest uppercase text-primary-500">МАРКЕТИНГОВЫЙ АНАЛИЗ v4.0</span>
</div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои анализы</h1>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-0.5">Data-Driven + SMM Execution Ready</p>
</div>
<Button label="Создать новый анализ" icon="pi pi-plus" @click="$router.push('/marketing-analysis/v3/new')" class="shrink-0" />
</div>
<!-- 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">
<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-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'
]"
>
{{ f.label }}
<span v-if="f.value" class="ml-1 opacity-75">({{ countByStatus(f.value) }})</span>
</button>
<Button icon="pi pi-refresh" severity="secondary" text rounded size="small" :loading="loading" @click="load" v-tooltip.top="'Обновить'" />
</div>
</div>
</div>
<!-- Loading 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" />
</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" />
</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>
</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-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)" />
</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>
</div>
</div>
</TransitionGroup>
<!-- Pagination -->
<div v-if="filtered.length > pageSize" class="flex items-center justify-between pt-2">
<p class="text-xs text-surface-500 dark:text-surface-400">Показано {{ (currentPage - 1) * pageSize + 1 }}{{ Math.min(currentPage * pageSize, filtered.length) }} из {{ filtered.length }}</p>
<Paginator :rows="pageSize" :totalRecords="filtered.length" :first="(currentPage - 1) * pageSize" @page="currentPage = $event.page + 1" template="PrevPageLink PageLinks NextPageLink" />
</div>
</div>
<!-- Delete Confirm Dialog -->
<ConfirmDialog />
</div>
</template>
<style scoped>
.list-fade-enter-active,
.list-fade-leave-active {
transition: all 0.25s ease;
}
.list-fade-enter-from,
.list-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
+125 -124
View File
@@ -1,128 +1,5 @@
<template>
<div class="marketing-credentials-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1200px] mx-auto">
<div class="card mb-4">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Управление credentials социальных сетей</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">Настройте access tokens для платформ, на которых вы хотите автоматически публиковать контент</p>
</div>
<div class="card-content">
<form @submit.prevent="handleSaveCredentials" class="space-y-4">
<div class="grid">
<div class="col-12 md:col-4">
<div class="field">
<label for="platform" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Платформа <span class="text-red-500">*</span> </label>
<Dropdown id="platform" v-model="formData.platform" :options="platformOptions" placeholder="Выберите платформу" class="w-full" :class="{ 'p-invalid': errors.platform }" />
<small v-if="errors.platform" class="p-error">{{ errors.platform }}</small>
</div>
</div>
<!-- Поля для простых платформ (Facebook, LinkedIn, etc.) -->
<template v-if="formData.platform && formData.platform !== 'telegram'">
<div class="col-12 md:col-6">
<div class="field">
<label for="credentials" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Access Token <span class="text-red-500">*</span> </label>
<InputText id="credentials" v-model="formData.credentials" type="password" placeholder="Введите access token" class="w-full" :class="{ 'p-invalid': errors.credentials }" />
<small v-if="errors.credentials" class="p-error">{{ errors.credentials }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> Токен будет зашифрован перед сохранением </small>
</div>
</div>
</template>
<!-- Поля для Telegram -->
<template v-if="formData.platform === 'telegram'">
<div class="col-12 md:col-3">
<div class="field">
<label for="botToken" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Bot Token <span class="text-red-500">*</span> </label>
<InputText id="botToken" v-model="formData.botToken" type="password" placeholder="Введите bot token от @BotFather" class="w-full" :class="{ 'p-invalid': errors.botToken }" />
<small v-if="errors.botToken" class="p-error">{{ errors.botToken }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> Токен бота, полученный от @BotFather </small>
</div>
</div>
<div class="col-12 md:col-3">
<div class="field">
<label for="chatId" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Chat ID <span class="text-red-500">*</span> </label>
<InputText id="chatId" v-model="formData.chatId" type="text" placeholder="Введите chat ID (например: -1001234567890)" class="w-full" :class="{ 'p-invalid': errors.chatId }" />
<small v-if="errors.chatId" class="p-error">{{ errors.chatId }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> ID чата/канала (может быть отрицательным для групп и каналов) </small>
</div>
</div>
</template>
<div class="col-12 md:col-2">
<div class="field">
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">&nbsp;</label>
<Button type="submit" label="Сохранить" icon="pi pi-save" class="w-full" :loading="saving" />
</div>
</div>
</div>
</form>
</div>
</div>
<div 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>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadCredentials" :loading="loading" />
</div>
</div>
<div class="card-content">
<DataTable :value="credentials" :loading="loading" paginator :rows="10" :rowsPerPageOptions="[5, 10, 20]" dataKey="platform" :emptyMessage="'Нет настроенных платформ'">
<Column field="platform" header="Платформа" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<Tag :value="slotProps.data.platform" severity="info" />
</template>
</Column>
<Column field="hasCredentials" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag :value="slotProps.data.hasCredentials ? 'Настроено' : 'Не настроено'" :severity="slotProps.data.hasCredentials ? 'success' : 'warning'" />
</template>
</Column>
<Column field="createdAt" header="Создано" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.createdAt">{{ formatDateTime(slotProps.data.createdAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="updatedAt" header="Обновлено" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.updatedAt">{{ formatDateTime(slotProps.data.updatedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column header="Действия" style="min-width: 150px">
<template #body="slotProps">
<Button icon="pi pi-trash" severity="danger" size="small" v-tooltip.top="'Удалить credentials'" @click="confirmDelete(slotProps.data.platform)" :disabled="!slotProps.data.hasCredentials" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="deleteDialogVisible" :header="'Подтверждение удаления'" :style="{ width: '450px' }" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span
>Вы уверены, что хотите удалить credentials для платформы <b>{{ platformToDelete }}</b
>?</span
>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="deleteDialogVisible = false" severity="secondary" />
<Button label="Удалить" icon="pi pi-check" @click="handleDeleteCredentials" severity="danger" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import Column from 'primevue/column';
@@ -372,6 +249,130 @@ onMounted(() => {
});
</script>
<template>
<div class="marketing-credentials-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1200px] mx-auto">
<div class="card mb-4">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Управление credentials социальных сетей</h2>
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">Настройте access tokens для платформ, на которых вы хотите автоматически публиковать контент</p>
</div>
<div class="card-content">
<form @submit.prevent="handleSaveCredentials" class="space-y-4">
<div class="grid">
<div class="col-12 md:col-4">
<div class="field">
<label for="platform" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Платформа <span class="text-red-500">*</span> </label>
<Dropdown id="platform" v-model="formData.platform" :options="platformOptions" placeholder="Выберите платформу" class="w-full" :class="{ 'p-invalid': errors.platform }" />
<small v-if="errors.platform" class="p-error">{{ errors.platform }}</small>
</div>
</div>
<!-- Поля для простых платформ (Facebook, LinkedIn, etc.) -->
<template v-if="formData.platform && formData.platform !== 'telegram'">
<div class="col-12 md:col-6">
<div class="field">
<label for="credentials" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Access Token <span class="text-red-500">*</span> </label>
<InputText id="credentials" v-model="formData.credentials" type="password" placeholder="Введите access token" class="w-full" :class="{ 'p-invalid': errors.credentials }" />
<small v-if="errors.credentials" class="p-error">{{ errors.credentials }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> Токен будет зашифрован перед сохранением </small>
</div>
</div>
</template>
<!-- Поля для Telegram -->
<template v-if="formData.platform === 'telegram'">
<div class="col-12 md:col-3">
<div class="field">
<label for="botToken" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Bot Token <span class="text-red-500">*</span> </label>
<InputText id="botToken" v-model="formData.botToken" type="password" placeholder="Введите bot token от @BotFather" class="w-full" :class="{ 'p-invalid': errors.botToken }" />
<small v-if="errors.botToken" class="p-error">{{ errors.botToken }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> Токен бота, полученный от @BotFather </small>
</div>
</div>
<div class="col-12 md:col-3">
<div class="field">
<label for="chatId" class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300"> Chat ID <span class="text-red-500">*</span> </label>
<InputText id="chatId" v-model="formData.chatId" type="text" placeholder="Введите chat ID (например: -1001234567890)" class="w-full" :class="{ 'p-invalid': errors.chatId }" />
<small v-if="errors.chatId" class="p-error">{{ errors.chatId }}</small>
<small class="text-surface-500 dark:text-surface-400 mt-1 block"> ID чата/канала (может быть отрицательным для групп и каналов) </small>
</div>
</div>
</template>
<div class="col-12 md:col-2">
<div class="field">
<label class="block text-sm font-medium mb-2 text-surface-700 dark:text-surface-300">&nbsp;</label>
<Button type="submit" label="Сохранить" icon="pi pi-save" class="w-full" :loading="saving" />
</div>
</div>
</div>
</form>
</div>
</div>
<div 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>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadCredentials" :loading="loading" />
</div>
</div>
<div class="card-content">
<DataTable :value="credentials" :loading="loading" paginator :rows="10" :rowsPerPageOptions="[5, 10, 20]" dataKey="platform" :emptyMessage="'Нет настроенных платформ'">
<Column field="platform" header="Платформа" :sortable="true" style="min-width: 150px">
<template #body="slotProps">
<Tag :value="slotProps.data.platform" severity="info" />
</template>
</Column>
<Column field="hasCredentials" header="Статус" :sortable="true" style="min-width: 120px">
<template #body="slotProps">
<Tag :value="slotProps.data.hasCredentials ? 'Настроено' : 'Не настроено'" :severity="slotProps.data.hasCredentials ? 'success' : 'warning'" />
</template>
</Column>
<Column field="createdAt" header="Создано" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.createdAt">{{ formatDateTime(slotProps.data.createdAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column field="updatedAt" header="Обновлено" :sortable="true" style="min-width: 180px">
<template #body="slotProps">
<span v-if="slotProps.data.updatedAt">{{ formatDateTime(slotProps.data.updatedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
</Column>
<Column header="Действия" style="min-width: 150px">
<template #body="slotProps">
<Button icon="pi pi-trash" severity="danger" size="small" v-tooltip.top="'Удалить credentials'" @click="confirmDelete(slotProps.data.platform)" :disabled="!slotProps.data.hasCredentials" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="deleteDialogVisible" :header="'Подтверждение удаления'" :style="{ width: '450px' }" :modal="true">
<div class="confirmation-content">
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
<span
>Вы уверены, что хотите удалить credentials для платформы <b>{{ platformToDelete }}</b
>?</span
>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="deleteDialogVisible = false" severity="secondary" />
<Button label="Удалить" icon="pi pi-check" @click="handleDeleteCredentials" severity="danger" :loading="deleting" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.marketing-credentials-page {
font-family: var(--font-family);
+54 -37
View File
@@ -1,41 +1,5 @@
<template>
<div class="marketing-main-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<div class="max-w-[1920px] mx-auto">
<div class="flex justify-center">
<!-- Navigation Menu -->
<div class="w-full max-w-md">
<div class="card h-full">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Маркетинг</h2>
</div>
<div class="card-content space-y-3">
<div
v-for="item in menuItems"
:key="item.id"
class="menu-item p-4 rounded-lg cursor-pointer transition-colors"
:class="{
'bg-primary-50 dark:bg-primary-900': activeMenu === item.id,
'hover:bg-surface-100 dark:hover:bg-surface-800': activeMenu !== item.id
}"
@click="navigateTo(item.route)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i :class="[item.icon, item.iconColor, 'text-xl']"></i>
<span class="font-medium text-surface-900 dark:text-surface-0">{{ item.label }}</span>
</div>
<div class="w-2 h-2 rounded-full bg-surface-400"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import { ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -44,6 +8,13 @@ const router = useRouter();
// Menu items
const activeMenu = ref('main');
const menuItems = [
{
id: 'analysis-v3',
label: 'Маркетинговый анализ v4.0 (новый)',
icon: 'pi pi-sparkles',
iconColor: 'text-primary-500',
route: '/marketing-analysis/v3'
},
{
id: 'analysis',
label: 'Маркетинговый анализ',
@@ -101,6 +72,43 @@ const navigateTo = (route) => {
};
</script>
<template>
<div class="marketing-main-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<div class="max-w-[1920px] mx-auto">
<div class="flex justify-center">
<!-- Navigation Menu -->
<div class="w-full max-w-md">
<div class="card h-full">
<div class="card-header mb-4">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Маркетинг</h2>
</div>
<div class="card-content space-y-3">
<div
v-for="item in menuItems"
:key="item.id"
class="menu-item p-4 rounded-lg cursor-pointer transition-colors"
:class="{
'bg-primary-50 dark:bg-primary-900': activeMenu === item.id,
'hover:bg-surface-100 dark:hover:bg-surface-800': activeMenu !== item.id
}"
@click="navigateTo(item.route)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i :class="[item.icon, item.iconColor, 'text-xl']"></i>
<span class="font-medium text-surface-900 dark:text-surface-0">{{ item.label }}</span>
</div>
<div class="w-2 h-2 rounded-full bg-surface-400"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.marketing-main-page {
font-family: var(--font-family);
@@ -127,5 +135,14 @@ const navigateTo = (route) => {
.menu-item {
transition: all 0.2s ease;
border: 1px solid transparent;
}
.menu-item:hover {
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
</style>
+345 -344
View File
@@ -1,348 +1,5 @@
<template>
<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>
</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 }" />
<small v-if="errors.analysisId" class="p-error">{{ errors.analysisId }}</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>
<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>
</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>
</div>
<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>
</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>
</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" />
</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>
</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" />
</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>
</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" />
</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>
</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" />
</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>
<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>
<span class="text-surface-700 dark:text-surface-300 text-xs line-height-2">{{ theme }}</span>
</li>
</ul>
</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-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>
<div class="flex flex-wrap gap-1">
<Tag v-for="platform in plan.priorityPlatforms" :key="platform" :value="platform" severity="secondary" class="text-xs" />
</div>
</div>
</div>
</div>
</div>
</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>
<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" />
</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">
{{ formatDate(date) }}
</h4>
<div class="posts-container">
<div v-for="(post, idx) in posts" :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">
<div class="flex align-items-center justify-content-between mb-2">
<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" />
</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-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
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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 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>
<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 }}
</span>
</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-send"
size="small"
severity="success"
class="flex-1 text-xs"
:loading="post.taskId ? isTaskExecuting(post.taskId) : false"
:disabled="!post.taskId || isTaskExecuting(post.taskId)"
@click="handlePublishPost(post)"
/>
</div>
</div>
</div>
</div>
</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">
<div class="flex align-items-center justify-content-between mb-2">
<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" />
</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-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
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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 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>
<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 }}
</span>
</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
v-if="post.taskId"
label="Опубликовать"
icon="pi pi-send"
size="small"
severity="success"
class="flex-1 text-xs"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handlePublishPost(post)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Диалог для настройки 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>
<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>
</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" />
</template>
</Dialog>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import MarketingService from '@/service/MarketingService';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
@@ -1024,6 +681,350 @@ onBeforeUnmount(() => {
});
</script>
<template>
<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>
</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 }" />
<small v-if="errors.analysisId" class="p-error">{{ errors.analysisId }}</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>
<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>
</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>
</div>
<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>
</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>
</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" />
</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>
</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" />
</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>
</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" />
</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>
</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" />
</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>
<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>
<span class="text-surface-700 dark:text-surface-300 text-xs line-height-2">{{ theme }}</span>
</li>
</ul>
</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-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>
<div class="flex flex-wrap gap-1">
<Tag v-for="platform in plan.priorityPlatforms" :key="platform" :value="platform" severity="secondary" class="text-xs" />
</div>
</div>
</div>
</div>
</div>
</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>
<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" />
</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">
{{ formatDate(date) }}
</h4>
<div class="posts-container">
<div v-for="(post, idx) in posts" :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">
<div class="flex align-items-center justify-content-between mb-2">
<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" />
</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-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
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(getPostIndex(post))"
:disabled="isImageRegenerating(getPostIndex(post))"
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 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>
<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 }}
</span>
</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-send"
size="small"
severity="success"
class="flex-1 text-xs"
:loading="post.taskId ? isTaskExecuting(post.taskId) : false"
:disabled="!post.taskId || isTaskExecuting(post.taskId)"
@click="handlePublishPost(post)"
/>
</div>
</div>
</div>
</div>
</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">
<div class="flex align-items-center justify-content-between mb-2">
<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" />
</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-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
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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>
<Button
icon="pi pi-refresh"
size="small"
severity="secondary"
class="regenerate-image-btn"
:loading="isImageRegenerating(idx)"
:disabled="isImageRegenerating(idx)"
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 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>
<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 }}
</span>
</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
v-if="post.taskId"
label="Опубликовать"
icon="pi pi-send"
size="small"
severity="success"
class="flex-1 text-xs"
:loading="isTaskExecuting(post.taskId)"
:disabled="isTaskExecuting(post.taskId)"
@click="handlePublishPost(post)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Диалог для настройки 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>
<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>
</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" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.marketing-promotion-page {
font-family: var(--font-family);
+44 -43
View File
@@ -1,3 +1,47 @@
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import Chart from 'primevue/chart';
import { ref } from 'vue';
// Chart data
const chartData = ref({
labels: ['Неделя 1', 'Неделя 2', 'Неделя 3', 'Неделя 4', 'Неделя 5', 'Неделя 6'],
datasets: [
{
label: 'Охват',
data: [5000, 8000, 10000, 12000, 13000, 15000],
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}
]
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
</script>
<template>
<div class="marketing-results-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<div class="max-w-[1200px] mx-auto">
@@ -63,49 +107,6 @@
</div>
</template>
<script setup>
import Chart from 'primevue/chart';
import { ref } from 'vue';
// Chart data
const chartData = ref({
labels: ['Неделя 1', 'Неделя 2', 'Неделя 3', 'Неделя 4', 'Неделя 5', 'Неделя 6'],
datasets: [
{
label: 'Охват',
data: [5000, 8000, 10000, 12000, 13000, 15000],
borderColor: '#3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
}
]
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
}
}
}
};
</script>
<style scoped>
.marketing-results-page {
font-family: var(--font-family);
@@ -1,150 +1,5 @@
<template>
<div class="marketing-strategies-list-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1400px] mx-auto">
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои стратегии</h2>
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="success" @click="goToCreateStrategy" />
</div>
</div>
<div class="card-content">
<DataTable
:value="strategies"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="multiple"
removableSort
v-model:multiSortMeta="multiSortMeta"
filterDisplay="row"
:globalFilterFields="['analysisId', 'status']"
v-model:filters="filters"
:filters="filters"
dataKey="strategyId"
>
<template #header>
<div class="flex justify-content-between align-items-center flex-wrap gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск стратегий..." />
</span>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadStrategies" :loading="loading" />
</div>
</template>
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.analysisId }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
</Dropdown>
</template>
</Column>
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ slotProps.data.durationWeeks }} {{ pluralize(slotProps.data.durationWeeks, 'неделя', 'недели', 'недель') }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<div v-if="slotProps.data.priorityPlatforms && slotProps.data.priorityPlatforms.length > 0" class="flex flex-wrap gap-1">
<Tag v-for="platform in slotProps.data.priorityPlatforms" :key="platform" :value="platform" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column header="Действия" style="min-width: 200px" :showFilterMenu="false" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewStrategy(slotProps.data.strategyId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.strategyId)" />
</div>
</template>
<template #filter>
<span></span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог истории статусов -->
<Dialog v-model:visible="historyDialogVisible" :header="'История статусов стратегии'" :style="{ width: '600px' }" :modal="true">
<div v-if="historyLoading" class="text-center py-4">
<ProgressSpinner />
</div>
<div v-else-if="statusHistory && statusHistory.length > 0" class="status-history">
<Timeline :value="statusHistory" align="left" class="w-full">
<template #marker="slotProps">
<span class="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1" :class="getStatusColorClass(slotProps.item.status)">
<i :class="getStatusIcon(slotProps.item.status)"></i>
</span>
</template>
<template #content="slotProps">
<div class="flex flex-column">
<div class="flex align-items-center gap-2 mb-2">
<Tag :value="getStatusLabel(slotProps.item.status)" :severity="getStatusSeverity(slotProps.item.status)" />
<span class="text-sm text-surface-500 dark:text-surface-400">{{ formatDateTime(slotProps.item.timestamp) }}</span>
</div>
<p class="text-sm text-surface-700 dark:text-surface-300 m-0">{{ slotProps.item.message }}</p>
</div>
</template>
</Timeline>
</div>
<div v-else class="text-center py-4 text-surface-500">История статусов недоступна</div>
<template #footer>
<Button label="Закрыть" icon="pi pi-times" @click="historyDialogVisible = false" severity="secondary" />
</template>
</Dialog>
</div>
</template>
<script setup>
/** @deprecated Внимание: старая версия (V1/V2). Используйте V3. */
import MarketingService from '@/service/MarketingService';
import { FilterMatchMode } from '@primevue/core/api';
import Button from 'primevue/button';
@@ -319,6 +174,152 @@ onMounted(() => {
});
</script>
<template>
<div class="marketing-strategies-list-page bg-surface-50 dark:bg-surface-900 min-h-screen p-6">
<Toast />
<div class="max-w-[1400px] mx-auto">
<div class="card">
<div class="card-header mb-4">
<div class="flex justify-content-between align-items-center">
<h2 class="text-2xl font-bold text-surface-900 dark:text-surface-0">Мои стратегии</h2>
<Button label="Создать новую стратегию" icon="pi pi-plus" severity="success" @click="goToCreateStrategy" />
</div>
</div>
<div class="card-content">
<DataTable
:value="strategies"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
sortMode="multiple"
removableSort
v-model:multiSortMeta="multiSortMeta"
filterDisplay="row"
:globalFilterFields="['analysisId', 'status']"
v-model:filters="filters"
:filters="filters"
dataKey="strategyId"
>
<template #header>
<div class="flex justify-content-between align-items-center flex-wrap gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск стратегий..." />
</span>
<Button label="Обновить" icon="pi pi-refresh" severity="secondary" @click="loadStrategies" :loading="loading" />
</div>
</template>
<Column field="analysisId" header="ID анализа" :sortable="true" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.analysisId }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-if="filterModel" v-model="filterModel.value" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Все статусы" class="p-column-filter" @change="filterCallback()">
<template #option="slotProps">
<Tag :value="slotProps.option.label" :severity="slotProps.option.severity" />
</template>
</Dropdown>
</template>
</Column>
<Column field="durationWeeks" header="Длительность" :sortable="true" style="min-width: 120px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ slotProps.data.durationWeeks }} {{ pluralize(slotProps.data.durationWeeks, 'неделя', 'недели', 'недель') }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="priorityPlatforms" header="Платформы" style="min-width: 200px" :showFilterMenu="false">
<template #body="slotProps">
<div v-if="slotProps.data.priorityPlatforms && slotProps.data.priorityPlatforms.length > 0" class="flex flex-wrap gap-1">
<Tag v-for="platform in slotProps.data.priorityPlatforms" :key="platform" :value="platform" severity="secondary" />
</div>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="createdAt" header="Создана" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span>{{ formatDateTime(slotProps.data.createdAt) }}</span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column field="completedAt" header="Завершена" :sortable="true" style="min-width: 180px" :showFilterMenu="false">
<template #body="slotProps">
<span v-if="slotProps.data.completedAt">{{ formatDateTime(slotProps.data.completedAt) }}</span>
<span v-else class="text-surface-400"></span>
</template>
<template #filter>
<span></span>
</template>
</Column>
<Column header="Действия" style="min-width: 200px" :showFilterMenu="false" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2 flex-wrap">
<Button icon="pi pi-eye" severity="info" size="small" v-tooltip.top="'Просмотр деталей'" @click="viewStrategy(slotProps.data.strategyId)" :disabled="slotProps.data.status !== 'completed'" />
<Button icon="pi pi-history" severity="secondary" size="small" v-tooltip.top="'История статусов'" @click="viewHistory(slotProps.data.strategyId)" />
</div>
</template>
<template #filter>
<span></span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог истории статусов -->
<Dialog v-model:visible="historyDialogVisible" :header="'История статусов стратегии'" :style="{ width: '600px' }" :modal="true">
<div v-if="historyLoading" class="text-center py-4">
<ProgressSpinner />
</div>
<div v-else-if="statusHistory && statusHistory.length > 0" class="status-history">
<Timeline :value="statusHistory" align="left" class="w-full">
<template #marker="slotProps">
<span class="flex w-2rem h-2rem align-items-center justify-content-center text-white border-circle z-1" :class="getStatusColorClass(slotProps.item.status)">
<i :class="getStatusIcon(slotProps.item.status)"></i>
</span>
</template>
<template #content="slotProps">
<div class="flex flex-column">
<div class="flex align-items-center gap-2 mb-2">
<Tag :value="getStatusLabel(slotProps.item.status)" :severity="getStatusSeverity(slotProps.item.status)" />
<span class="text-sm text-surface-500 dark:text-surface-400">{{ formatDateTime(slotProps.item.timestamp) }}</span>
</div>
<p class="text-sm text-surface-700 dark:text-surface-300 m-0">{{ slotProps.item.message }}</p>
</div>
</template>
</Timeline>
</div>
<div v-else class="text-center py-4 text-surface-500">История статусов недоступна</div>
<template #footer>
<Button label="Закрыть" icon="pi pi-times" @click="historyDialogVisible = false" severity="secondary" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.marketing-strategies-list-page {
font-family: var(--font-family);
@@ -0,0 +1,680 @@
<script setup>
import { API_CONFIG } from '@/config/api';
import MarketingV3Service from '@/service/MarketingV3Service';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Divider from 'primevue/divider';
import Slider from 'primevue/slider';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const analysisId = computed(() => route.query.analysisId || '');
const strategyId = ref(null);
// Statuses: PREVIEW, QUEUED, PROCESSING, FAILED, COMPLETED
const status = ref('PREVIEW');
// Preview Phase Data
const previewLoading = ref(true);
const previewData = ref(null);
// Form Data
const formData = ref({
durationWeeks: 4,
priorityPlatforms: ['Instagram']
});
const platformOptions = [
{ label: 'Instagram', value: 'Instagram' },
{ label: 'TikTok', value: 'TikTok' },
{ label: 'YouTube Shorts', value: 'YouTube' },
{ label: 'LinkedIn', value: 'LinkedIn' },
{ label: 'VK', value: 'VK' },
{ label: 'Telegram', value: 'Telegram' }
];
// Logo Upload Logic
const fileInput = ref(null);
const logoFile = ref(null);
const logoPreviewUrl = ref(null);
const isDragging = ref(false);
const triggerFileInput = () => fileInput.value?.click();
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) processLogo(file);
};
const handleDrop = (event) => {
isDragging.value = false;
const file = event.dataTransfer.files[0];
if (file) processLogo(file);
};
const processLogo = (file) => {
if (!['image/png', 'image/jpeg', 'image/jpg'].includes(file.type)) {
toast.add({ severity: 'error', summary: 'Формат', detail: 'Доступны только PNG и JPG', life: 3000 });
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.add({ severity: 'error', summary: 'Размер', detail: 'Файл слишком тяжелый (макс 5МБ)', life: 3000 });
return;
}
logoFile.value = file;
logoPreviewUrl.value = URL.createObjectURL(file);
};
const removeLogo = () => {
if (logoPreviewUrl.value) URL.revokeObjectURL(logoPreviewUrl.value);
logoFile.value = null;
logoPreviewUrl.value = null;
if (fileInput.value) fileInput.value.value = '';
};
// Generation Logic
const generateLoading = ref(false);
const progressPercent = ref(0);
const loadingMessageIndex = ref(0);
const loadingMessages = [
'Анализируем контекст ниши...',
'Пишем продающие сценарии...',
'Рендерим видео-ролики Veo...',
'Генерируем креативы Imagen 3...',
'Накладываем логотип на активы...',
'Собираем контент-план...',
'Финальная проверка качества...'
];
const currentLoadingMessage = computed(() => loadingMessages[loadingMessageIndex.value]);
let pollInterval = null;
let messageInterval = null;
const strategyData = ref(null);
const activeWeeks = ref([1]);
const toggleWeek = (week) => {
if (activeWeeks.value.includes(week)) {
activeWeeks.value = activeWeeks.value.filter((w) => w !== week);
} else {
activeWeeks.value.push(week);
}
};
const startGeneration = async () => {
if (!analysisId.value) return;
try {
generateLoading.value = true;
const data = new FormData();
const requestPayload = {
durationWeeks: formData.value.durationWeeks,
priorityPlatforms: formData.value.priorityPlatforms
};
// КАТЕГОРИЧЕСКИ ВАЖНО для Backend (Multipart RequestPart):
data.append('request', new Blob([JSON.stringify(requestPayload)], { type: 'application/json' }), 'request.json');
if (logoFile.value) {
data.append('logo', logoFile.value);
}
const result = await MarketingV3Service.generateStrategy(analysisId.value, data);
if (result && result.strategyId) {
strategyId.value = result.strategyId;
status.value = 'QUEUED';
startPolling();
}
} catch (error) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: error.message || 'Не удалось запустить генерацию', life: 5000 });
} finally {
generateLoading.value = false;
}
};
const startPolling = () => {
progressPercent.value = 5;
messageInterval = setInterval(() => {
loadingMessageIndex.value = (loadingMessageIndex.value + 1) % loadingMessages.length;
if (progressPercent.value < 90) progressPercent.value += Math.floor(Math.random() * 8) + 1;
}, 4500);
pollInterval = setInterval(async () => {
try {
const data = await MarketingV3Service.getStrategyStatus(strategyId.value);
if (data.status === 'completed') {
progressPercent.value = 100;
strategyData.value = data;
setTimeout(() => {
status.value = 'COMPLETED';
stopPolling();
toast.add({ severity: 'success', summary: 'Готово', detail: 'Ваша стратегия готова к внедрению!', life: 4000 });
}, 1000);
} else if (data.status === 'failed') {
status.value = 'FAILED';
stopPolling();
} else {
status.value = data.status.toUpperCase() === 'PROCESSING' ? 'PROCESSING' : 'QUEUED';
}
} catch (e) {
console.error('Polling error', e);
}
}, 5000);
};
const stopPolling = () => {
if (pollInterval) clearInterval(pollInterval);
if (messageInterval) clearInterval(messageInterval);
};
// Helpers
const getPlatformIcon = (platform) => {
const p = platform?.toLowerCase() || '';
if (p.includes('insta')) return 'pi pi-instagram';
if (p.includes('tik')) return 'pi pi-tiktok';
if (p.includes('you')) return 'pi pi-youtube';
if (p.includes('tg') || p.includes('tele')) return 'pi pi-telegram';
if (p.includes('link')) return 'pi pi-linkedin';
if (p.includes('vk')) return 'pi pi-vk';
return 'pi pi-share-alt';
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' });
};
const copyText = (text, hashtags) => {
let fullText = text || '';
if (hashtags?.length) fullText += '\n\n' + hashtags.map((t) => `#${t}`).join(' ');
navigator.clipboard.writeText(fullText).then(() => {
toast.add({ severity: 'success', summary: 'Копирование', detail: 'Текст в буфере обмена', life: 2000 });
});
};
const getMediaUrl = (filename) => {
if (!filename) return null;
return `${API_CONFIG.BASE_URL}/api/marketing/v3/media/${filename}`;
};
// Media Preview Dialog
const mediaDialog = ref(false);
const activeMedia = ref(null);
const previewMedia = (post) => {
activeMedia.value = post;
mediaDialog.value = true;
};
// Lifecycle
const init = async () => {
if (!analysisId.value) {
router.push('/marketing-analysis/v3');
return;
}
try {
previewLoading.value = true;
const res = await MarketingV3Service.getStrategyPreview(analysisId.value);
previewData.value = res;
if (res.recommendedPlatforms?.length) {
formData.value.priorityPlatforms = res.recommendedPlatforms.filter((p) => platformOptions.some((o) => o.value === p));
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось загрузить рекомендации', life: 5000 });
} finally {
previewLoading.value = false;
}
};
onMounted(init);
onUnmounted(stopPolling);
</script>
<template>
<div class="marketing-strategy-v3-page bg-slate-50 dark:bg-slate-950 min-h-screen p-4 md:p-8">
<Toast />
<div class="max-w-5xl mx-auto">
<!-- 1. HEADER -->
<div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-1 rounded-md bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold tracking-wider uppercase">Content Factory V3</span>
</div>
<h1 class="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Генерация стратегии</h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">Создание визуального и текстового контента на основе вашего анализа</p>
</div>
<Button label="К анализу" icon="pi pi-arrow-left" severity="secondary" text class="rounded-xl hover:bg-white dark:hover:bg-slate-900 shadow-sm border border-slate-200 dark:border-slate-800" @click="goBack" />
</div>
<!-- 2. STAGE: PREVIEW & SETUP -->
<div v-if="status === 'PREVIEW'" class="space-y-8 animate-in fade-in duration-700">
<!-- Recommendation Card -->
<div class="relative overflow-hidden group">
<div v-if="previewLoading" class="card p-8 animate-pulse bg-emerald-50/50 dark:bg-emerald-900/10 border-emerald-100 dark:border-emerald-900/20">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-2xl bg-emerald-100 dark:bg-emerald-900/40"></div>
<div class="flex-1 space-y-4">
<div class="h-6 bg-emerald-100 dark:bg-emerald-900/40 rounded w-1/4"></div>
<div class="h-4 bg-emerald-50 dark:bg-emerald-900/20 rounded w-full"></div>
<div class="h-4 bg-emerald-50 dark:bg-emerald-900/20 rounded w-2/3"></div>
</div>
</div>
</div>
<div v-else-if="previewData" class="card relative p-8 bg-gradient-to-br from-emerald-500 to-emerald-600 dark:from-emerald-600 dark:to-emerald-800 border-none shadow-2xl shadow-emerald-200 dark:shadow-none overflow-hidden">
<!-- Abstract Background elements -->
<div class="absolute top-0 right-0 -mt-10 -mr-10 w-64 h-64 bg-white/10 blur-3xl rounded-full"></div>
<div class="absolute bottom-0 left-0 -mb-10 -ml-10 w-48 h-48 bg-black/5 blur-2xl rounded-full"></div>
<div class="flex flex-col md:flex-row items-start md:items-center gap-6 relative z-10 text-white">
<div class="p-4 bg-white/20 backdrop-blur-md rounded-2xl border border-white/30 shadow-inner">
<i class="pi pi-sparkles text-3xl animate-pulse" />
</div>
<div class="flex-1">
<h3 class="text-xl font-black mb-2 flex items-center gap-2">
{{ previewData.modelTitle || 'Оптимальная стратегия' }}
<i class="pi pi-info-circle text-xs opacity-60" />
</h3>
<p class="text-emerald-50/90 leading-relaxed font-medium">
{{ previewData.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Form Section -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<!-- Left: Duration & Platforms -->
<div class="lg:col-span-8 space-y-8">
<div class="card p-6 md:p-8 bg-white dark:bg-slate-900 border-slate-200/60 dark:border-slate-800 shadow-sm">
<h3 class="text-lg font-bold mb-8 flex items-center gap-2">
<span class="w-1.5 h-6 bg-emerald-500 rounded-full"></span>
Настройки кампании
</h3>
<div class="space-y-10">
<!-- Duration -->
<div>
<div class="flex justify-between items-center mb-6">
<label class="text-sm font-bold text-slate-700 dark:text-slate-300">Длительность в неделях</label>
<span class="px-3 py-1 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 rounded-lg text-lg font-black">{{ formData.durationWeeks }}</span>
</div>
<div class="px-1">
<Slider v-model="formData.durationWeeks" :min="1" :max="12" :step="1" class="custom-slider" />
</div>
<div class="flex justify-between mt-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest px-1">
<span>1 неделя</span>
<span>4 недели</span>
<span>12 недель</span>
</div>
</div>
<Divider class="opacity-40" />
<!-- Platforms -->
<div>
<label class="block text-sm font-bold text-slate-700 dark:text-slate-300 mb-6">Каналы продвижения</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="platform in platformOptions" :key="platform.value" class="relative">
<input type="checkbox" :id="platform.value" v-model="formData.priorityPlatforms" :value="platform.value" class="peer hidden" />
<label
:for="platform.value"
class="flex items-center gap-3 p-4 rounded-2xl border-2 border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 cursor-pointer transition-all peer-checked:border-emerald-500 peer-checked:bg-emerald-50/30 dark:peer-checked:bg-emerald-500/10 hover:border-emerald-200 dark:hover:border-emerald-800"
>
<div
class="w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors"
:class="formData.priorityPlatforms.includes(platform.value) ? 'bg-emerald-500 border-emerald-500 text-white' : 'border-slate-200 dark:border-slate-700'"
>
<i class="pi pi-check text-[10px] font-bold" v-if="formData.priorityPlatforms.includes(platform.value)" />
</div>
<span class="text-sm font-bold text-slate-600 dark:text-slate-400 peer-checked:text-emerald-700 dark:peer-checked:text-emerald-400">
{{ platform.label }}
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right: Brand & Submit -->
<div class="lg:col-span-4 space-y-8">
<!-- Logo Upload -->
<div class="card p-6 bg-white dark:bg-slate-900 border-slate-200/60 dark:border-slate-800 shadow-sm">
<h3 class="text-sm font-bold mb-4 text-slate-400 uppercase tracking-wider">Интеграция бренда</h3>
<div
class="relative border-2 border-dashed rounded-2xl p-6 text-center transition-all bg-slate-50/50 dark:bg-slate-800/20"
:class="[isDragging ? 'border-emerald-500 bg-emerald-50/50' : 'border-slate-200 dark:border-slate-700', logoPreviewUrl ? 'p-2' : 'hover:border-emerald-300']"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<input type="file" id="logoUpload" accept="image/*" class="hidden" @change="handleFileSelect" ref="fileInput" />
<div v-if="logoPreviewUrl" class="relative group aspect-square rounded-xl overflow-hidden shadow-inner bg-white dark:bg-slate-900">
<img :src="logoPreviewUrl" alt="Logo preview" class="w-full h-full object-contain p-4" />
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<Button icon="pi pi-trash" rounded severity="danger" class="w-12 h-12" @click="removeLogo" />
</div>
</div>
<div v-else class="py-4 cursor-pointer" @click="triggerFileInput">
<div class="w-12 h-12 bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center mx-auto mb-4 text-emerald-500">
<i class="pi pi-upload text-xl" />
</div>
<h4 class="text-slate-900 dark:text-white font-bold text-sm mb-1">Логотип бренда</h4>
<p class="text-slate-400 text-[10px] font-medium leading-relaxed">
Кликните или перетащите <br />
PNG, JPG до 5MB
</p>
</div>
</div>
</div>
<!-- General Submit -->
<button
@click="startGeneration"
class="w-full py-5 px-6 rounded-2xl bg-emerald-500 hover:bg-emerald-600 active:scale-[0.98] disabled:bg-slate-200 dark:disabled:bg-slate-800 disabled:text-slate-400 text-white font-black text-lg shadow-xl shadow-emerald-500/20 transition-all flex items-center justify-center gap-3 overflow-hidden group relative"
:disabled="!formData.priorityPlatforms.length || generateLoading"
>
<span v-if="!generateLoading" class="flex items-center gap-3"> ⚡ <span class="tracking-tight">Запустить Фабрику</span> </span>
<div v-else class="flex items-center gap-3">
<i class="pi pi-spin pi-spinner text-xl" />
<span>Подготовка...</span>
</div>
<!-- Subtle shine effect on hover -->
<div class="absolute top-0 -left-full w-full h-full bg-gradient-to-r from-transparent via-white/20 to-transparent group-hover:left-[200%] transition-all duration-1000 ease-in-out"></div>
</button>
</div>
</div>
</div>
<!-- 3. STAGE: POLLING SCREEN -->
<div v-else-if="status === 'QUEUED' || status === 'PROCESSING'" class="card p-12 md:p-24 text-center bg-white dark:bg-slate-900 border-none shadow-xl animate-in zoom-in duration-500">
<div class="max-w-md mx-auto">
<!-- Progress Circle -->
<div class="relative w-32 h-32 mx-auto mb-12">
<svg class="w-full h-full transform -rotate-90">
<circle cx="64" cy="64" r="58" stroke="currentColor" stroke-width="8" fill="transparent" class="text-slate-100 dark:text-slate-800" />
<circle
cx="64"
cy="64"
r="58"
stroke="currentColor"
stroke-width="8"
fill="transparent"
stroke-dasharray="364.4"
:stroke-dashoffset="364.4 - (364.4 * progressPercent) / 100"
class="text-emerald-500 transition-all duration-1000 ease-out"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<i class="pi pi-bolt text-4xl text-emerald-500 animate-pulse" />
</div>
</div>
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-4">
{{ status === 'QUEUED' ? 'Запуск производства...' : 'Фабрика в работе' }}
</h2>
<div class="h-12 flex items-center justify-center mb-8">
<Transition name="slide-up" mode="out-in">
<p :key="loadingMessageIndex" class="text-slate-500 dark:text-slate-400 font-bold">
{{ currentLoadingMessage }}
</p>
</Transition>
</div>
<div class="bg-slate-100 dark:bg-slate-800 rounded-full h-1.5 overflow-hidden">
<div class="bg-emerald-500 h-full transition-all duration-1000" :style="{ width: `${progressPercent}%` }"></div>
</div>
<div class="mt-4 flex justify-between text-[10px] font-black text-slate-400 uppercase tracking-widest">
<span>Процесс</span>
<span class="text-emerald-500">{{ progressPercent }}%</span>
</div>
</div>
</div>
<!-- 4. STAGE: COMPLETED (Result Display) -->
<div v-else-if="status === 'COMPLETED' && strategyData" class="space-y-10 animate-in fade-in slide-in-from-bottom-5 duration-1000">
<!-- Strategy Rationale Banner -->
<div class="card p-8 md:p-12 bg-slate-900 dark:bg-black text-white relative overflow-hidden group">
<div class="absolute top-0 right-0 w-96 h-96 bg-emerald-500/20 blur-3xl rounded-full -mr-48 -mt-48 group-hover:bg-emerald-500/30 transition-colors duration-1000"></div>
<div class="relative z-10 flex flex-col md:flex-row gap-10 items-start">
<div class="flex-1">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/20 text-emerald-400 text-[10px] font-black uppercase tracking-wider border border-emerald-500/30 mb-6">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
Готовый результат
</div>
<h2 class="text-3xl font-black mb-6 tracking-tight">Стратегическое обоснование</h2>
<p class="text-slate-400 leading-relaxed text-lg font-medium whitespace-pre-line">
{{ strategyData.strategyData?.rationale }}
</p>
</div>
<div class="w-full md:w-64 space-y-4">
<div class="p-5 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-sm">
<p class="text-slate-500 text-[10px] font-black uppercase mb-2">Выбранная модель</p>
<p class="font-bold text-emerald-400">{{ strategyData.strategyData?.selectedModel || 'Адаптивная' }}</p>
</div>
<div class="p-5 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-sm">
<p class="text-slate-500 text-[10px] font-black uppercase mb-2">Срок реализации</p>
<p class="font-bold text-slate-200">{{ strategyData.strategyData?.durationWeeks }} недель</p>
</div>
</div>
</div>
</div>
<!-- Weekly Sprints -->
<div v-if="strategyData.weeklyPlans?.length">
<h3 class="text-xl font-black text-slate-900 dark:text-white mb-6 px-2 flex items-center gap-3">
<i class="pi pi-calendar text-emerald-500" />
План реализации (Спринты)
</h3>
<div class="space-y-4">
<div v-for="plan in strategyData.weeklyPlans" :key="plan.weekNumber" class="card overflow-hidden bg-white dark:bg-slate-900 border-slate-200/60 dark:border-slate-800 shadow-sm">
<div class="flex items-center justify-between p-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors" @click="toggleWeek(plan.weekNumber)">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-slate-900 dark:bg-emerald-500 text-white flex items-center justify-center font-black text-sm shadow-lg">
{{ plan.weekNumber }}
</div>
<div>
<h4 class="font-black text-slate-900 dark:text-white">Неделя {{ plan.weekNumber }}</h4>
<div class="flex gap-2 mt-1">
<span v-for="theme in plan.mainThemes?.slice(0, 2)" :key="theme" class="text-[10px] font-bold text-emerald-600 dark:text-emerald-400 uppercase tracking-tighter"> #{{ theme }} </span>
</div>
</div>
</div>
<i class="pi" :class="activeWeeks.includes(plan.weekNumber) ? 'pi-chevron-up' : 'pi-chevron-down'" />
</div>
<Transition name="expand">
<div v-if="activeWeeks.includes(plan.weekNumber)" class="p-6 pt-0 border-t border-slate-100 dark:border-slate-800/60 bg-slate-50/30 dark:bg-slate-900/40">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mt-6">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">Темы недели</p>
<div class="flex flex-wrap gap-2">
<Tag
v-for="theme in plan.mainThemes"
:key="theme"
:value="theme"
class="bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-bold border border-slate-200 dark:border-slate-700 rounded-lg px-3 py-1.5"
/>
</div>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3">Инструкция по реализации</p>
<div class="p-4 rounded-xl bg-emerald-50/50 dark:bg-emerald-950/20 border-l-4 border-emerald-500 text-slate-700 dark:text-slate-400 text-sm leading-relaxed whitespace-pre-line font-medium">
{{ plan.contentRecommendations }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Content Calendar -->
<div v-if="strategyData.postCalendar?.length">
<div class="flex items-center justify-between mb-8 px-2">
<h3 class="text-xl font-black text-slate-900 dark:text-white flex items-center gap-3">
<i class="pi pi-images text-emerald-500" />
Медиа-активы и посты
</h3>
<Button label="Скачать календарь" icon="pi pi-download" size="small" severity="secondary" rounded text class="font-bold" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div
v-for="(post, idx) in strategyData.postCalendar"
:key="idx"
class="card p-0 overflow-hidden bg-white dark:bg-slate-900 border-none shadow-xl shadow-slate-200/50 dark:shadow-none hover:-translate-y-2 transition-transform duration-500 group"
>
<!-- Media Header -->
<div class="aspect-square relative bg-slate-100 dark:bg-slate-800 flex items-center justify-center overflow-hidden">
<template v-if="post.contentType === 'video' || post.contentType === 'reels'">
<video v-if="getMediaUrl(post.mediaFilename)" :src="getMediaUrl(post.mediaFilename)" autoplay loop muted playsinline class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
<div v-else class="text-center p-8 opacity-40">
<i class="pi pi-video text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase">Видео в обработке</p>
</div>
<div class="absolute top-4 right-4 px-2 py-1 bg-black/60 rounded backdrop-blur-md text-[8px] font-black text-white uppercase tracking-tighter">Video Asset</div>
</template>
<template v-else>
<img v-if="getMediaUrl(post.mediaFilename)" :src="getMediaUrl(post.mediaFilename)" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
<div v-else class="text-center p-8 opacity-40">
<i class="pi pi-image text-5xl mb-2" />
<p class="text-[10px] font-bold uppercase tracking-widest">Генерация визуала...</p>
</div>
</template>
<div class="absolute inset-0 bg-emerald-500/10 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="absolute top-4 left-4 flex gap-2">
<div class="p-2 rounded-xl bg-white/90 dark:bg-slate-900/90 backdrop-blur-md shadow-lg">
<i :class="getPlatformIcon(post.platform)" class="text-lg text-emerald-500" />
</div>
</div>
</div>
<!-- Content Details -->
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ formatDate(post.publishDate) }}</p>
<h4 class="font-black text-slate-900 dark:text-white line-clamp-1 h-5">{{ post.theme }}</h4>
</div>
<span class="text-[10px] font-black px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded-lg text-slate-500 uppercase">{{ post.publishTime }}</span>
</div>
<div class="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800 mb-6 group-hover:bg-emerald-50/30 dark:group-hover:bg-emerald-900/10 transition-colors">
<p class="text-xs text-slate-600 dark:text-slate-400 line-clamp-3 font-medium leading-relaxed">{{ post.postText }}</p>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-copy" label="Копировать" class="flex-1 text-[10px] font-black uppercase tracking-wider rounded-xl py-3" outlined severity="secondary" @click="copyText(post.postText, post.hashtags)" />
<Button icon="pi pi-expand" class="w-12 h-12 rounded-xl" text severity="secondary" v-if="getMediaUrl(post.mediaFilename)" @click="previewMedia(post)" />
</div>
</div>
</div>
</div>
</div>
<!-- Actions Footer -->
<div class="flex flex-col md:flex-row gap-4 pt-10 border-t border-slate-200 dark:border-slate-800">
<Button label="Сохранить как PDF" icon="pi pi-file-pdf" severity="secondary" outlined class="flex-1 rounded-2xl font-black py-4" />
<Button label="Новая генерация" icon="pi pi-refresh" severity="secondary" class="flex-1 rounded-2xl font-black py-4" @click="status = 'PREVIEW'" />
</div>
</div>
<!-- Stage: FAILED -->
<div v-else-if="status === 'FAILED'" class="card p-12 text-center bg-white dark:bg-slate-900 border-none shadow-xl">
<div class="w-20 h-20 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-6 text-red-500">
<i class="pi pi-exclamation-triangle text-3xl" />
</div>
<h2 class="text-2xl font-black mb-2">Генерация прервана</h2>
<p class="text-slate-500 mb-8 max-w-sm mx-auto">Что-то пошло не так при работе искусственного интеллекта. Пожалуйста, попробуйте еще раз.</p>
<div class="flex justify-center gap-4">
<Button label="Назад к настройкам" severity="secondary" text class="font-bold" @click="status = 'PREVIEW'" />
<Button label="Повторить попытку" icon="pi pi-refresh" severity="danger" class="font-bold rounded-xl" @click="startGeneration" />
</div>
</div>
<!-- Media Modal -->
<Dialog v-model:visible="mediaDialog" :modal="true" :showHeader="false" :dismissableMask="true" contentClass="p-0 bg-transparent shadow-none" class="w-full max-w-4xl m-4 border-none">
<div class="relative rounded-3xl overflow-hidden bg-black/95 flex flex-col items-center justify-center p-2 group">
<Button icon="pi pi-times" rounded text class="absolute top-6 right-6 text-white/50 hover:text-white z-50 bg-white/10 hover:bg-white/20 backdrop-blur-md" @click="mediaDialog = false" />
<template v-if="activeMedia">
<video v-if="activeMedia.contentType === 'video' || activeMedia.contentType === 'reels'" :src="getMediaUrl(activeMedia.mediaFilename)" autoplay controls class="max-h-[90vh] w-auto max-w-full rounded-2xl overflow-hidden" />
<img v-else :src="getMediaUrl(activeMedia.mediaFilename)" alt="Full preview" class="max-h-[90vh] w-auto max-w-full rounded-2xl object-contain" />
<!-- Overlay Info in Modal -->
<div class="absolute bottom-6 left-6 right-6 p-6 rounded-2xl bg-black/60 backdrop-blur-xl border border-white/10 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="flex items-center gap-2 mb-2">
<i :class="getPlatformIcon(activeMedia.platform)" class="text-emerald-400" />
<span class="text-[10px] font-black uppercase tracking-widest text-emerald-400">{{ activeMedia.platform }}</span>
</div>
<h4 class="text-lg font-black mb-3">{{ activeMedia.theme }}</h4>
<p class="text-sm text-slate-400 font-medium leading-relaxed">{{ activeMedia.postText }}</p>
</div>
</template>
</div>
</Dialog>
</div>
</div>
</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;
}
/* 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;
}
</style>
+112 -112
View File
@@ -1,115 +1,3 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчёта</h5>
<p>Заполните форму для создания нового отчёта на основе собранных данных.</p>
</div>
</div>
<div class="col-12 lg:col-8">
<div class="card">
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="grid">
<div class="col-12">
<div class="field">
<label for="query">Тема исследования *</label>
<InputText id="query" v-model="formData.query" :class="{ 'p-invalid': errors.query }" placeholder="Введите тему для исследования" size="large" />
<small v-if="errors.query" class="p-error">{{ errors.query }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="lang">Язык отчёта</label>
<Dropdown id="lang" v-model="formData.lang" :options="langOptions" optionLabel="label" optionValue="value" placeholder="Выберите язык" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="report_type">Тип отчёта</label>
<Dropdown id="report_type" v-model="formData.report_type" :options="reportTypeOptions" optionLabel="label" optionValue="value" placeholder="Выберите тип" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="depth">Глубина исследования</label>
<Slider id="depth" v-model.number="formData.depth" :min="1" :max="5" :step="1" :disabled="false" />
<small class="text-600">Текущее значение: {{ formData.depth }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="breadth">Широта исследования</label>
<Slider id="breadth" v-model.number="formData.breadth" :min="2" :max="10" :step="1" :disabled="false" />
<small class="text-600">Текущее значение: {{ formData.breadth }}</small>
</div>
</div>
<div class="col-12">
<div class="flex gap-2">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" size="large" />
<Button type="button" label="Очистить форму" icon="pi pi-refresh" severity="secondary" @click="resetForm" :disabled="loading" />
</div>
</div>
</div>
</form>
</div>
</div>
<div class="col-12 lg:col-4">
<div class="card">
<h6>Информация о генерации</h6>
<div class="flex flex-column gap-3">
<div class="flex align-items-center gap-2">
<i class="pi pi-info-circle text-blue-500"></i>
<span class="text-sm">Отчёт будет создан с помощью AI-исследования</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-clock text-orange-500"></i>
<span class="text-sm">Время генерации: до 5 минут</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-green-500"></i>
<span class="text-sm">PDF-файл будет автоматически скачан</span>
</div>
</div>
</div>
<div class="card mt-3">
<h6>Параметры исследования</h6>
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-search text-purple-500"></i>
<span class="text-sm">Глубина: детальность анализа</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-globe text-cyan-500"></i>
<span class="text-sm">Широта: количество источников</span>
</div>
</div>
</div>
<div class="card mt-3">
<h6>Типы отчётов</h6>
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-file-pdf text-red-500"></i>
<span class="text-sm">Отчёт - полное исследование</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-question-circle text-blue-500"></i>
<span class="text-sm">Ответ - краткий ответ на вопрос</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useToast } from 'primevue/usetoast';
@@ -228,6 +116,118 @@ onMounted(() => {
});
</script>
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчёта</h5>
<p>Заполните форму для создания нового отчёта на основе собранных данных.</p>
</div>
</div>
<div class="col-12 lg:col-8">
<div class="card">
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="grid">
<div class="col-12">
<div class="field">
<label for="query">Тема исследования *</label>
<InputText id="query" v-model="formData.query" :class="{ 'p-invalid': errors.query }" placeholder="Введите тему для исследования" size="large" />
<small v-if="errors.query" class="p-error">{{ errors.query }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="lang">Язык отчёта</label>
<Dropdown id="lang" v-model="formData.lang" :options="langOptions" optionLabel="label" optionValue="value" placeholder="Выберите язык" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="report_type">Тип отчёта</label>
<Dropdown id="report_type" v-model="formData.report_type" :options="reportTypeOptions" optionLabel="label" optionValue="value" placeholder="Выберите тип" />
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="depth">Глубина исследования</label>
<Slider id="depth" v-model.number="formData.depth" :min="1" :max="5" :step="1" :disabled="false" />
<small class="text-600">Текущее значение: {{ formData.depth }}</small>
</div>
</div>
<div class="col-12 md:col-6">
<div class="field">
<label for="breadth">Широта исследования</label>
<Slider id="breadth" v-model.number="formData.breadth" :min="2" :max="10" :step="1" :disabled="false" />
<small class="text-600">Текущее значение: {{ formData.breadth }}</small>
</div>
</div>
<div class="col-12">
<div class="flex gap-2">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" size="large" />
<Button type="button" label="Очистить форму" icon="pi pi-refresh" severity="secondary" @click="resetForm" :disabled="loading" />
</div>
</div>
</div>
</form>
</div>
</div>
<div class="col-12 lg:col-4">
<div class="card">
<h6>Информация о генерации</h6>
<div class="flex flex-column gap-3">
<div class="flex align-items-center gap-2">
<i class="pi pi-info-circle text-blue-500"></i>
<span class="text-sm">Отчёт будет создан с помощью AI-исследования</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-clock text-orange-500"></i>
<span class="text-sm">Время генерации: до 5 минут</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-green-500"></i>
<span class="text-sm">PDF-файл будет автоматически скачан</span>
</div>
</div>
</div>
<div class="card mt-3">
<h6>Параметры исследования</h6>
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-search text-purple-500"></i>
<span class="text-sm">Глубина: детальность анализа</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-globe text-cyan-500"></i>
<span class="text-sm">Широта: количество источников</span>
</div>
</div>
</div>
<div class="card mt-3">
<h6>Типы отчётов</h6>
<div class="flex flex-column gap-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-file-pdf text-red-500"></i>
<span class="text-sm">Отчёт - полное исследование</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-question-circle text-blue-500"></i>
<span class="text-sm">Ответ - краткий ответ на вопрос</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
+218 -218
View File
@@ -1,221 +1,3 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center">
<div>
<h5>История отчётов</h5>
<p>Просмотр и скачивание ранее созданных отчётов</p>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text v-tooltip.top="'Обновить список'" />
<Button label="Создать отчёт" icon="pi pi-plus" @click="goToGenerator" severity="success" />
</div>
</div>
</div>
</div>
<!-- Фильтры -->
<div class="col-12">
<div class="card">
<h6>Фильтры</h6>
<div class="grid">
<div class="col-12 md:col-3">
<div class="field">
<label for="search">Поиск</label>
<InputText id="search" v-model="filters.search" placeholder="Поиск по заголовку, автору или компании" @input="onSearch" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="format">Формат</label>
<Dropdown id="format" v-model="filters.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Все форматы" @change="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateFrom">Дата с</label>
<Calendar id="dateFrom" v-model="filters.dateFrom" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateTo">Дата по</label>
<Calendar id="dateTo" v-model="filters.dateTo" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-3">
<div class="field">
<label>&nbsp;</label>
<div class="flex gap-2">
<Button label="Применить" icon="pi pi-filter" @click="applyFilters" severity="info" />
<Button label="Сбросить" icon="pi pi-times" @click="resetFilters" severity="secondary" text />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="col-12">
<div class="grid">
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-orange-500">{{ thisWeekReports }}</div>
<div class="text-sm text-600">За эту неделю</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-purple-500">{{ totalSize }}</div>
<div class="text-sm text-600">Общий размер</div>
</div>
</div>
</div>
</div>
<!-- Таблица отчётов -->
<div class="col-12">
<div class="card">
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
@sort="onSort"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
:sortField="sortField"
:sortOrder="sortOrder"
removableSort
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="font-semibold">{{ data.reportTitle }}</div>
<div class="text-sm text-600" v-if="data.description">{{ data.description }}</div>
</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-user text-blue-500"></i>
<span>{{ data.authorName }}</span>
</div>
</template>
</Column>
<Column field="companyName" header="Компания" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-building text-green-500"></i>
<span>{{ data.companyName }}</span>
</div>
</template>
</Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDate(data.startDate) }}</div>
<div class="text-sm text-600">по {{ formatDate(data.endDate) }}</div>
</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" :icon="data.format === 'PDF' ? 'pi pi-file-pdf' : 'pi pi-file-word'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-database text-orange-500"></i>
<span>{{ formatFileSize(data.fileSize) }}</span>
</div>
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDateTime(data.createdAt) }}</div>
<div class="text-xs text-600">{{ getTimeAgo(data.createdAt) }}</div>
</div>
</template>
</Column>
<Column header="Действия" :exportable="false" style="min-width: 120px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" v-tooltip.top="'Скачать отчёт'" />
<Button icon="pi pi-eye" @click="viewReport(data)" severity="info" text rounded v-tooltip.top="'Просмотреть детали'" />
<Button icon="pi pi-trash" @click="confirmDelete(data)" severity="danger" text rounded v-tooltip.top="'Удалить отчёт'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог просмотра отчёта -->
<Dialog v-model:visible="showReportDialog" :header="selectedReport?.reportTitle" :modal="true" :style="{ width: '50vw' }" :maximizable="true">
<div v-if="selectedReport" class="flex flex-column gap-3">
<div class="grid">
<div class="col-6"><strong>Автор:</strong> {{ selectedReport.authorName }}</div>
<div class="col-6"><strong>Компания:</strong> {{ selectedReport.companyName }}</div>
<div class="col-6"><strong>Период:</strong> {{ formatDate(selectedReport.startDate) }} - {{ formatDate(selectedReport.endDate) }}</div>
<div class="col-6">
<strong>Формат:</strong>
<Tag :value="selectedReport.format" :severity="selectedReport.format === 'PDF' ? 'danger' : 'info'" />
</div>
<div class="col-6"><strong>Размер:</strong> {{ formatFileSize(selectedReport.fileSize) }}</div>
<div class="col-6"><strong>Создан:</strong> {{ formatDateTime(selectedReport.createdAt) }}</div>
</div>
</div>
<template #footer>
<Button label="Скачать" icon="pi pi-download" @click="handleDownload(selectedReport?.id)" severity="success" />
<Button label="Закрыть" icon="pi pi-times" @click="showReportDialog = false" severity="secondary" text />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" :header="'Удаление отчёта'" :modal="true" :style="{ width: '25vw' }">
<div class="flex align-items-center gap-3">
<i class="pi pi-exclamation-triangle text-orange-500 text-2xl"></i>
<span>Вы уверены, что хотите удалить этот отчёт? Это действие нельзя отменить.</span>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" severity="secondary" text />
<Button label="Удалить" icon="pi pi-trash" @click="handleDelete" severity="danger" />
</template>
</Dialog>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useConfirm } from 'primevue/useconfirm';
@@ -439,6 +221,224 @@ onMounted(() => {
});
</script>
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center">
<div>
<h5>История отчётов</h5>
<p>Просмотр и скачивание ранее созданных отчётов</p>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text v-tooltip.top="'Обновить список'" />
<Button label="Создать отчёт" icon="pi pi-plus" @click="goToGenerator" severity="success" />
</div>
</div>
</div>
</div>
<!-- Фильтры -->
<div class="col-12">
<div class="card">
<h6>Фильтры</h6>
<div class="grid">
<div class="col-12 md:col-3">
<div class="field">
<label for="search">Поиск</label>
<InputText id="search" v-model="filters.search" placeholder="Поиск по заголовку, автору или компании" @input="onSearch" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="format">Формат</label>
<Dropdown id="format" v-model="filters.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Все форматы" @change="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateFrom">Дата с</label>
<Calendar id="dateFrom" v-model="filters.dateFrom" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-2">
<div class="field">
<label for="dateTo">Дата по</label>
<Calendar id="dateTo" v-model="filters.dateTo" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
</div>
</div>
<div class="col-12 md:col-3">
<div class="field">
<label>&nbsp;</label>
<div class="flex gap-2">
<Button label="Применить" icon="pi pi-filter" @click="applyFilters" severity="info" />
<Button label="Сбросить" icon="pi pi-times" @click="resetFilters" severity="secondary" text />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="col-12">
<div class="grid">
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-orange-500">{{ thisWeekReports }}</div>
<div class="text-sm text-600">За эту неделю</div>
</div>
</div>
<div class="col-12 md:col-3">
<div class="card text-center">
<div class="text-2xl font-bold text-purple-500">{{ totalSize }}</div>
<div class="text-sm text-600">Общий размер</div>
</div>
</div>
</div>
</div>
<!-- Таблица отчётов -->
<div class="col-12">
<div class="card">
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
@sort="onSort"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
:sortField="sortField"
:sortOrder="sortOrder"
removableSort
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="font-semibold">{{ data.reportTitle }}</div>
<div class="text-sm text-600" v-if="data.description">{{ data.description }}</div>
</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-user text-blue-500"></i>
<span>{{ data.authorName }}</span>
</div>
</template>
</Column>
<Column field="companyName" header="Компания" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-building text-green-500"></i>
<span>{{ data.companyName }}</span>
</div>
</template>
</Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDate(data.startDate) }}</div>
<div class="text-sm text-600">по {{ formatDate(data.endDate) }}</div>
</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" :icon="data.format === 'PDF' ? 'pi pi-file-pdf' : 'pi pi-file-word'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
<div class="flex align-items-center gap-2">
<i class="pi pi-database text-orange-500"></i>
<span>{{ formatFileSize(data.fileSize) }}</span>
</div>
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
<div class="flex flex-column">
<div class="text-sm">{{ formatDateTime(data.createdAt) }}</div>
<div class="text-xs text-600">{{ getTimeAgo(data.createdAt) }}</div>
</div>
</template>
</Column>
<Column header="Действия" :exportable="false" style="min-width: 120px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" v-tooltip.top="'Скачать отчёт'" />
<Button icon="pi pi-eye" @click="viewReport(data)" severity="info" text rounded v-tooltip.top="'Просмотреть детали'" />
<Button icon="pi pi-trash" @click="confirmDelete(data)" severity="danger" text rounded v-tooltip.top="'Удалить отчёт'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Диалог просмотра отчёта -->
<Dialog v-model:visible="showReportDialog" :header="selectedReport?.reportTitle" :modal="true" :style="{ width: '50vw' }" :maximizable="true">
<div v-if="selectedReport" class="flex flex-column gap-3">
<div class="grid">
<div class="col-6"><strong>Автор:</strong> {{ selectedReport.authorName }}</div>
<div class="col-6"><strong>Компания:</strong> {{ selectedReport.companyName }}</div>
<div class="col-6"><strong>Период:</strong> {{ formatDate(selectedReport.startDate) }} - {{ formatDate(selectedReport.endDate) }}</div>
<div class="col-6">
<strong>Формат:</strong>
<Tag :value="selectedReport.format" :severity="selectedReport.format === 'PDF' ? 'danger' : 'info'" />
</div>
<div class="col-6"><strong>Размер:</strong> {{ formatFileSize(selectedReport.fileSize) }}</div>
<div class="col-6"><strong>Создан:</strong> {{ formatDateTime(selectedReport.createdAt) }}</div>
</div>
</div>
<template #footer>
<Button label="Скачать" icon="pi pi-download" @click="handleDownload(selectedReport?.id)" severity="success" />
<Button label="Закрыть" icon="pi pi-times" @click="showReportDialog = false" severity="secondary" text />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" :header="'Удаление отчёта'" :modal="true" :style="{ width: '25vw' }">
<div class="flex align-items-center gap-3">
<i class="pi pi-exclamation-triangle text-orange-500 text-2xl"></i>
<span>Вы уверены, что хотите удалить этот отчёт? Это действие нельзя отменить.</span>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" severity="secondary" text />
<Button label="Удалить" icon="pi pi-trash" @click="handleDelete" severity="danger" />
</template>
</Dialog>
</template>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
+135 -135
View File
@@ -1,138 +1,3 @@
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчётов</h5>
<p>Создайте новый отчёт с помощью AI-исследования по выбранной теме.</p>
</div>
</div>
<!-- Форма генерации отчёта -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Создать новый отчёт</h5>
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="field">
<label for="query">Тема исследования *</label>
<InputText id="query" v-model="formData.query" :class="{ 'p-invalid': errors.query }" placeholder="Введите тему для исследования" />
<small v-if="errors.query" class="p-error">{{ errors.query }}</small>
</div>
<div class="field">
<label for="lang">Язык отчёта</label>
<Dropdown id="lang" v-model="formData.lang" :options="langOptions" optionLabel="label" optionValue="value" placeholder="Выберите язык" />
</div>
<div class="field">
<label for="report_type">Тип отчёта</label>
<Dropdown id="report_type" v-model="formData.report_type" :options="reportTypeOptions" optionLabel="label" optionValue="value" placeholder="Выберите тип" />
</div>
<div class="field">
<label for="depth">Глубина исследования: {{ formData.depth }}</label>
<Slider id="depth" v-model.number="formData.depth" :min="1" :max="5" :step="1" :disabled="false" />
</div>
<div class="field">
<label for="breadth">Широта исследования: {{ formData.breadth }}</label>
<Slider id="breadth" v-model.number="formData.breadth" :min="2" :max="10" :step="1" :disabled="false" />
</div>
<div class="field">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" class="w-full" />
</div>
</form>
</div>
</div>
<!-- Статистика -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Статистика отчётов</h5>
<div class="grid">
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
</div>
</div>
</div>
<!-- История отчётов -->
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-3">
<h5>История отчётов</h5>
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text />
</div>
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="font-semibold">{{ data.reportTitle }}</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable></Column>
<Column field="companyName" header="Компания" sortable></Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div>{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
{{ formatFileSize(data.fileSize) }}
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
{{ formatDateTime(data.createdAt) }}
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="{ data }">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import ReportService from '@/service/ReportService.js';
import { useToast } from 'primevue/usetoast';
@@ -344,6 +209,141 @@ onMounted(() => {
});
</script>
<template>
<div class="grid">
<div class="col-12">
<div class="card">
<h5>Генерация отчётов</h5>
<p>Создайте новый отчёт с помощью AI-исследования по выбранной теме.</p>
</div>
</div>
<!-- Форма генерации отчёта -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Создать новый отчёт</h5>
<form @submit.prevent="handleGenerate" class="p-fluid">
<div class="field">
<label for="query">Тема исследования *</label>
<InputText id="query" v-model="formData.query" :class="{ 'p-invalid': errors.query }" placeholder="Введите тему для исследования" />
<small v-if="errors.query" class="p-error">{{ errors.query }}</small>
</div>
<div class="field">
<label for="lang">Язык отчёта</label>
<Dropdown id="lang" v-model="formData.lang" :options="langOptions" optionLabel="label" optionValue="value" placeholder="Выберите язык" />
</div>
<div class="field">
<label for="report_type">Тип отчёта</label>
<Dropdown id="report_type" v-model="formData.report_type" :options="reportTypeOptions" optionLabel="label" optionValue="value" placeholder="Выберите тип" />
</div>
<div class="field">
<label for="depth">Глубина исследования: {{ formData.depth }}</label>
<Slider id="depth" v-model.number="formData.depth" :min="1" :max="5" :step="1" :disabled="false" />
</div>
<div class="field">
<label for="breadth">Широта исследования: {{ formData.breadth }}</label>
<Slider id="breadth" v-model.number="formData.breadth" :min="2" :max="10" :step="1" :disabled="false" />
</div>
<div class="field">
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" class="w-full" />
</div>
</form>
</div>
</div>
<!-- Статистика -->
<div class="col-12 lg:col-6">
<div class="card">
<h5>Статистика отчётов</h5>
<div class="grid">
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
<div class="text-sm text-600">Всего отчётов</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
<div class="text-sm text-600">За этот месяц</div>
</div>
</div>
</div>
</div>
</div>
<!-- История отчётов -->
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-3">
<h5>История отчётов</h5>
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text />
</div>
<DataTable
:value="reports"
:loading="loading"
:paginator="true"
:rows="pageSize"
:totalRecords="totalElements"
:lazy="true"
@page="onPageChange"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
emptyMessage="Отчёты не найдены"
responsiveLayout="scroll"
>
<Column field="reportTitle" header="Заголовок" sortable>
<template #body="{ data }">
<div class="font-semibold">{{ data.reportTitle }}</div>
</template>
</Column>
<Column field="authorName" header="Автор" sortable></Column>
<Column field="companyName" header="Компания" sortable></Column>
<Column field="startDate" header="Период" sortable>
<template #body="{ data }">
<div>{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}</div>
</template>
</Column>
<Column field="format" header="Формат" sortable>
<template #body="{ data }">
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" />
</template>
</Column>
<Column field="fileSize" header="Размер" sortable>
<template #body="{ data }">
{{ formatFileSize(data.fileSize) }}
</template>
</Column>
<Column field="createdAt" header="Создан" sortable>
<template #body="{ data }">
{{ formatDateTime(data.createdAt) }}
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="{ data }">
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<style scoped>
.p-fluid .field {
margin-bottom: 1rem;
+105 -105
View File
@@ -1,3 +1,108 @@
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import { ChannelType } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'AnalyticsDashboard',
setup() {
const toast = useToast();
// Reactive data
const systemStats = ref({});
const publishingStats = ref({});
const collecting = ref(false);
// Methods
const loadAnalytics = async () => {
try {
const [systemStatsData, publishingStatsData] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats()]);
systemStats.value = systemStatsData;
publishingStats.value = publishingStatsData;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить аналитику: ' + error.message,
life: 3000
});
}
};
const collectAnalytics = async () => {
collecting.value = true;
try {
await AnalyticsService.collectAnalytics();
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Сбор аналитики запущен',
life: 3000
});
// Перезагружаем данные после сбора
setTimeout(() => {
loadAnalytics();
}, 2000);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось запустить сбор аналитики: ' + error.message,
life: 3000
});
} finally {
collecting.value = false;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT'
}).format(value || 0);
};
const formatDateTime = (dateString) => {
if (!dateString) return 'Не определено';
return new Date(dateString).toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getChannelTypeLabel = (type) => {
const labels = {
[ChannelType.TELEGRAM]: 'Telegram',
[ChannelType.VK]: 'VK',
[ChannelType.INSTAGRAM]: 'Instagram'
};
return labels[type] || type;
};
// Lifecycle
onMounted(() => {
loadAnalytics();
});
return {
systemStats,
publishingStats,
collecting,
loadAnalytics,
collectAnalytics,
formatCurrency,
formatDateTime,
getChannelTypeLabel
};
}
};
</script>
<template>
<div class="analytics-dashboard">
<!-- Заголовок с кнопкой сбора аналитики -->
@@ -187,111 +292,6 @@
</div>
</template>
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import { ChannelType } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'AnalyticsDashboard',
setup() {
const toast = useToast();
// Reactive data
const systemStats = ref({});
const publishingStats = ref({});
const collecting = ref(false);
// Methods
const loadAnalytics = async () => {
try {
const [systemStatsData, publishingStatsData] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats()]);
systemStats.value = systemStatsData;
publishingStats.value = publishingStatsData;
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить аналитику: ' + error.message,
life: 3000
});
}
};
const collectAnalytics = async () => {
collecting.value = true;
try {
await AnalyticsService.collectAnalytics();
toast.add({
severity: 'success',
summary: 'Успех',
detail: 'Сбор аналитики запущен',
life: 3000
});
// Перезагружаем данные после сбора
setTimeout(() => {
loadAnalytics();
}, 2000);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось запустить сбор аналитики: ' + error.message,
life: 3000
});
} finally {
collecting.value = false;
}
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'KZT'
}).format(value || 0);
};
const formatDateTime = (dateString) => {
if (!dateString) return 'Не определено';
return new Date(dateString).toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getChannelTypeLabel = (type) => {
const labels = {
[ChannelType.TELEGRAM]: 'Telegram',
[ChannelType.VK]: 'VK',
[ChannelType.INSTAGRAM]: 'Instagram'
};
return labels[type] || type;
};
// Lifecycle
onMounted(() => {
loadAnalytics();
});
return {
systemStats,
publishingStats,
collecting,
loadAnalytics,
collectAnalytics,
formatCurrency,
formatDateTime,
getChannelTypeLabel
};
}
};
</script>
<style scoped>
.analytics-dashboard {
padding: 1rem;
+135 -135
View File
@@ -1,138 +1,3 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление кампаниями</h5>
<Button icon="pi pi-plus" label="Создать кампанию" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="campaigns"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'goal', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск кампаний..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="goal" header="Цель" :sortable="true">
<template #body="slotProps">
<span>{{ slotProps.data.goal }}</span>
</template>
</Column>
<Column field="budget" header="Бюджет" :sortable="true">
<template #body="slotProps">
<span class="font-semibold text-green-600">
{{ formatCurrency(slotProps.data.budget) }}
</span>
</template>
</Column>
<Column field="startAt" header="Начало" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.startAt) }}</span>
</template>
</Column>
<Column field="endAt" header="Окончание" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.endAt) }}</span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editCampaign(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования кампании -->
<Dialog v-model:visible="showCreateDialog" :header="editingCampaign ? 'Редактировать кампанию' : 'Создать кампанию'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название кампании *</label>
<InputText id="name" v-model="campaignForm.name" :class="{ 'p-invalid': !campaignForm.name }" />
<small v-if="!campaignForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="goal">Цель кампании</label>
<Textarea id="goal" v-model="campaignForm.goal" rows="3" />
</div>
<div class="field">
<label for="budget">Бюджет</label>
<InputNumber id="budget" v-model="campaignForm.budget" mode="currency" currency="KZT" locale="ru-RU" :min="0" />
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="startAt">Дата начала</label>
<Calendar id="startAt" v-model="campaignForm.startAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="endAt">Дата окончания</label>
<Calendar id="endAt" v-model="campaignForm.endAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
</div>
<div class="field">
<label for="status">Статус</label>
<Dropdown id="status" v-model="campaignForm.status" :options="statusOptions" optionLabel="label" optionValue="value" />
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingCampaign ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveCampaign" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить кампанию "{{ campaignToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteCampaign" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import CampaignService from '@/service/CampaignService.js';
import { CampaignStatus } from '@/types/smm.js';
@@ -367,6 +232,141 @@ export default {
};
</script>
<template>
<div class="card">
<div class="card-header">
<h5>Управление кампаниями</h5>
<Button icon="pi pi-plus" label="Создать кампанию" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="campaigns"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'goal', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск кампаний..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="goal" header="Цель" :sortable="true">
<template #body="slotProps">
<span>{{ slotProps.data.goal }}</span>
</template>
</Column>
<Column field="budget" header="Бюджет" :sortable="true">
<template #body="slotProps">
<span class="font-semibold text-green-600">
{{ formatCurrency(slotProps.data.budget) }}
</span>
</template>
</Column>
<Column field="startAt" header="Начало" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.startAt) }}</span>
</template>
</Column>
<Column field="endAt" header="Окончание" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.endAt) }}</span>
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editCampaign(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования кампании -->
<Dialog v-model:visible="showCreateDialog" :header="editingCampaign ? 'Редактировать кампанию' : 'Создать кампанию'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название кампании *</label>
<InputText id="name" v-model="campaignForm.name" :class="{ 'p-invalid': !campaignForm.name }" />
<small v-if="!campaignForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="goal">Цель кампании</label>
<Textarea id="goal" v-model="campaignForm.goal" rows="3" />
</div>
<div class="field">
<label for="budget">Бюджет</label>
<InputNumber id="budget" v-model="campaignForm.budget" mode="currency" currency="KZT" locale="ru-RU" :min="0" />
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="startAt">Дата начала</label>
<Calendar id="startAt" v-model="campaignForm.startAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="endAt">Дата окончания</label>
<Calendar id="endAt" v-model="campaignForm.endAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
</div>
<div class="field">
<label for="status">Статус</label>
<Dropdown id="status" v-model="campaignForm.status" :options="statusOptions" optionLabel="label" optionValue="value" />
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingCampaign ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveCampaign" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить кампанию "{{ campaignToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteCampaign" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<style scoped>
.card {
background: var(--surface-card);
+111 -111
View File
@@ -1,114 +1,3 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление каналами</h5>
<Button icon="pi pi-plus" label="Создать канал" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="channels"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'type']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск каналов..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="type" header="Тип" :sortable="true">
<template #body="slotProps">
<Tag :value="getTypeLabel(slotProps.data.type)" :severity="getTypeSeverity(slotProps.data.type)" />
</template>
</Column>
<Column field="apiKeyRef" header="API Ключ" :sortable="true">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.apiKeyRef }}</span>
</template>
</Column>
<Column field="isActive" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.isActive ? 'Активен' : 'Неактивен'" :severity="slotProps.data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editChannel(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования канала -->
<Dialog v-model:visible="showCreateDialog" :header="editingChannel ? 'Редактировать канал' : 'Создать канал'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название канала *</label>
<InputText id="name" v-model="channelForm.name" :class="{ 'p-invalid': !channelForm.name }" />
<small v-if="!channelForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="type">Тип канала *</label>
<Dropdown id="type" v-model="channelForm.type" :options="typeOptions" optionLabel="label" optionValue="value" :class="{ 'p-invalid': !channelForm.type }" />
<small v-if="!channelForm.type" class="p-error">Тип канала обязателен</small>
</div>
<div class="field">
<label for="apiKeyRef">API Ключ *</label>
<InputText id="apiKeyRef" v-model="channelForm.apiKeyRef" :class="{ 'p-invalid': !channelForm.apiKeyRef }" />
<small v-if="!channelForm.apiKeyRef" class="p-error">API ключ обязателен</small>
<small class="text-600">Ссылка на API ключ в системе</small>
</div>
<div class="field">
<div class="flex align-items-center">
<Checkbox id="isActive" v-model="channelForm.isActive" :binary="true" />
<label for="isActive" class="ml-2">Канал активен</label>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingChannel ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveChannel" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить канал "{{ channelToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteChannel" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import ChannelService from '@/service/ChannelService.js';
import { ChannelType } from '@/types/smm.js';
@@ -311,6 +200,117 @@ export default {
};
</script>
<template>
<div class="card">
<div class="card-header">
<h5>Управление каналами</h5>
<Button icon="pi pi-plus" label="Создать канал" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="channels"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['name', 'type']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск каналов..." />
</span>
</div>
</template>
<Column field="name" header="Название" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.name }}</span>
</template>
</Column>
<Column field="type" header="Тип" :sortable="true">
<template #body="slotProps">
<Tag :value="getTypeLabel(slotProps.data.type)" :severity="getTypeSeverity(slotProps.data.type)" />
</template>
</Column>
<Column field="apiKeyRef" header="API Ключ" :sortable="true">
<template #body="slotProps">
<span class="font-mono text-sm">{{ slotProps.data.apiKeyRef }}</span>
</template>
</Column>
<Column field="isActive" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.isActive ? 'Активен' : 'Неактивен'" :severity="slotProps.data.isActive ? 'success' : 'danger'" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editChannel(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования канала -->
<Dialog v-model:visible="showCreateDialog" :header="editingChannel ? 'Редактировать канал' : 'Создать канал'" :modal="true" :style="{ width: '50vw' }" :closable="false">
<div class="p-fluid">
<div class="field">
<label for="name">Название канала *</label>
<InputText id="name" v-model="channelForm.name" :class="{ 'p-invalid': !channelForm.name }" />
<small v-if="!channelForm.name" class="p-error">Название обязательно</small>
</div>
<div class="field">
<label for="type">Тип канала *</label>
<Dropdown id="type" v-model="channelForm.type" :options="typeOptions" optionLabel="label" optionValue="value" :class="{ 'p-invalid': !channelForm.type }" />
<small v-if="!channelForm.type" class="p-error">Тип канала обязателен</small>
</div>
<div class="field">
<label for="apiKeyRef">API Ключ *</label>
<InputText id="apiKeyRef" v-model="channelForm.apiKeyRef" :class="{ 'p-invalid': !channelForm.apiKeyRef }" />
<small v-if="!channelForm.apiKeyRef" class="p-error">API ключ обязателен</small>
<small class="text-600">Ссылка на API ключ в системе</small>
</div>
<div class="field">
<div class="flex align-items-center">
<Checkbox id="isActive" v-model="channelForm.isActive" :binary="true" />
<label for="isActive" class="ml-2">Канал активен</label>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingChannel ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveChannel" :loading="saving" />
</template>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить канал "{{ channelToDelete?.name }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteChannel" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<style scoped>
.card {
background: var(--surface-card);
+193 -193
View File
@@ -1,196 +1,3 @@
<template>
<div class="card">
<div class="card-header">
<h5>Управление контентом</h5>
<Button icon="pi pi-plus" label="Создать контент" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="content"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['topic', 'locale', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск контента..." />
</span>
</div>
</template>
<Column field="topic" header="Тема" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.topic }}</span>
</template>
</Column>
<Column field="locale" header="Локаль" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.locale" severity="info" />
</template>
</Column>
<Column field="campaignId" header="Кампания" :sortable="true">
<template #body="slotProps">
<span>{{ getCampaignName(slotProps.data.campaignId) }}</span>
</template>
</Column>
<Column field="postDraft" header="Черновик" :sortable="true">
<template #body="slotProps">
<span class="text-sm">{{ truncateText(slotProps.data.postDraft, 50) }}</span>
</template>
</Column>
<Column field="scheduledAt" header="Запланировано" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.scheduledAt) }}</span>
</template>
</Column>
<Column field="priority" header="Приоритет" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.priority" :severity="getPrioritySeverity(slotProps.data.priority)" />
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" class="p-button-rounded p-button-text p-button-sm" @click="viewContent(slotProps.data)" v-tooltip.top="'Просмотр'" />
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editContent(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button v-if="slotProps.data.status === 'PENDING_APPROVAL'" icon="pi pi-check" class="p-button-rounded p-button-text p-button-success p-button-sm" @click="approveContent(slotProps.data)" v-tooltip.top="'Одобрить'" />
<Button v-if="slotProps.data.status === 'APPROVED'" icon="pi pi-send" class="p-button-rounded p-button-text p-button-info p-button-sm" @click="publishContent(slotProps.data)" v-tooltip.top="'Опубликовать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования контента -->
<Dialog v-model:visible="showCreateDialog" :header="editingContent ? 'Редактировать контент' : 'Создать контент'" :modal="true" :style="{ width: '70vw' }" :closable="false">
<div class="p-fluid">
<div class="grid">
<div class="col-6">
<div class="field">
<label for="campaignId">Кампания *</label>
<Dropdown id="campaignId" v-model="contentForm.campaignId" :options="campaigns" optionLabel="name" optionValue="id" :class="{ 'p-invalid': !contentForm.campaignId }" />
<small v-if="!contentForm.campaignId" class="p-error">Кампания обязательна</small>
</div>
</div>
<div class="col-6">
<div class="field">
<label for="locale">Локаль *</label>
<InputText id="locale" v-model="contentForm.locale" :class="{ 'p-invalid': !contentForm.locale }" />
<small v-if="!contentForm.locale" class="p-error">Локаль обязательна</small>
</div>
</div>
</div>
<div class="field">
<label for="topic">Тема *</label>
<InputText id="topic" v-model="contentForm.topic" :class="{ 'p-invalid': !contentForm.topic }" />
<small v-if="!contentForm.topic" class="p-error">Тема обязательна</small>
</div>
<div class="field">
<label for="postDraft">Черновик поста</label>
<Textarea id="postDraft" v-model="contentForm.postDraft" rows="4" />
</div>
<div class="field">
<label for="assetsRefs">Ссылки на ресурсы</label>
<InputText id="assetsRefs" v-model="contentForm.assetsRefs" />
<small class="text-600">Разделите ссылки запятыми</small>
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="scheduledAt">Время публикации</label>
<Calendar id="scheduledAt" v-model="contentForm.scheduledAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="priority">Приоритет</label>
<InputNumber id="priority" v-model="contentForm.priority" :min="1" :max="10" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingContent ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveContent" :loading="saving" />
</template>
</Dialog>
<!-- Диалог просмотра контента -->
<Dialog v-model:visible="showViewDialog" header="Просмотр контента" :modal="true" :style="{ width: '60vw' }">
<div v-if="viewingContent" class="content-view">
<div class="field">
<label>Тема:</label>
<p class="font-semibold">{{ viewingContent.topic }}</p>
</div>
<div class="field">
<label>Локаль:</label>
<p>{{ viewingContent.locale }}</p>
</div>
<div class="field">
<label>Кампания:</label>
<p>{{ getCampaignName(viewingContent.campaignId) }}</p>
</div>
<div class="field">
<label>Черновик поста:</label>
<p class="whitespace-pre-wrap">{{ viewingContent.postDraft }}</p>
</div>
<div class="field">
<label>Ссылки на ресурсы:</label>
<p>{{ viewingContent.assetsRefs }}</p>
</div>
<div class="field">
<label>Время публикации:</label>
<p>{{ formatDate(viewingContent.scheduledAt) }}</p>
</div>
<div class="field">
<label>Приоритет:</label>
<p>{{ viewingContent.priority }}</p>
</div>
<div class="field">
<label>Статус:</label>
<Tag :value="getStatusLabel(viewingContent.status)" :severity="getStatusSeverity(viewingContent.status)" />
</div>
</div>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить контент "{{ contentToDelete?.topic }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteContent" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<script>
import CampaignService from '@/service/CampaignService.js';
import ContentService from '@/service/ContentService.js';
@@ -501,6 +308,199 @@ export default {
};
</script>
<template>
<div class="card">
<div class="card-header">
<h5>Управление контентом</h5>
<Button icon="pi pi-plus" label="Создать контент" @click="showCreateDialog = true" class="p-button-success" />
</div>
<div class="card-content">
<DataTable
:value="content"
:loading="loading"
paginator
:rows="10"
:rowsPerPageOptions="[5, 10, 20]"
sortMode="multiple"
removableSort
filterDisplay="row"
:globalFilterFields="['topic', 'locale', 'status']"
v-model:filters="filters"
:filters="filters"
>
<template #header>
<div class="flex justify-content-between">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="Поиск контента..." />
</span>
</div>
</template>
<Column field="topic" header="Тема" :sortable="true">
<template #body="slotProps">
<span class="font-semibold">{{ slotProps.data.topic }}</span>
</template>
</Column>
<Column field="locale" header="Локаль" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.locale" severity="info" />
</template>
</Column>
<Column field="campaignId" header="Кампания" :sortable="true">
<template #body="slotProps">
<span>{{ getCampaignName(slotProps.data.campaignId) }}</span>
</template>
</Column>
<Column field="postDraft" header="Черновик" :sortable="true">
<template #body="slotProps">
<span class="text-sm">{{ truncateText(slotProps.data.postDraft, 50) }}</span>
</template>
</Column>
<Column field="scheduledAt" header="Запланировано" :sortable="true">
<template #body="slotProps">
<span>{{ formatDate(slotProps.data.scheduledAt) }}</span>
</template>
</Column>
<Column field="priority" header="Приоритет" :sortable="true">
<template #body="slotProps">
<Tag :value="slotProps.data.priority" :severity="getPrioritySeverity(slotProps.data.priority)" />
</template>
</Column>
<Column field="status" header="Статус" :sortable="true">
<template #body="slotProps">
<Tag :value="getStatusLabel(slotProps.data.status)" :severity="getStatusSeverity(slotProps.data.status)" />
</template>
</Column>
<Column header="Действия" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button icon="pi pi-eye" class="p-button-rounded p-button-text p-button-sm" @click="viewContent(slotProps.data)" v-tooltip.top="'Просмотр'" />
<Button icon="pi pi-pencil" class="p-button-rounded p-button-text p-button-sm" @click="editContent(slotProps.data)" v-tooltip.top="'Редактировать'" />
<Button v-if="slotProps.data.status === 'PENDING_APPROVAL'" icon="pi pi-check" class="p-button-rounded p-button-text p-button-success p-button-sm" @click="approveContent(slotProps.data)" v-tooltip.top="'Одобрить'" />
<Button v-if="slotProps.data.status === 'APPROVED'" icon="pi pi-send" class="p-button-rounded p-button-text p-button-info p-button-sm" @click="publishContent(slotProps.data)" v-tooltip.top="'Опубликовать'" />
<Button icon="pi pi-trash" class="p-button-rounded p-button-text p-button-danger p-button-sm" @click="confirmDelete(slotProps.data)" v-tooltip.top="'Удалить'" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Диалог создания/редактирования контента -->
<Dialog v-model:visible="showCreateDialog" :header="editingContent ? 'Редактировать контент' : 'Создать контент'" :modal="true" :style="{ width: '70vw' }" :closable="false">
<div class="p-fluid">
<div class="grid">
<div class="col-6">
<div class="field">
<label for="campaignId">Кампания *</label>
<Dropdown id="campaignId" v-model="contentForm.campaignId" :options="campaigns" optionLabel="name" optionValue="id" :class="{ 'p-invalid': !contentForm.campaignId }" />
<small v-if="!contentForm.campaignId" class="p-error">Кампания обязательна</small>
</div>
</div>
<div class="col-6">
<div class="field">
<label for="locale">Локаль *</label>
<InputText id="locale" v-model="contentForm.locale" :class="{ 'p-invalid': !contentForm.locale }" />
<small v-if="!contentForm.locale" class="p-error">Локаль обязательна</small>
</div>
</div>
</div>
<div class="field">
<label for="topic">Тема *</label>
<InputText id="topic" v-model="contentForm.topic" :class="{ 'p-invalid': !contentForm.topic }" />
<small v-if="!contentForm.topic" class="p-error">Тема обязательна</small>
</div>
<div class="field">
<label for="postDraft">Черновик поста</label>
<Textarea id="postDraft" v-model="contentForm.postDraft" rows="4" />
</div>
<div class="field">
<label for="assetsRefs">Ссылки на ресурсы</label>
<InputText id="assetsRefs" v-model="contentForm.assetsRefs" />
<small class="text-600">Разделите ссылки запятыми</small>
</div>
<div class="grid">
<div class="col-6">
<div class="field">
<label for="scheduledAt">Время публикации</label>
<Calendar id="scheduledAt" v-model="contentForm.scheduledAt" dateFormat="yy-mm-dd" showTime hourFormat="24" />
</div>
</div>
<div class="col-6">
<div class="field">
<label for="priority">Приоритет</label>
<InputNumber id="priority" v-model="contentForm.priority" :min="1" :max="10" />
</div>
</div>
</div>
</div>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="cancelEdit" class="p-button-text" />
<Button :label="editingContent ? 'Обновить' : 'Создать'" icon="pi pi-check" @click="saveContent" :loading="saving" />
</template>
</Dialog>
<!-- Диалог просмотра контента -->
<Dialog v-model:visible="showViewDialog" header="Просмотр контента" :modal="true" :style="{ width: '60vw' }">
<div v-if="viewingContent" class="content-view">
<div class="field">
<label>Тема:</label>
<p class="font-semibold">{{ viewingContent.topic }}</p>
</div>
<div class="field">
<label>Локаль:</label>
<p>{{ viewingContent.locale }}</p>
</div>
<div class="field">
<label>Кампания:</label>
<p>{{ getCampaignName(viewingContent.campaignId) }}</p>
</div>
<div class="field">
<label>Черновик поста:</label>
<p class="whitespace-pre-wrap">{{ viewingContent.postDraft }}</p>
</div>
<div class="field">
<label>Ссылки на ресурсы:</label>
<p>{{ viewingContent.assetsRefs }}</p>
</div>
<div class="field">
<label>Время публикации:</label>
<p>{{ formatDate(viewingContent.scheduledAt) }}</p>
</div>
<div class="field">
<label>Приоритет:</label>
<p>{{ viewingContent.priority }}</p>
</div>
<div class="field">
<label>Статус:</label>
<Tag :value="getStatusLabel(viewingContent.status)" :severity="getStatusSeverity(viewingContent.status)" />
</div>
</div>
</Dialog>
<!-- Диалог подтверждения удаления -->
<Dialog v-model:visible="showDeleteDialog" header="Подтверждение удаления" :modal="true" :style="{ width: '25vw' }">
<p>Вы уверены, что хотите удалить контент "{{ contentToDelete?.topic }}"?</p>
<template #footer>
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" class="p-button-text" />
<Button label="Удалить" icon="pi pi-trash" @click="deleteContent" class="p-button-danger" :loading="deleting" />
</template>
</Dialog>
</template>
<style scoped>
.card {
background: var(--surface-card);
+128 -128
View File
@@ -1,3 +1,131 @@
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import CampaignService from '@/service/CampaignService.js';
import ContentService from '@/service/ContentService.js';
import { CampaignStatus, ContentStatus } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'SmmDashboard',
setup() {
const toast = useToast();
// Reactive data
const stats = ref({});
const recentCampaigns = ref([]);
const recentContent = ref([]);
const loading = ref(false);
// Methods
const loadDashboardData = async () => {
loading.value = true;
try {
const [systemStats, publishingStats, campaigns, content] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats(), CampaignService.getAllCampaigns(), ContentService.getAllContent()]);
stats.value = {
campaigns: systemStats.campaigns?.total || 0,
activeCampaigns: systemStats.campaigns?.activeCampaigns || 0,
channels: systemStats.channels?.total || 0,
activeChannels: systemStats.channels?.activeChannels || 0,
content: systemStats.content?.total || 0,
pendingApproval: systemStats.content?.pendingApproval || 0,
published: publishingStats.totalPublished || 0,
publishingRate: publishingStats.publishingRate || 0
};
// Получаем последние 5 кампаний
recentCampaigns.value = campaigns.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)).slice(0, 5);
// Получаем последние 5 контентов
recentContent.value = content.sort((a, b) => new Date(b.scheduledAt || b.createdAt) - new Date(a.scheduledAt || a.createdAt)).slice(0, 5);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить данные: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const truncateText = (text, length) => {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
};
const getStatusLabel = (status) => {
const labels = {
[CampaignStatus.PLANNED]: 'Запланирована',
[CampaignStatus.ACTIVE]: 'Активна',
[CampaignStatus.COMPLETED]: 'Завершена'
};
return labels[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
[CampaignStatus.PLANNED]: 'info',
[CampaignStatus.ACTIVE]: 'success',
[CampaignStatus.COMPLETED]: 'secondary'
};
return severities[status] || 'info';
};
const getContentStatusLabel = (status) => {
const labels = {
[ContentStatus.DRAFT]: 'Черновик',
[ContentStatus.PENDING_APPROVAL]: 'Ожидает',
[ContentStatus.APPROVED]: 'Одобрен',
[ContentStatus.PUBLISHED]: 'Опубликован',
[ContentStatus.FAILED]: 'Ошибка'
};
return labels[status] || status;
};
const getContentStatusSeverity = (status) => {
const severities = {
[ContentStatus.DRAFT]: 'info',
[ContentStatus.PENDING_APPROVAL]: 'warning',
[ContentStatus.APPROVED]: 'success',
[ContentStatus.PUBLISHED]: 'success',
[ContentStatus.FAILED]: 'danger'
};
return severities[status] || 'info';
};
// Lifecycle
onMounted(() => {
loadDashboardData();
});
return {
stats,
recentCampaigns,
recentContent,
loading,
formatDate,
truncateText,
getStatusLabel,
getStatusSeverity,
getContentStatusLabel,
getContentStatusSeverity
};
}
};
</script>
<template>
<div class="smm-dashboard">
<div class="grid">
@@ -158,134 +286,6 @@
</div>
</template>
<script>
import AnalyticsService from '@/service/AnalyticsService.js';
import CampaignService from '@/service/CampaignService.js';
import ContentService from '@/service/ContentService.js';
import { CampaignStatus, ContentStatus } from '@/types/smm.js';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref } from 'vue';
export default {
name: 'SmmDashboard',
setup() {
const toast = useToast();
// Reactive data
const stats = ref({});
const recentCampaigns = ref([]);
const recentContent = ref([]);
const loading = ref(false);
// Methods
const loadDashboardData = async () => {
loading.value = true;
try {
const [systemStats, publishingStats, campaigns, content] = await Promise.all([AnalyticsService.getSystemStats(), AnalyticsService.getPublishingStats(), CampaignService.getAllCampaigns(), ContentService.getAllContent()]);
stats.value = {
campaigns: systemStats.campaigns?.total || 0,
activeCampaigns: systemStats.campaigns?.activeCampaigns || 0,
channels: systemStats.channels?.total || 0,
activeChannels: systemStats.channels?.activeChannels || 0,
content: systemStats.content?.total || 0,
pendingApproval: systemStats.content?.pendingApproval || 0,
published: publishingStats.totalPublished || 0,
publishingRate: publishingStats.publishingRate || 0
};
// Получаем последние 5 кампаний
recentCampaigns.value = campaigns.sort((a, b) => new Date(b.startAt) - new Date(a.startAt)).slice(0, 5);
// Получаем последние 5 контентов
recentContent.value = content.sort((a, b) => new Date(b.scheduledAt || b.createdAt) - new Date(a.scheduledAt || a.createdAt)).slice(0, 5);
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить данные: ' + error.message,
life: 3000
});
} finally {
loading.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const truncateText = (text, length) => {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
};
const getStatusLabel = (status) => {
const labels = {
[CampaignStatus.PLANNED]: 'Запланирована',
[CampaignStatus.ACTIVE]: 'Активна',
[CampaignStatus.COMPLETED]: 'Завершена'
};
return labels[status] || status;
};
const getStatusSeverity = (status) => {
const severities = {
[CampaignStatus.PLANNED]: 'info',
[CampaignStatus.ACTIVE]: 'success',
[CampaignStatus.COMPLETED]: 'secondary'
};
return severities[status] || 'info';
};
const getContentStatusLabel = (status) => {
const labels = {
[ContentStatus.DRAFT]: 'Черновик',
[ContentStatus.PENDING_APPROVAL]: 'Ожидает',
[ContentStatus.APPROVED]: 'Одобрен',
[ContentStatus.PUBLISHED]: 'Опубликован',
[ContentStatus.FAILED]: 'Ошибка'
};
return labels[status] || status;
};
const getContentStatusSeverity = (status) => {
const severities = {
[ContentStatus.DRAFT]: 'info',
[ContentStatus.PENDING_APPROVAL]: 'warning',
[ContentStatus.APPROVED]: 'success',
[ContentStatus.PUBLISHED]: 'success',
[ContentStatus.FAILED]: 'danger'
};
return severities[status] || 'info';
};
// Lifecycle
onMounted(() => {
loadDashboardData();
});
return {
stats,
recentCampaigns,
recentContent,
loading,
formatDate,
truncateText,
getStatusLabel,
getStatusSeverity,
getContentStatusLabel,
getContentStatusSeverity
};
}
};
</script>
<style scoped>
.smm-dashboard {
padding: 1rem;
+185
View File
@@ -0,0 +1,185 @@
// --- ENUMS ---
export type BusinessStage =
| "JUST_STARTING"
| "LESS_THAN_ONE_YEAR"
| "ONE_TO_THREE_YEARS"
| "MORE_THAN_THREE_YEARS";
export type ClientTarget = "PRIVATE_CLIENTS" | "BUSINESS" | "MIXED";
export type OfferType = "SERVICES" | "GOODS" | "SUBSCRIPTION";
export type AverageCheck = "LOW" | "MEDIUM" | "HIGH";
export type CustomerBehavior =
| "BUY_QUICKLY"
| "COMPARE_OPTIONS"
| "REQUEST_PROPOSAL"
| "CHECK_REVIEWS"
| "NEED_CONSULTATION";
export type GeoScope = "SINGLE_CITY" | "MULTI_CITY" | "FULL_COUNTRY" | "ONLINE";
export type PurchaseFrequency =
| "ONCE"
| "MULTIPLE_PER_YEAR"
| "REGULARLY";
export type VisualFactor =
| "VISUALS_IMPORTANT"
| "PARTIALLY"
| "EXPLANATION_IMPORTANT";
export type DecisionPriority =
| "PRICE"
| "QUALITY"
| "SPEED"
| "TRUST"
| "SERVICE";
export type DiscoveryMethod =
| "SEARCH_WEB"
| "RECOMMENDATIONS"
| "PRICE_SELECTION"
| "REVIEWS"
| "CONVENIENCE";
export type PriceFeedback =
| "LOWER_THAN_EXPECTED"
| "AS_EXPECTED"
| "HIGHER_THAN_EXPECTED"
| "NOT_DISCUSSED";
export type SmmStatus = "NONE" | "DORMANT" | "IRREGULAR" | "ACTIVE";
export type LeadVolume = "NONE" | "SOMETIMES" | "REGULARLY";
export type ResponseHandler = "MYSELF" | "EMPLOYEE" | "DELAYED";
export type BusinessConstraint =
| "NONE"
| "SEASONALITY"
| "SMALL_TEAM"
| "LIMITED_BUDGET"
| "LONG_DEAL_CYCLE";
// --- MAPPINGS ДЛЯ UI ---
export const BusinessStageLabels: Record<BusinessStage, string> = {
JUST_STARTING: "Только запускаемся",
LESS_THAN_ONE_YEAR: "Работаем до 1 года",
ONE_TO_THREE_YEARS: "Работаем 1–3 года",
MORE_THAN_THREE_YEARS: "Работаем более 3 лет"
};
export const ClientTargetLabels: Record<ClientTarget, string> = {
PRIVATE_CLIENTS: "Работаем с конечными клиентами (B2C)",
BUSINESS: "Работаем с бизнесом (B2B)",
MIXED: "Смешанная модель (B2B + B2C)"
};
export const OfferTypeLabels: Record<OfferType, string> = {
SERVICES: "Услуги",
GOODS: "Товары",
SUBSCRIPTION: "Подписка / доступ"
};
export const AverageCheckLabels: Record<AverageCheck, string> = {
LOW: "Низкий чек",
MEDIUM: "Средний чек",
HIGH: "Высокий чек"
};
export const CustomerBehaviorLabels: Record<CustomerBehavior, string> = {
BUY_QUICKLY: "Покупают быстро, без долгих раздумий",
COMPARE_OPTIONS: "Сравнивают варианты и предложения",
REQUEST_PROPOSAL: "Запрашивают индивидуальное коммерческое предложение",
CHECK_REVIEWS: "Внимательно читают отзывы и рейтинги",
NEED_CONSULTATION: "Нуждаются в консультации перед покупкой"
};
export const GeoScopeLabels: Record<GeoScope, string> = {
SINGLE_CITY: "Один город",
MULTI_CITY: "Несколько городов",
FULL_COUNTRY: "Вся страна",
ONLINE: "Онлайн по всему миру"
};
export const PurchaseFrequencyLabels: Record<PurchaseFrequency, string> = {
ONCE: "Одна покупка",
MULTIPLE_PER_YEAR: "Несколько раз в год",
REGULARLY: "Регулярно (ежемесячно / чаще)"
};
export const VisualFactorLabels: Record<VisualFactor, string> = {
VISUALS_IMPORTANT: "Визуал критически важен (эстетика, фото, видео)",
PARTIALLY: "Важно, но не критично",
EXPLANATION_IMPORTANT:
"Тексты и экспертность важнее визуала для доверия"
};
export const DecisionPriorityLabels: Record<DecisionPriority, string> = {
PRICE: "Цена",
QUALITY: "Качество",
SPEED: "Скорость",
TRUST: "Доверие к бренду",
SERVICE: "Сервис и сопровождение"
};
export const DiscoveryMethodLabels: Record<DiscoveryMethod, string> = {
SEARCH_WEB: "Ищут в поиске и сравнивают",
RECOMMENDATIONS: "Приходят по рекомендациям / сарафанке",
PRICE_SELECTION: "Выбирают по цене / акциям",
REVIEWS: "Ориентируются на отзывы и рейтинги",
CONVENIENCE: "Выбирают по удобству (сайт, UX, доставка)"
};
export const PriceFeedbackLabels: Record<PriceFeedback, string> = {
LOWER_THAN_EXPECTED: "Часто говорят, что «дёшево / ниже ожиданий»",
AS_EXPECTED: "Цена воспринимается как ожидаемая",
HIGHER_THAN_EXPECTED: "Считают, что «дорого / выше ожиданий»",
NOT_DISCUSSED: "Цену обычно подробно не обсуждают"
};
export const SmmStatusLabels: Record<SmmStatus, string> = {
NONE: "Соцсетей нет",
DORMANT: "Есть, но почти не ведём",
IRREGULAR: "Ведём нерегулярно",
ACTIVE: "Активно ведём и развиваем"
};
export const LeadVolumeLabels: Record<LeadVolume, string> = {
NONE: "Заявок практически нет",
SOMETIMES: "Иногда приходят заявки",
REGULARLY: "Заявки приходят регулярно"
};
export const ResponseHandlerLabels: Record<ResponseHandler, string> = {
MYSELF: "Отвечает лично основатель / владелец",
EMPLOYEE: "Есть выделенный сотрудник",
DELAYED: "Отвечаем не сразу, с задержками"
};
export const BusinessConstraintLabels: Record<BusinessConstraint, string> = {
NONE: "Серьезных ограничений нет",
SEASONALITY: "Ярко выраженная сезонность",
SMALL_TEAM: "Маленькая команда",
LIMITED_BUDGET: "Ограниченный маркетинговый бюджет",
LONG_DEAL_CYCLE: "Длинный цикл сделки"
};
// --- MAIN REQUEST INTERFACE ---
export interface MarketingAnalysisV3Request {
businessNiche: string;
productName: string;
goal: string;
businessStage: BusinessStage;
clientTarget: ClientTarget;
offerType: OfferType;
averageCheck: AverageCheck;
customerBehaviors: CustomerBehavior[]; // min 1, max 2
geoScope: GeoScope;
mainCity?: string;
presenceCities?: string[]; // max 5
promotionCities: string[]; // max 5
productDescription: string;
purchaseFrequency: PurchaseFrequency;
visualFactor: VisualFactor;
decisionPriorities: DecisionPriority[]; // min 1, max 2
discoveryMethods: DiscoveryMethod[]; // min 1, max 2
priceFeedback: PriceFeedback;
smmStatus: SmmStatus;
leadVolume: LeadVolume;
responseHandler: ResponseHandler;
constraints?: BusinessConstraint[];
userSocialLinks?: string[]; // max 5
knownCompetitorLinks?: string[]; // max 5
detailLevel: string;
analysisType: string[]; // ['РЫНОК', 'КОНКУРЕНТЫ', 'ЦА', 'КАНАЛЫ']
}
+135
View File
@@ -0,0 +1,135 @@
export interface MarketingAnalysisV3Result {
"0_executive_summary": ExecutiveSummary;
"1_market_landscape": MarketLandscape;
"2_geo_structure": GeoStructure;
"3_competitor_map": CompetitorProfile[];
"4_content_profile": ContentProfile;
"5_competition_intensity": CompetitionIntensity;
"6_reputation_analysis": ReputationAnalysis;
"7_behavioral_pattern": BehavioralPattern;
"8_search_demand": SearchDemand;
"9_user_positioning": UserPositioning;
"10_structured_conclusions": string[];
"11_smm_strategy_rationale": string;
}
export interface ExecutiveSummary {
businessStage: string;
geography: string;
activeCompetitors: number;
competitionLevel: string;
averageNicheEr: string;
averageRating: number;
demandTrend: string;
keyFigures: string[];
}
export interface TimeSeriesPoint {
period: string;
value: number;
}
export interface MarketLandscape {
activePlayersByPlatform: Record<string, number>;
cityDistribution: Record<string, number>;
demandDynamics: TimeSeriesPoint[];
nicheReputationLevel: number;
}
export interface CityMetrics {
city: string;
activePlayers: number;
avgEr: number;
avgRating: number;
avgPostsPerMonth: number;
}
export interface GeoStructure {
cityComparison: CityMetrics[];
densityIndex: number;
}
export interface CompetitorProfile {
name: string;
platform: string;
followers: number;
postsPerMonth: number;
er: number;
rating: number;
reviews: number;
strengths: string[];
weaknesses: string[];
}
export interface ContentProfile {
demoContentPercent: number;
expertContentPercent: number;
salesContentPercent: number;
reviewsContentPercent: number;
engagementContentPercent: number;
videoShare: number;
avgTextLength: string;
ctaFrequency: string;
}
export interface CompetitionIntensity {
ciiIndex: number;
intensityLabel: string;
contributingFactors: string[];
}
export interface ReputationAnalysis {
avgNicheRating: number;
medianReviews: number;
starDistribution: Record<string, number>;
highTrustBusinessShare: number;
avgOwnerResponseSpeed: string;
}
export interface BehavioralPattern {
promoFrequency: string;
bookingFrequency: string;
dmRequestFrequency: string;
priceVisibility: string;
avgCycleDuration: string;
commonCta: string[];
}
export interface SearchDemand {
avgFrequency: string;
seasonality: TimeSeriesPoint[];
peakPeriods: string[];
relatedQueries: string[];
}
export interface RadarMetrics {
activity: number;
engagement: number;
video: number;
reputation: number;
frequency: number;
}
export interface UserPositioning {
radarChart: RadarMetrics;
status: string;
gaps: string[];
}
export type MarketingAnalysisStatus =
| "QUEUED"
| "PROCESSING"
| "COMPLETED"
| "FAILED";
export interface MarketingAnalysisV3Document {
id: string;
userId: string;
status: MarketingAnalysisStatus;
requestData: unknown;
resultData?: MarketingAnalysisV3Result;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
@@ -1,47 +0,0 @@
// vite.config.mjs
import { fileURLToPath, URL } from "node:url";
import { PrimeVueResolver } from "file:///Users/magzhanzhumabaev/Desktop/kt%20cloud/sakai-vue/node_modules/@primevue/auto-import-resolver/index.mjs";
import vue from "file:///Users/magzhanzhumabaev/Desktop/kt%20cloud/sakai-vue/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import Components from "file:///Users/magzhanzhumabaev/Desktop/kt%20cloud/sakai-vue/node_modules/unplugin-vue-components/dist/vite.js";
import { defineConfig } from "file:///Users/magzhanzhumabaev/Desktop/kt%20cloud/sakai-vue/node_modules/vite/dist/node/index.js";
var __vite_injected_original_import_meta_url = "file:///Users/magzhanzhumabaev/Desktop/kt%20cloud/sakai-vue/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,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiL1VzZXJzL21hZ3poYW56aHVtYWJhZXYvRGVza3RvcC9rdCBjbG91ZC9zYWthaS12dWVcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9tYWd6aGFuemh1bWFiYWV2L0Rlc2t0b3Ava3QgY2xvdWQvc2FrYWktdnVlL3ZpdGUuY29uZmlnLm1qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMvbWFnemhhbnpodW1hYmFldi9EZXNrdG9wL2t0JTIwY2xvdWQvc2FrYWktdnVlL3ZpdGUuY29uZmlnLm1qc1wiO2ltcG9ydCB7IGZpbGVVUkxUb1BhdGgsIFVSTCB9IGZyb20gJ25vZGU6dXJsJztcblxuaW1wb3J0IHsgUHJpbWVWdWVSZXNvbHZlciB9IGZyb20gJ0BwcmltZXZ1ZS9hdXRvLWltcG9ydC1yZXNvbHZlcic7XG5pbXBvcnQgdnVlIGZyb20gJ0B2aXRlanMvcGx1Z2luLXZ1ZSc7XG5pbXBvcnQgQ29tcG9uZW50cyBmcm9tICd1bnBsdWdpbi12dWUtY29tcG9uZW50cy92aXRlJztcbmltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gJ3ZpdGUnO1xuXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgICBvcHRpbWl6ZURlcHM6IHtcbiAgICAgICAgbm9EaXNjb3Zlcnk6IHRydWVcbiAgICB9LFxuICAgIHBsdWdpbnM6IFtcbiAgICAgICAgdnVlKCksXG4gICAgICAgIENvbXBvbmVudHMoe1xuICAgICAgICAgICAgcmVzb2x2ZXJzOiBbUHJpbWVWdWVSZXNvbHZlcigpXVxuICAgICAgICB9KVxuICAgIF0sXG4gICAgcmVzb2x2ZToge1xuICAgICAgICBhbGlhczoge1xuICAgICAgICAgICAgJ0AnOiBmaWxlVVJMVG9QYXRoKG5ldyBVUkwoJy4vc3JjJywgaW1wb3J0Lm1ldGEudXJsKSlcbiAgICAgICAgfVxuICAgIH0sXG4gICAgc2VydmVyOiB7XG4gICAgICAgIHByb3h5OiB7XG4gICAgICAgICAgICAnL2FwaSc6IHtcbiAgICAgICAgICAgICAgICB0YXJnZXQ6ICdodHRwczovL2FwaS5rb250dXJhaS5reicsXG4gICAgICAgICAgICAgICAgY2hhbmdlT3JpZ2luOiB0cnVlLFxuICAgICAgICAgICAgICAgIHNlY3VyZTogdHJ1ZSxcbiAgICAgICAgICAgICAgICBjb25maWd1cmU6IChwcm94eSwgX29wdGlvbnMpID0+IHtcbiAgICAgICAgICAgICAgICAgICAgcHJveHkub24oJ2Vycm9yJywgKGVyciwgX3JlcSwgX3JlcykgPT4ge1xuICAgICAgICAgICAgICAgICAgICAgICAgY29uc29sZS5sb2coJ3Byb3h5IGVycm9yJywgZXJyKTtcbiAgICAgICAgICAgICAgICAgICAgfSk7XG4gICAgICAgICAgICAgICAgICAgIHByb3h5Lm9uKCdwcm94eVJlcScsIChwcm94eVJlcSwgcmVxLCBfcmVzKSA9PiB7XG4gICAgICAgICAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnU2VuZGluZyBSZXF1ZXN0IHRvIHRoZSBUYXJnZXQ6JywgcmVxLm1ldGhvZCwgcmVxLnVybCk7XG4gICAgICAgICAgICAgICAgICAgIH0pO1xuICAgICAgICAgICAgICAgICAgICBwcm94eS5vbigncHJveHlSZXMnLCAocHJveHlSZXMsIHJlcSwgX3JlcykgPT4ge1xuICAgICAgICAgICAgICAgICAgICAgICAgY29uc29sZS5sb2coJ1JlY2VpdmVkIFJlc3BvbnNlIGZyb20gdGhlIFRhcmdldDonLCBwcm94eVJlcy5zdGF0dXNDb2RlLCByZXEudXJsKTtcbiAgICAgICAgICAgICAgICAgICAgfSk7XG4gICAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgfVxuICAgICAgICB9XG4gICAgfVxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQTRVLFNBQVMsZUFBZSxXQUFXO0FBRS9XLFNBQVMsd0JBQXdCO0FBQ2pDLE9BQU8sU0FBUztBQUNoQixPQUFPLGdCQUFnQjtBQUN2QixTQUFTLG9CQUFvQjtBQUxnTCxJQUFNLDJDQUEyQztBQVE5UCxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUN4QixjQUFjO0FBQUEsSUFDVixhQUFhO0FBQUEsRUFDakI7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNMLElBQUk7QUFBQSxJQUNKLFdBQVc7QUFBQSxNQUNQLFdBQVcsQ0FBQyxpQkFBaUIsQ0FBQztBQUFBLElBQ2xDLENBQUM7QUFBQSxFQUNMO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDTCxPQUFPO0FBQUEsTUFDSCxLQUFLLGNBQWMsSUFBSSxJQUFJLFNBQVMsd0NBQWUsQ0FBQztBQUFBLElBQ3hEO0FBQUEsRUFDSjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ0osT0FBTztBQUFBLE1BQ0gsUUFBUTtBQUFBLFFBQ0osUUFBUTtBQUFBLFFBQ1IsY0FBYztBQUFBLFFBQ2QsUUFBUTtBQUFBLFFBQ1IsV0FBVyxDQUFDLE9BQU8sYUFBYTtBQUM1QixnQkFBTSxHQUFHLFNBQVMsQ0FBQyxLQUFLLE1BQU0sU0FBUztBQUNuQyxvQkFBUSxJQUFJLGVBQWUsR0FBRztBQUFBLFVBQ2xDLENBQUM7QUFDRCxnQkFBTSxHQUFHLFlBQVksQ0FBQyxVQUFVLEtBQUssU0FBUztBQUMxQyxvQkFBUSxJQUFJLGtDQUFrQyxJQUFJLFFBQVEsSUFBSSxHQUFHO0FBQUEsVUFDckUsQ0FBQztBQUNELGdCQUFNLEdBQUcsWUFBWSxDQUFDLFVBQVUsS0FBSyxTQUFTO0FBQzFDLG9CQUFRLElBQUksc0NBQXNDLFNBQVMsWUFBWSxJQUFJLEdBQUc7QUFBQSxVQUNsRixDQUFDO0FBQUEsUUFDTDtBQUFBLE1BQ0o7QUFBQSxJQUNKO0FBQUEsRUFDSjtBQUNKLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==