Добавлен веб-личный кабинет: посты, контент-план, медиа-библиотека и генерация картинок
This commit is contained in:
1 parent
d8c086d3b4
commit
81625b2e6f
35 files changed
+2204
-48
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
users.db
|
||||
venv/
|
||||
cabinet_service/media/
|
||||
.idea/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
import base64
|
||||
import struct
|
||||
|
||||
import requests
|
||||
|
||||
from config import PIAPI_API_KEY, PIAPI_IMAGE_MODEL, get_proxies
|
||||
|
||||
PIAPI_IMAGE_URL = "https://api.piapi.ai/v1/images/generations"
|
||||
|
||||
# Доступные размеры (gpt-image-1)
|
||||
IMAGE_SIZES = {
|
||||
"square": ("1024x1024", "Квадрат 1024x1024"),
|
||||
"wide": ("1536x1024", "Горизонтальный 1536x1024"),
|
||||
"tall": ("1024x1536", "Вертикальный 1024x1536"),
|
||||
}
|
||||
|
||||
# Пресеты стилей - дописываются к промпту
|
||||
STYLE_PRESETS = {
|
||||
"none": ("Без стиля", ""),
|
||||
"photo": ("Фотореализм", ", ultra realistic photo, professional photography, natural lighting"),
|
||||
"illustration": ("Иллюстрация", ", digital illustration, vibrant colors, detailed"),
|
||||
"minimal": ("Минимализм", ", minimalist style, clean background, simple composition"),
|
||||
"3d": ("3D-рендер", ", 3D render, soft shadows, modern design"),
|
||||
"flat": ("Плоская графика", ", flat vector illustration, bold shapes, social media style"),
|
||||
}
|
||||
|
||||
|
||||
def detect_ext(data: bytes) -> str:
|
||||
"""Определяет расширение файла по magic-байтам."""
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return ".png"
|
||||
if data[:2] == b"\xff\xd8":
|
||||
return ".jpg"
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return ".webp"
|
||||
if data[:4] == b"GIF8":
|
||||
return ".gif"
|
||||
return ".png"
|
||||
|
||||
|
||||
class ImageGenerator:
|
||||
"""Генерация изображений через PiAPI (OpenAI-совместимый /images/generations)."""
|
||||
|
||||
def __init__(self, model: str | None = None):
|
||||
self.model = model or PIAPI_IMAGE_MODEL
|
||||
self.proxies = get_proxies()
|
||||
|
||||
def generate(self, prompt: str, size: str = "square") -> bytes:
|
||||
"""Генерирует картинку и возвращает её байты. Поднимает исключение при ошибке."""
|
||||
if size not in IMAGE_SIZES:
|
||||
size = "square"
|
||||
size_value = IMAGE_SIZES[size][0]
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_IMAGE_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size_value,
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=240,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json().get("data") or []
|
||||
if not data:
|
||||
raise RuntimeError("Пустой ответ от API картинок")
|
||||
|
||||
item = data[0]
|
||||
if item.get("b64_json"):
|
||||
return base64.b64decode(item["b64_json"])
|
||||
if item.get("url"):
|
||||
img_response = requests.get(item["url"], proxies=self.proxies, timeout=120)
|
||||
img_response.raise_for_status()
|
||||
return img_response.content
|
||||
|
||||
raise RuntimeError("Неожиданный формат ответа от API картинок")
|
||||
|
||||
def build_prompt(self, prompt: str, style: str = "none") -> str:
|
||||
"""Дополняет промпт пресетом стиля."""
|
||||
if style in STYLE_PRESETS:
|
||||
return prompt.strip() + STYLE_PRESETS[style][1]
|
||||
return prompt.strip()
|
||||
+172
-29
@@ -1,28 +1,26 @@
|
||||
import random
|
||||
import os
|
||||
from groq import Groq
|
||||
from config import GROQ_API_KEY
|
||||
import json
|
||||
import requests
|
||||
from config import PIAPI_API_KEY, PIAPI_MODEL, get_proxies
|
||||
|
||||
PIAPI_URL = "https://api.piapi.ai/v1/chat/completions"
|
||||
|
||||
class ContentGenerator:
|
||||
def __init__(self):
|
||||
"""Инициализация генератора контента"""
|
||||
print("🔄 Инициализация ContentGenerator...")
|
||||
|
||||
# Проверяем, есть ли ключ Groq
|
||||
if GROQ_API_KEY:
|
||||
try:
|
||||
self.client = Groq(api_key=GROQ_API_KEY)
|
||||
self.model = "llama-3.3-70b-versatile"
|
||||
self.use_groq = True
|
||||
print("✅ Groq API подключен!")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка подключения Groq: {e}")
|
||||
self.use_groq = False
|
||||
# Проверяем, есть ли ключ PiAPI
|
||||
if PIAPI_API_KEY:
|
||||
self.model = PIAPI_MODEL
|
||||
self.use_piapi = True
|
||||
print("✅ PiAPI подключен!")
|
||||
else:
|
||||
print("⚠️ Groq API ключ не найден, использую шаблоны")
|
||||
self.use_groq = False
|
||||
print("⚠️ PiAPI ключ не найден, использую шаблоны")
|
||||
self.use_piapi = False
|
||||
|
||||
# Шаблоны для запасного варианта (если Groq не работает)
|
||||
# Шаблоны для запасного варианта (если PiAPI не работает)
|
||||
self.templates = {
|
||||
'instagram': [
|
||||
"🔥 {business_name} — это не просто бизнес, это миссия!\n\n"
|
||||
@@ -116,22 +114,22 @@ class ContentGenerator:
|
||||
"""
|
||||
print(f"🔄 Генерация поста для {platform}...")
|
||||
|
||||
# Пробуем сгенерировать через Groq
|
||||
if self.use_groq:
|
||||
# Пробуем сгенерировать через PiAPI
|
||||
if self.use_piapi:
|
||||
try:
|
||||
post = self._generate_with_groq(business_type, business_description, platform)
|
||||
post = self._generate_with_piapi(business_type, business_description, platform)
|
||||
if post and len(post) > 20:
|
||||
print("✅ Пост сгенерирован через Groq!")
|
||||
print("✅ Пост сгенерирован через PiAPI!")
|
||||
return post
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка Groq: {e}, использую шаблоны")
|
||||
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны")
|
||||
|
||||
# Если Groq не работает - используем шаблоны
|
||||
# Если PiAPI не работает - используем шаблоны
|
||||
print("🔄 Использую шаблоны...")
|
||||
return self._generate_with_templates(business_type, business_description, platform)
|
||||
|
||||
def _generate_with_groq(self, business_type, business_description, platform):
|
||||
"""Генерация через Groq API"""
|
||||
def _generate_with_piapi(self, business_type, business_description, platform):
|
||||
"""Генерация через PiAPI (OpenAI-совместимый chat/completions)"""
|
||||
|
||||
platform_names = {
|
||||
'instagram': 'Instagram',
|
||||
@@ -160,17 +158,162 @@ class ContentGenerator:
|
||||
Пост должен быть уникальным и интересным.
|
||||
"""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты профессиональный копирайтер. Пиши ярко, полезно и вовлекающе."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.9,
|
||||
max_tokens=500
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 500
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
def generate_post_with_style(self, business_type, business_description, platform, style, wishes):
|
||||
"""Генерирует пост с учётом стиля и пожеланий пользователя"""
|
||||
print(f"🔄 Генерация поста в своём стиле для {platform}...")
|
||||
|
||||
if self.use_piapi:
|
||||
try:
|
||||
platform_names = {
|
||||
'instagram': 'Instagram',
|
||||
'linkedin': 'LinkedIn',
|
||||
'telegram': 'Telegram',
|
||||
'twitter': 'Twitter/X',
|
||||
'ad': 'рекламный пост'
|
||||
}
|
||||
|
||||
prompt = f"""
|
||||
Ты — профессиональный SMM-специалист и копирайтер.
|
||||
|
||||
Напиши пост для {platform_names.get(platform, 'соцсетей')} о бизнесе.
|
||||
|
||||
Тип бизнеса: {business_type}
|
||||
Описание бизнеса: {business_description}
|
||||
Стиль поста: {style}
|
||||
Пожелания к посту: {wishes}
|
||||
|
||||
Требования к посту:
|
||||
- Длина: 100-200 слов
|
||||
- Строго придерживайся указанного стиля и пожеланий
|
||||
- Призыв к действию в конце
|
||||
- Используй 2-3 эмодзи
|
||||
|
||||
Напиши только текст поста, без лишних пояснений.
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты профессиональный копирайтер. Точно следуешь стилю и пожеланиям клиента."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 500
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
post = response.json()["choices"][0]["message"]["content"].strip()
|
||||
if post and len(post) > 20:
|
||||
print("✅ Пост в стиле сгенерирован через PiAPI!")
|
||||
return post
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны")
|
||||
|
||||
print("🔄 Использую шаблоны...")
|
||||
post = self._generate_with_templates(business_type, business_description, platform)
|
||||
return f"{post}\n\n💭 Пожелания учтены частично (без ИИ): {wishes}"
|
||||
|
||||
def generate_content_plan(self, business_type, business_description, days):
|
||||
"""Генерирует контент-план на несколько дней вперёд"""
|
||||
print(f"🔄 Генерация контент-плана на {days} дн...")
|
||||
|
||||
if self.use_piapi:
|
||||
try:
|
||||
prompt = f"""
|
||||
Ты — SMM-стратег. Составь контент-план на {days} дней для бизнеса.
|
||||
|
||||
Тип бизнеса: {business_type}
|
||||
Описание бизнеса: {business_description}
|
||||
|
||||
Для каждого дня укажи платформу (instagram, linkedin, telegram, twitter или ad) и короткую идею поста (1 предложение).
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без пояснений в формате:
|
||||
{{"plan": [{{"day": 1, "platform": "instagram", "idea": "..."}}, ...]}}
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1000,
|
||||
"response_format": {"type": "json_object"}
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
parsed = json.loads(content)
|
||||
plan = parsed.get("plan")
|
||||
if plan:
|
||||
print("✅ Контент-план сгенерирован через PiAPI!")
|
||||
return plan
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблонный план")
|
||||
|
||||
print("🔄 Использую шаблонный план...")
|
||||
return self._generate_template_plan(business_type, days)
|
||||
|
||||
def _generate_template_plan(self, business_type, days):
|
||||
"""Простой контент-план без обращения к ИИ (запасной вариант)"""
|
||||
platforms = ['instagram', 'linkedin', 'telegram', 'twitter', 'ad']
|
||||
ideas = [
|
||||
f"Расскажи историю успеха {business_type}",
|
||||
f"Покажи закулисье работы {business_type}",
|
||||
"Поделись отзывом клиента",
|
||||
"Развенчай миф из твоей ниши",
|
||||
"Дай практический совет подписчикам",
|
||||
"Проведи опрос среди аудитории",
|
||||
"Расскажи о новинке или акции"
|
||||
]
|
||||
plan = []
|
||||
for day in range(1, days + 1):
|
||||
plan.append({
|
||||
'day': day,
|
||||
'platform': platforms[(day - 1) % len(platforms)],
|
||||
'idea': ideas[(day - 1) % len(ideas)]
|
||||
})
|
||||
return plan
|
||||
|
||||
def _generate_with_templates(self, business_type, business_description, platform):
|
||||
"""Генерация через шаблоны (запасной вариант)"""
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from ai_core.image_generator import IMAGE_SIZES, STYLE_PRESETS, ImageGenerator, detect_ext
|
||||
from ai_core.neural_network import ContentGenerator
|
||||
from config import SECRET_KEY
|
||||
from database import (
|
||||
create_web_user,
|
||||
delete_media_by_id,
|
||||
delete_plan_item,
|
||||
delete_post_by_id,
|
||||
delete_user_by_id,
|
||||
get_media_by_id_for_user,
|
||||
get_media_for_user,
|
||||
get_plan_for_user,
|
||||
get_plan_stats_for_user,
|
||||
get_post_by_id_for_user,
|
||||
get_posts_for_user,
|
||||
get_stats_for_user,
|
||||
get_user_by_email,
|
||||
get_user_by_id,
|
||||
replace_plan_for_user,
|
||||
save_media,
|
||||
save_post_for_user,
|
||||
update_plan_status,
|
||||
update_post_content,
|
||||
update_user_field_by_id,
|
||||
)
|
||||
from keyboards.reply_keyboards import BUSINESS_TYPES
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
MEDIA_DIR = BASE_DIR / "media"
|
||||
MEDIA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
app = FastAPI(title="Личный кабинет")
|
||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media")
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
content_gen = ContentGenerator()
|
||||
image_gen = ImageGenerator()
|
||||
|
||||
PLATFORMS = [
|
||||
("instagram", "Instagram"),
|
||||
("linkedin", "LinkedIn"),
|
||||
("telegram", "Telegram"),
|
||||
("twitter", "Twitter/X"),
|
||||
("ad", "Рекламный пост"),
|
||||
]
|
||||
|
||||
PLATFORM_LABELS = dict(PLATFORMS)
|
||||
|
||||
def platform_label(platform: str) -> str:
|
||||
return PLATFORM_LABELS.get(platform, platform)
|
||||
|
||||
|
||||
class NotAuthenticated(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@app.exception_handler(NotAuthenticated)
|
||||
async def not_authenticated_handler(request: Request, exc: NotAuthenticated):
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
|
||||
|
||||
def current_user(request: Request) -> dict:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
raise NotAuthenticated()
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
request.session.clear()
|
||||
raise NotAuthenticated()
|
||||
return user
|
||||
|
||||
|
||||
def flash(request: Request, message: str):
|
||||
request.session["flash"] = message
|
||||
|
||||
|
||||
def render(request: Request, template: str, status_code: int = 200, **context):
|
||||
context.setdefault("user", None)
|
||||
context["flash_message"] = request.session.pop("flash", None)
|
||||
return templates.TemplateResponse(request, template, context, status_code=status_code)
|
||||
|
||||
|
||||
def business_name_of(user: dict) -> str:
|
||||
return BUSINESS_TYPES.get(str(user["business_type"]), {}).get("name", "Не указан")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
# === РЕГИСТРАЦИЯ ===
|
||||
|
||||
@app.get("/register")
|
||||
def register_form(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
return render(request, "register.html", business_types=BUSINESS_TYPES)
|
||||
|
||||
|
||||
@app.post("/register")
|
||||
def register_submit(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
phone: str = Form(...),
|
||||
business_type: str = Form(...),
|
||||
description: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
password_confirm: str = Form(...),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
errors = []
|
||||
if business_type not in BUSINESS_TYPES:
|
||||
errors.append("Выбери вид бизнеса из списка.")
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен быть не короче 8 символов.")
|
||||
if password != password_confirm:
|
||||
errors.append("Пароли не совпадают.")
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
errors.append("Введи корректный email.")
|
||||
if not errors and get_user_by_email(email):
|
||||
errors.append("Пользователь с таким email уже зарегистрирован.")
|
||||
|
||||
if errors:
|
||||
return render(
|
||||
request, "register.html", status_code=400,
|
||||
business_types=BUSINESS_TYPES, errors=errors,
|
||||
form={
|
||||
"name": name, "phone": phone, "business_type": business_type,
|
||||
"description": description, "email": email,
|
||||
},
|
||||
)
|
||||
|
||||
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
user_id = create_web_user(email, password_hash, name, phone, int(business_type), description)
|
||||
request.session["user_id"] = user_id
|
||||
flash(request, "Регистрация завершена! Добро пожаловать.")
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
|
||||
|
||||
# === ВХОД / ВЫХОД ===
|
||||
|
||||
@app.get("/login")
|
||||
def login_form(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
return render(request, "login.html")
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login_submit(request: Request, email: str = Form(...), password: str = Form(...)):
|
||||
email = email.strip().lower()
|
||||
user = get_user_by_email(email)
|
||||
valid = (
|
||||
user is not None
|
||||
and user["password_hash"]
|
||||
and bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8"))
|
||||
)
|
||||
if not valid:
|
||||
return render(
|
||||
request, "login.html", status_code=400,
|
||||
errors=["Неверный email или пароль."], form={"email": email},
|
||||
)
|
||||
|
||||
request.session["user_id"] = user["id"]
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
# === КАБИНЕТ ===
|
||||
|
||||
@app.get("/dashboard")
|
||||
def dashboard(request: Request, user: dict = Depends(current_user)):
|
||||
return render(request, "dashboard.html", user=user, business_name=business_name_of(user))
|
||||
|
||||
|
||||
@app.get("/profile/edit")
|
||||
def profile_edit_form(request: Request, user: dict = Depends(current_user)):
|
||||
return render(request, "profile_edit.html", user=user, business_types=BUSINESS_TYPES)
|
||||
|
||||
|
||||
@app.post("/profile/edit")
|
||||
def profile_edit_submit(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
phone: str = Form(...),
|
||||
business_type: str = Form(...),
|
||||
description: str = Form(...),
|
||||
user: dict = Depends(current_user),
|
||||
):
|
||||
if business_type not in BUSINESS_TYPES:
|
||||
return render(
|
||||
request, "profile_edit.html", status_code=400,
|
||||
user=user, business_types=BUSINESS_TYPES,
|
||||
errors=["Выбери вид бизнеса из списка."],
|
||||
)
|
||||
|
||||
update_user_field_by_id(user["id"], "name", name)
|
||||
update_user_field_by_id(user["id"], "phone", phone)
|
||||
update_user_field_by_id(user["id"], "business_type", int(business_type))
|
||||
update_user_field_by_id(user["id"], "description", description)
|
||||
flash(request, "Профиль обновлён.")
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
|
||||
|
||||
@app.get("/generate")
|
||||
def generate_form(request: Request, user: dict = Depends(current_user)):
|
||||
return render(request, "generate.html", user=user, platforms=PLATFORMS)
|
||||
|
||||
|
||||
@app.post("/generate")
|
||||
def generate_submit(
|
||||
request: Request,
|
||||
platform: str = Form(...),
|
||||
style: str = Form(...),
|
||||
wishes: str = Form(""),
|
||||
user: dict = Depends(current_user),
|
||||
):
|
||||
description = user["description"] or "не указано"
|
||||
content = content_gen.generate_post_with_style(
|
||||
business_name_of(user), description, platform, style, wishes
|
||||
)
|
||||
save_post_for_user(user["id"], platform, content, style, wishes)
|
||||
return render(
|
||||
request, "generate.html", user=user, platforms=PLATFORMS, result=content,
|
||||
form={"platform": platform, "style": style, "wishes": wishes},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/history")
|
||||
def history(request: Request, user: dict = Depends(current_user)):
|
||||
posts = get_posts_for_user(user["id"], limit=50)
|
||||
return render(request, "history.html", user=user, posts=posts, platform_label=platform_label)
|
||||
|
||||
|
||||
@app.get("/posts/{post_id}")
|
||||
def post_detail(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||||
post = get_post_by_id_for_user(post_id, user["id"])
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||||
return render(request, "post_detail.html", user=user, post=post, platform_label=platform_label)
|
||||
|
||||
|
||||
@app.get("/posts/{post_id}/edit")
|
||||
def post_edit_form(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||||
post = get_post_by_id_for_user(post_id, user["id"])
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||||
return render(request, "post_edit.html", user=user, post=post, platform_label=platform_label)
|
||||
|
||||
|
||||
@app.post("/posts/{post_id}/edit")
|
||||
def post_edit_submit(
|
||||
request: Request,
|
||||
post_id: int,
|
||||
content: str = Form(...),
|
||||
user: dict = Depends(current_user),
|
||||
):
|
||||
post = get_post_by_id_for_user(post_id, user["id"])
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return render(
|
||||
request, "post_edit.html", status_code=400,
|
||||
user=user, post=post, platform_label=platform_label,
|
||||
errors=["Текст поста не может быть пустым."],
|
||||
)
|
||||
update_post_content(user["id"], post_id, content)
|
||||
flash(request, "Пост обновлён.")
|
||||
return RedirectResponse(f"/posts/{post_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/posts/{post_id}/delete")
|
||||
def post_delete(request: Request, post_id: int, user: dict = Depends(current_user)):
|
||||
post = get_post_by_id_for_user(post_id, user["id"])
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Пост не найден")
|
||||
delete_post_by_id(user["id"], post_id)
|
||||
flash(request, "Пост удалён.")
|
||||
return RedirectResponse("/history", status_code=303)
|
||||
|
||||
|
||||
@app.get("/plan")
|
||||
def plan_form(request: Request, user: dict = Depends(current_user)):
|
||||
items = get_plan_for_user(user["id"])
|
||||
status_counts = get_plan_stats_for_user(user["id"])
|
||||
return render(
|
||||
request, "plan.html",
|
||||
user=user, plan=items, status_counts=status_counts, platform_label=platform_label,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/plan")
|
||||
def plan_submit(request: Request, days: int = Form(7), user: dict = Depends(current_user)):
|
||||
days = max(1, min(days, 14))
|
||||
description = user["description"] or "не указано"
|
||||
plan = content_gen.generate_content_plan(business_name_of(user), description, days)
|
||||
replace_plan_for_user(user["id"], plan)
|
||||
flash(request, f"Контент-план на {days} дн. сохранён.")
|
||||
return RedirectResponse("/plan", status_code=303)
|
||||
|
||||
|
||||
@app.post("/plan/{item_id}/status")
|
||||
def plan_status_submit(
|
||||
request: Request,
|
||||
item_id: int,
|
||||
status: str = Form(...),
|
||||
user: dict = Depends(current_user),
|
||||
):
|
||||
try:
|
||||
update_plan_status(user["id"], item_id, status)
|
||||
flash(request, "Статус пункта обновлён.")
|
||||
except ValueError:
|
||||
flash(request, "Недопустимый статус.")
|
||||
return RedirectResponse("/plan", status_code=303)
|
||||
|
||||
|
||||
@app.post("/plan/{item_id}/delete")
|
||||
def plan_item_delete(request: Request, item_id: int, user: dict = Depends(current_user)):
|
||||
delete_plan_item(user["id"], item_id)
|
||||
flash(request, "Пункт плана удалён.")
|
||||
return RedirectResponse("/plan", status_code=303)
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
def stats(request: Request, user: dict = Depends(current_user)):
|
||||
return render(request, "stats.html", user=user, stats=get_stats_for_user(user["id"]))
|
||||
|
||||
|
||||
@app.get("/delete-account")
|
||||
def delete_confirm_form(request: Request, user: dict = Depends(current_user)):
|
||||
return render(request, "delete_confirm.html", user=user)
|
||||
|
||||
|
||||
@app.post("/delete-account")
|
||||
def delete_account_submit(
|
||||
request: Request, confirm: str = Form(""), user: dict = Depends(current_user)
|
||||
):
|
||||
if confirm != "yes":
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
delete_user_by_id(user["id"])
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
# === ГЕНЕРАЦИЯ КАРТИНОК ===
|
||||
|
||||
@app.get("/images")
|
||||
def images_gallery(request: Request, user: dict = Depends(current_user)):
|
||||
items = get_media_for_user(user["id"])
|
||||
return render(request, "images.html", user=user, images=items)
|
||||
|
||||
|
||||
@app.get("/images/generate")
|
||||
def image_generate_form(request: Request, user: dict = Depends(current_user)):
|
||||
return render(
|
||||
request, "image_generate.html",
|
||||
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/images/generate")
|
||||
def image_generate_submit(
|
||||
request: Request,
|
||||
prompt: str = Form(...),
|
||||
size: str = Form("square"),
|
||||
style: str = Form("none"),
|
||||
user: dict = Depends(current_user),
|
||||
):
|
||||
prompt = prompt.strip()
|
||||
if not prompt:
|
||||
return render(
|
||||
request, "image_generate.html", status_code=400,
|
||||
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
|
||||
form={"prompt": prompt, "size": size, "style": style},
|
||||
errors=["Опиши, что нужно нарисовать."],
|
||||
)
|
||||
|
||||
full_prompt = image_gen.build_prompt(prompt, style)
|
||||
try:
|
||||
image_bytes = image_gen.generate(full_prompt, size)
|
||||
except Exception as e:
|
||||
return render(
|
||||
request, "image_generate.html", status_code=502,
|
||||
user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS,
|
||||
form={"prompt": prompt, "size": size, "style": style},
|
||||
errors=[f"Ошибка генерации картинки: {e}"],
|
||||
)
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}"
|
||||
(MEDIA_DIR / filename).write_bytes(image_bytes)
|
||||
save_media(user["id"], prompt, style, filename)
|
||||
flash(request, "Картинка сгенерирована и сохранена в библиотеку.")
|
||||
return RedirectResponse("/images", status_code=303)
|
||||
|
||||
|
||||
@app.post("/images/{media_id}/delete")
|
||||
def image_delete(request: Request, media_id: int, user: dict = Depends(current_user)):
|
||||
item = get_media_by_id_for_user(media_id, user["id"])
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Картинка не найдена")
|
||||
filename = delete_media_by_id(user["id"], media_id)
|
||||
if filename:
|
||||
file_path = MEDIA_DIR / filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
flash(request, "Картинка удалена.")
|
||||
return RedirectResponse("/images", status_code=303)
|
||||
@@ -0,0 +1,13 @@
|
||||
import uvicorn
|
||||
|
||||
from config import CABINET_SERVICE_HOST, CABINET_SERVICE_PORT
|
||||
from cabinet_service.app import app
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=CABINET_SERVICE_HOST,
|
||||
port=CABINET_SERVICE_PORT,
|
||||
log_level="warning"
|
||||
)
|
||||
@@ -0,0 +1,555 @@
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #d1d5db;
|
||||
--text: #1f2328;
|
||||
--text-muted: #4b5563;
|
||||
--text-faint: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-soft: #eef2ff;
|
||||
--accent-soft-border: #c7d2fe;
|
||||
--accent-soft-hover: #e0e7ff;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--danger-text: #b91c1c;
|
||||
--success-bg: #ecfdf3;
|
||||
--success-border: #a7e3bb;
|
||||
--success-text: #14532d;
|
||||
--warn-bg: #fffbeb;
|
||||
--warn-border: #fde68a;
|
||||
--warn-text: #78350f;
|
||||
--error-bg: #fef2f2;
|
||||
--error-border: #f3b4b4;
|
||||
--error-text: #7f1d1d;
|
||||
--table-head: #f9fafb;
|
||||
--badge-bg: #f3f4f6;
|
||||
--badge-text: #374151;
|
||||
--input-bg: #ffffff;
|
||||
--published-surface: #fafefb;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-faint: #94a3b8;
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #93c5fd;
|
||||
--accent-soft: #172554;
|
||||
--accent-soft-border: #1e40af;
|
||||
--accent-soft-hover: #1e3a8a;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #f87171;
|
||||
--danger-text: #f87171;
|
||||
--success-bg: #064e3b;
|
||||
--success-border: #059669;
|
||||
--success-text: #a7f3d0;
|
||||
--warn-bg: #451a03;
|
||||
--warn-border: #b45309;
|
||||
--warn-text: #fde68a;
|
||||
--error-bg: #450a0a;
|
||||
--error-border: #991b1b;
|
||||
--error-text: #fecaca;
|
||||
--table-head: #243244;
|
||||
--badge-bg: #334155;
|
||||
--badge-text: #e2e8f0;
|
||||
--input-bg: #0f172a;
|
||||
--published-surface: #0b2e22;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--border: #334155;
|
||||
--border-strong: #475569;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-faint: #94a3b8;
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #93c5fd;
|
||||
--accent-soft: #172554;
|
||||
--accent-soft-border: #1e40af;
|
||||
--accent-soft-hover: #1e3a8a;
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #f87171;
|
||||
--danger-text: #f87171;
|
||||
--success-bg: #064e3b;
|
||||
--success-border: #059669;
|
||||
--success-text: #a7f3d0;
|
||||
--warn-bg: #451a03;
|
||||
--warn-border: #b45309;
|
||||
--warn-text: #fde68a;
|
||||
--error-bg: #450a0a;
|
||||
--error-border: #991b1b;
|
||||
--error-text: #fecaca;
|
||||
--table-head: #243244;
|
||||
--badge-bg: #334155;
|
||||
--badge-text: #e2e8f0;
|
||||
--input-bg: #0f172a;
|
||||
--published-surface: #0b2e22;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-soft-border);
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.result {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.flash {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.errors {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.errors ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form select,
|
||||
.form textarea {
|
||||
font: inherit;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form input:focus,
|
||||
.form select:focus,
|
||||
.form textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form button,
|
||||
.button-danger {
|
||||
align-self: flex-start;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tile-danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.post-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--table-head);
|
||||
}
|
||||
|
||||
/* === Действия с постами / планом === */
|
||||
|
||||
.post-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-soft-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--accent-soft-hover);
|
||||
}
|
||||
|
||||
.button-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.button-link-danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.post-notes {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* === Контент-план === */
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chip-published {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.chip-postponed {
|
||||
background: var(--warn-bg);
|
||||
border-color: var(--warn-border);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.plan-item-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-day {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-platform {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.plan-idea {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-text);
|
||||
}
|
||||
|
||||
.status-badge.status-published {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status-badge.status-postponed {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.plan-item.status-published {
|
||||
background: var(--published-surface);
|
||||
}
|
||||
|
||||
.status-select {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* === Медиа-библиотека === */
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.media-card a.media-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
background: var(--table-head);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
padding: 10px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.media-prompt {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-faint);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Личный кабинет{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
<script>
|
||||
(function () {
|
||||
function isDark() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
}
|
||||
function setIcon(btn) {
|
||||
if (btn) btn.textContent = isDark() ? '☀️' : '🌙';
|
||||
}
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
// Тёмная тема по умолчанию: dark включается всегда, кроме явного выбора 'light'
|
||||
if (saved !== 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-theme-toggle]');
|
||||
if (!btn) return;
|
||||
if (isDark()) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
try { localStorage.setItem('theme', 'light'); } catch (e) {}
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
try { localStorage.setItem('theme', 'dark'); } catch (e) {}
|
||||
}
|
||||
setIcon(btn);
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setIcon(document.querySelector('[data-theme-toggle]'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="{{ '/dashboard' if user else '/login' }}">🗂 Личный кабинет</a>
|
||||
<nav>
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Переключить тему">🌙</button>
|
||||
{% if user %}
|
||||
<a href="/dashboard">Главная</a>
|
||||
<a href="/generate">Пост</a>
|
||||
<a href="/plan">Контент-план</a>
|
||||
<a href="/history">История</a>
|
||||
<a href="/images">Картинки</a>
|
||||
<a href="/stats">Статистика</a>
|
||||
<a href="/profile/edit">Профиль</a>
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<button type="submit" class="link-button">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
{% else %}
|
||||
<a href="/login">Вход</a>
|
||||
<a href="/register">Регистрация</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% if flash_message %}
|
||||
<div class="flash">{{ flash_message }}</div>
|
||||
{% endif %}
|
||||
{% if errors %}
|
||||
<div class="errors">
|
||||
<ul>
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
function copyText(text, btn) {
|
||||
function done(ok) {
|
||||
if (!btn) return;
|
||||
var old = btn.textContent;
|
||||
btn.textContent = ok ? '✅ Скопировано' : '❌ Ошибка';
|
||||
setTimeout(function () { btn.textContent = old; }, 1500);
|
||||
}
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||||
document.body.removeChild(ta);
|
||||
done(ok);
|
||||
}
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(function () { done(true); }, fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-copy-target]');
|
||||
if (!btn) return;
|
||||
var el = document.getElementById(btn.getAttribute('data-copy-target'));
|
||||
if (el) copyText(el.textContent, btn);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Главная — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Привет, {{ user.name }}!</h1>
|
||||
<div class="card">
|
||||
<p><strong>Телефон:</strong> {{ user.phone }}</p>
|
||||
<p><strong>Вид бизнеса:</strong> {{ business_name }}</p>
|
||||
<p><strong>Описание:</strong> {{ user.description }}</p>
|
||||
<p><strong>В кабинете с:</strong> {{ user.created_at }}</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<a class="tile" href="/generate">💡 Сгенерировать пост</a>
|
||||
<a class="tile" href="/plan">📅 Контент-план</a>
|
||||
<a class="tile" href="/history">📜 История постов</a>
|
||||
<a class="tile" href="/images">🖼 Медиа-библиотека</a>
|
||||
<a class="tile" href="/stats">📈 Статистика</a>
|
||||
<a class="tile" href="/profile/edit">✏️ Редактировать профиль</a>
|
||||
<a class="tile tile-danger" href="/delete-account">🗑 Удалить аккаунт</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Удаление аккаунта — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Удалить аккаунт?</h1>
|
||||
<p>Это безвозвратно удалит твой профиль и всю историю постов.</p>
|
||||
<form method="post" action="/delete-account" class="form">
|
||||
<input type="hidden" name="confirm" value="yes">
|
||||
<button type="submit" class="button-danger">Да, удалить аккаунт</button>
|
||||
</form>
|
||||
<p><a href="/dashboard">Отмена, вернуться в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Сгенерировать пост — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Пост в своём стиле</h1>
|
||||
<form method="post" action="/generate" class="form">
|
||||
<label>Платформа
|
||||
<select name="platform" required>
|
||||
{% for key, label in platforms %}
|
||||
<option value="{{ key }}" {% if form and form.platform == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль (например: дружелюбный, с юмором, экспертный)
|
||||
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
|
||||
</label>
|
||||
<label>Пожелания к посту
|
||||
<textarea name="wishes" rows="3">{{ form.wishes if form else '' }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Сгенерировать</button>
|
||||
</form>
|
||||
{% if result %}
|
||||
<h2>Готовый пост</h2>
|
||||
<div class="card result">{{ result }}</div>
|
||||
{% endif %}
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}История постов — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>История постов</h1>
|
||||
{% if posts %}
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li class="card">
|
||||
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</div>
|
||||
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
|
||||
<div class="post-actions">
|
||||
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
|
||||
<a class="button-link" href="/posts/{{ post.id }}">👁 Открыть</a>
|
||||
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
|
||||
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
|
||||
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>Постов пока нет.</p>
|
||||
<p><a href="/generate">💡 Сгенерировать первый пост</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Сгенерировать картинку — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Генерация картинки</h1>
|
||||
<form method="post" action="/images/generate" class="form">
|
||||
<label>Что нарисовать?
|
||||
<textarea name="prompt" rows="4" required placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
|
||||
</label>
|
||||
<label>Размер
|
||||
<select name="size" required>
|
||||
{% for key, (value, label) in sizes.items() %}
|
||||
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль
|
||||
<select name="style">
|
||||
{% for key, (label, suffix) in styles.items() %}
|
||||
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">🎨 Сгенерировать</button>
|
||||
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
|
||||
</form>
|
||||
<p><a href="/images">← Назад в библиотеку</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Картинки — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Медиа-библиотека</h1>
|
||||
<p><a class="copy-btn" href="/images/generate">🎨 Сгенерировать картинку</a></p>
|
||||
{% if images %}
|
||||
<div class="media-grid">
|
||||
{% for img in images %}
|
||||
<div class="media-card">
|
||||
<a href="/media/{{ img.filename }}" target="_blank" title="Открыть в полном размере">
|
||||
<img class="media-thumb" src="/media/{{ img.filename }}" alt="{{ img.prompt }}" loading="lazy">
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<div class="media-prompt" title="{{ img.prompt }}">{{ img.prompt }}</div>
|
||||
<div class="post-meta">{{ img.created_at }}</div>
|
||||
<div class="post-actions">
|
||||
<a class="copy-btn" href="/media/{{ img.filename }}" download>⬇ Скачать</a>
|
||||
<form method="post" action="/images/{{ img.id }}/delete" class="inline-form"
|
||||
onsubmit="return confirm('Удалить эту картинку?');">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>Картинок пока нет.</p>
|
||||
<p><a href="/images/generate">🎨 Сгенерировать первую картинку</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Вход — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Вход</h1>
|
||||
<form method="post" action="/login" class="form">
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||
</label>
|
||||
<label>Пароль
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<button type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Ещё нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Контент-план — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Контент-план</h1>
|
||||
|
||||
{% if plan %}
|
||||
<div class="chips">
|
||||
<span class="chip chip-planned">📝 В плане: {{ status_counts.get('planned', 0) }}</span>
|
||||
<span class="chip chip-published">✅ Опубликовано: {{ status_counts.get('published', 0) }}</span>
|
||||
<span class="chip chip-postponed">🕓 Отложено: {{ status_counts.get('postponed', 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="plan-list">
|
||||
{% for item in plan %}
|
||||
<div class="card plan-item status-{{ item.status }}">
|
||||
<div class="plan-item-head">
|
||||
<span class="plan-day">День {{ item.day }}</span>
|
||||
<span class="plan-platform">{{ platform_label(item.platform) }}</span>
|
||||
<span class="status-badge status-{{ item.status }}">
|
||||
{% if item.status == 'published' %}✅ Опубликовано
|
||||
{% elif item.status == 'postponed' %}🕓 Отложено
|
||||
{% else %}📝 В плане{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plan-idea">{{ item.idea }}</div>
|
||||
<div class="post-actions">
|
||||
<form method="post" action="/plan/{{ item.id }}/status" class="inline-form">
|
||||
<select name="status" class="status-select">
|
||||
<option value="planned" {% if item.status == 'planned' %}selected{% endif %}>📝 В плане</option>
|
||||
<option value="published" {% if item.status == 'published' %}selected{% endif %}>✅ Опубликовано</option>
|
||||
<option value="postponed" {% if item.status == 'postponed' %}selected{% endif %}>🕓 Отложено</option>
|
||||
</select>
|
||||
<button type="submit" class="copy-btn">Сохранить</button>
|
||||
</form>
|
||||
<form method="post" action="/plan/{{ item.id }}/delete" class="inline-form"
|
||||
onsubmit="return confirm('Удалить этот пункт плана?');">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>Контент-плана пока нет. Составь его на 1–14 дней — он будет сохранён, и ты сможешь отмечать опубликованные и отложенные посты.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% if plan %}Пересоставить план{% else %}Составить план{% endif %}</h2>
|
||||
<form method="post" action="/plan" class="form"
|
||||
onsubmit="{% if plan %}return confirm('Новый план заменит текущий (отметки статусов сбросятся). Продолжить?');{% endif %}">
|
||||
<label>На сколько дней (1-14)
|
||||
<input type="number" name="days" min="1" max="14" value="7" required>
|
||||
</label>
|
||||
<button type="submit">📅 Составить план</button>
|
||||
</form>
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пост — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Пост</h1>
|
||||
<div class="card">
|
||||
<div class="post-meta">
|
||||
{{ platform_label(post.platform) }} · {{ post.created_at }}
|
||||
{% if post.style %} · Стиль: {{ post.style }}{% endif %}
|
||||
</div>
|
||||
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
|
||||
{% if post.wishes %}
|
||||
<div class="post-notes"><strong>Пожелания:</strong> {{ post.wishes }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
|
||||
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
|
||||
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
|
||||
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/history">← Назад к истории</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Изменить пост — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Изменить пост</h1>
|
||||
<div class="card">
|
||||
<p class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</p>
|
||||
<form method="post" action="/posts/{{ post.id }}/edit" class="form">
|
||||
<label>Текст поста
|
||||
<textarea name="content" rows="12" required>{{ post.content }}</textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">💾 Сохранить</button>
|
||||
<a class="button-link" href="/posts/{{ post.id }}">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/history">← Назад к истории</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Профиль — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Редактировать профиль</h1>
|
||||
<form method="post" action="/profile/edit" class="form">
|
||||
<label>Имя
|
||||
<input type="text" name="name" value="{{ user.name }}" required>
|
||||
</label>
|
||||
<label>Телефон
|
||||
<input type="text" name="phone" value="{{ user.phone }}" required>
|
||||
</label>
|
||||
<label>Вид бизнеса
|
||||
<select name="business_type" required>
|
||||
{% for key, value in business_types.items() %}
|
||||
<option value="{{ key }}" {% if key == user.business_type|string %}selected{% endif %}>{{ value.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Описание бизнеса
|
||||
<textarea name="description" rows="4" required>{{ user.description }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Регистрация — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Регистрация</h1>
|
||||
<form method="post" action="/register" class="form">
|
||||
<label>Имя
|
||||
<input type="text" name="name" value="{{ form.name if form else '' }}" required>
|
||||
</label>
|
||||
<label>Телефон
|
||||
<input type="text" name="phone" value="{{ form.phone if form else '' }}" required>
|
||||
</label>
|
||||
<label>Вид бизнеса
|
||||
<select name="business_type" required>
|
||||
<option value="" disabled {% if not form %}selected{% endif %}>Выбери вид бизнеса</option>
|
||||
{% for key, value in business_types.items() %}
|
||||
<option value="{{ key }}" {% if form and form.business_type == key %}selected{% endif %}>{{ value.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Описание бизнеса
|
||||
<textarea name="description" rows="4" required>{{ form.description if form else '' }}</textarea>
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||
</label>
|
||||
<label>Пароль
|
||||
<input type="password" name="password" minlength="8" required>
|
||||
</label>
|
||||
<label>Повтори пароль
|
||||
<input type="password" name="password_confirm" minlength="8" required>
|
||||
</label>
|
||||
<button type="submit">Зарегистрироваться</button>
|
||||
</form>
|
||||
<p>Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Статистика — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Статистика использования</h1>
|
||||
<div class="card">
|
||||
<p><strong>В кабинете с:</strong> {{ stats.created_at }}</p>
|
||||
<p><strong>Всего постов:</strong> {{ stats.total_posts }}</p>
|
||||
</div>
|
||||
<h2>По платформам</h2>
|
||||
{% if stats.by_platform %}
|
||||
<ul>
|
||||
{% for platform, count in stats.by_platform.items() %}
|
||||
<li>{{ platform }}: {{ count }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Пока нет постов.</p>
|
||||
{% endif %}
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -1,19 +1,59 @@
|
||||
import os
|
||||
import secrets
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загружаем переменные из .env
|
||||
load_dotenv()
|
||||
|
||||
# Токен бота
|
||||
TOKEN = "8876908400:AAGb4_ayjfhWuUI_evP8vvc5YUUiG8jxepE"
|
||||
TOKEN = os.getenv("BOT_TOKEN")
|
||||
|
||||
# Ключ Groq
|
||||
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
||||
if not TOKEN:
|
||||
print("❌ BOT_TOKEN не найден в .env файле!")
|
||||
print("👉 Создай файл .env и добавь: BOT_TOKEN=твой_токен")
|
||||
|
||||
# Ключ PiAPI
|
||||
PIAPI_API_KEY = os.getenv("PIAPI_API_KEY")
|
||||
PIAPI_MODEL = os.getenv("PIAPI_MODEL", "gpt-4o")
|
||||
|
||||
# Модель генерации картинок через PiAPI (список доступных: /v1/models)
|
||||
PIAPI_IMAGE_MODEL = os.getenv("PIAPI_IMAGE_MODEL", "gpt-image-1")
|
||||
|
||||
# Прокси для доступа к внешним API (PiAPI и др.).
|
||||
# Формат в .env: PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS
|
||||
PROXY_HOST = os.getenv("PROXY_HOST")
|
||||
PROXY_PORT = os.getenv("PROXY_PORT")
|
||||
PROXY_USER = os.getenv("PROXY_USER")
|
||||
PROXY_PASS = os.getenv("PROXY_PASS")
|
||||
|
||||
def get_proxies():
|
||||
"""Возвращает dict прокси для requests, или None, если прокси не настроен."""
|
||||
if not (PROXY_HOST and PROXY_PORT):
|
||||
return None
|
||||
auth = f"{PROXY_USER}:{PROXY_PASS}@" if (PROXY_USER and PROXY_PASS) else ""
|
||||
proxy = f"http://{auth}{PROXY_HOST}:{PROXY_PORT}"
|
||||
return {"http": proxy, "https": proxy}
|
||||
|
||||
# Сервис "Личный кабинет" (отдельный FastAPI-процесс, веб-интерфейс на браузере)
|
||||
CABINET_SERVICE_HOST = os.getenv("CABINET_SERVICE_HOST", "127.0.0.1")
|
||||
CABINET_SERVICE_PORT = int(os.getenv("CABINET_SERVICE_PORT", "8001"))
|
||||
|
||||
# Публичный адрес кабинета - именно эту ссылку бот присылает пользователю.
|
||||
# Для реального использования (не localhost) нужно задать в .env настоящий
|
||||
# домен/IP, доступный из браузера пользователя.
|
||||
CABINET_WEB_URL = os.getenv("CABINET_WEB_URL", f"http://{CABINET_SERVICE_HOST}:{CABINET_SERVICE_PORT}")
|
||||
|
||||
# Ключ подписи cookie-сессий веб-кабинета
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
SECRET_KEY = secrets.token_hex(32)
|
||||
print("⚠️ SECRET_KEY не найден в .env - сгенерирован временный ключ.")
|
||||
print("👉 Добавь SECRET_KEY в .env, иначе все сессии слетают при каждом перезапуске.")
|
||||
|
||||
# Проверяем, что ключ загрузился
|
||||
if not GROQ_API_KEY:
|
||||
print("❌ GROQ_API_KEY не найден в .env файле!")
|
||||
print("👉 Создай файл .env и добавь: GROQ_API_KEY=твой_ключ")
|
||||
if not PIAPI_API_KEY:
|
||||
print("❌ PIAPI_API_KEY не найден в .env файле!")
|
||||
print("👉 Создай файл .env и добавь: PIAPI_API_KEY=твой_ключ")
|
||||
else:
|
||||
print("✅ GROQ_API_KEY загружен!")
|
||||
print("✅ PIAPI_API_KEY загружен!")
|
||||
|
||||
+388
@@ -1,5 +1,8 @@
|
||||
import sqlite3
|
||||
|
||||
# Поля профиля, которые разрешено менять через "личный кабинет"
|
||||
EDITABLE_FIELDS = {'name', 'phone', 'business_type', 'description'}
|
||||
|
||||
def create_db():
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
@@ -16,6 +19,67 @@ def create_db():
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'registered' not in columns:
|
||||
cursor.execute('ALTER TABLE users ADD COLUMN registered BOOLEAN DEFAULT 0')
|
||||
print("✅ Колонка 'registered' добавлена в таблицу users")
|
||||
if 'created_at' not in columns:
|
||||
# SQLite не разрешает non-constant DEFAULT в ALTER TABLE ADD COLUMN
|
||||
cursor.execute('ALTER TABLE users ADD COLUMN created_at TIMESTAMP')
|
||||
cursor.execute('UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL')
|
||||
print("✅ Колонка 'created_at' добавлена в таблицу users")
|
||||
if 'email' not in columns:
|
||||
# Без UNIQUE - SQLite не позволяет добавить UNIQUE через ALTER TABLE,
|
||||
# уникальность email проверяется на уровне приложения (get_user_by_email)
|
||||
cursor.execute('ALTER TABLE users ADD COLUMN email TEXT')
|
||||
print("✅ Колонка 'email' добавлена в таблицу users")
|
||||
if 'password_hash' not in columns:
|
||||
cursor.execute('ALTER TABLE users ADD COLUMN password_hash TEXT')
|
||||
print("✅ Колонка 'password_hash' добавлена в таблицу users")
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
telegram_id INTEGER,
|
||||
platform TEXT,
|
||||
content TEXT,
|
||||
style TEXT,
|
||||
wishes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute("PRAGMA table_info(posts)")
|
||||
posts_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'user_id' not in posts_columns:
|
||||
# Посты веб-кабинета привязаны к users.id, посты из бота - к telegram_id
|
||||
cursor.execute('ALTER TABLE posts ADD COLUMN user_id INTEGER')
|
||||
print("✅ Колонка 'user_id' добавлена в таблицу posts")
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS content_plan (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
day INTEGER,
|
||||
platform TEXT,
|
||||
idea TEXT,
|
||||
status TEXT DEFAULT 'planned',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
prompt TEXT,
|
||||
style TEXT,
|
||||
filename TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ База данных создана!")
|
||||
@@ -50,5 +114,329 @@ def update_user_description(telegram_id, description):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def update_user_field(telegram_id, field, value):
|
||||
"""Обновляет одно поле профиля. field обязан быть из EDITABLE_FIELDS."""
|
||||
if field not in EDITABLE_FIELDS:
|
||||
raise ValueError(f"Поле '{field}' нельзя редактировать")
|
||||
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f'UPDATE users SET {field} = ? WHERE telegram_id = ?', (value, telegram_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def delete_user(telegram_id):
|
||||
"""Удаляет пользователя и всю его историю постов."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM users WHERE telegram_id = ?', (telegram_id,))
|
||||
cursor.execute('DELETE FROM posts WHERE telegram_id = ?', (telegram_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def save_post(telegram_id, platform, content, style=None, wishes=None):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO posts (telegram_id, platform, content, style, wishes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (telegram_id, platform, content, style, wishes))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_posts(telegram_id, limit=10):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT platform, content, style, wishes, created_at
|
||||
FROM posts
|
||||
WHERE telegram_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
''', (telegram_id, limit))
|
||||
posts = cursor.fetchall()
|
||||
conn.close()
|
||||
return posts
|
||||
|
||||
def get_stats(telegram_id):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT created_at FROM users WHERE telegram_id = ?', (telegram_id,))
|
||||
row = cursor.fetchone()
|
||||
created_at = row[0] if row else None
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM posts WHERE telegram_id = ?', (telegram_id,))
|
||||
total_posts = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('''
|
||||
SELECT platform, COUNT(*) FROM posts
|
||||
WHERE telegram_id = ?
|
||||
GROUP BY platform
|
||||
''', (telegram_id,))
|
||||
by_platform = dict(cursor.fetchall())
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
'created_at': created_at,
|
||||
'total_posts': total_posts,
|
||||
'by_platform': by_platform
|
||||
}
|
||||
|
||||
# === ФУНКЦИИ ДЛЯ ВЕБ-КАБИНЕТА (ключ - внутренний users.id, не telegram_id) ===
|
||||
|
||||
def _row_conn():
|
||||
conn = sqlite3.connect('users.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def get_user_by_email(email):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM users WHERE email = ?', (email,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_user_by_id(user_id):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_web_user(email, password_hash, name, phone, business_type, description):
|
||||
"""Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO users (name, phone, business_type, description, registered, created_at, email, password_hash)
|
||||
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?)
|
||||
''', (name, phone, business_type, description, email, password_hash))
|
||||
conn.commit()
|
||||
user_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return user_id
|
||||
|
||||
|
||||
def update_user_field_by_id(user_id, field, value):
|
||||
"""Обновляет одно поле профиля по внутреннему id. field обязан быть из EDITABLE_FIELDS."""
|
||||
if field not in EDITABLE_FIELDS:
|
||||
raise ValueError(f"Поле '{field}' нельзя редактировать")
|
||||
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f'UPDATE users SET {field} = ? WHERE id = ?', (value, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_user_by_id(user_id):
|
||||
"""Удаляет пользователя веб-кабинета и всю его историю постов."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||
cursor.execute('DELETE FROM posts WHERE user_id = ?', (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_post_for_user(user_id, platform, content, style=None, wishes=None):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO posts (user_id, platform, content, style, wishes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (user_id, platform, content, style, wishes))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_posts_for_user(user_id, limit=10):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT id, platform, content, style, wishes, created_at
|
||||
FROM posts
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
''', (user_id, limit))
|
||||
posts = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return posts
|
||||
|
||||
|
||||
def get_stats_for_user(user_id):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT created_at FROM users WHERE id = ?', (user_id,))
|
||||
row = cursor.fetchone()
|
||||
created_at = row[0] if row else None
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM posts WHERE user_id = ?', (user_id,))
|
||||
total_posts = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('''
|
||||
SELECT platform, COUNT(*) FROM posts
|
||||
WHERE user_id = ?
|
||||
GROUP BY platform
|
||||
''', (user_id,))
|
||||
by_platform = dict(cursor.fetchall())
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
'created_at': created_at,
|
||||
'total_posts': total_posts,
|
||||
'by_platform': by_platform
|
||||
}
|
||||
|
||||
|
||||
# === УПРАВЛЕНИЕ ПОСТАМИ (веб-кабинет) ===
|
||||
|
||||
def get_post_by_id_for_user(post_id, user_id):
|
||||
"""Возвращает один пост веб-пользователя (только если он принадлежит user_id)."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def update_post_content(user_id, post_id, content):
|
||||
"""Редактирует текст поста. Меняет только посты, принадлежащие user_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('UPDATE posts SET content = ? WHERE id = ? AND user_id = ?', (content, post_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_post_by_id(user_id, post_id):
|
||||
"""Удаляет пост. Удаляет только посты, принадлежащие user_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# === КОНТЕНТ-ПЛАН (веб-кабинет) ===
|
||||
|
||||
PLAN_STATUSES = ('planned', 'published', 'postponed')
|
||||
|
||||
def replace_plan_for_user(user_id, plan_items):
|
||||
"""Полностью заменяет контент-план пользователя новыми пунктами.
|
||||
plan_items: список словарей {'day': int, 'platform': str, 'idea': str}"""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM content_plan WHERE user_id = ?', (user_id,))
|
||||
cursor.executemany(
|
||||
'INSERT INTO content_plan (user_id, day, platform, idea) VALUES (?, ?, ?, ?)',
|
||||
[(user_id, item['day'], item['platform'], item['idea']) for item in plan_items]
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_plan_for_user(user_id):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM content_plan WHERE user_id = ? ORDER BY day, id', (user_id,))
|
||||
rows = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def update_plan_status(user_id, item_id, status):
|
||||
if status not in PLAN_STATUSES:
|
||||
raise ValueError(f"Недопустимый статус '{status}'")
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE content_plan SET status = ? WHERE id = ? AND user_id = ?',
|
||||
(status, item_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_plan_item(user_id, item_id):
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM content_plan WHERE id = ? AND user_id = ?', (item_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_plan_stats_for_user(user_id):
|
||||
"""Считает пункты плана по статусам: {'planned': n, 'published': n, ...}"""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT status, COUNT(*) FROM content_plan WHERE user_id = ? GROUP BY status', (user_id,))
|
||||
result = dict(cursor.fetchall())
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
# === МЕДИА-БИБЛИОТЕКА (веб-кабинет) ===
|
||||
|
||||
def save_media(user_id, prompt, style, filename):
|
||||
"""Сохраняет запись о сгенерированной картинке. Возвращает id записи."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO media (user_id, prompt, style, filename) VALUES (?, ?, ?, ?)',
|
||||
(user_id, prompt, style, filename)
|
||||
)
|
||||
conn.commit()
|
||||
media_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return media_id
|
||||
|
||||
|
||||
def get_media_for_user(user_id, limit=50):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, prompt, style, filename, created_at FROM media '
|
||||
'WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?',
|
||||
(user_id, limit)
|
||||
)
|
||||
rows = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_media_by_id_for_user(media_id, user_id):
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def delete_media_by_id(user_id, media_id):
|
||||
"""Удаляет запись о картинке. Возвращает filename, чтобы удалить и сам файл."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT filename FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
cursor.execute('DELETE FROM media WHERE id = ? AND user_id = ?', (media_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return row[0]
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
# Создаем базу при первом импорте
|
||||
create_db()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-1
@@ -1,6 +1,7 @@
|
||||
from database import get_user, update_user_description
|
||||
from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, BUSINESS_TYPES
|
||||
from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, get_cabinet_link_keyboard, BUSINESS_TYPES
|
||||
from handlers.content import handle_content_generation
|
||||
from config import CABINET_WEB_URL
|
||||
|
||||
def show_main_menu(user_id, bot):
|
||||
"""Показывает главное меню"""
|
||||
@@ -87,6 +88,15 @@ def handle_menu_choice(message, bot):
|
||||
handle_content_generation(message, bot, text)
|
||||
return True
|
||||
|
||||
elif text == "🗂 Личный кабинет":
|
||||
bot.send_message(
|
||||
user_id,
|
||||
"🗂 Полная версия личного кабинета теперь в браузере: профиль, "
|
||||
"посты в своём стиле, контент-план, история и статистика.",
|
||||
reply_markup=get_cabinet_link_keyboard(CABINET_WEB_URL)
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def show_profile(message, bot):
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
from telebot.types import ReplyKeyboardMarkup, KeyboardButton
|
||||
from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
# === 9 ВИДОВ ПРЕДПРИНИМАТЕЛЕЙ ===
|
||||
BUSINESS_TYPES = {
|
||||
@@ -21,10 +21,17 @@ def get_main_menu_keyboard():
|
||||
KeyboardButton("✏️ Обновить описание"),
|
||||
KeyboardButton("📊 Статистика"),
|
||||
KeyboardButton("💡 Предложить контент"),
|
||||
KeyboardButton("⚙️ Настройки")
|
||||
KeyboardButton("⚙️ Настройки"),
|
||||
KeyboardButton("🗂 Личный кабинет")
|
||||
)
|
||||
return keyboard
|
||||
|
||||
def get_cabinet_link_keyboard(cabinet_web_url):
|
||||
"""Инлайн-кнопка со ссылкой на веб-версию личного кабинета"""
|
||||
keyboard = InlineKeyboardMarkup()
|
||||
keyboard.add(InlineKeyboardButton("🌐 Открыть личный кабинет", url=cabinet_web_url))
|
||||
return keyboard
|
||||
|
||||
def get_business_types_keyboard():
|
||||
"""Клавиатура с 9 видами предпринимателей"""
|
||||
keyboard = ReplyKeyboardMarkup(row_width=3, resize_keyboard=True)
|
||||
|
||||
@@ -2,6 +2,7 @@ import telebot
|
||||
import requests
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from telebot import apihelper
|
||||
@@ -87,12 +88,12 @@ def status_bot(message):
|
||||
import datetime
|
||||
now = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
# Проверяем, есть ли GROQ_API_KEY
|
||||
# Проверяем, есть ли PIAPI_API_KEY
|
||||
try:
|
||||
from config import GROQ_API_KEY
|
||||
groq_status = "подключен ✅" if GROQ_API_KEY else "не подключен ⚠️"
|
||||
from config import PIAPI_API_KEY
|
||||
piapi_status = "подключен ✅" if PIAPI_API_KEY else "не подключен ⚠️"
|
||||
except:
|
||||
groq_status = "не подключен ⚠️"
|
||||
piapi_status = "не подключен ⚠️"
|
||||
|
||||
status_text = f"""
|
||||
📊 **СТАТУС БОТА**
|
||||
@@ -103,7 +104,7 @@ def status_bot(message):
|
||||
📋 **Информация:**
|
||||
• Бот запущен и работает
|
||||
• База данных: подключена ✅
|
||||
• Groq API: {groq_status}
|
||||
• PiAPI: {piapi_status}
|
||||
|
||||
💡 **Команды управления:**
|
||||
/stop - остановить бота
|
||||
@@ -133,6 +134,7 @@ def help_command(message):
|
||||
📊 Статистика - статистика
|
||||
💡 Предложить контент - сгенерировать пост для соцсетей
|
||||
⚙️ Настройки - настройки бота
|
||||
🗂 Личный кабинет - расширенный профиль, пост в своём стиле, контент-план, история постов и статистика
|
||||
|
||||
💡 **Как использовать:**
|
||||
1. Напиши /start
|
||||
@@ -194,6 +196,11 @@ def handle_messages(message):
|
||||
|
||||
# === ЗАПУСК БОТА ===
|
||||
if __name__ == '__main__':
|
||||
from cabinet_service.runner import run as run_cabinet_service
|
||||
cabinet_thread = threading.Thread(target=run_cabinet_service, daemon=True)
|
||||
cabinet_thread.start()
|
||||
print("🗂 Сервис личного кабинета запущен в фоне")
|
||||
|
||||
print("🤖 БОТ ЗАПУЩЕН!")
|
||||
print("📋 Команды:")
|
||||
print(" /start - начать работу")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
+6
-4
@@ -1,8 +1,10 @@
|
||||
pytelegrambotapi>=4.16.0
|
||||
sqlalchemy>=2.0.23
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
urllib3>=2.1.0
|
||||
groq>=0.5.0
|
||||
transformers>=4.36.0 # (опционально, для локальных моделей)
|
||||
torch>=2.1.0 # (опционально, для локальных моделей)
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.29.0
|
||||
bcrypt>=4.1.0
|
||||
itsdangerous>=2.1.0
|
||||
jinja2>=3.1.0
|
||||
python-multipart>=0.0.9
|
||||
Reference in new issue
Block a user