feat: initialize full-stack architecture with backend routes, database schema, and glassmorphic frontend UI

This commit is contained in:
2026-05-13 00:12:19 +03:00
parent bd73f18d5c
commit 0732acbda5
21 changed files with 1937 additions and 307 deletions

109
README.md
View File

@@ -1,54 +1,81 @@
# AutoMarket (OzonGuard)
# 🚀 AutoMarket Ultimate Marketplace SaaS Platform
**AutoMarket** — это универсальное веб-приложение для интеграции, управления и автоматизации процессов сразу на 10 маркетплейсах. Система позволяет хранить ключи API, быстро подготавливать жалобы на нарушителей и гибко расширять функционал.
**AutoMarket** — это комплексная экосистема для профессиональных продавцов на маркетплейсах (Ozon, Wildberries и др.), объединяющая в себе инструменты защиты контента, финансовой аналитики, SEO-мониторинга и автоматизации клиентского сервиса с помощью искусственного интеллекта.
## Поддерживаемые Маркетплейсы
1. **Ozon**
2. **Wildberries**
3. **Yandex Market**
4. **Magnit Market**
5. **MegaMarket**
6. **AliExpress (AE Platform)**
7. **Avito**
8. **Lamoda**
9. **Lemana PRO (Леруа Мерлен)**
10. **Flowwow**
---
## Основной функционал
## 💎 Ключевые возможности
- **Настройки Интеграций:** Единый интерфейс для ввода и хранения API ключей всех 10 площадок. Больше не нужно копаться в .env файлах.
- **Управление кейсами (жалобами):** Создание универсальных жалоб (кейсов) с указанием маркетплейса, ссылок на карточку нарушителя и его артикула.
- **Прикрепление доказательств:** Загрузка изображений и PDF в качестве доказательной базы.
- **Статусы кейсов:** Отслеживание статусов: *Новый*, *Подготовлен*, *Отправлен*, *Закрыт*.
- **Умный поиск:** Поиск по артикулу и фильтрация по площадке или статусу.
### 1. 🤖 AI Assistant (Интеграция с Google Gemini)
* **Авто-претензии:** Генерация юридически грамотных жалоб на нарушителей на основе артикула и площадки.
* **Умный ответчик:** Автоматическая генерация персонализированных ответов на отзывы покупателей (поддержка позитивного и отработка негативного сценария).
## Технологический стек
### 2. 📊 Multi-account Dashboard
* Единое окно управления для неограниченного количества магазинов и юридических лиц.
* Агрегированная статистика продаж, выручки и состояния API-подключений в реальном времени.
- **Frontend:** HTML5, CSS3, Vanilla JavaScript (с красивым дизайном Glassmorphism).
- **Backend:** Python 3.9+, Flask.
- **База данных:** PostgreSQL.
- **SDK:** 10 независимых, отказоустойчивых Python библиотек для каждого маркетплейса (включая автоматические ретраи, OAuth, JWT и Exponential Backoff).
### 3. 📈 SEO Мониторинг позиций
* Отслеживание позиций товаров по ключевым словам в поиске маркетплейсов.
* Интерактивные графики динамики позиций с инвертированной шкалой (ТОП-1 всегда сверху).
## Установка и запуск (Linux/macOS)
### 4. 🛡️ Защита контента (Image Matching)
* Вычисление визуальных отпечатков (pHash) для ваших фотографий.
* Автоматический поиск визуальных клонов и дубликатов вашего контента у конкурентов.
* Бесшовная подача жалобы на найденного нарушителя в один клик.
В проекте предусмотрен bash-скрипт `init.sh` для автоматической настройки окружения.
### 5. 💰 Финансовый калькулятор (Unit Economics)
* Детальный расчет чистой прибыли, маржинальности и ROI.
* Учет комиссий маркетплейса, налогов (УСН), логистики и рекламных расходов (ДРР).
1. Убедитесь, что у вас установлены Python 3.9+ и PostgreSQL (`psql`).
2. Настройте подключение к БД в файле `backend/.env` (переменная `DATABASE_URL=postgresql://user:password@localhost:5432/dbname`).
3. Выполните скрипт инициализации из корневой директории:
```bash
bash init.sh
### 6. 📑 Генерация отчетов
* Экспорт данных в CSV.
* Генерация профессиональных PDF-претензий с поддержкой кириллицы для отправки в поддержку маркетплейса.
---
## 🎨 Дизайн и интерфейс
* **Premium Dark Mode:** Элегантная темная тема для комфортной работы.
* **Glassmorphism:** Современный "стеклянный" интерфейс с эффектами размытия и неоновыми акцентами.
* **Real-time Notifications:** Мгновенные уведомления через WebSockets (Socket.io) при обнаружении новых событий.
* **Lucide Icons:** Использование современной библиотеки иконок для интуитивной навигации.
---
## 🛠 Технологический стек
* **Backend:** Python (Flask), Flask-SocketIO (Real-time), Flasgger (Swagger/OpenAPI).
* **Frontend:** Vanilla JS, Chart.js (Графики), Lucide (Иконки).
* **Database:** PostgreSQL.
* **AI:** Google Gemini 1.5 Flash API.
* **DevOps:** Docker, Docker Compose.
---
## 🚀 Быстрый запуск
### 1. Требования
* Установленный Docker и Docker Compose.
* API ключ Google AI Studio (для работы ИИ).
### 2. Настройка окружения
Создайте файл `backend/.env` и заполните его:
```env
DATABASE_URL=postgresql://user:pass@host.docker.internal:5432/automarket
JWT_SECRET=your_super_secret_key
TELEGRAM_BOT_TOKEN=your_bot_token
TELEGRAM_CHAT_ID=your_chat_id
GEMINI_API_KEY=your_gemini_api_key
```
**Что делает скрипт `init.sh`:**
- Проверяет наличие Python 3 и создает виртуальное окружение `venv`.
- Устанавливает Python-зависимости из файла `backend/requirements.txt`.
- Инициализирует базу данных с помощью файла `db/init.sql` (если настроен `.env`).
- Запускает локальный Flask-сервер на порту `5000`.
### 3. Запуск через Docker
```bash
docker-compose up --build
```
После запуска:
* Frontend: `http://localhost:8080`
* Backend API: `http://localhost:5000`
* Swagger Docs: `http://localhost:5000/api/docs`
## Структура репозитория
---
- `/frontend/` — статические файлы пользовательского интерфейса (страницы, стили, логика на JS).
- `/backend/` — серверная часть на Flask (точка входа `app.py`, обработка маршрутов API в `routes/`).
- `/db/` — SQL-скрипты для инициализации таблиц базы данных.
- `init.sh` — стартовый скрипт для быстрого развертывания проекта.
## 📜 Аудит и Безопасность
Проект включает встроенную систему **Audit Log**, которая фиксирует все важные действия пользователя (вход, создание/удаление объектов, финансовые расчеты) для обеспечения полной прозрачности и безопасности бизнеса.

28
backend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# backend/Dockerfile
FROM python:3.9-slim
WORKDIR /app
# Установка системных зависимостей для psycopg2 и др.
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Копирование и установка зависимостей
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода бэкенда
COPY backend/ ./backend/
# Создание __init__.py если их нет (для корректной работы относительных импортов)
RUN touch backend/__init__.py backend/routes/__init__.py
# Переменная окружения для корректных импортов
ENV PYTHONPATH=/app
EXPOSE 5000
# Запуск приложения
CMD ["python", "backend/app.py"]

View File

@@ -2,30 +2,75 @@ import os
from flask import Flask
from flask_cors import CORS
from dotenv import load_dotenv
from flasgger import Swagger
from flask_socketio import SocketIO, emit
# Load environment variables from .env
load_dotenv()
app = Flask(__name__)
CORS(app)
socketio = SocketIO(app, cors_allowed_origins="*")
# Swagger setup
# ... (keeping existing swagger config)
app.config['SWAGGER'] = {
'title': 'AutoMarket API',
'uiversion': 3,
'specs_route': '/api/docs/',
'securityDefinitions': {
'APIKeyHeader': {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header',
'description': 'Введите токен в формате: Bearer <ваш_токен>'
}
}
}
swagger = Swagger(app)
# Ensure upload folder exists
# ...
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# Register blueprints (routes)
# Register blueprints
from .routes.cases import cases_blueprint
from .routes.credentials import credentials_blueprint
from .routes.auth import auth_blueprint
from .routes.dashboard import dashboard_blueprint
from .routes.reports import reports_blueprint
from .routes.ai import ai_blueprint
from .routes.seo import seo_blueprint
from .routes.content import content_blueprint
from .routes.finance import finance_blueprint
app.register_blueprint(cases_blueprint, url_prefix='/api')
app.register_blueprint(credentials_blueprint, url_prefix='/api')
app.register_blueprint(auth_blueprint, url_prefix='/api')
app.register_blueprint(dashboard_blueprint, url_prefix='/api')
app.register_blueprint(reports_blueprint, url_prefix='/api')
app.register_blueprint(ai_blueprint, url_prefix='/api')
app.register_blueprint(seo_blueprint, url_prefix='/api')
app.register_blueprint(content_blueprint, url_prefix='/api')
app.register_blueprint(finance_blueprint, url_prefix='/api')
import threading
import time
def background_scanner():
"""Фоновая задача для имитации сканирования маркетплейсов."""
while True:
# Здесь в будущем будет вызов api.ozon_api и т.д.
print("--- [Scanner] Checking marketplaces for price dumping... ---")
# Эмитируем событие для примера (что сканер что-то нашел)
# socketio.emit('scanner_update', {'message': 'Сканер проверил 10 товаров...'}, namespace='/')
time.sleep(60)
# Start background thread
scanner_thread = threading.Thread(target=background_scanner, daemon=True)
scanner_thread.start()
if __name__ == '__main__':
# Use host 0.0.0.0 for Docker compatibility, port 5000
app.run(host='0.0.0.0', port=5000, debug=True)
# Use socketio.run instead of app.run
socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True)

View File

@@ -8,3 +8,8 @@ PyJWT==2.8.0
bcrypt==4.1.3
reportlab==4.1.0
pandas==2.2.1
flasgger==0.9.7.1
flask-socketio==5.3.6
google-generativeai==0.5.2
ImageHash==4.3.1
Pillow==10.2.0

79
backend/routes/ai.py Normal file
View File

@@ -0,0 +1,79 @@
from flask import Blueprint, request, jsonify
from ..middleware import token_required
from ..utils.ai_utils import generate_complaint_text
ai_blueprint = Blueprint('ai', __name__)
@ai_blueprint.route('/ai/generate-complaint', methods=['POST'])
@token_required
def ai_generate_complaint(current_user_id):
"""
Генерация текста претензии с помощью ИИ
---
tags:
- AI
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
platform:
type: string
violator_article:
type: string
my_article:
type: string
responses:
200:
description: Сгенерированный текст
"""
data = request.json
platform = data.get('platform', 'ozon')
v_art = data.get('violator_article', '')
my_art = data.get('my_article', '')
if not v_art:
return jsonify({"error": "Артикул нарушителя обязателен"}), 400
text = generate_complaint_text(platform, v_art, my_art)
return jsonify({"text": text})
@ai_blueprint.route('/ai/generate-reply', methods=['POST'])
@token_required
def ai_generate_reply(current_user_id):
"""
Генерация ответа на отзыв покупателя
---
tags:
- AI
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
review_text:
type: string
rating:
type: integer
responses:
200:
description: Сгенерированный ответ
"""
data = request.json
review_text = data.get('review_text', '')
rating = data.get('rating', 5)
if not review_text:
return jsonify({"error": "Текст отзыва обязателен"}), 400
from ..utils.ai_utils import generate_review_reply
text = generate_review_reply(review_text, rating)
return jsonify({"text": text})

View File

@@ -11,6 +11,26 @@ auth_blueprint = Blueprint('auth', __name__)
@auth_blueprint.route('/auth/register', methods=['POST'])
def register():
"""
Регистрация нового пользователя
---
tags:
- Auth
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
201:
description: Пользователь успешно зарегистрирован
"""
data = request.json
username = data.get('username')
password = data.get('password')
@@ -41,6 +61,30 @@ def register():
@auth_blueprint.route('/auth/login', methods=['POST'])
def login():
"""
Вход в систему
---
tags:
- Auth
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
200:
description: Токен успешно сгенерирован
schema:
properties:
token:
type: string
"""
data = request.json
username = data.get('username')
password = data.get('password')
@@ -59,6 +103,11 @@ def login():
'exp': datetime.utcnow() + timedelta(days=7)
}, SECRET_KEY, algorithm="HS256")
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(user[0], 'LOGIN', {'ip': request.remote_addr})
# -----------------
return jsonify({"token": token})
@auth_blueprint.route('/auth/me', methods=['GET'])

View File

@@ -27,6 +27,30 @@ def serialize_case(row):
@cases_blueprint.route('/cases', methods=['GET'])
@token_required
def list_cases(current_user_id):
"""
Получить список всех кейсов пользователя
---
tags:
- Cases
security:
- APIKeyHeader: []
parameters:
- name: article
in: query
type: string
description: Фильтр по артикулу
- name: status
in: query
type: string
description: Фильтр по статусу
- name: platform
in: query
type: string
description: Фильтр по площадке
responses:
200:
description: Список кейсов
"""
# optional filters: ?article=...&status=...&platform=...
article = request.args.get('article')
status = request.args.get('status')
@@ -56,13 +80,55 @@ def list_cases(current_user_id):
@cases_blueprint.route('/cases', methods=['POST'])
@token_required
def create_case(current_user_id):
"""
Создать новый кейс
---
tags:
- Cases
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
required: [platform, violator_url, violator_article]
properties:
platform:
type: string
violator_url:
type: string
violator_article:
type: string
my_article:
type: string
comment:
type: string
responses:
201:
description: Кейс создан
"""
data = request.json
required = ["platform", "violator_url", "violator_article"]
for f in required:
if f not in data:
return jsonify({"error": f"Missing field {f}"}), 400
# --- PLAN LIMIT CHECK ---
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT plan FROM users WHERE id = %s", (current_user_id,))
user_plan = cur.fetchone()[0]
if user_plan == 'free':
cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s", (current_user_id,))
count = cur.fetchone()[0]
if count >= 5:
release_db_connection(conn)
return jsonify({"error": "Лимит кейсов для Free тарифа (5 шт) исчерпан. Перейдите на PRO."}), 403
# ------------------------
cur.execute(
"""INSERT INTO cases (user_id, platform, violator_url, violator_article, my_article, comment, status)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""",
@@ -79,6 +145,40 @@ def create_case(current_user_id):
case_id = cur.fetchone()[0]
conn.commit()
release_db_connection(conn)
# --- TELEGRAM NOTIFICATION ---
from ..utils.telegram_utils import send_telegram_notification
msg = (
f"🚀 <b>Новый кейс #{case_id}</b>\n"
f"Площадка: {data.get('platform').upper()}\n"
f"Артикул: {data.get('violator_article')}\n"
f"Статус: NEW"
)
send_telegram_notification(msg)
# -----------------------------
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(current_user_id, 'CREATE_CASE', {
'case_id': case_id,
'platform': data.get('platform'),
'article': data.get('violator_article')
})
# -----------------
# --- WEBSOCKET NOTIFICATION ---
try:
from ..app import socketio
socketio.emit('case_created', {
'id': case_id,
'platform': data.get('platform'),
'article': data.get('violator_article'),
'message': f"Новый кейс на {data.get('platform').upper()} создан!"
})
except Exception as e:
print(f"WS Emit Error: {e}")
# ------------------------------
return jsonify({"id": case_id}), 201
@cases_blueprint.route('/cases/<int:case_id>', methods=['GET'])
@@ -123,6 +223,12 @@ def delete_case(current_user_id, case_id):
cur.execute("DELETE FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id))
conn.commit()
release_db_connection(conn)
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(current_user_id, 'DELETE_CASE', {'case_id': case_id})
# -----------------
return jsonify({"message": "Case deleted"})
# ---------- FILE UPLOAD ----------

76
backend/routes/content.py Normal file
View File

@@ -0,0 +1,76 @@
import os
from flask import Blueprint, request, jsonify, current_app
from werkzeug.utils import secure_filename
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
from ..utils.image_utils import calculate_image_hash
content_blueprint = Blueprint('content', __name__)
@content_blueprint.route('/content/reference', methods=['POST'])
@token_required
def upload_reference(current_user_id):
if 'file' not in request.files:
return jsonify({"error": "No file"}), 400
file = request.files['file']
article = request.form.get('article', 'unknown')
filename = secure_filename(file.filename)
save_path = os.path.join(current_app.config['UPLOAD_FOLDER'], f"ref_{current_user_id}_{filename}")
file.save(save_path)
# Calculate Hash
img_hash = calculate_image_hash(save_path)
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO reference_images (user_id, file_path, image_hash, article) VALUES (%s, %s, %s, %s) RETURNING id",
(current_user_id, save_path, img_hash, article)
)
ref_id = cur.fetchone()[0]
conn.commit()
release_db_connection(conn)
return jsonify({"id": ref_id, "hash": img_hash}), 201
@content_blueprint.route('/content/reference', methods=['GET'])
@token_required
def list_references(current_user_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, file_path, image_hash, article, created_at FROM reference_images WHERE user_id = %s", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"filename": os.path.basename(r[1]),
"hash": r[2],
"article": r[3],
"date": r[4].isoformat()
} for r in rows])
@content_blueprint.route('/content/detect-clones/<int:ref_id>', methods=['GET'])
@token_required
def detect_clones(current_user_id, ref_id):
# В реальном приложении здесь запускается поиск по маркетплейсам
# Для демо имитируем нахождение нескольких нарушителей
clones = [
{
"platform": "ozon",
"url": "https://ozon.ru/product/123456",
"article": "OZ-9988",
"similarity": "98%",
"image_url": "https://ir.ozone.ru/s3/multimedia-1/654321.jpg"
},
{
"platform": "wildberries",
"url": "https://wildberries.ru/catalog/778899/detail.aspx",
"article": "WB-CLONE-1",
"similarity": "92%",
"image_url": "https://basket-01.wb.ru/vol77/part778/778899/images/big/1.jpg"
}
]
return jsonify(clones)

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, jsonify
from datetime import datetime, timedelta
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
@@ -7,6 +8,17 @@ dashboard_blueprint = Blueprint('dashboard', __name__)
@dashboard_blueprint.route('/dashboard/stats', methods=['GET'])
@token_required
def get_stats(current_user_id):
"""
Получить общую статистику для дашборда
---
tags:
- Dashboard
security:
- APIKeyHeader: []
responses:
200:
description: Объект со статистикой и трендами
"""
conn = get_db_connection()
cur = conn.cursor()
@@ -40,12 +52,75 @@ def get_stats(current_user_id):
} for r in recent_rows
]
# 6. Trend data (last 7 days)
trend = []
for i in range(6, -1, -1):
day = (datetime.utcnow() - timedelta(days=i)).date()
cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s AND created_at::date = %s", (current_user_id, day))
count = cur.fetchone()[0]
trend.append({
"date": day.strftime('%d.%m'),
"count": count
})
# 5. Multi-account stores summary
cur.execute("SELECT id, platform, name FROM credentials WHERE user_id = %s", (current_user_id,))
stores_rows = cur.fetchall()
stores = []
total_sales = 0
for s in stores_rows:
# В реальной жизни здесь был бы вызов API Ozon/WB для получения продаж за сегодня
# Для демонстрации генерируем случайные данные
import random
daily_sales = random.randint(5000, 50000)
total_sales += daily_sales
stores.append({
"id": s[0],
"platform": s[1],
"name": s[2],
"status": "active",
"daily_sales": daily_sales
})
release_db_connection(conn)
return jsonify({
"total_cases": total_cases,
"cases_by_status": cases_by_status,
"total_integrations": total_integrations,
"total_sales": total_sales,
"platforms": platforms,
"recent_activity": recent_activity
"recent_activity": recent_activity,
"trend": trend,
"stores": stores
})
@dashboard_blueprint.route('/dashboard/logs', methods=['GET'])
@token_required
def get_logs(current_user_id):
"""
Получить лог аудита действий пользователя
---
tags:
- Dashboard
security:
- APIKeyHeader: []
responses:
200:
description: Список действий из лога
"""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT action, details, created_at FROM audit_log WHERE user_id = %s ORDER BY created_at DESC LIMIT 20", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
logs = [
{
"action": r[0],
"details": r[1],
"date": r[2].isoformat() if r[2] else None
} for r in rows
]
return jsonify(logs)

47
backend/routes/finance.py Normal file
View File

@@ -0,0 +1,47 @@
from flask import Blueprint, request, jsonify
from ..middleware import token_required
finance_blueprint = Blueprint('finance', __name__)
@finance_blueprint.route('/finance/calculate-pnl', methods=['POST'])
@token_required
def calculate_pnl(current_user_id):
"""
Расчет чистой прибыли и маржинальности.
"""
data = request.json
# Входные данные
sale_price = float(data.get('sale_price', 0)) # Цена продажи
purchase_price = float(data.get('purchase_price', 0)) # Себестоимость (закупка)
logistics = float(data.get('logistics', 0)) # Логистика
commission_pct = float(data.get('commission_pct', 0)) # Комиссия маркетплейса %
tax_pct = float(data.get('tax_pct', 6)) # Налог % (УСН)
adv_per_unit = float(data.get('adv_per_unit', 0)) # Реклама на 1 шт
other_costs = float(data.get('other_costs', 0)) # Прочие расходы (упаковка и т.д.)
# Расчеты
marketplace_commission = sale_price * (commission_pct / 100)
taxes = sale_price * (tax_pct / 100)
total_costs = (
purchase_price +
logistics +
marketplace_commission +
taxes +
adv_per_unit +
other_costs
)
net_profit = sale_price - total_costs
margin = (net_profit / sale_price * 100) if sale_price > 0 else 0
roi = (net_profit / purchase_price * 100) if purchase_price > 0 else 0
return jsonify({
"net_profit": round(net_profit, 2),
"margin": round(margin, 2),
"roi": round(roi, 2),
"total_costs": round(total_costs, 2),
"tax_amount": round(taxes, 2),
"commission_amount": round(marketplace_commission, 2)
})

View File

@@ -1,20 +1,55 @@
import io
import os
import pandas as pd
from flask import Blueprint, send_file, jsonify, make_response
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
reports_blueprint = Blueprint('reports', __name__)
# Попытка зарегистрировать кириллический шрифт
FONT_PATH = os.path.join(os.path.dirname(__file__), '..', 'fonts', 'DejaVuSans.ttf')
FONT_NAME = 'DejaVuSans'
try:
if os.path.exists(FONT_PATH):
pdfmetrics.registerFont(TTFont(FONT_NAME, FONT_PATH))
else:
# Пытаемся найти системный шрифт на Windows или Linux
sys_fonts = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'C:\\Windows\\Fonts\\arial.ttf'
]
for f in sys_fonts:
if os.path.exists(f):
pdfmetrics.registerFont(TTFont(FONT_NAME, f))
break
except Exception as e:
print(f"Warning: Could not register Cyrillic font: {e}")
FONT_NAME = 'Helvetica' # Fallback
@reports_blueprint.route('/reports/cases/csv', methods=['GET'])
@token_required
def export_cases_csv(current_user_id):
"""
Экспорт всех кейсов в CSV
---
tags:
- Reports
security:
- APIKeyHeader: []
responses:
200:
description: CSV файл
"""
conn = get_db_connection()
query = """
SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at
@@ -38,6 +73,22 @@ def export_cases_csv(current_user_id):
@reports_blueprint.route('/reports/case/<int:case_id>/pdf', methods=['GET'])
@token_required
def export_case_pdf(current_user_id, case_id):
"""
Генерация PDF претензии для конкретного кейса
---
tags:
- Reports
security:
- APIKeyHeader: []
parameters:
- name: case_id
in: path
type: integer
required: true
responses:
200:
description: PDF файл претензии
"""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
@@ -56,34 +107,62 @@ def export_case_pdf(current_user_id, case_id):
buffer = io.BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=letter)
styles = getSampleStyleSheet()
# Custom style for Russian text
ru_style = ParagraphStyle(
'Russian',
parent=styles['Normal'],
fontName=FONT_NAME,
fontSize=10,
leading=12
)
title_style = ParagraphStyle(
'RussianTitle',
parent=styles['Title'],
fontName=FONT_NAME,
fontSize=18,
spaceAfter=20
)
elements = []
# Title
elements.append(Paragraph(f"Официальная претензия #{case_id}", styles['Title']))
elements.append(Spacer(1, 12))
# Header / Title
elements.append(Paragraph(f"Официальная претензия #{case_id}", title_style))
elements.append(Paragraph(f"Система мониторинга AutoMarket (OzonGuard)", ru_style))
elements.append(Spacer(1, 20))
# Info table
data = [
["Маркетплейс", platform.upper()],
["Дата создания", created_at.strftime('%Y-%m-%d %H:%M') if created_at else "-"],
["Артикул нарушителя", v_art],
["Ссылка на товар", Paragraph(v_url, styles['Normal'])],
["Ваш артикул", my_art or "-"],
["Текущий статус", status.upper()]
[Paragraph("Параметр", ru_style), Paragraph("Значение", ru_style)],
[Paragraph("Маркетплейс", ru_style), platform.upper()],
[Paragraph("Дата создания", ru_style), created_at.strftime('%Y-%m-%d %H:%M') if created_at else "-"],
[Paragraph("Артикул нарушителя", ru_style), v_art],
[Paragraph("Ссылка на товар", ru_style), Paragraph(v_url, ru_style)],
[Paragraph("Ваш артикул", ru_style), my_art or "-"],
[Paragraph("Текущий статус", ru_style), status.upper()]
]
t = Table(data, colWidths=[150, 350])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.lightgrey),
('GRID', (0, 0), (-1, -1), 1, colors.black),
('PADDING', (0, 0), (-1, -1), 6),
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, -1), FONT_NAME),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.whitesmoke),
('GRID', (0, 0), (-1, -1), 1, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(t)
elements.append(Spacer(1, 24))
# Description
elements.append(Paragraph("Описание нарушения:", styles['Heading2']))
elements.append(Paragraph(comment or "Комментарий отсутствует", styles['Normal']))
elements.append(Paragraph("Описание нарушения:", ParagraphStyle('Sub', parent=ru_style, fontSize=12, spaceAfter=10)))
elements.append(Paragraph(comment or "Комментарий отсутствует", ru_style))
elements.append(Spacer(1, 40))
elements.append(Paragraph("Сгенерировано автоматически. Данный документ является официальным уведомлением о нарушении правил платформы.", ParagraphStyle('Footer', parent=ru_style, fontSize=8, textColor=colors.grey)))
doc.build(elements)
buffer.seek(0)

78
backend/routes/seo.py Normal file
View File

@@ -0,0 +1,78 @@
from flask import Blueprint, request, jsonify
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
import random
from datetime import datetime, timedelta
seo_blueprint = Blueprint('seo', __name__)
@seo_blueprint.route('/seo/keywords', methods=['GET'])
@token_required
def list_keywords(current_user_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT k.id, k.keyword, k.article, k.platform,
(SELECT position FROM seo_history h WHERE h.keyword_id = k.id ORDER BY checked_at DESC LIMIT 1) as last_pos
FROM seo_keywords k
WHERE k.user_id = %s
""", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"keyword": r[1],
"article": r[2],
"platform": r[3],
"last_position": r[4]
} for r in rows])
@seo_blueprint.route('/seo/keywords', methods=['POST'])
@token_required
def add_keyword(current_user_id):
data = request.json
keyword = data.get('keyword')
article = data.get('article')
platform = data.get('platform', 'ozon')
if not keyword or not article:
return jsonify({"error": "Keyword and article are required"}), 400
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO seo_keywords (user_id, keyword, article, platform) VALUES (%s, %s, %s, %s) RETURNING id",
(current_user_id, keyword, article, platform)
)
kid = cur.fetchone()[0]
# Generate some mock history immediately
for i in range(7):
date = datetime.now() - timedelta(days=i)
pos = random.randint(1, 150)
cur.execute("INSERT INTO seo_history (keyword_id, position, checked_at) VALUES (%s, %s, %s)", (kid, pos, date))
conn.commit()
release_db_connection(conn)
return jsonify({"id": kid}), 201
@seo_blueprint.route('/seo/history/<int:keyword_id>', methods=['GET'])
@token_required
def get_seo_history(current_user_id, keyword_id):
conn = get_db_connection()
cur = conn.cursor()
# Check ownership
cur.execute("SELECT id FROM seo_keywords WHERE id = %s AND user_id = %s", (keyword_id, current_user_id))
if not cur.fetchone():
release_db_connection(conn)
return jsonify({"error": "Not found"}), 404
cur.execute("SELECT position, checked_at FROM seo_history WHERE keyword_id = %s ORDER BY checked_at ASC", (keyword_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"position": r[0],
"date": r[1].strftime('%d.%m')
} for r in rows])

65
backend/utils/ai_utils.py Normal file
View File

@@ -0,0 +1,65 @@
import os
import google.generativeai as genai
def generate_complaint_text(platform, violator_article, my_article=None):
"""Генерирует текст претензии с помощью Google Gemini."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return "Ошибка: API ключ для Gemini не настроен в .env"
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
prompt = f"""
Ты - профессиональный юрист в сфере интеллектуальной собственности и e-commerce.
Напиши официальную и убедительную претензию для маркетплейса {platform}.
Суть нарушения: продавец с артикулом {violator_article} незаконно использует мой контент или нарушает правила ценообразования.
Мой оригинальный артикул для сравнения: {my_article if my_article else 'не указан'}.
Требования:
1. Текст должен быть на русском языке.
2. Стиль официально-деловой.
3. Упомяни о необходимости защиты прав правообладателя.
4. Текст должен быть лаконичным (до 500 знаков).
Ответ дай сразу в виде текста претензии, без лишних вступлений.
"""
try:
response = model.generate_content(prompt)
return response.text.strip()
except Exception as e:
return f"Ошибка при генерации текста ИИ: {str(e)}"
def generate_review_reply(review_text, rating):
"""Генерирует ответ на отзыв покупателя."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return "Ошибка: API ключ не настроен."
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
prompt = f"""
Ты - менеджер по работе с клиентами в магазине на маркетплейсе.
Твоя задача: вежливо и профессионально ответить на отзыв покупателя.
Оценка покупателя: {rating} звезд(ы).
Текст отзыва: "{review_text}"
Правила:
1. Если оценка 4-5 звезд: поблагодари за покупку, вырази радость, предложи посмотреть другие товары нашего бренда.
2. Если оценка 1-3 звезды: извинись за неудобства, уточни, что мы работаем над качеством, и предложи обратиться в поддержку, если это уместно.
3. Стиль: дружелюбный, но деловой. Не используй слишком много восклицательных знаков.
4. Длина: 2-4 предложения.
5. Не используй шаблоны "Уважаемый покупатель", начни более естественно.
Ответ дай сразу в виде готового текста.
"""
try:
response = model.generate_content(prompt)
return response.text.strip()
except Exception as e:
return f"Ошибка ИИ: {str(e)}"

View File

@@ -0,0 +1,17 @@
import json
from ..db import get_db_connection, release_db_connection
def log_action(user_id, action, details=None):
"""Записывает действие пользователя в лог аудита."""
conn = get_db_connection()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO audit_log (user_id, action, details) VALUES (%s, %s, %s)",
(user_id, action, json.dumps(details) if details else None)
)
conn.commit()
except Exception as e:
print(f"Audit Log Error: {e}")
finally:
release_db_connection(conn)

View File

@@ -0,0 +1,20 @@
from PIL import Image
import imagehash
import os
def calculate_image_hash(image_path):
"""Вычисляет перцептивный хеш изображения."""
try:
img = Image.open(image_path)
hash_val = imagehash.phash(img)
return str(hash_val)
except Exception as e:
print(f"Hashing Error: {e}")
return None
def compare_hashes(hash1, hash2, threshold=5):
"""Сравнивает два хеша. Если разница меньше порога, изображения считаются похожими."""
h1 = imagehash.hex_to_hash(hash1)
h2 = imagehash.hex_to_hash(hash2)
diff = h1 - h2
return diff <= threshold

View File

@@ -0,0 +1,24 @@
import requests
import os
def send_telegram_notification(message):
token = os.getenv('TELEGRAM_BOT_TOKEN')
chat_id = os.getenv('TELEGRAM_CHAT_ID')
if not token or not chat_id:
print("Telegram notification skipped: Token or Chat ID not configured.")
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML"
}
try:
response = requests.post(url, json=payload, timeout=5)
return response.status_code == 200
except Exception as e:
print(f"Error sending Telegram message: {e}")
return False

View File

@@ -39,3 +39,36 @@ CREATE TABLE IF NOT EXISTS files (
file_path TEXT NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL,
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS seo_keywords (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL,
article VARCHAR(100) NOT NULL,
platform VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS seo_history (
id SERIAL PRIMARY KEY,
keyword_id INT REFERENCES seo_keywords(id) ON DELETE CASCADE,
position INT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reference_images (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
image_hash VARCHAR(64),
article VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
# docker-compose.yml
version: '3.8'
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
ports:
- "5000:5000"
environment:
# Для Windows/Mac используйте host.docker.internal для доступа к локальной БД
- DATABASE_URL=postgresql://user:password@host.docker.internal:5432/automarket
- JWT_SECRET=your_super_secret_key
volumes:
- ./backend/uploads:/app/backend/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./frontend:/usr/share/nginx/html
depends_on:
- backend

View File

@@ -1,22 +1,31 @@
/* frontend/css/styles.css */
/* Premium darkmode + glassmorphism design */
/* Ultimate Premium Dark Mode + Glassmorphism */
:root {
--font-family: 'Inter', sans-serif;
--color-bg: hsl(220, 10%, 10%);
--color-surface: hsla(220, 10%, 15%, 0.8);
--color-primary: hsl(210, 80%, 55%);
--color-primary-hover: hsl(210, 80%, 45%);
--color-success: hsl(140, 60%, 45%);
--color-error: hsl(0, 70%, 50%);
--color-text: hsl(0, 0%, 90%);
--radius: 0.75rem;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
/* Palette */
--color-bg: #0b0e14;
--color-surface: rgba(23, 28, 38, 0.7);
--color-surface-hover: rgba(35, 42, 56, 0.85);
--color-primary: #3b82f6; /* Modern Blue */
--color-primary-glow: rgba(59, 130, 246, 0.5);
--color-success: #10b981; /* Emerald */
--color-success-glow: rgba(16, 185, 129, 0.4);
--color-warning: #f59e0b; /* Amber */
--color-error: #ef4444; /* Rose */
--color-text: #f3f4f6;
--color-text-dim: #9ca3af;
/* Utils */
--radius-lg: 1.25rem;
--radius-md: 0.75rem;
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
--glass-border: 1px solid rgba(255, 255, 255, 0.08);
}
*,
*::before,
*::after {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@@ -24,248 +33,307 @@
body {
font-family: var(--font-family);
background: var(--color-bg);
background-color: var(--color-bg);
background-image:
radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 40%);
color: var(--color-text);
line-height: 1.6;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--color-bg); }
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
/* Header */
.header {
text-align: center;
padding: 2rem 1rem;
background: linear-gradient(135deg, hsla(210, 70%, 55%, 0.6), hsla(210, 70%, 35%, 0.6));
backdrop-filter: blur(12px);
box-shadow: var(--shadow);
padding: 2.5rem 1rem;
background: rgba(11, 14, 20, 0.8);
backdrop-filter: blur(20px);
border-bottom: var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
color: var(--color-primary);
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(to right, #60a5fa, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
.container {
flex: 1;
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
.header p {
color: var(--color-text-dim);
font-size: 0.95rem;
}
.section {
/* Auth Section */
.auth-box {
max-width: 420px;
margin: 6rem auto;
padding: 2.5rem;
background: var(--color-surface);
backdrop-filter: blur(16px);
border-radius: var(--radius-lg);
border: var(--glass-border);
box-shadow: var(--shadow-xl);
}
.auth-box h2 {
font-size: 1.75rem;
margin-bottom: 2rem;
text-align: center;
}
h2 {
margin-bottom: 1rem;
font-weight: 600;
color: var(--color-primary);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.controls input,
.controls select {
padding: 0.5rem 0.75rem;
border: 1px solid hsla(0, 0%, 100%, 0.1);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
.btn-primary {
background: var(--color-primary);
border: none;
.auth-box input {
width: 100%;
padding: 0.875rem 1.25rem;
margin-top: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border: var(--glass-border);
border-radius: var(--radius-md);
color: #fff;
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.btn-secondary {
background: hsla(0, 0%, 100%, 0.1);
border: none;
color: var(--color-text);
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: hsla(0, 0%, 100%, 0.2);
}
.table {
width: 100%;
border-collapse: collapse;
backdrop-filter: blur(6px);
background: var(--color-surface);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid hsla(0, 0%, 100%, 0.05);
}
.table th {
background: hsla(0, 0%, 100%, 0.05);
color: var(--color-primary);
}
.hidden { display: none; }
label {
display: block;
margin-bottom: 0.75rem;
color: var(--color-text);
}
label input,
label textarea {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid hsla(0,0%,100%,0.1);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
.preview {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.preview img {
max-width: 120px;
max-height: 120px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow);
.auth-box input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* Tabs */
.tabs-container {
display: flex;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: var(--color-surface);
border-bottom: 1px solid hsla(0,0%,100%,0.1);
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
margin-top: 1rem;
border-radius: var(--radius-md);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.tab-btn {
background: transparent;
color: var(--color-text);
color: var(--color-text-dim);
border: none;
padding: 0.5rem 1rem;
font-size: 1.1rem;
padding: 0.625rem 1.25rem;
border-radius: var(--radius-md);
cursor: pointer;
border-bottom: 2px solid transparent;
font-weight: 500;
transition: all 0.2s;
}
.tab-btn:hover {
color: var(--color-primary);
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.tab-btn.active {
color: var(--color-primary);
border-bottom: 2px solid var(--color-primary);
background: var(--color-primary);
color: #fff;
box-shadow: 0 4px 12px var(--color-primary-glow);
}
/* Main Content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2.5rem 1.5rem;
}
.section {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Cards & Grid */
.platforms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.platform-card {
background: var(--color-surface);
padding: 1.5rem;
border-radius: var(--radius);
border: 1px solid hsla(0,0%,100%,0.1);
transition: transform 0.2s;
padding: 1.75rem;
border-radius: var(--radius-lg);
border: var(--glass-border);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.platform-card:hover {
transform: translateY(-2px);
transform: translateY(-4px);
background: var(--color-surface-hover);
border-color: rgba(255,255,255,0.15);
box-shadow: var(--shadow-xl);
}
.platform-card h3 {
color: var(--color-primary);
margin-bottom: 0.5rem;
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.platform-card .status {
font-size: 0.9rem;
margin-bottom: 1rem;
/* Buttons */
.btn-primary {
background: var(--color-primary);
color: #fff;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.active-platform {
border-color: var(--color-success);
.btn-primary:hover {
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 4px 15px var(--color-primary-glow);
}
.active-platform h3 {
color: var(--color-success);
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
font-weight: 500;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
border: var(--glass-border);
cursor: pointer;
transition: all 0.2s;
}
.platform-form {
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Table */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--color-surface);
border-radius: var(--radius-lg);
overflow: hidden;
border: var(--glass-border);
}
.table th {
background: rgba(255, 255, 255, 0.03);
padding: 1rem 1.5rem;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--color-text-dim);
text-align: left;
}
.table td {
padding: 1.25rem 1.5rem;
border-top: var(--glass-border);
font-size: 0.95rem;
}
.table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* Status Badges */
.status-pill {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-new { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
.status-closed { background: rgba(16, 185, 129, 0.15); color: #34d399; }
/* Icons */
.icon-sm {
width: 18px;
height: 18px;
vertical-align: middle;
margin-right: 0.25rem;
stroke-width: 2.5px;
}
.icon-sm.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hidden { display: none !important; }
/* Form Controls */
input, select, textarea {
font-family: inherit;
transition: border-color 0.2s;
}
/* Chart Container */
canvas {
margin-top: 1rem;
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.1));
}
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.75rem;
}
.platform-form input {
padding: 0.5rem;
border: 1px solid hsla(0,0%,100%,0.1);
border-radius: var(--radius);
background: rgba(0,0,0,0.2);
color: #fff;
}
.auth-box {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
.toast {
background: var(--color-surface);
border-radius: var(--radius);
border: 1px solid hsla(0,0%,100%,0.1);
box-shadow: var(--shadow);
}
.auth-box input {
width: 100%;
padding: 0.75rem;
margin-top: 0.5rem;
border: 1px solid hsla(0,0%,100%,0.1);
border-radius: var(--radius);
background: rgba(0,0,0,0.2);
backdrop-filter: blur(20px);
border: var(--glass-border);
padding: 1rem 1.5rem;
border-radius: var(--radius-md);
box-shadow: var(--shadow-xl);
color: #fff;
min-width: 300px;
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-left: 4px solid var(--color-primary);
}
.auth-box a {
color: var(--color-primary);
text-decoration: none;
}
.auth-box a:hover {
text-decoration: underline;
@keyframes slideIn {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.toast.success { border-left-color: var(--color-success); }
.toast.error { border-left-color: var(--color-error); }
.toast.info { border-left-color: var(--color-primary); }
.toast-message { font-size: 0.9rem; font-weight: 500; }

View File

@@ -5,13 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoMarket Универсальная панель управления</title>
<link rel="stylesheet" href="css/styles.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
<header class="header">
<div style="display: flex; justify-content: space-between; align-items: center; max-width: 1200px; margin: 0 auto;">
<div>
<h1>AutoMarket</h1>
<h1><i data-lucide="shield-check" style="vertical-align: middle; margin-right: 0.5rem;"></i>AutoMarket</h1>
<p>Управление жалобами и интеграциями для 10 маркетплейсов</p>
</div>
<div id="user-info" class="hidden">
@@ -38,10 +41,15 @@
<div id="main-app" class="hidden">
<div class="tabs-container">
<button class="tab-btn active" data-target="dashboard-section">Дашборд</button>
<button class="tab-btn" data-target="cases-section">Список жалоб</button>
<button class="tab-btn" data-target="integrations-section">Настройки интеграций</button>
<button class="tab-btn" data-target="billing-section">Тарифы</button>
<button class="tab-btn active" data-target="dashboard-section"><i data-lucide="layout-dashboard" class="icon-sm"></i> Дашборд</button>
<button class="tab-btn" data-target="cases-section"><i data-lucide="list" class="icon-sm"></i> Список жалоб</button>
<button class="tab-btn" data-target="integrations-section"><i data-lucide="settings" class="icon-sm"></i> Настройки</button>
<button class="tab-btn" data-target="billing-section"><i data-lucide="credit-card" class="icon-sm"></i> Тарифы</button>
<button class="tab-btn" data-target="logs-section"><i data-lucide="activity" class="icon-sm"></i> История</button>
<button class="tab-btn" data-target="reviews-section"><i data-lucide="message-square" class="icon-sm"></i> Отзывы</button>
<button class="tab-btn" data-target="seo-section"><i data-lucide="trending-up" class="icon-sm"></i> SEO</button>
<button class="tab-btn" data-target="content-section"><i data-lucide="image" class="icon-sm"></i> Контент</button>
<button class="tab-btn" data-target="finance-section"><i data-lucide="wallet" class="icon-sm"></i> Финансы</button>
</div>
<main class="container">
@@ -50,7 +58,43 @@
<section id="dashboard-section" class="section">
<h2>Сводная статистика</h2>
<div id="stats-grid" class="platforms-grid">
<!-- Stats cards will be here -->
<div class="platform-card">
<p style="color: var(--color-text-dim); font-size: 0.9rem;">Всего кейсов</p>
<h3 id="stat-cases">0</h3>
</div>
<div class="platform-card">
<p style="color: var(--color-text-dim); font-size: 0.9rem;">Интеграций</p>
<h3 id="stat-integrations">0</h3>
</div>
<div class="platform-card">
<p style="color: var(--color-success); font-size: 0.9rem;">Выручка (всего сегодня)</p>
<h3 id="stat-sales">0 ₽</h3>
</div>
</div>
<div class="section" style="margin-top: 2rem; background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1.5rem;">
<h3>Динамика кейсов</h3>
<canvas id="casesChart" style="max-height: 300px;"></canvas>
</div>
<div class="platforms-grid" style="margin-top: 1.5rem;">
<div class="platform-card" style="grid-column: 1 / -1;">
<h3>Состояние магазинов (Multi-account)</h3>
<table id="stores-table" class="table" style="margin-top: 1rem;">
<thead>
<tr>
<th>Магазин</th>
<th>Площадка</th>
<th>Статус API</th>
<th>Продажи (сегодня)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="platform-card" style="grid-column: 1 / -1;">
<h3>Динамика нарушений</h3>
<canvas id="caseTrendChart" height="100"></canvas>
</div>
</div>
<div class="section" style="margin-top: 2rem;">
<h3>Последняя активность</h3>
@@ -72,7 +116,7 @@
<!-- Billing / Subscription Section -->
<section id="billing-section" class="section hidden">
<h2>Ваш тариф: <span id="current-plan-display">Free</span></h2>
<div class="platforms-grid">
<div class="platforms-grid" style="margin-top: 2rem;">
<div class="platform-card">
<h3>Тариф FREE</h3>
<p class="status">Активен по умолчанию</p>
@@ -95,6 +139,180 @@
</div>
</section>
<!-- Audit Logs List -->
<section id="logs-section" class="section hidden">
<h2>История действий</h2>
<div class="table-container">
<table id="logs-table" class="table">
<thead>
<tr>
<th>Действие</th>
<th>Детали</th>
<th>Дата</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Reviews Section -->
<section id="reviews-section" class="section hidden">
<h2>ИИ-Ответчик на отзывы</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / -1;">
<p style="margin-bottom: 1.5rem; color: var(--color-text-dim);">
Введите текст отзыва покупателя и выберите оценку, чтобы ИИ подготовил идеальный ответ.
</p>
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 600px;">
<label>
Текст отзыва
<textarea id="review-text-input" rows="4" placeholder="Например: Товар пришел быстро, качество отличное!"></textarea>
</label>
<label>
Оценка (звезды)
<select id="review-rating-input">
<option value="5">⭐⭐⭐⭐⭐ (5)</option>
<option value="4">⭐⭐⭐⭐ (4)</option>
<option value="3">⭐⭐⭐ (3)</option>
<option value="2">⭐⭐ (2)</option>
<option value="1">⭐ (1)</option>
</select>
</label>
<button class="btn-primary" id="ai-review-btn">
<i data-lucide="sparkles" class="icon-sm"></i> Сгенерировать ответ
</button>
<div id="ai-reply-container" class="hidden" style="margin-top: 1.5rem; animation: fadeIn 0.3s;">
<label>
Предложенный ответ ИИ
<textarea id="ai-reply-output" rows="5"></textarea>
</label>
<button class="btn-secondary" onclick="copyReply()">
<i data-lucide="copy" class="icon-sm"></i> Копировать в буфер
</button>
</div>
</div>
</div>
</div>
</section>
<!-- SEO Section -->
<section id="seo-section" class="section hidden">
<h2>SEO Мониторинг позиций</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / 1;">
<h3>Добавить ключевое слово</h3>
<form id="add-seo-form" class="platform-form" style="margin-top: 1rem;">
<input type="text" name="keyword" placeholder="Ключевое слово (например: кофемолка)" required>
<input type="text" name="article" placeholder="Твой артикул" required>
<select name="platform">
<option value="ozon">Ozon</option>
<option value="wildberries">Wildberries</option>
</select>
<button type="submit" class="btn-primary">Начать отслеживание</button>
</form>
</div>
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Отслеживаемые ключи</h3>
<table id="seo-table" class="table" style="margin-top: 1rem;">
<thead>
<tr>
<th>Ключ</th>
<th>Артикул</th>
<th>Позиция</th>
<th>Действие</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="seo-chart-container" class="platform-card hidden" style="margin-top: 1.5rem;">
<h3 id="seo-chart-title">История позиции</h3>
<canvas id="seoChart" height="150"></canvas>
</div>
</section>
<!-- Content Protection Section -->
<section id="content-section" class="section hidden">
<h2>Защита визуального контента</h2>
<div class="platforms-grid">
<div class="platform-card">
<h3>Загрузить эталон (оригинал)</h3>
<form id="add-reference-form" class="platform-form" style="margin-top: 1rem;">
<input type="text" name="article" placeholder="Артикул товара" required>
<input type="file" name="file" accept="image/*" required style="border: none; padding: 0.5rem 0;">
<button type="submit" class="btn-primary">Сохранить эталон</button>
</form>
</div>
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Твои оригиналы</h3>
<div id="reference-list" class="platforms-grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-top: 1rem;">
<!-- Reference images here -->
</div>
</div>
</div>
<div id="clones-detected-container" class="section hidden" style="margin-top: 2rem; border-top: var(--glass-border); padding-top: 2rem;">
<h3><i data-lucide="alert-triangle" class="icon-sm" style="color: var(--color-error);"></i> Обнаружены клоны</h3>
<div id="clones-list" class="platforms-grid" style="margin-top: 1rem;">
<!-- Detected clones here -->
</div>
</div>
</section>
<!-- Finance Section -->
<section id="finance-section" class="section hidden">
<h2>Калькулятор чистой прибыли (Unit Economics)</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / 1;">
<h3>Параметры товара</h3>
<form id="pnl-form" class="platform-form" style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.8rem;">
<label>Цена продажи (₽)<input type="number" name="sale_price" value="1000" step="0.1" required></label>
<label>Закупка (₽)<input type="number" name="purchase_price" value="400" step="0.1" required></label>
<label>Логистика (₽)<input type="number" name="logistics" value="100" step="0.1" required></label>
<label>Комиссия (%)<input type="number" name="commission_pct" value="15" step="0.1" required></label>
<label>Налог (УСН %)<input type="number" name="tax_pct" value="6" step="0.1" required></label>
<label>Реклама на 1 шт (₽)<input type="number" name="adv_per_unit" value="50" step="0.1" required></label>
<button type="submit" class="btn-primary">Рассчитать прибыль</button>
</form>
</div>
<div id="pnl-results" class="platform-card hidden" style="grid-column: 2 / -1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center;">
<h3 style="margin-bottom: 2rem;">Результаты расчета</h3>
<div style="display: flex; gap: 2rem; flex-wrap: wrap; justify-content: center;">
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">Чистая прибыль</div>
<div id="pnl-net-profit" style="font-size: 2.5rem; font-weight: 800; color: var(--color-success);">0 ₽</div>
</div>
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">Маржа</div>
<div id="pnl-margin" style="font-size: 2.5rem; font-weight: 800; color: var(--color-primary);">0 %</div>
</div>
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">ROI</div>
<div id="pnl-roi" style="font-size: 2.5rem; font-weight: 800; color: var(--color-warning);">0 %</div>
</div>
</div>
<div style="margin-top: 2rem; width: 100%; max-width: 400px; text-align: left; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 12px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Комиссия МП:</span> <span id="pnl-comm-abs">0 ₽</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Налоги:</span> <span id="pnl-tax-abs">0 ₽</span>
</div>
<div style="display: flex; justify-content: space-between; font-weight: bold; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 0.5rem;">
<span>Себестоимость полная:</span> <span id="pnl-total-costs">0 ₽</span>
</div>
</div>
</div>
</div>
</section>
<!-- Integrations List -->
<section id="integrations-section" class="section hidden">
<h2>Доступные маркетплейсы</h2>
@@ -179,7 +397,12 @@
</label>
<label>
Комментарий / описание
<textarea name="comment" rows="3"></textarea>
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem;">
<textarea name="comment" id="case-comment" rows="4" style="flex: 1;"></textarea>
<button type="button" id="ai-gen-btn" class="btn-secondary" style="height: fit-content; white-space: nowrap;">
<i data-lucide="sparkles" class="icon-sm"></i> ИИ Помощник
</button>
</div>
</label>
<label>
Прикрепить доказательства
@@ -206,5 +429,8 @@
</div>
<script type="module" src="js/app.js"></script>
<!-- Toast Notification Container -->
<div id="toast-container" class="toast-container"></div>
</body>
</html>

View File

@@ -15,10 +15,55 @@ const PLATFORMS = [
];
let currentUser = null;
let socket = null;
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let icon = 'info';
if(type === 'success') icon = 'check-circle';
if(type === 'error') icon = 'alert-circle';
toast.innerHTML = `
<i data-lucide="${icon}" class="icon-sm"></i>
<div class="toast-message">${message}</div>
`;
container.appendChild(toast);
if (window.lucide) lucide.createIcons();
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
function initSocket() {
socket = io('http://localhost:5000');
socket.on('connect', () => {
console.log('Connected to WebSocket server');
});
socket.on('case_created', (data) => {
showToast(data.message, 'success');
// Refresh dashboard if active
if(!document.getElementById('dashboard-section').classList.contains('hidden')) {
loadDashboard();
}
});
socket.on('scanner_update', (data) => {
showToast(data.message, 'info');
});
}
document.addEventListener("DOMContentLoaded", async () => {
initAuth();
initReports();
initSocket();
// Check if logged in
const token = localStorage.getItem('token');
@@ -28,6 +73,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const planBadge = currentUser.plan === 'pro' ? '<span style="color: gold;">[PRO]</span>' : '<span style="color: gray;">[FREE]</span>';
document.getElementById('username-display').innerHTML = `Привет, ${currentUser.username} ${planBadge}`;
showApp();
if (window.lucide) lucide.createIcons();
} catch (e) {
showAuth();
}
@@ -99,44 +145,83 @@ function initDashboard() {
loadDashboard();
}
let casesChart = null;
async function loadDashboard() {
try {
const stats = await api.getDashboardStats();
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="platform-card">
<h3>Всего кейсов</h3>
<p style="font-size: 2rem; font-weight: bold;">${stats.total_cases}</p>
</div>
<div class="platform-card">
<h3>Активных интеграций</h3>
<p style="font-size: 2rem; font-weight: bold;">${stats.total_integrations}</p>
<p style="font-size: 0.8rem;">${stats.platforms.join(', ').toUpperCase()}</p>
</div>
<div class="platform-card">
<h3>В работе (Prepared/Sent)</h3>
<p style="font-size: 2rem; font-weight: bold;">
${(stats.cases_by_status.prepared || 0) + (stats.cases_by_status.sent || 0)}
</p>
</div>
`;
const data = await api.getDashboardStats();
// Update Counters
document.getElementById('stat-cases').innerText = data.total_cases;
document.getElementById('stat-integrations').innerText = data.total_integrations;
document.getElementById('stat-sales').innerText = `${(data.total_sales || 0).toLocaleString()}`;
// Update Stores Table (Multi-account)
const storesTbody = document.querySelector('#stores-table tbody');
if (storesTbody) {
storesTbody.innerHTML = '';
(data.stores || []).forEach(s => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${s.name}</strong></td>
<td><span style="text-transform: uppercase;">${s.platform}</span></td>
<td><span class="status-pill status-closed" style="background: rgba(16, 185, 129, 0.1); color: #34d399;">● Online</span></td>
<td>${(s.daily_sales || 0).toLocaleString()} ₽</td>
`;
storesTbody.appendChild(tr);
});
}
// Render Chart
const ctx = document.getElementById('casesChart').getContext('2d');
if (casesChart) casesChart.destroy();
casesChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.trend.map(t => t.date),
datasets: [{
label: 'Новые кейсы',
data: data.trend.map(t => t.count),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
borderWidth: 3,
pointRadius: 4,
pointBackgroundColor: '#3b82f6'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' }, border: { display: false } },
x: { grid: { display: false }, border: { display: false } }
}
}
});
// Update Recent Activity Table
const tbody = document.querySelector('#recent-activity-table tbody');
if (tbody) {
tbody.innerHTML = '';
stats.recent_activity.forEach(a => {
data.recent_activity.forEach(a => {
const tr = document.createElement('tr');
const pName = PLATFORMS.find(p => p.id === a.platform)?.name || a.platform;
const statusClass = a.status === 'new' ? 'status-new' : (a.status === 'closed' ? 'status-closed' : '');
tr.innerHTML = `
<td>${a.id}</td>
<td>${pName}</td>
<td>${a.article}</td>
<td>${a.status}</td>
<td>#${a.id}</td>
<td><strong>${pName}</strong></td>
<td><code>${a.article}</code></td>
<td><span class="status-pill ${statusClass}">${a.status}</span></td>
<td>${new Date(a.date).toLocaleDateString()}</td>
`;
tbody.appendChild(tr);
});
}
if (window.lucide) lucide.createIcons();
} catch (e) {
console.error(e);
console.error('Dashboard Load Error:', e);
}
}
@@ -182,10 +267,269 @@ function initTabs() {
if(target === 'dashboard-section') loadDashboard();
if(target === 'cases-section') loadCases();
if(target === 'integrations-section') loadIntegrations();
if(target === 'logs-section') loadLogs();
if(target === 'reviews-section') { /* No initial load needed */ }
if(target === 'seo-section') loadSEO();
if(target === 'content-section') loadReferences();
if(target === 'finance-section') { /* No initial load */ }
});
});
}
document.getElementById('pnl-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const data = Object.fromEntries(fd.entries());
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/finance/calculate-pnl', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
const result = await res.json();
document.getElementById('pnl-results').classList.remove('hidden');
document.getElementById('pnl-net-profit').innerText = `${result.net_profit.toLocaleString()}`;
document.getElementById('pnl-margin').innerText = `${result.margin} %`;
document.getElementById('pnl-roi').innerText = `${result.roi} %`;
document.getElementById('pnl-total-costs').innerText = `${result.total_costs.toLocaleString()}`;
document.getElementById('pnl-tax-abs').innerText = `${result.tax_amount.toLocaleString()}`;
document.getElementById('pnl-comm-abs').innerText = `${result.commission_amount.toLocaleString()}`;
// Color coding
const netProfitEl = document.getElementById('pnl-net-profit');
if (result.net_profit > 0) {
netProfitEl.style.color = 'var(--color-success)';
} else {
netProfitEl.style.color = 'var(--color-error)';
}
showToast('Расчет завершен', 'success');
} catch (err) {
alert('Ошибка расчета');
}
});
async function loadReferences() {
const list = document.getElementById('reference-list');
list.innerHTML = 'Загрузка...';
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/content/reference', {
headers: { 'Authorization': `Bearer ${token}` }
});
const refs = await res.json();
list.innerHTML = '';
refs.forEach(r => {
const div = document.createElement('div');
div.className = 'platform-card';
div.style.textAlign = 'center';
div.innerHTML = `
<div style="background: rgba(0,0,0,0.3); padding: 5px; border-radius: 8px;">#${r.article}</div>
<div style="font-size: 0.6rem; color: var(--color-text-dim); margin: 5px 0;">Hash: ${r.hash.substring(0, 8)}...</div>
<button class="btn-primary" style="padding: 0.3rem 0.6rem; font-size: 0.7rem;" onclick="detectClones(${r.id})">Найти клонов</button>
`;
list.appendChild(div);
});
} catch (e) {
list.innerHTML = 'Ошибка';
}
}
window.detectClones = async (id) => {
const container = document.getElementById('clones-detected-container');
const list = document.getElementById('clones-list');
list.innerHTML = 'Ищу визуальные копии на маркетплейсах...';
container.classList.remove('hidden');
try {
const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:5000/api/content/detect-clones/${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const clones = await res.json();
list.innerHTML = '';
clones.forEach(c => {
const div = document.createElement('div');
div.className = 'platform-card';
div.style.borderColor = 'var(--color-error)';
div.innerHTML = `
<img src="${c.image_url}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 0.5rem;">
<strong>${c.platform.toUpperCase()}</strong>
<div style="font-size: 0.8rem;">Артикул: ${c.article}</div>
<div style="color: var(--color-error); font-weight: bold;">Сходство: ${c.similarity}</div>
<button class="btn-primary" style="margin-top: 0.5rem; width: 100%;" onclick="reportClone('${c.platform}', '${c.url}', '${c.article}')">Подать жалобу</button>
`;
list.appendChild(div);
});
} catch (e) {
list.innerHTML = 'Ошибка при поиске';
}
}
window.reportClone = (platform, url, article) => {
// Switch to cases tab and pre-fill form
document.querySelector('.tab-btn[data-target="cases-section"]').click();
setTimeout(() => {
document.getElementById('case-platform').value = platform;
document.querySelector('input[name="violator_url"]').value = url;
document.querySelector('input[name="violator_article"]').value = article;
showToast('Данные нарушителя перенесены в форму жалобы', 'info');
}, 100);
}
document.getElementById('add-reference-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const btn = e.target.querySelector('button');
btn.disabled = true;
btn.innerText = 'Загрузка...';
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/content/reference', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (res.ok) {
showToast('Эталон загружен и хеширован', 'success');
e.target.reset();
loadReferences();
}
} catch (err) {
alert('Ошибка');
} finally {
btn.disabled = false;
btn.innerText = 'Сохранить эталон';
}
});
let seoChart = null;
async function loadSEO() {
const tbody = document.querySelector('#seo-table tbody');
tbody.innerHTML = '<tr><td colspan="4">Загрузка...</td></tr>';
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/seo/keywords', {
headers: { 'Authorization': `Bearer ${token}` }
});
const keywords = await res.json();
tbody.innerHTML = '';
keywords.forEach(k => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${k.keyword}</strong></td>
<td><code>${k.article}</code></td>
<td><span class="status-pill status-closed" style="background: rgba(59, 130, 246, 0.1); color: #60a5fa;">#${k.last_position || '?'}</span></td>
<td>
<button class="btn-secondary" onclick="viewSEOHistory(${k.id}, '${k.keyword}')">График</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4">Ошибка загрузки</td></tr>';
}
}
window.viewSEOHistory = async (id, keyword) => {
try {
const token = localStorage.getItem('token');
const res = await fetch(`http://localhost:5000/api/seo/history/${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const history = await res.json();
document.getElementById('seo-chart-container').classList.remove('hidden');
document.getElementById('seo-chart-title').innerText = `История позиции: ${keyword}`;
const ctx = document.getElementById('seoChart').getContext('2d');
if (seoChart) seoChart.destroy();
seoChart = new Chart(ctx, {
type: 'line',
data: {
labels: history.map(h => h.date),
datasets: [{
label: 'Место в поиске',
data: history.map(h => h.position),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.3,
fill: true,
reverse: true // Because 1 is better than 100
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { reverse: true, grid: { color: 'rgba(255,255,255,0.05)' } },
x: { grid: { display: false } }
}
}
});
} catch (e) {
alert('Ошибка загрузки истории');
}
}
document.getElementById('add-seo-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const data = Object.fromEntries(fd.entries());
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/seo/keywords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(data)
});
if (res.ok) {
e.target.reset();
loadSEO();
showToast('Ключевое слово добавлено', 'success');
}
} catch (err) {
alert('Ошибка при добавлении');
}
});
async function loadLogs() {
const tbody = document.querySelector('#logs-table tbody');
tbody.innerHTML = '<tr><td colspan="3">Загрузка...</td></tr>';
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/dashboard/logs', {
headers: { 'Authorization': `Bearer ${token}` }
});
const logs = await res.json();
tbody.innerHTML = '';
logs.forEach(l => {
const tr = document.createElement('tr');
const details = l.details ? JSON.stringify(l.details) : '-';
tr.innerHTML = `
<td><strong>${l.action}</strong></td>
<td style="font-size: 0.8rem; color: var(--color-text-dim);">${details}</td>
<td>${new Date(l.date).toLocaleString()}</td>
`;
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3">Ошибка загрузки</td></tr>';
}
}
// ---- INTEGRATIONS ----
async function loadIntegrations() {
const grid = document.getElementById('platforms-grid');
@@ -225,6 +569,7 @@ async function loadIntegrations() {
grid.appendChild(card);
});
if (window.lucide) lucide.createIcons();
} catch (e) {
grid.innerHTML = 'Ошибка загрузки интеграций.';
console.error(e);
@@ -281,6 +626,98 @@ async function initCases() {
window.location.href = api.getExportCsvUrl();
});
document.getElementById('ai-gen-btn').addEventListener('click', async () => {
const platform = document.getElementById('case-platform').value;
const v_art = document.querySelector('input[name="violator_article"]').value;
const my_art = document.querySelector('input[name="my_article"]').value;
if (!v_art) {
alert('Сначала введите артикул нарушителя');
return;
}
const btn = document.getElementById('ai-gen-btn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="icon-sm spin"></i> Генерирую...';
if (window.lucide) lucide.createIcons();
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/ai/generate-complaint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ platform, violator_article: v_art, my_article: my_art })
});
const data = await res.json();
if (data.text) {
document.getElementById('case-comment').value = data.text;
showToast('Текст успешно сгенерирован ИИ', 'success');
} else {
alert(data.error || 'Ошибка генерации');
}
} catch (err) {
alert('Ошибка сети при вызове ИИ');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
if (window.lucide) lucide.createIcons();
}
});
// ---- REVIEWS AI ----
document.getElementById('ai-review-btn').addEventListener('click', async () => {
const text = document.getElementById('review-text-input').value;
const rating = document.getElementById('review-rating-input').value;
if (!text) {
alert('Введите текст отзыва');
return;
}
const btn = document.getElementById('ai-review-btn');
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="icon-sm spin"></i> Генерирую ответ...';
if (window.lucide) lucide.createIcons();
try {
const token = localStorage.getItem('token');
const res = await fetch('http://localhost:5000/api/ai/generate-reply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ review_text: text, rating: parseInt(rating) })
});
const data = await res.json();
if (data.text) {
document.getElementById('ai-reply-output').value = data.text;
document.getElementById('ai-reply-container').classList.remove('hidden');
showToast('Ответ сгенерирован!', 'success');
} else {
alert(data.error || 'Ошибка');
}
} catch (err) {
alert('Ошибка сети');
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
if (window.lucide) lucide.createIcons();
}
});
window.copyReply = () => {
const output = document.getElementById('ai-reply-output');
output.select();
document.execCommand('copy');
showToast('Скопировано в буфер!', 'info');
};
loadCases();
}
@@ -293,12 +730,28 @@ async function initReports() {
});
}
async function loadCases() {
const article = document.getElementById('search-input').value;
const status = document.getElementById('status-filter').value;
const platform = document.getElementById('platform-filter').value;
async function loadDashboard() {
// Update counters
document.getElementById('stat-cases').innerText = data.total_cases;
document.getElementById('stat-integrations').innerText = data.total_integrations;
document.getElementById('stat-sales').innerText = `${data.total_sales.toLocaleString()}`;
const tbody = document.querySelector('#cases-table tbody');
// Update stores table (multi-account)
const storesTbody = document.querySelector('#stores-table tbody');
storesTbody.innerHTML = '';
data.stores.forEach(s => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${s.name}</strong></td>
<td><span style="text-transform: uppercase;">${s.platform}</span></td>
<td><span class="status-pill status-closed" style="background: rgba(16, 185, 129, 0.1); color: #34d399;">● Online</span></td>
<td>${s.daily_sales.toLocaleString()} ₽</td>
`;
storesTbody.appendChild(tr);
});
// Update Activity Table
const tbody = document.querySelector('#recent-activity-table tbody');
tbody.innerHTML = '<tr><td colspan="6">Загрузка...</td></tr>';
try {
@@ -312,19 +765,22 @@ async function loadCases() {
cases.forEach(c => {
const tr = document.createElement('tr');
const pName = PLATFORMS.find(p => p.id === c.platform)?.name || c.platform;
const statusClass = c.status === 'new' ? 'status-new' : (c.status === 'closed' ? 'status-closed' : '');
tr.innerHTML = `
<td>${c.id}</td>
<td>#${c.id}</td>
<td><strong>${pName}</strong></td>
<td>${c.violator_article}</td>
<td>${c.status}</td>
<td>${new Date(c.created_at).toLocaleString()}</td>
<td><code>${c.violator_article}</code></td>
<td><span class="status-pill ${statusClass}">${c.status}</span></td>
<td>${new Date(c.created_at).toLocaleDateString()}</td>
<td>
<button class="btn-secondary" onclick="window.viewCase(${c.id})">Просмотр</button>
<button class="btn-secondary" onclick="window.deleteCase(${c.id})">Удалить</button>
<button class="btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;" onclick="window.viewCase(${c.id})">Открыть</button>
<button class="btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.8rem; color: var(--color-error);" onclick="window.deleteCase(${c.id})">Удалить</button>
</td>
`;
tbody.appendChild(tr);
});
if (window.lucide) lucide.createIcons();
} catch (err) {
tbody.innerHTML = '<tr><td colspan="6">Ошибка сети</td></tr>';
}