This commit is contained in:
Ilnar
2026-03-05 06:55:42 +03:00
commit 36a7d530c1
50 changed files with 3091 additions and 0 deletions

138
README.md Normal file
View File

@@ -0,0 +1,138 @@
# AdsAssistant — генерация и автотестирование рекламных текстов
Монорепозиторий из 3 сервисов:
1) **agents_service** (FastAPI) — агент №1 (генерация текстов) и агент №2 (анализ/ранжирование по метрикам)
2) **backend_django** (Django + DRF + JWT, SQLite) — хранение брифов/вариантов/тестов/результатов + Swagger
3) **frontend** (React/Vite) — пользовательский интерфейс для создания брифа, выбора форматов и генерации текстов
## Возможности (MVP0)
- Пользователь создаёт бриф и **сам выбирает форматы**: `social_post`, `search_ad`, `email`
- Агент №1 генерирует тексты по выбранным форматам
- Backend сохраняет варианты и отдаёт их фронтенду
- Swagger для backend: `http://localhost:8000/api/docs/`
- Swagger для agents: `http://localhost:8001/docs`
> Модуль тестирования (создание тестов, сегменты, ручной ввод результатов, анализ) уже заложен в backend,
> но UI для него можно расширять следующим шагом.
---
## Быстрый старт через Docker (рекомендуется)
### 1) Требования
- Docker Desktop (Windows/macOS) или Docker Engine + Compose (Linux)
### 2) Скачивание
Склонируйте репозиторий или распакуйте архив в папку, например `adsassistant_full_project`.
### 3) Настройка секретов GigaChat
Откройте файл: `agents_service/.env` и заполните:
```env
GIGACHAT_CLIENT_ID=...
GIGACHAT_CLIENT_SECRET=...
```
### 4) Запуск
Из корня проекта:
```bash
docker compose up --build
```
Откройте:
- Frontend: http://localhost:5174
- Backend Swagger: http://localhost:8000/api/docs/
- Agents Swagger: http://localhost:8001/docs
---
## Запуск без Docker (локальная разработка)
### 1) Agents Service (8001)
```bash
cd agents_service
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# заполните GIGACHAT_CLIENT_ID / GIGACHAT_CLIENT_SECRET
python -m uvicorn src.main:app --reload --port 8001
```
### 2) Django Backend (8000)
```bash
cd backend_django
python -m venv .venv
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
### 3) Frontend (5174)
```bash
cd frontend
npm install
cp .env.example .env
npm run dev
```
---
## Первый сценарий использования
1) Создайте пользователя:
- В Swagger backend: `POST /api/auth/register/`
2) Получите JWT:
- `POST /api/auth/token/``access`
3) Во фронтенде войдите с логином/паролем
4) Создайте бриф:
- заполните продукт, аудиторию, выберите форматы
5) Нажмите **«Сгенерировать тексты (Агент №1)»**
6) Посмотрите список вариантов (ID + format + payload)
---
## Полезные адреса
- Frontend: `http://localhost:5174`
- Backend API: `http://localhost:8000/api/`
- Backend Swagger: `http://localhost:8000/api/docs/`
- Django Admin: `http://localhost:8000/admin/` (можно создать суперпользователя локально: `python manage.py createsuperuser`)
- Agents Swagger: `http://localhost:8001/docs`
---
## Структура репозитория
```text
adsassistant_full_project/
├── docker-compose.yml
├── agents_service/
├── backend_django/
└── frontend/
```
---
## Примечания
- SQLite база backend сохраняется в docker volume `backend_db`.
- В Docker-сборке frontend использует `VITE_API_BASE_URL=http://localhost:8000` (см. `frontend/.env.production`).
- Для продакшена рекомендуется:
- вынести SQLite на PostgreSQL
- включить HTTPS и нормальные секреты
- добавить роль admin и отдельную админ-панель/страницы
Note: frontend/public is optional; docker build does not require it.
## JWT токены (время жизни)
В `backend_django/adsassistant_backend/adsassistant_backend/settings.py` настроено:
- ACCESS token: 1 день
- REFRESH token: 7 дней

6
agents_service/.env Normal file
View File

@@ -0,0 +1,6 @@
GIGACHAT_CLIENT_ID=019cb2c4-b0cc-782b-af27-07bf919ce7c4
GIGACHAT_CLIENT_SECRET=58757e78-eaa4-4f67-b06a-2d02b655fd5d
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
GIGACHAT_VERIFY_SSL_CERTS=false
PORT=8001

View File

@@ -0,0 +1,6 @@
GIGACHAT_CLIENT_ID=your_client_id_here
GIGACHAT_CLIENT_SECRET=your_client_secret_here
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
GIGACHAT_VERIFY_SSL_CERTS=false
PORT=8001

13
agents_service/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
COPY .env.example ./.env.example
EXPOSE 8001
CMD ["python","-m","uvicorn","src.main:app","--host","0.0.0.0","--port","8001"]

14
agents_service/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Agents Service (FastAPI)
```bash
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
python -m uvicorn src.main:app --reload --port 8001
```
- Swagger: http://localhost:8001/docs
Docker: build from root with `docker compose up --build`

View File

@@ -0,0 +1,7 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.29,<1
pydantic>=2.10,<3
python-dotenv>=1,<2
langchain>=0.3.27,<0.4
langchain-community>=0.3.0,<0.4
gigachat>=0.1.0

View File

@@ -0,0 +1,193 @@
from math import inf
import re
def safe_div(a, b):
return (a / b) if b else None
KPI_LABELS = {
"ctr": "CTR",
"cpc": "CPC",
"cr": "CR",
"cpl": "CPL",
"cpa": "CPA",
}
def parse_policy_text(text: str) -> dict:
"""Very small heuristic parser for free-form requests.
Supports phrases like:
'Оптимизируй по CPA, хороший до 800, средний до 1200, клики минимум 20'
"""
if not text:
return {}
t = text.lower()
kpi = None
for k in ["cpa","cpl","cpc","ctr","cr"]:
if k in t:
kpi = k
break
direction = "max" if kpi in ("ctr","cr") else "min"
nums = list(map(float, re.findall(r"(\d+[\.,]?\d*)", t)))
good = nums[0] if len(nums) >= 1 else None
ok = nums[1] if len(nums) >= 2 else None
min_clicks = 0
m = re.search(r"клик[аио]в?\s*(?:минимум|min)\s*(\d+)", t)
if m: min_clicks = int(m.group(1))
min_impr = 0
m = re.search(r"показ[а-я]*\s*(?:минимум|min)\s*(\d+)", t)
if m: min_impr = int(m.group(1))
return {
"mode": "text",
"primary_kpi": kpi or "cpl",
"direction": direction,
"good_threshold": good,
"ok_threshold": ok,
"min_clicks": min_clicks,
"min_impressions": min_impr,
"query_text": text,
}
class AnalyzeAgent:
def analyze(self, req: dict) -> dict:
rows = req["rows"]
objective = (req.get("objective") or "leads").lower()
policy = req.get("policy") or {}
if policy.get("mode") == "text" and policy.get("query_text"):
policy = {**parse_policy_text(policy.get("query_text")), **policy}
primary_kpi = (policy.get("primary_kpi") or "").lower() or ("cpl" if objective=="leads" else "cpa" if objective=="conversions" else "cpc")
direction = (policy.get("direction") or ("max" if primary_kpi in ("ctr","cr") else "min")).lower()
good_th = policy.get("good_threshold", None)
ok_th = policy.get("ok_threshold", None)
min_impr = int(policy.get("min_impressions") or 0)
min_clicks = int(policy.get("min_clicks") or 0)
# score per (variant, segment) first
by_variant = {}
for r in rows:
vid = r.get("variant_id")
fmt = r.get("format")
seg_id = r.get("segment_id")
seg_name = r.get("segment_name") or (f"Сегмент {seg_id}" if seg_id is not None else None)
impressions = int(r.get("impressions") or 0)
clicks = int(r.get("clicks") or 0)
conversions = int(r.get("conversions") or 0)
leads = int(r.get("leads") or 0)
spend = float(r.get("spend") or 0.0)
ctr = safe_div(clicks, impressions)
cr = safe_div(conversions, clicks)
cpc = safe_div(spend, clicks)
cpa = safe_div(spend, conversions) if conversions else None
cpl = safe_div(spend, leads) if leads else None
m = {
"impressions": impressions, "clicks": clicks, "conversions": conversions, "leads": leads, "spend": spend,
"ctr": ctr, "cr": cr, "cpc": cpc, "cpa": cpa, "cpl": cpl
}
entry = by_variant.setdefault(vid, {"variant_id": vid, "format": fmt, "totals": {"impressions":0,"clicks":0,"conversions":0,"leads":0,"spend":0.0}, "segments": []})
entry["format"] = entry["format"] or fmt
entry["totals"]["impressions"] += impressions
entry["totals"]["clicks"] += clicks
entry["totals"]["conversions"] += conversions
entry["totals"]["leads"] += leads
entry["totals"]["spend"] += spend
entry["segments"].append({"segment_id": seg_id, "segment_name": seg_name, "metrics": m})
# compute aggregated metrics per variant
scored=[]
for vid, data in by_variant.items():
t = data["totals"]
ctr = safe_div(t["clicks"], t["impressions"])
cr = safe_div(t["conversions"], t["clicks"])
cpc = safe_div(t["spend"], t["clicks"])
cpa = safe_div(t["spend"], t["conversions"]) if t["conversions"] else None
cpl = safe_div(t["spend"], t["leads"]) if t["leads"] else None
agg = {**t, "ctr": ctr, "cr": cr, "cpc": cpc, "cpa": cpa, "cpl": cpl}
# choose KPI value
kpi_value = agg.get(primary_kpi)
# low data rule
low_data = (t["impressions"] < min_impr) or (t["clicks"] < min_clicks)
status = "low_data" if low_data else "unknown"
if not low_data and kpi_value is not None:
if direction == "min":
if good_th is not None and kpi_value <= good_th:
status = "good"
elif ok_th is not None and kpi_value <= ok_th:
status = "ok"
else:
status = "bad"
else:
if good_th is not None and kpi_value >= good_th:
status = "good"
elif ok_th is not None and kpi_value >= ok_th:
status = "ok"
else:
status = "bad"
# sort key
if kpi_value is None:
sort_k = inf if direction=="min" else -inf
else:
sort_k = kpi_value if direction=="min" else -kpi_value
scored.append({
"variant_id": vid,
"format": data["format"],
"status": status,
"kpi": primary_kpi,
"kpi_value": kpi_value,
"metrics": agg,
"segments": data["segments"],
"_sort": (0 if status!="low_data" else 1, sort_k, -(ctr or 0)),
})
scored.sort(key=lambda x: x["_sort"])
ranking=[]
for i, s in enumerate(scored, start=1):
ranking.append({
"rank": i,
"variant_id": s["variant_id"],
"format": s["format"],
"status": s["status"],
"kpi": s["kpi"],
"kpi_value": s["kpi_value"],
"metrics": s["metrics"],
"segments": s["segments"],
})
# recommendations (deterministic, but "agent-like")
rec=[]
if ranking:
good = [x for x in ranking if x["status"]=="good"]
bad = [x for x in ranking if x["status"]=="bad"]
ok = [x for x in ranking if x["status"]=="ok"]
ld = [x for x in ranking if x["status"]=="low_data"]
if good:
rec.append(f"Масштабируйте: лучший текст #{good[0]['variant_id']} (статус: хороший по {KPI_LABELS.get(primary_kpi, primary_kpi)}).")
if ok:
rec.append("Средние варианты можно улучшить: проверьте УТП/CTA и уточните аудиторию сегмента.")
if bad:
rec.append("Плохие варианты лучше переписать: попробуйте другой заголовок/обещание, проверьте ограничения и соответствие сегменту.")
if ld:
rec.append("Для части вариантов мало данных. Наберите больше показов/кликов, затем повторите анализ.")
return {
"policy_used": {
"mode": policy.get("mode") or "thresholds",
"primary_kpi": primary_kpi,
"direction": direction,
"good_threshold": good_th,
"ok_threshold": ok_th,
"min_impressions": min_impr,
"min_clicks": min_clicks,
"query_text": policy.get("query_text"),
},
"ranking": ranking,
"recommendations": rec,
}

View File

@@ -0,0 +1,29 @@
from src.chains.text_generation import TextGenerationChain
class TextGenAgent:
def __init__(self):
self.chain = TextGenerationChain()
async def generate(self, req: dict) -> dict:
formats = req["formats"]
n = int(req.get("variants_per_format", 3))
brief = {
"product": req["product"],
"audience": req["audience"],
"usp": req.get("usp"),
"benefits": req.get("benefits") or [],
"constraints": req.get("constraints"),
"tone": req.get("tone"),
}
variants = []
for fmt in formats:
items = await self.chain.generate_for_format(brief, fmt, n)
variants.append({
"format": fmt,
"items": [{
"payload": it,
"placement_tips": "Тестируйте 2-3 варианта в одном канале, фиксируйте клики/лиды вручную.",
"expected_effect": "Гипотеза: улучшение CTR/CR за счёт другого УТП/CTA."
} for it in items],
})
return {"formats": formats, "variants": variants}

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, HTTPException
from src.models.schemas import TextGenRequest, AnalyzeRequest
from src.agents.textgen_agent import TextGenAgent
from src.agents.analyze_agent import AnalyzeAgent
router = APIRouter()
_textgen = None
_analyze = AnalyzeAgent()
def get_textgen():
global _textgen
if _textgen is None:
_textgen = TextGenAgent()
return _textgen
@router.post("/texts/generate")
async def texts_generate(req: TextGenRequest):
try:
return await get_textgen().generate(req.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tests/analyze")
async def tests_analyze(req: AnalyzeRequest):
try:
return _analyze.analyze(req.model_dump())
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,50 @@
import json
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from src.llm.gigachat_client import GigaChatClient
FORMAT_SPECS = {
"social_post": {
"instruction": "Пост для соцсетей: hook (1 строка), body (3-6 строк), cta (1 строка).",
"schema": {"hook":"...", "body":"...", "cta":"..."}
},
"search_ad": {
"instruction": "Поисковое объявление: 5 заголовков (<= 56 символов) и 5 описаний (<= 81 символ).",
"schema": {"headlines":["..."], "descriptions":["..."], "cta":"..."}
},
"email": {
"instruction": "Email: subject, preheader, body (коротко), cta.",
"schema": {"subject":"...", "preheader":"...", "body":"...", "cta":"..."}
}
}
def _escape_braces(s: str) -> str:
return s.replace("{","{{").replace("}","}}")
class TextGenerationChain:
def __init__(self):
self.client = GigaChatClient(temperature=0.7, max_tokens=2200)
async def generate_for_format(self, brief: dict, fmt: str, n: int) -> list[dict]:
spec = FORMAT_SPECS.get(fmt, {"instruction":"Рекламный текст + CTA.", "schema":{"text":"...", "cta":"..."}})
schema = _escape_braces(json.dumps(spec["schema"], ensure_ascii=False, indent=2))
prompt = PromptTemplate(
input_variables=["brief_json","n"],
template=(
"Ты маркетолог и копирайтер. Пиши по-русски.\n"
"Соблюдай ограничения, не обещай гарантии.\n"
f"Формат: {fmt}. {spec['instruction']}\n\n"
"Бриф (JSON): {brief_json}\n"
"Сгенерируй {n} вариантов.\n"
"Верни ТОЛЬКО JSON массив объектов, без markdown.\n"
"Пример схемы одного объекта:\n"
f"{schema}\n"
),
)
chain = LLMChain(llm=self.client.llm, prompt=prompt)
raw = await chain.apredict(brief_json=json.dumps(brief, ensure_ascii=False), n=str(n))
start, end = raw.find("["), raw.rfind("]")
if start == -1 or end == -1:
raise ValueError("LLM did not return JSON array")
return json.loads(raw[start:end+1])

View File

@@ -0,0 +1,22 @@
import os
import base64
from langchain_community.llms import GigaChat
class GigaChatClient:
def __init__(self, temperature: float = 0.6, max_tokens: int = 2000):
client_id = (os.getenv("GIGACHAT_CLIENT_ID") or "").strip().strip('"').strip("'")
client_secret = (os.getenv("GIGACHAT_CLIENT_SECRET") or "").strip().strip('"').strip("'")
if not client_id or not client_secret:
raise ValueError("Set GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET in .env")
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("utf-8")
scope = (os.getenv("GIGACHAT_SCOPE") or "GIGACHAT_API_PERS").strip()
model = (os.getenv("GIGACHAT_MODEL") or "GigaChat").strip()
verify_ssl = (os.getenv("GIGACHAT_VERIFY_SSL_CERTS","false").lower() in ("1","true","yes","y","on"))
self.llm = GigaChat(
credentials=credentials,
scope=scope,
model=model,
temperature=temperature,
max_tokens=max_tokens,
verify_ssl_certs=verify_ssl,
)

View File

@@ -0,0 +1,15 @@
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
app = FastAPI(title="AdsAssistant Agents Service", version="0.1.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
from src.api.routes import router # noqa
app.include_router(router, prefix="/api/v1")
@app.get("/health")
async def health():
return {"ok": True}

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
class TextGenRequest(BaseModel):
product: str
audience: str
usp: Optional[str] = None
benefits: List[str] = []
constraints: Optional[str] = None
tone: Optional[str] = None
formats: List[str] = Field(..., min_length=1)
variants_per_format: int = Field(3, ge=1, le=10)
class AnalyzeRequest(BaseModel):
rows: List[Dict[str, Any]] # {variant_id, format, impressions, clicks, conversions, leads, spend}
objective: str = "leads"
policy: Optional[Dict[str, Any]] = None
notes: Optional[str] = None

5
backend_django/.env Normal file
View File

@@ -0,0 +1,5 @@
DJANGO_SECRET_KEY=dev-secret
DJANGO_DEBUG=1
ALLOWED_HOSTS=127.0.0.1,localhost
CORS_ALLOWED_ORIGINS=http://localhost:5174
AGENTS_SERVICE_URL=http://agents:8001

View File

@@ -0,0 +1,5 @@
DJANGO_SECRET_KEY=dev-secret
DJANGO_DEBUG=1
ALLOWED_HOSTS=127.0.0.1,localhost
CORS_ALLOWED_ORIGINS=http://localhost:5174
AGENTS_SERVICE_URL=http://localhost:8001

16
backend_django/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy Django project (project root folder) and manage.py
WORKDIR /app/adsassistant_backend
COPY adsassistant_backend/ ./
COPY manage.py ./manage.py
EXPOSE 8000
CMD ["bash","-lc","mkdir -p data && python manage.py makemigrations api && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]

15
backend_django/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Django Backend
```bash
python -m venv .venv
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
Swagger: http://localhost:8000/api/docs/
Docker: build from root with `docker compose up --build`

View File

@@ -0,0 +1,64 @@
import os
from pathlib import Path
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-secret")
DEBUG = os.getenv("DJANGO_DEBUG","1") == "1"
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS","127.0.0.1,localhost").split(",") if h.strip()]
INSTALLED_APPS = [
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes","django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
"corsheaders","rest_framework","drf_spectacular","api",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware","django.middleware.security.SecurityMiddleware","django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware","django.middleware.csrf.CsrfViewMiddleware","django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware","django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF="adsassistant_backend.urls"
TEMPLATES=[{"BACKEND":"django.template.backends.django.DjangoTemplates","DIRS":[],"APP_DIRS":True,"OPTIONS":{"context_processors":[
"django.template.context_processors.debug","django.template.context_processors.request","django.contrib.auth.context_processors.auth","django.contrib.messages.context_processors.messages",
]}}]
WSGI_APPLICATION="adsassistant_backend.wsgi.application"
DATABASES={"default":{"ENGINE":"django.db.backends.sqlite3","NAME":BASE_DIR/"data"/"db.sqlite3"}}
LANGUAGE_CODE="ru-ru"
TIME_ZONE="Europe/Amsterdam"
USE_I18N=True
USE_TZ=True
STATIC_URL="static/"
CORS_ALLOWED_ORIGINS=[o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS","http://localhost:5174").split(",") if o.strip()]
REST_FRAMEWORK={
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS={
"TITLE":"AdsAssistant Backend API",
"VERSION":"0.1.0",
"SERVE_INCLUDE_SCHEMA": False,
# By default, endpoints are protected with JWT (bearerAuth).
# Public endpoints can override with @extend_schema(auth=[]).
"SECURITY":[{"bearerAuth": []}],
"COMPONENTS":{
"securitySchemes":{
"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}
}
},
"SWAGGER_UI_SETTINGS":{"persistAuthorization": True},
}
AGENTS_SERVICE_URL=os.getenv("AGENTS_SERVICE_URL","http://localhost:8001").rstrip("/")
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField"
# JWT settings (SimpleJWT)
# Access token lifetime controls how long the user can call API without re-login.
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=1),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
}

View File

@@ -0,0 +1,9 @@
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/", include("api.urls")),
]

View File

@@ -0,0 +1,4 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE","adsassistant_backend.settings")
application = get_wsgi_application()

View File

@@ -0,0 +1,9 @@
from django.contrib import admin
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot
admin.site.register(Brief)
admin.site.register(TextVariant)
admin.site.register(Test)
admin.site.register(Segment)
admin.site.register(Assignment)
admin.site.register(ResultEntry)
admin.site.register(MetricsSnapshot)

View File

@@ -0,0 +1,98 @@
from django.db import models
from django.contrib.auth.models import User
class Brief(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="briefs")
product = models.TextField()
audience = models.TextField()
usp = models.TextField(blank=True, null=True)
benefits = models.JSONField(default=list, blank=True)
constraints = models.TextField(blank=True, null=True)
tone = models.CharField(max_length=120, blank=True, null=True)
formats = models.JSONField(default=list) # client chooses formats
variants_per_format = models.PositiveIntegerField(default=3)
created_at = models.DateTimeField(auto_now_add=True)
class TextVariant(models.Model):
brief = models.ForeignKey(Brief, on_delete=models.CASCADE, related_name="variants")
format = models.CharField(max_length=50)
payload = models.JSONField(default=dict)
placement_tips = models.TextField(blank=True, default="")
expected_effect = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
class Test(models.Model):
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tests")
brief = models.ForeignKey(Brief, on_delete=models.CASCADE, related_name="tests")
name = models.CharField(max_length=200, default="Тест")
channel = models.CharField(max_length=120, blank=True, default="")
duration_days = models.PositiveIntegerField(default=3)
sample_size = models.PositiveIntegerField(default=0)
objective = models.CharField(max_length=32, default="leads") # leads|conversions|clicks
status = models.CharField(max_length=20, default="draft")
created_at = models.DateTimeField(auto_now_add=True)
class Segment(models.Model):
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="segments")
name = models.CharField(max_length=200)
description = models.TextField(blank=True, default="")
class Assignment(models.Model):
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="assignments")
segment = models.ForeignKey(Segment, on_delete=models.CASCADE, related_name="assignments")
variant = models.ForeignKey(TextVariant, on_delete=models.CASCADE, related_name="assignments")
class ResultEntry(models.Model):
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="results")
segment = models.ForeignKey(Segment, on_delete=models.CASCADE, related_name="results")
variant = models.ForeignKey(TextVariant, on_delete=models.CASCADE, related_name="results")
date = models.DateField()
impressions = models.PositiveIntegerField(default=0)
clicks = models.PositiveIntegerField(default=0)
conversions = models.PositiveIntegerField(default=0)
leads = models.PositiveIntegerField(default=0)
spend = models.FloatField(default=0.0)
created_at = models.DateTimeField(auto_now_add=True)
class MetricsSnapshot(models.Model):
test = models.OneToOneField(Test, on_delete=models.CASCADE, related_name="snapshot")
ranking = models.JSONField(default=list)
recommendations = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class OptimizationPolicy(models.Model):
"""User-defined optimization rules for Agent #2.
Supports two modes:
- thresholds: structured KPI + thresholds
- text: free-form query that describes ranking/thresholds
"""
MODE_CHOICES = [
("thresholds", "thresholds"),
("text", "text"),
]
KPI_CHOICES = [
("cpa", "cpa"),
("cpl", "cpl"),
("cpc", "cpc"),
("ctr", "ctr"),
("cr", "cr"),
]
test = models.OneToOneField("Test", on_delete=models.CASCADE, related_name="policy")
mode = models.CharField(max_length=16, choices=MODE_CHOICES, default="thresholds")
# Structured mode
primary_kpi = models.CharField(max_length=8, choices=KPI_CHOICES, default="cpl")
direction = models.CharField(max_length=8, default="min") # min or max
good_threshold = models.FloatField(null=True, blank=True)
ok_threshold = models.FloatField(null=True, blank=True)
min_impressions = models.IntegerField(default=0)
min_clicks = models.IntegerField(default=0)
# Text mode
query_text = models.TextField(blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -0,0 +1,63 @@
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot, OptimizationPolicy
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=6)
class Meta:
model = User
fields = ("username","password","email")
def create(self, validated):
user = User(username=validated["username"], email=validated.get("email",""))
user.set_password(validated["password"])
user.save()
return user
class BriefSerializer(serializers.ModelSerializer):
class Meta:
model = Brief
fields = "__all__"
read_only_fields = ("id","owner","created_at")
class TextVariantSerializer(serializers.ModelSerializer):
class Meta:
model = TextVariant
fields = "__all__"
read_only_fields = ("id","created_at")
class TestSerializer(serializers.ModelSerializer):
class Meta:
model = Test
fields = "__all__"
read_only_fields = ("id","owner","created_at")
class SegmentSerializer(serializers.ModelSerializer):
class Meta:
model = Segment
fields = "__all__"
read_only_fields = ("id",)
class AssignmentSerializer(serializers.ModelSerializer):
class Meta:
model = Assignment
fields = "__all__"
read_only_fields = ("id",)
class ResultEntrySerializer(serializers.ModelSerializer):
class Meta:
model = ResultEntry
fields = "__all__"
read_only_fields = ("id","created_at")
class MetricsSnapshotSerializer(serializers.ModelSerializer):
class Meta:
model = MetricsSnapshot
fields = "__all__"
read_only_fields = ("id","created_at")
class OptimizationPolicySerializer(serializers.ModelSerializer):
class Meta:
model = OptimizationPolicy
fields = "__all__"
read_only_fields = ("id","test","updated_at")

View File

@@ -0,0 +1,77 @@
import requests
def _raise_with_detail(r: requests.Response):
"""Raise an HTTPError but include upstream error body for easier debugging."""
try:
detail = r.json()
except Exception:
detail = r.text
http_error_msg = f"{r.status_code} {r.url}: {detail}"
raise requests.HTTPError(http_error_msg, response=r)
from django.conf import settings
from django.db.models import Sum
from .models import Test, ResultEntry, TextVariant
def agents_generate_texts(payload: dict) -> dict:
url = f"{settings.AGENTS_SERVICE_URL}/api/v1/texts/generate"
r = requests.post(url, json=payload, timeout=180)
if r.status_code >= 400:
_raise_with_detail(r)
return r.json()
def agents_analyze(rows: list[dict], objective: str, policy: dict | None = None) -> dict:
url = f"{settings.AGENTS_SERVICE_URL}/api/v1/tests/analyze"
payload = {"rows": rows, "objective": objective}
if policy:
payload["policy"] = policy
r = requests.post(url, json=payload, timeout=90)
if r.status_code >= 400:
_raise_with_detail(r)
return r.json()
def aggregate_test_rows(test: Test) -> list[dict]:
qs = (ResultEntry.objects.filter(test=test).values("variant_id").annotate(
impressions=Sum("impressions"), clicks=Sum("clicks"), conversions=Sum("conversions"), leads=Sum("leads"), spend=Sum("spend")
))
rows=[]
for row in qs:
v = TextVariant.objects.get(id=row["variant_id"])
rows.append({
"variant_id": v.id, "format": v.format,
"impressions": int(row["impressions"] or 0),
"clicks": int(row["clicks"] or 0),
"conversions": int(row["conversions"] or 0),
"leads": int(row["leads"] or 0),
"spend": float(row["spend"] or 0.0),
})
return rows
def aggregate_test_rows_by_segment(test: Test) -> list[dict]:
"""Aggregate stats per (segment, variant)."""
qs = (ResultEntry.objects.filter(test=test)
.values("segment_id", "variant_id")
.annotate(impressions=Sum("impressions"), clicks=Sum("clicks"),
conversions=Sum("conversions"), leads=Sum("leads"), spend=Sum("spend")))
rows = []
# preload formats
fmt_map = {tv.id: tv.format for tv in TextVariant.objects.filter(brief=test.brief)}
seg_map = {s.id: s.name for s in test.segments.all()}
for r in qs:
rows.append({
"variant_id": r["variant_id"],
"format": fmt_map.get(r["variant_id"]),
"segment_id": r["segment_id"],
"segment_name": seg_map.get(r["segment_id"]),
"impressions": int(r["impressions"] or 0),
"clicks": int(r["clicks"] or 0),
"conversions": int(r["conversions"] or 0),
"leads": int(r["leads"] or 0),
"spend": float(r["spend"] or 0.0),
})
return rows

View File

@@ -0,0 +1,23 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenRefreshView
from .views import (
register,
PublicTokenObtainPairView,
BriefViewSet,
TextVariantViewSet,
TestViewSet,
)
router = DefaultRouter()
router.register(r"briefs", BriefViewSet, basename="brief")
router.register(r"variants", TextVariantViewSet, basename="variant")
router.register(r"tests", TestViewSet, basename="test")
urlpatterns = [
path("auth/register/", register),
path("auth/token/", PublicTokenObtainPairView.as_view(), name="token_obtain_pair"),
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("", include(router.urls)),
]

View File

@@ -0,0 +1,274 @@
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework_simplejwt.views import TokenObtainPairView
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot, OptimizationPolicy
from .serializers import (
RegisterSerializer,
BriefSerializer,
TextVariantSerializer,
TestSerializer,
SegmentSerializer,
AssignmentSerializer,
ResultEntrySerializer,
MetricsSnapshotSerializer,
OptimizationPolicySerializer,
)
from .services import agents_generate_texts, agents_analyze, aggregate_test_rows, aggregate_test_rows_by_segment
# --- Public Auth Endpoints ---
@extend_schema(
auth=[],
request=RegisterSerializer,
responses={
200: inline_serializer(
name="RegisterResponse",
fields={
"id": serializers.IntegerField(),
"username": serializers.CharField(),
},
)
},
)
@api_view(["POST"])
@permission_classes([permissions.AllowAny])
def register(request):
ser = RegisterSerializer(data=request.data)
ser.is_valid(raise_exception=True)
user = ser.save()
return Response({"id": user.id, "username": user.username})
class PublicTokenObtainPairView(TokenObtainPairView):
"""Same JWT token endpoint, but marked as public for Swagger (auth=[])."""
@extend_schema(auth=[])
def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
# --- Protected API (JWT required by DEFAULT_PERMISSION_CLASSES) ---
class BriefViewSet(viewsets.ModelViewSet):
serializer_class = BriefSerializer
permission_classes = [permissions.IsAuthenticated]
queryset = Brief.objects.all()
def get_queryset(self):
return Brief.objects.filter(owner=self.request.user).order_by("-id")
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@action(detail=True, methods=["GET"])
def segments(self, request, pk=None):
test = self.get_object()
qs = Segment.objects.filter(test=test).order_by("id")
return Response(SegmentSerializer(qs, many=True).data)
@action(detail=True, methods=["GET"])
def assignments(self, request, pk=None):
test = self.get_object()
qs = Assignment.objects.filter(test=test).order_by("id")
return Response(AssignmentSerializer(qs, many=True).data)
@action(detail=True, methods=["GET"])
def results(self, request, pk=None):
test = self.get_object()
qs = ResultEntry.objects.filter(test=test).order_by("-date", "-id")
return Response(ResultEntrySerializer(qs, many=True).data)
@action(detail=True, methods=["POST"])
def generate(self, request, pk=None):
brief = self.get_object()
# Build payload for agents service and ensure types are correct for validation
formats = brief.formats or []
if isinstance(formats, str):
# try to parse comma-separated / json-like strings
formats = [x.strip() for x in formats.split(",") if x.strip()]
if not isinstance(formats, list):
formats = []
benefits = brief.benefits or []
if isinstance(benefits, str):
benefits = [benefits]
if not isinstance(benefits, list):
benefits = []
if not formats:
# Without formats the agents service will return 422. Give a clear error to user.
return Response({"detail": "Выберите хотя бы один формат в брифе (например: Поисковое объявление)."}, status=status.HTTP_400_BAD_REQUEST)
payload = {
"product": brief.product,
"audience": brief.audience,
"usp": brief.usp,
"benefits": benefits,
"constraints": brief.constraints,
"tone": brief.tone,
"formats": formats,
"variants_per_format": max(1, min(int(brief.variants_per_format or 3), 10)),
}
res = agents_generate_texts(payload)
created = []
for block in res.get("variants", []):
fmt = block.get("format")
for item in block.get("items", []):
v = TextVariant.objects.create(
brief=brief,
format=fmt,
payload=item.get("payload") or {},
placement_tips=item.get("placement_tips", ""),
expected_effect=item.get("expected_effect", ""),
)
created.append(v.id)
return Response({"created_variant_ids": created})
class TextVariantViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = TextVariantSerializer
permission_classes = [permissions.IsAuthenticated]
queryset = TextVariant.objects.all()
def get_queryset(self):
qs = TextVariant.objects.filter(brief__owner=self.request.user).order_by("-id")
brief_id = self.request.query_params.get("brief")
if brief_id:
qs = qs.filter(brief_id=brief_id)
return qs
class TestViewSet(viewsets.ModelViewSet):
serializer_class = TestSerializer
permission_classes = [permissions.IsAuthenticated]
queryset = Test.objects.all()
def get_queryset(self):
return Test.objects.filter(owner=self.request.user).order_by("-id")
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@action(detail=True, methods=["GET","POST"])
def policy(self, request, pk=None):
"""Get or update optimization rules for this test."""
test = self.get_object()
obj, _ = OptimizationPolicy.objects.get_or_create(test=test)
if request.method == "GET":
return Response(OptimizationPolicySerializer(obj).data)
data = request.data or {}
# Normalize direction by KPI
kpi = (data.get("primary_kpi") or obj.primary_kpi or "cpl").lower()
direction = data.get("direction")
if not direction:
direction = "max" if kpi in ("ctr","cr") else "min"
for k in ("mode","primary_kpi","direction","good_threshold","ok_threshold","min_impressions","min_clicks","query_text"):
if k in data:
setattr(obj, k, data.get(k))
obj.direction = direction
obj.save()
return Response(OptimizationPolicySerializer(obj).data)
@action(detail=True, methods=["GET"])
def segments(self, request, pk=None):
test = self.get_object()
qs = Segment.objects.filter(test=test).order_by("id")
return Response(SegmentSerializer(qs, many=True).data)
@action(detail=True, methods=["GET"])
def assignments(self, request, pk=None):
test = self.get_object()
qs = Assignment.objects.filter(test=test).order_by("id")
return Response(AssignmentSerializer(qs, many=True).data)
@action(detail=True, methods=["GET"])
def results(self, request, pk=None):
test = self.get_object()
qs = ResultEntry.objects.filter(test=test).order_by("-date", "-id")
return Response(ResultEntrySerializer(qs, many=True).data)
@action(detail=True, methods=["POST"])
def add_segments(self, request, pk=None):
test = self.get_object()
segments = request.data.get("segments") or []
ids = []
for s in segments:
seg = Segment.objects.create(
test=test,
name=s.get("name", "Segment"),
description=s.get("description", ""),
)
ids.append(seg.id)
return Response({"created_segment_ids": ids})
@action(detail=True, methods=["POST"])
def assign(self, request, pk=None):
test = self.get_object()
assignments = request.data.get("assignments") or []
ids = []
for a in assignments:
seg = Segment.objects.get(id=a["segment_id"], test=test)
var = TextVariant.objects.get(id=a["variant_id"], brief=test.brief)
obj = Assignment.objects.create(test=test, segment=seg, variant=var)
ids.append(obj.id)
return Response({"created_assignment_ids": ids})
@action(detail=True, methods=["POST"])
def add_results(self, request, pk=None):
test = self.get_object()
rows = request.data.get("results") or []
ids = []
for r in rows:
seg = Segment.objects.get(id=r["segment_id"], test=test)
var = TextVariant.objects.get(id=r["variant_id"], brief=test.brief)
obj = ResultEntry.objects.create(
test=test,
segment=seg,
variant=var,
date=r["date"],
impressions=r.get("impressions", 0),
clicks=r.get("clicks", 0),
conversions=r.get("conversions", 0),
leads=r.get("leads", 0),
spend=r.get("spend", 0.0),
)
ids.append(obj.id)
return Response({"created_result_ids": ids})
@action(detail=True, methods=["POST"])
def analyze(self, request, pk=None):
test = self.get_object()
policy = None
if hasattr(test, "policy"):
policy = OptimizationPolicySerializer(test.policy).data
# allow overriding policy from request
if isinstance(request.data, dict) and request.data.get("policy"):
policy = request.data.get("policy")
rows = aggregate_test_rows_by_segment(test)
res = agents_analyze(rows, test.objective, policy=policy)
snap, _ = MetricsSnapshot.objects.update_or_create(
test=test,
defaults={
"ranking": res.get("ranking", []),
"recommendations": res.get("recommendations", []),
},
)
return Response(MetricsSnapshotSerializer(snap).data)
@action(detail=True, methods=["GET"])
def report(self, request, pk=None):
test = self.get_object()
if hasattr(test, "snapshot"):
return Response(MetricsSnapshotSerializer(test.snapshot).data)
return Response({"detail": "No analysis yet"}, status=status.HTTP_404_NOT_FOUND)

8
backend_django/manage.py Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python
import os, sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "adsassistant_backend.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,7 @@
Django>=5.0,<6
djangorestframework>=3.15,<4
djangorestframework-simplejwt>=5.3,<6
drf-spectacular>=0.27,<1
django-cors-headers>=4.4,<5
python-dotenv>=1,<2
requests>=2.32,<3

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
services:
agents:
build: ./agents_service
env_file:
- ./agents_service/.env
ports:
- "8001:8001"
backend:
build: ./backend_django
env_file:
- ./backend_django/.env
environment:
# Make sure backend can reach agents by service name inside compose network
- AGENTS_SERVICE_URL=http://agents:8001
- CORS_ALLOWED_ORIGINS=http://localhost:5174,http://localhost:8080
- ALLOWED_HOSTS=127.0.0.1,localhost
depends_on:
- agents
volumes:
# Persist SQLite DB on host
- backend_db:/app/adsassistant_backend/data
ports:
- "8000:8000"
frontend:
build: ./frontend
environment:
# Nginx serves static files; API base is baked at build time for Vite.
# For simplicity we keep default and use browser -> localhost:8000.
# If you want to change, set VITE_API_BASE_URL before build.
- NGINX_PORT=80
ports:
- "5174:80"
depends_on:
- backend
volumes:
backend_db:

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

1
frontend/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json tsconfig.json vite.config.ts index.html ./
COPY src ./src
RUN npm install
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

15
frontend/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Frontend
```bash
npm install
cp .env.example .env
npm run dev
```
Docker: UI served by nginx on http://localhost:5174
## Тестирование
- /tests — создание теста
- /tests/:id — сегменты, распределение, ручной ввод результатов, Analyze

4
frontend/index.html Normal file
View File

@@ -0,0 +1,4 @@
<!doctype html><html lang="ru"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>AdsAssistant</title> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

10
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "adsassistant-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}

0
frontend/public/.keep Normal file
View File

View File

@@ -0,0 +1,26 @@
export const API_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000').replace(/\/$/, '')
const TOKEN_KEY = 'adsassistant_token'
export function setToken(t: string){ localStorage.setItem(TOKEN_KEY,t) }
export function getToken(){ return localStorage.getItem(TOKEN_KEY) }
export function clearToken(){ localStorage.removeItem(TOKEN_KEY) }
async function handle(res: Response){
const text = await res.text()
let data: any = null
try{ data = text ? JSON.parse(text) : null } catch { data = text }
if(!res.ok) throw new Error((data && (data.detail||data.error)) || res.statusText)
return data
}
export async function apiPost(path:string, body?:any){
const token = getToken()
const res = await fetch(`${API_BASE}${path}`,{
method:'POST',
headers:{'Content-Type':'application/json', ...(token?{Authorization:`Bearer ${token}`}:{})},
body: JSON.stringify(body ?? {}),
})
return handle(res)
}
export async function apiGet(path:string){
const token = getToken()
const res = await fetch(`${API_BASE}${path}`,{ headers: token?{Authorization:`Bearer ${token}`}:{}})
return handle(res)
}

View File

@@ -0,0 +1,8 @@
import React, { useEffect, useRef } from 'react'
type Props = React.TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }
export default function AutoTextarea(props: Props){
const ref = useRef<HTMLTextAreaElement|null>(null)
const resize=()=>{ const el=ref.current; if(!el) return; el.style.height='0px'; el.style.height=el.scrollHeight+'px' }
useEffect(()=>{ resize() },[props.value])
return <textarea {...props} ref={ref} onChange={(e)=>{ props.onChange(e); requestAnimationFrame(resize) }} style={{...(props.style||{}), overflow:'hidden'}}/>
}

View File

@@ -0,0 +1,62 @@
import React from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { clearToken, getToken } from '../api/client'
function NavItem({ to, label }: { to: string; label: string }) {
const loc = useLocation()
const active = loc.pathname === to || loc.pathname.startsWith(to + '/')
return (
<Link
to={to}
className="small"
style={{
padding: '10px 12px',
borderRadius: 12,
border: '1px solid transparent',
background: active ? 'rgba(45,96,255,.10)' : 'transparent',
color: active ? '#2d60ff' : undefined,
fontWeight: active ? 700 : 600,
textDecoration: 'none',
}}
>
{label}
</Link>
)
}
export default function NavBar() {
const nav = useNavigate()
const token = getToken()
return (
<div className="card compact" style={{ marginBottom: 16 }}>
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div className="row" style={{ alignItems: 'center' }}>
<div style={{ fontWeight: 800, letterSpacing: '-0.03em' }}>AdsAssistant</div>
<div className="row" style={{ marginLeft: 10, gap: 8 }}>
<NavItem to="/briefs" label="Брифы" />
<NavItem to="/tests" label="Тесты" />
</div>
</div>
<div className="row" style={{ alignItems: 'center' }}>
{token ? (
<button
className="btn secondary"
onClick={() => {
clearToken()
nav('/login')
}}
>
Выйти
</button>
) : (
<Link to="/login" className="small">
Вход
</Link>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import React, { useId, useState } from 'react'
export default function Tooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false)
const id = useId()
return (
<span
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
<button
type="button"
aria-describedby={id}
className="tipbtn"
title={text}
onClick={() => setOpen((v) => !v)}
>
i
</button>
{open && (
<span id={id} role="tooltip" className="tooltip">
{text}
</span>
)}
</span>
)
}

28
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import './styles/app.css'
import Login from './pages/Login'
import Briefs from './pages/Briefs'
import Tests from './pages/Tests'
import TestDetail from './pages/TestDetail'
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/briefs" element={<Briefs />} />
<Route path="/tests" element={<Tests />} />
<Route path="/tests/:id" element={<TestDetail />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import AutoTextarea from '../components/AutoTextarea'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
const FORMATS = [
{ id: 'social_post', label: 'Пост для соцсетей' },
{ id: 'search_ad', label: 'Поисковое объявление' },
{ id: 'email', label: 'Emailписьмо' },
]
function formatRu(fmt: string) {
const v = (fmt || '').toLowerCase()
if (v === 'search_ad') return 'Поисковое объявление'
if (v === 'social_post') return 'Пост для соцсетей'
if (v === 'email') return 'Emailписьмо'
return fmt
}
function PayloadView({ format, payload }: { format: string; payload: any }) {
const p = payload || {}
if (format === 'search_ad') {
const headlines: string[] = p.headlines || []
const descriptions: string[] = p.descriptions || []
return (
<div>
<div className="small" style={{ marginBottom: 8 }}>
Заголовки
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{headlines.map((h, i) => (
<span key={i} className="pill">
{h}
</span>
))}
{!headlines.length && <span className="small"></span>}
</div>
<div className="small" style={{ marginTop: 12, marginBottom: 8 }}>
Описания
</div>
<div className="list" style={{ gap: 8 }}>
{descriptions.map((d, i) => (
<div key={i} className="item" style={{ boxShadow: 'none', padding: 12 }}>
<div className="meta">
<div className="s">{d}</div>
</div>
</div>
))}
{!descriptions.length && <div className="small"></div>}
</div>
{p.cta && (
<div style={{ marginTop: 12 }}>
<span className="small">CTA: </span>
<span style={{ fontWeight: 700 }}>{p.cta}</span>
</div>
)}
</div>
)
}
if (format === 'social_post') {
return (
<div className="list">
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">H</div>
<div className="meta">
<div className="h">Hook</div>
<div className="s">{p.hook || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">B</div>
<div className="meta">
<div className="h">Body</div>
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
{p.body || '—'}
</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">C</div>
<div className="meta">
<div className="h">CTA</div>
<div className="s">{p.cta || '—'}</div>
</div>
</div>
</div>
)
}
if (format === 'email') {
return (
<div className="list">
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">S</div>
<div className="meta">
<div className="h">Subject</div>
<div className="s">{p.subject || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">P</div>
<div className="meta">
<div className="h">Preheader</div>
<div className="s">{p.preheader || '—'}</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">B</div>
<div className="meta">
<div className="h">Body</div>
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
{p.body || '—'}
</div>
</div>
</div>
<div className="item" style={{ boxShadow: 'none' }}>
<div className="avatar">C</div>
<div className="meta">
<div className="h">CTA</div>
<div className="s">{p.cta || '—'}</div>
</div>
</div>
</div>
)
}
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{JSON.stringify(p, null, 2)}</pre>
}
export default function Briefs() {
const [briefs, setBriefs] = useState<any[]>([])
const [variants, setVariants] = useState<any[]>([])
const [selectedBrief, setSelectedBrief] = useState<number | null>(null)
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [form, setForm] = useState<any>({
product: '',
audience: '',
usp: '',
benefits: [],
constraints: '',
tone: 'дружелюбный',
formats: ['social_post', 'search_ad'],
variants_per_format: 3,
})
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
const loadBriefs = async () => {
const bs = await apiGet('/api/briefs/')
setBriefs(bs)
if (bs.length && selectedBrief == null) setSelectedBrief(bs[0].id)
}
const loadVariants = async (briefId: number) => {
const vs = await apiGet('/api/variants/?brief=' + briefId)
setVariants(vs)
}
useEffect(() => {
loadBriefs().catch((e) => setErr(e.message))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (selectedBrief) loadVariants(selectedBrief).catch((e) => setErr(e.message))
}, [selectedBrief])
return (
<div className="container">
<NavBar />
<div className="row">
<div className="card" style={{ flex: 1, minWidth: 380 }}>
<div className="cardtitle">
<h2 style={{ margin: 0 }}>Бриф</h2>
<div className="small">Генерация рекламных текстов</div>
</div>
<div className="row" style={{ justifyContent: 'flex-end', marginBottom: 10 }}>
<button
className="btn secondary"
type="button"
onClick={() =>
setForm({
...form,
product: 'Онлайн‑курс по английскому',
audience: 'Взрослые 2545, хотят разговорный английский для работы и поездок',
usp: '1й урок бесплатно',
constraints: 'Без обещаний гарантированного результата',
tone: 'дружелюбный',
formats: ['search_ad', 'social_post'],
variants_per_format: 3,
})
}
>
Пресет 1
</button>
<button
className="btn secondary"
type="button"
onClick={() =>
setForm({
...form,
product: 'Сервис доставки здорового питания',
audience: 'Мужчины и женщины 2340, следят за питанием, мало времени готовить',
usp: 'Скидка 20% на первый заказ',
constraints: 'Не использовать медицинские обещания. Без “гарантируем похудение”.',
tone: 'энергичный',
formats: ['social_post', 'email'],
variants_per_format: 3,
})
}
>
Пресет 2
</button>
</div>
{err && <div style={{ color: '#fe5c73', marginBottom: 10 }}>{err}</div>}
<div style={{ display: 'grid', gap: 12 }}>
<div>
<div className="tiprow">
<div className="small">Описание продукта</div>
<Tooltip text="Что вы продаёте и в чём суть предложения. Чем точнее — тем лучше тексты." />
</div>
<AutoTextarea className="input" value={form.product} onChange={(e) => setForm({ ...form, product: e.target.value })} />
</div>
<div>
<div className="tiprow">
<div className="small">Аудитория</div>
<Tooltip text="Кто покупает: возраст, гео, интересы, боль. Это влияет на стиль и формулировки." />
</div>
<AutoTextarea className="input" value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })} />
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow">
<div className="small">Tone</div>
<Tooltip text="Тональность: дружелюбный, строгий, экспертный и т.п. Меняет стиль текста." />
</div>
<input className="input" value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })} />
</div>
<div style={{ width: 190 }}>
<div className="tiprow">
<div className="small">Вариантов/формат</div>
<Tooltip text="Сколько вариантов агент генерирует на каждый выбранный формат." />
</div>
<input
className="input"
type="number"
value={form.variants_per_format}
onChange={(e) => setForm({ ...form, variants_per_format: Number(e.target.value) })}
/>
</div>
</div>
<div>
<div className="tiprow">
<div className="small">УТП</div>
<Tooltip text="Уникальное торговое предложение: почему выбрать вас (например: 1-й урок бесплатно)." />
</div>
<AutoTextarea className="input" value={form.usp} onChange={(e) => setForm({ ...form, usp: e.target.value })} />
</div>
<div>
<div className="tiprow">
<div className="small">Ограничения</div>
<Tooltip text="Что нельзя обещать/упоминать (например: без гарантий результата, без “лучшие”)." />
</div>
<AutoTextarea className="input" value={form.constraints} onChange={(e) => setForm({ ...form, constraints: e.target.value })} />
</div>
<div className="card compact">
<div className="tiprow" style={{ marginBottom: 8 }}>
<div className="small">Форматы (выбирает клиент)</div>
<Tooltip text="Какие типы текстов генерировать: соцсети / поиск / email. Можно выбрать несколько." />
</div>
<div className="row" style={{ gap: 14 }}>
{FORMATS.map((f) => (
<label key={f.id} className="small" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="checkbox"
checked={form.formats.includes(f.id)}
onChange={(e) => {
const set = new Set(form.formats)
if (e.target.checked) set.add(f.id)
else set.delete(f.id)
setForm({ ...form, formats: Array.from(set) })
}}
/>
{f.label}
</label>
))}
</div>
</div>
<button
className="btn"
disabled={busy || !form.product || !form.audience || !form.formats.length}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const b = await apiPost('/api/briefs/', form)
await loadBriefs()
setSelectedBrief(b.id)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Сохранить бриф
</button>
{selectedBrief && (
<button
className="btn secondary"
disabled={busy}
onClick={async () => {
setBusy(true)
setErr(null)
try {
await apiPost(`/api/briefs/${selectedBrief}/generate/`)
await loadVariants(selectedBrief)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Сгенерировать тексты
</button>
)}
</div>
</div>
<div className="card" style={{ flex: 1.2, minWidth: 420 }}>
<div className="cardtitle">
<h2 style={{ margin: 0 }}>Тексты</h2>
<div className="small">Структурированный вывод</div>
</div>
<div style={{ marginTop: 10 }}>
<div className="small">Выбранный бриф</div>
<select className="input" value={selectedBrief ?? ''} onChange={(e) => setSelectedBrief(Number(e.target.value))}>
{briefs.map((b) => (
<option key={b.id} value={b.id}>
{String(b.product).slice(0, 60)}
</option>
))}
</select>
</div>
<div className="list" style={{ marginTop: 14 }}>
{variantsSorted.map((v, idx) => (
<div key={v.id} className="item">
<div className="avatar">{String(formatRu(v.format || '?')).slice(0, 1).toUpperCase()}</div>
<div className="meta">
<div className="h">
Текст {idx + 1} <span className="small"> {formatRu(v.format)}</span>
</div>
<div style={{ marginTop: 10 }}>
<PayloadView format={v.format} payload={v.payload} />
</div>
</div>
</div>
))}
{!variantsSorted.length && (
<div className="small" style={{ padding: 12 }}>
Пока нет текстов. Сгенерируй варианты.
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import React, { useState } from 'react'
import { apiPost, setToken } from '../api/client'
import { useNavigate } from 'react-router-dom'
export default function Login(){
const [username,setUsername]=useState('')
const [password,setPassword]=useState('')
const [err,setErr]=useState<string|null>(null)
const nav=useNavigate()
return (
<div className="container" style={{maxWidth:520,paddingTop:60}}>
<div className="card">
<h2 style={{marginTop:0}}>Вход</h2>
<div style={{display:'grid',gap:10,marginTop:12}}>
<input className="input" placeholder="username" value={username} onChange={e=>setUsername(e.target.value)} />
<input className="input" placeholder="password" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
{err && <div style={{color:'#fca5a5'}}>{err}</div>}
<button className="btn" onClick={async()=>{
setErr(null)
try{
const res = await apiPost('/api/auth/token/',{username,password})
setToken(res.access)
nav('/briefs')
}catch(e:any){ setErr(e.message||String(e)) }
}}>Войти</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,618 @@
import React, { useEffect, useMemo, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
import { Link, useParams } from 'react-router-dom'
function formatRu(fmt: string) {
const v = (fmt || '').toLowerCase()
if (v === 'search_ad') return 'Поисковое объявление'
if (v === 'social_post') return 'Пост для соцсетей'
if (v === 'email') return 'Emailписьмо'
return fmt
}
function objectiveRu(x: string) {
const v = (x || '').toLowerCase()
if (v === 'leads') return 'Лиды'
if (v === 'conversions') return 'Конверсии'
if (v === 'clicks') return 'Клики'
return x
}
function statusRu(x: string) {
const v = (x || '').toLowerCase()
if (v === 'draft') return 'Черновик'
if (v === 'running') return 'Запущен'
if (v === 'done' || v === 'finished') return 'Завершён'
return x
}
function safePct01(x: any) {
if (x == null || Number.isNaN(Number(x))) return '—'
return (Number(x) * 100).toFixed(2) + '%'
}
function safeNum(x: any) {
if (x == null || Number.isNaN(Number(x))) return '—'
const n = Number(x)
if (!Number.isFinite(n)) return '—'
return Math.abs(n - Math.round(n)) < 1e-9 ? String(Math.round(n)) : n.toFixed(2)
}
type Agg = {
segment_id: number
variant_id: number
impressions: number
clicks: number
conversions: number
leads: number
spend: number
ctr: number | null
cpc: number | null
cr: number | null
cpl: number | null
cpa: number | null
}
export default function TestDetail() {
const { id } = useParams()
const testId = Number(id)
const [test, setTest] = useState<any | null>(null)
const [brief, setBrief] = useState<any | null>(null)
const [variants, setVariants] = useState<any[]>([])
const [segments, setSegments] = useState<any[]>([])
const [assignments, setAssignments] = useState<any[]>([])
const [results, setResults] = useState<any[]>([])
const [snapshot, setSnapshot] = useState<any | null>(null)
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const todayISO = new Date().toISOString().slice(0, 10)
const [segName, setSegName] = useState('Группа A')
const [segDesc, setSegDesc] = useState('')
const [assignForm, setAssignForm] = useState<any>({ segment_id: '', variant_id: '' })
const [result, setResult] = useState<any>({
segment_id: '',
variant_id: '',
date: todayISO,
impressions: 0,
clicks: 0,
conversions: 0,
leads: 0,
spend: 0,
})
const load = async () => {
setErr(null)
const t = await apiGet(`/api/tests/${testId}/`)
setTest(t)
const b = await apiGet(`/api/briefs/${t.brief}/`)
setBrief(b)
const vs = await apiGet(`/api/variants/?brief=${t.brief}`)
setVariants(vs)
let segs: any[] = []
let asg: any[] = []
let rs: any[] = []
try {
segs = await apiGet(`/api/tests/${testId}/segments/`)
asg = await apiGet(`/api/tests/${testId}/assignments/`)
rs = await apiGet(`/api/tests/${testId}/results/`)
} catch (e: any) {
segs = []
asg = []
rs = []
}
setSegments(segs)
setAssignments(asg)
setResults(rs)
if (segs?.length) {
setAssignForm((a: any) => ({ ...a, segment_id: a.segment_id || segs[0].id }))
setResult((r: any) => ({ ...r, segment_id: r.segment_id || segs[0].id }))
}
if (vs?.length) {
setAssignForm((a: any) => ({ ...a, variant_id: a.variant_id || vs[0].id }))
}
}
useEffect(() => {
if (!Number.isFinite(testId)) return
load().catch((e: any) => setErr(e.message || String(e)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testId])
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
const textNumberByVariantId = useMemo(() => {
const map = new Map<number, number>()
variantsSorted.forEach((v, idx) => map.set(Number(v.id), idx + 1))
return map
}, [variantsSorted])
function variantDisplay(variantId: any) {
const v = variants.find((x) => Number(x.id) === Number(variantId))
const n = textNumberByVariantId.get(Number(variantId))
if (!v) return n ? `Текст №${n}` : `Текст`
const fmt = formatRu(v.format)
return n ? `Текст №${n}${fmt}` : fmt
}
function segmentName(segId: any) {
const s = segments.find((x) => Number(x.id) === Number(segId))
return s?.name || 'Сегмент'
}
const assignedVariantIdsForSegment = useMemo(() => {
const segId = Number(result.segment_id || assignForm.segment_id)
const ids = assignments
.filter((a) => Number(a.segment) === segId || Number(a.segment_id) === segId)
.map((a) => Number(a.variant) || Number(a.variant_id))
return new Set(ids)
}, [assignForm.segment_id, assignments, result.segment_id])
const variantsForSelectedSegment = useMemo(() => {
const ids = assignedVariantIdsForSegment
const filtered = variantsSorted.filter((v) => ids.has(Number(v.id)))
return filtered.length ? filtered : variantsSorted
}, [assignedVariantIdsForSegment, variantsSorted])
useEffect(() => {
if (!result.segment_id) return
const list = variantsForSelectedSegment
if (!list.length) return
const ok = list.some((v) => Number(v.id) === Number(result.variant_id))
if (!ok) setResult((r: any) => ({ ...r, variant_id: list[0].id }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [result.segment_id, variantsForSelectedSegment.length])
const aggByVariant = useMemo(() => {
const map = new Map<number, Agg[]>()
const tmp = new Map<string, Agg>()
for (const r of results) {
const segId = Number(r.segment)
const varId = Number(r.variant)
const key = `${segId}:${varId}`
const cur =
tmp.get(key) ||
({
segment_id: segId,
variant_id: varId,
impressions: 0,
clicks: 0,
conversions: 0,
leads: 0,
spend: 0,
ctr: null,
cpc: null,
cr: null,
cpl: null,
cpa: null,
} as Agg)
cur.impressions += Number(r.impressions || 0)
cur.clicks += Number(r.clicks || 0)
cur.conversions += Number(r.conversions || 0)
cur.leads += Number(r.leads || 0)
cur.spend += Number(r.spend || 0)
tmp.set(key, cur)
}
for (const cur of tmp.values()) {
cur.ctr = cur.impressions > 0 ? cur.clicks / cur.impressions : null
cur.cpc = cur.clicks > 0 ? cur.spend / cur.clicks : null
cur.cr = cur.clicks > 0 ? cur.conversions / cur.clicks : null
cur.cpl = cur.leads > 0 ? cur.spend / cur.leads : null
cur.cpa = cur.conversions > 0 ? cur.spend / cur.conversions : null
const arr = map.get(cur.variant_id) || []
arr.push(cur)
map.set(cur.variant_id, arr)
}
for (const [varId, arr] of map.entries()) {
arr.sort((a, b) => segmentName(a.segment_id).localeCompare(segmentName(b.segment_id)))
map.set(varId, arr)
}
return map
}, [results, segments])
const ranking = snapshot?.ranking || []
const recs = snapshot?.recommendations || []
const cleanedReason = (s: string) => {
if (!s) return ''
return s.replace(/Primary[^;]*;?\s*/gi, '').replace(/\s{2,}/g, ' ').trim()
}
const applyResultPreset = (n: 1 | 2) => {
if (n === 1) setResult((r: any) => ({ ...r, impressions: 5000, clicks: 60, leads: 5, conversions: 2, spend: 2400 }))
else setResult((r: any) => ({ ...r, impressions: 3000, clicks: 30, leads: 3, conversions: 3, spend: 3400 }))
}
const presetSegmentsAB = async () => {
setBusy(true)
setErr(null)
try {
await apiPost(`/api/tests/${testId}/add_segments/`, {
segments: [
{ name: 'Группа A', description: 'Аудитория A' },
{ name: 'Группа B', description: 'Аудитория B' },
],
})
await load()
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}
const presetAssignmentsAB = async () => {
if (segments.length < 2 || variantsSorted.length < 2) {
setErr('Нужно минимум 2 сегмента и 2 текста, чтобы сделать пресет распределения.')
return
}
setBusy(true)
setErr(null)
try {
const segA = segments[0].id
const segB = segments[1].id
const vA = variantsSorted[0].id
const vB = variantsSorted[1].id
await apiPost(`/api/tests/${testId}/assign/`, {
assignments: [
{ segment_id: segA, variant_id: vA },
{ segment_id: segB, variant_id: vB },
],
})
await load()
setAssignForm({ segment_id: segA, variant_id: vA })
setResult((r: any) => ({ ...r, segment_id: segA, variant_id: vA }))
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}
return (
<div className="container">
<NavBar />
<div className="card">
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0 }}>{test?.name || 'Тест'}</h2>
{test && (
<div className="small">
цель: <b>{objectiveRu(test.objective)}</b> бриф: <b>{brief ? String(brief.product).slice(0, 60) : ''}</b> статус:{' '}
<b>{statusRu(test.status)}</b>
</div>
)}
</div>
<div className="row">
<Link to="/tests" className="btn secondary">
Назад
</Link>
<button className="btn" disabled={busy} onClick={async () => {
setBusy(true); setErr(null);
try { const res = await apiPost(`/api/tests/${testId}/analyze/`); setSnapshot(res); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Проанализировать
</button>
</div>
</div>
{err && <div style={{ color: '#fe5c73', marginTop: 10 }}>{err}</div>}
</div>
<div className="row section">
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Сегменты</h3>
<Tooltip text="Создайте аудитории/группы (A/B). Потом распределите тексты по сегментам." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Название</div><Tooltip text="Название аудитории/группы, например: “Группа A: 2534 Москва”." /></div>
<input className="input" value={segName} onChange={(e) => setSegName(e.target.value)} />
</div>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Описание (опц.)</div><Tooltip text="Коротко: интересы/гео/признак сегмента." /></div>
<input className="input" value={segDesc} onChange={(e) => setSegDesc(e.target.value)} />
</div>
<button className="btn" disabled={busy || !segName.trim()} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/add_segments/`, { segments: [{ name: segName, description: segDesc }] }); await load(); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Добавить
</button>
<button className="btn secondary" disabled={busy} onClick={presetSegmentsAB}>Пресет A/B</button>
</div>
</div>
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Распределение текстов</h3>
<Tooltip text="Привяжите текст к сегменту (A/B). Это влияет на список текстов при вводе результатов." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ flex: 1, minWidth: 160 }}>
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="На какую аудиторию показываем выбранный текст." /></div>
<select className="input" value={assignForm.segment_id || ''} onChange={(e) => setAssignForm({ ...assignForm, segment_id: Number(e.target.value) })} disabled={!segments.length}>
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
</select>
</div>
<div style={{ flex: 1, minWidth: 220 }}>
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Какой текст показываем этому сегменту." /></div>
<select className="input" value={assignForm.variant_id || ''} onChange={(e) => setAssignForm({ ...assignForm, variant_id: Number(e.target.value) })} disabled={!variantsSorted.length}>
{variantsSorted.length ? variantsSorted.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Сначала сгенерируйте тексты</option>}
</select>
</div>
<button className="btn" disabled={busy || !assignForm.segment_id || !assignForm.variant_id} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/assign/`, { assignments: [{ segment_id: assignForm.segment_id, variant_id: assignForm.variant_id }] }); await load(); setResult((r:any)=>({...r, segment_id: assignForm.segment_id, variant_id: assignForm.variant_id})); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Добавить
</button>
<button className="btn secondary" disabled={busy} onClick={presetAssignmentsAB}>Пресет A/B</button>
</div>
</div>
</div>
<div className="row section">
<div className="card" style={{ flex: 1, minWidth: 320 }}>
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Результаты (ручной ввод)</h3>
<Tooltip text="Введите показатели по тексту и сегменту: показы, клики, конверсии, лиды, расход." />
</div>
<div className="row" style={{ alignItems: 'flex-end' }}>
<div style={{ width: 170 }}>
<div className="tiprow"><div className="small">Дата</div><Tooltip text="Дата, к которой относится статистика (если вводите по дням)." /></div>
<input className="input" type="date" value={result.date} onChange={(e) => setResult({ ...result, date: e.target.value })} />
</div>
<div style={{ flex: 1, minWidth: 180 }}>
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="Сегмент аудитории. Список текстов справа фильтруется по распределению." /></div>
<select className="input" value={result.segment_id || ''} onChange={(e) => setResult({ ...result, segment_id: Number(e.target.value) })} disabled={!segments.length}>
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
</select>
</div>
<div style={{ flex: 1, minWidth: 260 }}>
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Показывает только тексты, распределённые выбранному сегменту." /></div>
<select className="input" value={result.variant_id || ''} onChange={(e) => setResult({ ...result, variant_id: Number(e.target.value) })} disabled={!variantsForSelectedSegment.length}>
{variantsForSelectedSegment.length ? variantsForSelectedSegment.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Нет текстов для сегмента</option>}
</select>
</div>
</div>
<div className="row" style={{ marginTop: 12 }}>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Показы</div><Tooltip text="Impressions — нужно для CTR (клики/показы)." /></div>
<input className="input" type="number" value={result.impressions} onChange={(e) => setResult({ ...result, impressions: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Клики</div><Tooltip text="Clicks — нужно для CTR и CPC (расход/клики)." /></div>
<input className="input" type="number" value={result.clicks} onChange={(e) => setResult({ ...result, clicks: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Конверсии</div><Tooltip text="Conversions — нужно для CR и CPA (расход/конверсии)." /></div>
<input className="input" type="number" value={result.conversions} onChange={(e) => setResult({ ...result, conversions: Number(e.target.value) })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Лиды</div><Tooltip text="Leads — для лидогенерации (CPL = расход/лиды)." /></div>
<input className="input" type="number" value={result.leads} onChange={(e) => setResult({ ...result, leads: Number(e.target.value) })} />
</div>
<div style={{ width: 180 }}>
<div className="tiprow"><div className="small">Расход</div><Tooltip text="Spend — расходы на этот текст (для CPC/CPL/CPA)." /></div>
<input className="input" type="number" value={result.spend} onChange={(e) => setResult({ ...result, spend: Number(e.target.value) })} />
</div>
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" disabled={busy || !result.segment_id || !result.variant_id || !result.date} onClick={async () => {
setBusy(true); setErr(null);
try { await apiPost(`/api/tests/${testId}/add_results/`, { results: [result] }); await load(); }
catch(e:any){ setErr(e.message||String(e)); }
finally{ setBusy(false); }
}}>
Сохранить
</button>
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(1)}>Пресет 1</button>
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(2)}>Пресет 2</button>
</div>
<div className="small" style={{ marginTop: 10 }}>
После ввода результатов нажмите <b>«Проанализировать»</b> сверху появится рейтинг и метрики.
</div>
</div>
</div>
<div className="card section">
<div className="cardtitle">
<h3 style={{ margin: 0 }}>Сводка и таблицы</h3>
<div className="small">Результаты теста и история</div>
</div>
<div className="row">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>Сегменты</div>
<div className="tx-wrap">
<table className="tx-table">
<thead><tr><th>#</th><th>Название</th><th>Описание</th></tr></thead>
<tbody>
{segments.map((s) => (
<tr key={s.id}>
<td className="small">{s.id}</td>
<td style={{ fontWeight: 800 }}>{s.name}</td>
<td className="small">{s.description}</td>
</tr>
))}
{!segments.length && <tr><td colSpan={3} className="small">Пока нет сегментов.</td></tr>}
</tbody>
</table>
</div>
</div>
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>Распределение (A/B)</div>
<div className="tx-wrap">
<table className="tx-table">
<thead><tr><th>#</th><th>Сегмент</th><th>Текст</th></tr></thead>
<tbody>
{assignments.map((a) => (
<tr key={a.id}>
<td className="small">{a.id}</td>
<td className="small">{segmentName(a.segment || a.segment_id)}</td>
<td style={{ fontWeight: 800 }} className="small">{variantDisplay(a.variant || a.variant_id)}</td>
</tr>
))}
{!assignments.length && <tr><td colSpan={3} className="small">Пока нет распределения.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
<div className="row section">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="small" style={{ marginBottom: 8 }}>История результатов</div>
<div className="tx-wrap">
<table className="tx-table">
<thead>
<tr>
<th>Дата</th><th>Сегмент</th><th>Текст</th><th>Показы</th><th>Клики</th><th>Лиды</th><th>Конв.</th><th>Расход</th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.id}>
<td className="small">{r.date}</td>
<td className="small">{segmentName(r.segment)}</td>
<td className="small">{variantDisplay(r.variant)}</td>
<td>{r.impressions}</td>
<td>{r.clicks}</td>
<td>{r.leads}</td>
<td>{r.conversions}</td>
<td style={{ fontWeight: 800 }}>{safeNum(r.spend)}</td>
</tr>
))}
{!results.length && <tr><td colSpan={8} className="small">Пока нет введённых результатов.</td></tr>}
</tbody>
</table>
</div>
</div>
</div>
<div className="row section">
<div style={{ flex: 1, minWidth: 340 }}>
<div className="cardtitle">
<div className="small">Рейтинг текстов (лучший худший)</div>
<div className="meta2">
<span className="kv"><b>CTR</b> <Tooltip text="ClickThrough Rate: клики / показы." /></span>
<span className="kv"><b>CPC</b> <Tooltip text="Cost per Click: расход / клики." /></span>
<span className="kv"><b>CR</b> <Tooltip text="Conversion Rate: конверсии / клики." /></span>
<span className="kv"><b>CPL</b> <Tooltip text="Cost per Lead: расход / лиды." /></span>
<span className="kv"><b>CPA</b> <Tooltip text="Cost per Action: расход / конверсии." /></span>
</div>
</div>
{!snapshot ? (
<div className="small">Пока анализа нет. Нажмите «Проанализировать».</div>
) : (
<>
<div className="list" style={{ marginTop: 12 }}>
{ranking.map((r: any) => {
const varId = Number(r.variant_id)
const perSeg = aggByVariant.get(varId) || []
const reason = cleanedReason(r.reason || '')
return (
<div key={varId} className="item">
<div className="avatar">{r.rank}</div>
<div className="meta">
<div className="h">{variantDisplay(varId)}</div>
{reason && <div className="s">{reason}</div>}
<div className="sep" />
<div className="small" style={{ marginBottom: 8 }}>Показатели по сегментам</div>
{!perSeg.length ? (
<div className="small">Нет введённых результатов для этого текста.</div>
) : (
<div className="tx-wrap" style={{ boxShadow: 'none' }}>
<table className="tx-table">
<thead>
<tr>
<th>Сегмент</th>
<th>CTR <Tooltip text="клики / показы" /></th>
<th>CPC <Tooltip text="расход / клики" /></th>
<th>CR <Tooltip text="конверсии / клики" /></th>
<th>CPL <Tooltip text="расход / лиды" /></th>
<th>CPA <Tooltip text="расход / конверсии" /></th>
</tr>
</thead>
<tbody>
{perSeg.map((m) => (
<tr key={`${m.segment_id}:${m.variant_id}`}>
<td className="small">{segmentName(m.segment_id)}</td>
<td className="small">{safePct01(m.ctr)}</td>
<td className="small">{safeNum(m.cpc)}</td>
<td className="small">{safePct01(m.cr)}</td>
<td className="small">{safeNum(m.cpl)}</td>
<td className="small">{safeNum(m.cpa)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
})}
</div>
<div className="card compact" style={{ marginTop: 12, border: '1px solid var(--border)', boxShadow: 'none' }}>
<div style={{ fontWeight: 800, marginBottom: 6 }}>Рекомендации</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{recs.map((x: string, i: number) => (
<li key={i} className="small" style={{ marginBottom: 6 }}>
{x}
</li>
))}
</ul>
</div>
</>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react'
import { apiGet, apiPost } from '../api/client'
import NavBar from '../components/NavBar'
import Tooltip from '../components/Tooltip'
import { Link, useNavigate } from 'react-router-dom'
function briefLabel(briefs: any[], id: any) {
const b = briefs.find((x) => Number(x.id) === Number(id))
if (!b) return `Бриф`
const name = String(b.product || '').trim()
const short = name.length > 42 ? name.slice(0, 42) + '…' : name
return short || 'Бриф'
}
export default function Tests() {
const [tests, setTests] = useState<any[]>([])
const [briefs, setBriefs] = useState<any[]>([])
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const nav = useNavigate()
const [form, setForm] = useState<any>({
brief: '',
name: 'Тест',
channel: '',
duration_days: 3,
sample_size: 0,
objective: 'leads',
status: 'draft',
})
const load = async () => {
setErr(null)
const [ts, bs] = await Promise.all([apiGet('/api/tests/'), apiGet('/api/briefs/')])
setTests(ts)
setBriefs(bs)
if (!form.brief && bs.length) setForm((f: any) => ({ ...f, brief: bs[0].id }))
}
useEffect(() => {
load().catch((e: any) => setErr(e.message || String(e)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="container">
<NavBar />
<div className="stats">
<div className="stat"><div className="dot">B</div><div><div className="label">Briefs</div><div className="value">{briefs.length}</div></div></div>
<div className="stat"><div className="dot">T</div><div><div className="label">Tests</div><div className="value">{tests.length}</div></div></div>
<div className="stat"><div className="dot">A</div><div><div className="label">Objective</div><div className="value">{String(form.objective).toUpperCase()}</div></div></div>
<div className="stat"><div className="dot">D</div><div><div className="label">Duration</div><div className="value">{form.duration_days}d</div></div></div>
</div>
<div style={{ height: 16 }} />
<div className="row">
<div className="card" style={{ flex: 1, minWidth: 340 }}>
<h2 style={{ marginTop: 0 }}>Создать тест</h2>
{err && <div style={{ color: '#fca5a5' }}>{err}</div>}
<div style={{ display: 'grid', gap: 10 }}>
<div>
<div className="tiprow"><div className="small">Бриф</div><Tooltip text="Выбираем бриф (продукт/аудитория/УТП), из которого берём варианты текстов для теста." /></div>
<select
className="input"
value={form.brief ?? ''}
onChange={(e) => setForm({ ...form, brief: Number(e.target.value) })}
>
{briefs.map((b) => (
<option key={b.id} value={b.id}>{String(b.product).slice(0, 50)}</option>
))}
</select>
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow"><div className="small">Название</div><Tooltip text="Короткое имя теста для удобства: например “Яндексоиск_март_1”." /></div>
<input className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div style={{ width: 180 }}>
<div className="tiprow"><div className="small">Цель</div><Tooltip text="По чему ранжируем варианты: лиды / конверсии / клики. Агент №2 будет оптимизировать под эту цель." /></div>
<select className="input" value={form.objective} onChange={(e) => setForm({ ...form, objective: e.target.value })}>
<option value="leads">Лиды</option>
<option value="conversions">Конверсии</option>
<option value="clicks">Клики</option>
</select>
</div>
</div>
<div className="row">
<div style={{ flex: 1 }}>
<div className="tiprow"><div className="small">Канал (опц.)</div><Tooltip text="Где тестируете: Яндекс, VK, Email и т.д. Нужно для истории и будущих рекомендаций по каналу." /></div>
<input className="input" value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })} />
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Дней</div><Tooltip text="Плановая длительность теста (сколько дней собираете статистику)." /></div>
<input
className="input"
type="number"
value={form.duration_days}
onChange={(e) => setForm({ ...form, duration_days: Number(e.target.value) })}
/>
</div>
<div style={{ width: 160 }}>
<div className="tiprow"><div className="small">Выборка</div><Tooltip text="Плановый объём данных (например показы/клики). Ориентир “достаточно ли данных” для выводов." /></div>
<input
className="input"
type="number"
value={form.sample_size}
onChange={(e) => setForm({ ...form, sample_size: Number(e.target.value) })}
/>
</div>
</div>
<button
className="btn"
disabled={busy || !form.brief}
onClick={async () => {
setBusy(true)
setErr(null)
try {
const res = await apiPost('/api/tests/', form)
await load()
nav(`/tests/${res.id}`)
} catch (e: any) {
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}}
>
Создать
</button>
</div>
</div>
<div className="card" style={{ flex: 1, minWidth: 340 }}>
<h2 style={{ marginTop: 0 }}>Тесты</h2>
<div className="small">Открой тест, чтобы добавить сегменты, распределение, результаты и анализ.</div>
<table className="table" style={{ marginTop: 12 }}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Objective</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{tests.map((t) => (
<tr key={t.id}>
<td>{t.id}</td>
<td>
<Link to={`/tests/${t.id}`} style={{ fontWeight: 700 }}>
{t.name}
</Link>
<div className="small">{briefLabel(briefs, t.brief)}</div>
</td>
<td>{t.objective}</td>
<td className="small">{t.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

402
frontend/src/styles/app.css Normal file
View File

@@ -0,0 +1,402 @@
:root{
--bg: #f5f7fa;
--card: #ffffff;
--text: #343c6a;
--muted: #718ebf;
--border: #e6eff5;
--primary: #2d60ff;
--danger: #fe5c73;
--shadow: 0 10px 30px rgba(0,0,0,.06);
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
--pad: 16px;
--pad-lg: 22px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
a{color:inherit;text-decoration:none}
a:hover{opacity:.9;text-decoration:underline}
.container{max-width:1180px;margin:0 auto;padding:24px}
.row{display:flex;gap:16px;flex-wrap:wrap}
.card{
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--pad-lg);
box-shadow: var(--shadow);
}
.card.compact{padding: var(--pad)}
h2,h3{letter-spacing:-0.02em}
h2{font-size:22px;line-height:28px}
h3{font-size:18px;line-height:24px}
.small{color:var(--muted);font-size:12px}
.input{
width:100%;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 14px;
background:#fff;
color: var(--text);
outline: none;
}
.input:focus{
border-color: rgba(45,96,255,.55);
box-shadow: 0 0 0 4px rgba(45,96,255,.10);
}
textarea{resize:none}
.btn{
border: none;
border-radius: 12px;
padding: 11px 14px;
cursor: pointer;
font-weight: 600;
background: var(--primary);
color: #fff;
transition: transform .04s ease, opacity .15s ease;
}
.btn:hover{opacity:.95}
.btn:active{transform: translateY(1px)}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn.secondary{
background:#eef3ff;
color: var(--primary);
border: 1px solid rgba(45,96,255,.18);
}
.badge{
display:inline-flex;
align-items:center;
gap:6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
background: #fff;
}
.badge::before{
content:"";
width:8px;height:8px;border-radius:999px;
background: var(--muted);
}
.badge.active::before{background: #16a34a}
.badge.paused::before{background: var(--danger)}
.table{width:100%;border-collapse:separate;border-spacing:0}
.table th{
text-align:left;
font-size:12px;
color: var(--muted);
font-weight:700;
padding: 10px 10px;
border-bottom: 1px solid var(--border);
}
.table td{
padding: 12px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
font-size: 14px;
}
pre{
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
overflow:auto;
}
.code{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
background:#f8fafc;
border:1px solid var(--border);
padding:2px 6px;
border-radius: 8px;
}
/* --- BankDash-like dashboard layout helpers --- */
.shell{
display:flex;
gap: 18px;
align-items: flex-start;
}
.sidebar{
width: 230px;
position: sticky;
top: 18px;
}
.main{
flex: 1;
min-width: 340px;
}
.brand{
display:flex;
align-items:center;
gap:10px;
font-weight:800;
letter-spacing:-0.03em;
}
.nav{
display:grid;
gap: 6px;
margin-top: 14px;
}
.nav a{
padding: 10px 12px;
border-radius: 12px;
border: 1px solid transparent;
color: var(--muted);
font-weight: 600;
}
.nav a.active{
background: rgba(45,96,255,.10);
color: var(--primary);
border-color: rgba(45,96,255,.12);
}
.topbar{
display:flex;
justify-content: space-between;
align-items:center;
gap: 12px;
margin-bottom: 16px;
}
.search{
display:flex;
align-items:center;
gap: 10px;
background:#fff;
border: 1px solid var(--border);
border-radius: 999px;
padding: 10px 14px;
min-width: 280px;
color: var(--muted);
}
.iconbtn{
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
cursor: pointer;
}
.iconbtn:hover{background:#f8fafc}
.stats{
display:grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 14px;
}
@media (max-width: 1100px){
.stats{ grid-template-columns: repeat(2, minmax(160px, 1fr)); }
.sidebar{ display:none; }
}
.stat{
display:flex;
align-items:center;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: #fff;
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.stat .dot{
width: 42px;
height: 42px;
border-radius: 14px;
display:flex;
align-items:center;
justify-content:center;
background: rgba(45,96,255,.10);
color: var(--primary);
font-weight: 800;
}
.stat .label{ font-size: 12px; color: var(--muted); }
.stat .value{ font-size: 18px; font-weight: 800; letter-spacing: -0.02em; }
.cardtitle{
display:flex;
justify-content: space-between;
align-items:center;
gap: 12px;
margin-bottom: 10px;
}
.pill{
border: 1px solid rgba(45,96,255,.20);
background: #fff;
color: var(--primary);
border-radius: 999px;
padding: 7px 12px;
font-weight: 700;
cursor:pointer;
}
.pill:hover{background: rgba(45,96,255,.06)}
.pill.primary{
background: var(--primary);
color:#fff;
border-color: transparent;
}
.pill.danger{
background: rgba(254,92,115,.10);
color: var(--danger);
border-color: rgba(254,92,115,.20);
}
.list{
display:grid;
gap: 10px;
}
.item{
display:flex;
gap: 12px;
align-items:flex-start;
padding: 14px;
border-radius: 16px;
border: 1px solid var(--border);
background:#fff;
box-shadow: var(--shadow);
}
.item .avatar{
width: 44px; height: 44px;
border-radius: 16px;
background: rgba(45,96,255,.10);
display:flex;
align-items:center;
justify-content:center;
font-weight: 800;
color: var(--primary);
}
.item .meta{ flex:1; }
.item .meta .h{ font-weight: 800; margin-bottom: 2px; }
.item .meta .s{ color: var(--muted); font-size: 12px; }
.item .actions{ display:flex; gap: 8px; align-items:center; }
/* Tooltips */
.tiprow{display:flex;align-items:center;justify-content:space-between;gap:10px}
.tipbtn{
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
font-weight: 800;
cursor: pointer;
line-height: 1;
display:inline-flex;
align-items:center;
justify-content:center;
}
.tipbtn:hover{background:#f8fafc;color:var(--text)}
.tooltip{
position: absolute;
top: 28px;
right: 0;
min-width: 240px;
max-width: 320px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
box-shadow: var(--shadow);
font-size: 12px;
line-height: 1.35;
z-index: 50;
}
.tooltip::before{
content:"";
position:absolute;
top:-6px;
right:10px;
width:10px;height:10px;
transform: rotate(45deg);
background:#fff;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
}
/* BankDash-like transaction table */
.tx-wrap{
background:#fff;
border:1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow:hidden;
}
.tx-table{
width:100%;
border-collapse:separate;
border-spacing:0;
}
.tx-table th{
font-size:12px;
color: var(--muted);
font-weight:800;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.tx-table td{
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
font-size: 14px;
}
.tx-table tr:last-child td{border-bottom:none}
.tx-row{
display:flex;
align-items:center;
gap: 12px;
}
.tx-ic{
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(45,96,255,.25);
display:flex;
align-items:center;
justify-content:center;
color: var(--primary);
background: rgba(45,96,255,.06);
font-weight: 900;
}
.tx-btn{
border: 1px solid rgba(45,96,255,.28);
background: #fff;
color: var(--primary);
padding: 8px 12px;
border-radius: 999px;
font-weight: 700;
}
.tx-btn:hover{background: rgba(45,96,255,.06)}
.section{
margin-top: 16px;
}
.meta2{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:6px}
.kv{display:inline-flex;gap:6px;align-items:center;color:var(--muted);font-size:12px}
.kv b{color:var(--text);font-weight:800}
.sep{height:1px;background:var(--border);margin:14px 0}

1
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx","strict":true,"skipLibCheck":true},"include":["src"]}

3
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], server: { port: 5174 } })