diff --git a/bot_instance.py b/bot_instance.py new file mode 100644 index 0000000..b8bfc1f --- /dev/null +++ b/bot_instance.py @@ -0,0 +1,52 @@ +""" +Глобальный экземпляр бота — доступен из любого процесса +""" +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.client.session.aiohttp import AiohttpSession +from config import BOT_TOKEN, get_proxy_url, PROXY_TYPE, PROXY_HOST, PROXY_PORT +import logging + +logger = logging.getLogger(__name__) + +# Глобальный синглтон +bot: Bot | None = None + + +def get_bot() -> Bot: + """Получить экземпляр бота (создаёт если ещё не создан)""" + global bot + + if bot is None: + bot = _create_bot() + + return bot + + +def _create_bot() -> Bot: + """Создать бота с настройкой прокси""" + proxy_url = get_proxy_url() + + if proxy_url: + logger.info(f'🔒 Создание бота с прокси: {PROXY_TYPE}://{PROXY_HOST}:{PROXY_PORT}') + aiogram_session = AiohttpSession(proxy=proxy_url) + return Bot( + token=BOT_TOKEN, + session=aiogram_session, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + ) + else: + logger.info('🔓 Создание бота без прокси') + return Bot( + token=BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + ) + + +async def close_bot(): + """Закрыть сессию бота""" + global bot + if bot and bot.session: + await bot.session.close() + logger.info('🔒 Сессия бота закрыта') diff --git a/handlers/smart_broadcast.py b/handlers/smart_broadcast.py index a055f0a..6bc34ec 100644 --- a/handlers/smart_broadcast.py +++ b/handlers/smart_broadcast.py @@ -156,21 +156,27 @@ async def cancel_broadcast(callback: CallbackQuery, state: FSMContext): async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None) -> bool: """ Отправить умную рассылку с кнопкой "Прочитал" - + Использует прямые HTTP запросы для работы из любого event loop + Args: bot: Bot instance text: Текст рассылки recipients: 'all_and_chat', 'all_verified', 'ig_only' photo_file_id: ID фото (опционально) - + Returns: bool: Успешность отправки """ + import aiohttp + from config import BOT_TOKEN, get_proxy_url + try: # Inline клавиатура с кнопкой "Прочитал" - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="✅ Прочитал", callback_data="broadcast_read")] - ]) + reply_markup = { + "inline_keyboard": [ + [{"text": "✅ Прочитал", "callback_data": "broadcast_read"}] + ] + } # Получаем список пользователей async with AsyncSessionLocal() as session: @@ -187,25 +193,40 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', result = await session.execute(stmt) users = list(result.scalars().all()) + # URL для Telegram API + bot_token = BOT_TOKEN + proxy_url = get_proxy_url() + # Отправляем в чат первым chat_message_id = None try: - if photo_file_id: - msg = await bot.send_photo( - ADMIN_CHAT_ID, - photo_file_id, - caption=f"📢 Объявление от администрации\n\n{text}", - reply_markup=keyboard, - parse_mode='HTML' - ) - else: - msg = await bot.send_message( - ADMIN_CHAT_ID, - f"📢 Объявление от администрации\n\n{text}", - reply_markup=keyboard, - parse_mode='HTML' - ) - chat_message_id = msg.message_id + async with aiohttp.ClientSession() as http_session: + if photo_file_id: + url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" + data = aiohttp.FormData() + data.add_field('chat_id', str(ADMIN_CHAT_ID)) + data.add_field('photo', photo_file_id) + data.add_field('caption', f"📢 Объявление от администрации\n\n{text}") + data.add_field('reply_markup', json.dumps(reply_markup)) + data.add_field('parse_mode', 'HTML') + + async with http_session.post(url, data=data, proxy=proxy_url) as resp: + result_data = await resp.json() + if result_data.get('ok'): + chat_message_id = result_data['result']['message_id'] + else: + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = { + 'chat_id': ADMIN_CHAT_ID, + 'text': f"📢 Объявление от администрации\n\n{text}", + 'reply_markup': json.dumps(reply_markup), + 'parse_mode': 'HTML' + } + + async with http_session.post(url, json=payload, proxy=proxy_url) as resp: + result_data = await resp.json() + if result_data.get('ok'): + chat_message_id = result_data['result']['message_id'] except Exception as e: logger.error(f"Ошибка отправки в чат: {e}") @@ -231,40 +252,63 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', success_count = 0 error_count = 0 - for user in users: - try: - if photo_file_id: - await bot.send_photo( - user.user_id, - photo_file_id, - caption=f"📢 Объявление от администрации\n\n{text}", - reply_markup=keyboard, - parse_mode='HTML' - ) - else: - await bot.send_message( - user.user_id, - f"📢 Объявление от администрации\n\n{text}", - reply_markup=keyboard, - parse_mode='HTML' - ) - success_count += 1 - except Exception as e: - logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") - error_count += 1 + # Используем один HTTP сеанс для всех запросов + async with aiohttp.ClientSession() as http_session: + for user in users: + try: + if photo_file_id: + url = f"https://api.telegram.org/bot{bot_token}/sendPhoto" + data = aiohttp.FormData() + data.add_field('chat_id', str(user.user_id)) + data.add_field('photo', photo_file_id) + data.add_field('caption', f"📢 Объявление от администрации\n\n{text}") + data.add_field('reply_markup', json.dumps(reply_markup)) + data.add_field('parse_mode', 'HTML') + + async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + result_data = await resp.json() + if result_data.get('ok'): + success_count += 1 + else: + error_count += 1 + else: + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = { + 'chat_id': user.user_id, + 'text': f"📢 Объявление от администрации\n\n{text}", + 'reply_markup': json.dumps(reply_markup), + 'parse_mode': 'HTML' + } + + async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + result_data = await resp.json() + if result_data.get('ok'): + success_count += 1 + else: + error_count += 1 + except Exception as e: + logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") + error_count += 1 logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}") - # Уведомляем админа - await bot.send_message( - ADMIN_USER_ID, - f"📊 Результаты рассылки #{broadcast_id}\n\n" - f"✅ Доставлено: {success_count}\n" - f"❌ Ошибки: {error_count}\n" - f"📈 Прочитали: 0/{len(users)}\n\n" - f"Статистика обновляется в реальном времени в веб-панели", - parse_mode='HTML' - ) + # Уведомляем админа через HTTP + try: + async with aiohttp.ClientSession() as http_session: + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = { + 'chat_id': ADMIN_USER_ID, + 'text': f"📊 Результаты рассылки #{broadcast_id}\n\n" + f"✅ Доставлено: {success_count}\n" + f"❌ Ошибки: {error_count}\n" + f"📈 Прочитали: 0/{len(users)}\n\n" + f"Статистика обновляется в реальном времени в веб-панели", + 'parse_mode': 'HTML' + } + async with http_session.post(url, json=payload, proxy=proxy_url) as resp: + await resp.json() + except Exception as e: + logger.error(f"Ошибка уведомления админа: {e}") return True diff --git a/main.py b/main.py index 85fd172..f1d22e1 100644 --- a/main.py +++ b/main.py @@ -178,29 +178,8 @@ logging.basicConfig( logger = logging.getLogger(__name__) -def create_bot_with_proxy() -> Bot: - """Создание бота с настройкой прокси""" - proxy_url = get_proxy_url() - - if proxy_url: - logger.info(f'🔒 Использование прокси: {PROXY_TYPE}://{PROXY_HOST}:{PROXY_PORT}') - - # Создаём сессию aiogram с прокси - aiogram_session = AiohttpSession(proxy=proxy_url) - - bot = Bot( - token=BOT_TOKEN, - session=aiogram_session, - default=DefaultBotProperties(parse_mode=ParseMode.HTML) - ) - else: - logger.info('🔓 Работа без прокси') - bot = Bot( - token=BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML) - ) - - return bot +# Функция create_bot_with_proxy перенесена в bot_instance.py +# Теперь используется глобальный экземпляр через get_bot() async def on_startup(bot: Bot): @@ -250,16 +229,17 @@ async def on_shutdown(bot: Bot): ) except Exception: pass - - # Закрываем сессию - await bot.session.close() - logger.info('🔒 Сессия закрыта') + + # Закрываем сессию через глобальный модуль + from bot_instance import close_bot + await close_bot() async def main(): """Основная функция""" - # Создаём бота с прокси - bot = create_bot_with_proxy() + # Используем глобальный экземпляр бота + from bot_instance import get_bot, close_bot + bot = get_bot() # Создаём диспетчер с FSM storage (для состояний) dp = Dispatcher(storage=MemoryStorage()) diff --git a/services/initiative_group.py b/services/initiative_group.py index 1699e73..89c7ca2 100644 --- a/services/initiative_group.py +++ b/services/initiative_group.py @@ -71,15 +71,21 @@ class InitiativeGroupService: Returns: список участников """ from database.models import InitiativeGroup, User + from sqlalchemy.orm import joinedload + + # Используем joinedload для загрузки связи User + stmt = ( + select(InitiativeGroup) + .options(joinedload(InitiativeGroup.user)) + .join(User, InitiativeGroup.user_id == User.user_id) + ) - stmt = select(InitiativeGroup).join(User, InitiativeGroup.user_id == User.user_id) - if active_only: stmt = stmt.where(InitiativeGroup.is_active == True) - + stmt = stmt.order_by(InitiativeGroup.joined_at) result = await self.session.execute(stmt) - members = list(result.scalars().all()) + members = list(result.scalars().unique().all()) return [ { diff --git a/web/app.py b/web/app.py index 895d284..c4c3be3 100644 --- a/web/app.py +++ b/web/app.py @@ -520,164 +520,44 @@ async def api_delete_ad(ad_id: int, username: str = Depends(get_current_admin)): # ============================================================================ -# РАССЫЛКИ +# РАССЫЛКИ — единая страница создания и статистики # ============================================================================ @app.get("/broadcast", response_class=HTMLResponse) async def broadcast_page(request: Request, username: str = Depends(get_current_admin)): - """Рассылки""" - return templates.TemplateResponse("broadcast.html", { - "request": request, "username": username - }) + """Рассылки — создание и статистика""" + from database.models import Broadcast, BroadcastRead + from sqlalchemy import func - -@app.get("/broadcast", response_class=HTMLResponse) -async def broadcast_page(request: Request, username: str = Depends(get_current_admin)): - """Страница рассылок""" - return templates.TemplateResponse("broadcast.html", { - "request": request, "username": username - }) - - -@app.post("/api/broadcast/send") -async def api_send_broadcast( - text: str = Form(...), - photo: UploadFile = File(None), - recipients: str = Form("all_and_chat"), - username: str = Depends(get_current_admin) -): - """Отправить рассылку""" - import aiohttp - from pathlib import Path - from services.initiative_group import InitiativeGroupService - - # Логируем начало рассылки - logger.info(f"📬 НАЧАЛО РАССЫЛКИ: получатели={recipients}, текст_len={len(text) if text else 0}") - - photo_id = None - - # Если есть картинка — загружаем - if photo and photo.filename: - temp_path = Path("data") / f"broadcast_{photo.filename}" - with open(temp_path, "wb") as f: - content = await photo.read() - f.write(content) - - try: - async with aiohttp.ClientSession() as session: - url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" - data = aiohttp.FormData() - data.add_field('chat_id', config.ADMIN_USER_ID) - data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename) - data.add_field('caption', 'Test') - - async with session.post(url, data=data) as resp: - result = await resp.json() - if result.get('ok'): - photo_id = result['result']['photo'][-1]['file_id'] - except Exception as e: - logger.error(f"Ошибка загрузки фото: {e}") - - # Получаем получателей async with AsyncSessionLocal() as session: - if recipients == 'ig_only': - # Только ИГ - ig_service = InitiativeGroupService(session) - ig_user_ids = await ig_service.get_member_ids(active_only=True) - stmt = select(User).where(User.user_id.in_(ig_user_ids)) - users = list((await session.execute(stmt)).scalars().all()) - elif recipients == 'all_verified': - # Все верифицированные - stmt = select(User).where(User.verified == True) - users = list((await session.execute(stmt)).scalars().all()) - else: - # Все верифицированные (по умолчанию) - stmt = select(User).where(User.verified == True) - users = list((await session.execute(stmt)).scalars().all()) + # История рассылок + stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50) + result = await session.execute(stmt) + broadcasts = list(result.scalars().all()) - success_count = 0 - error_count = 0 - chat_sent = False + # Считаем прочтения для каждой + broadcasts_data = [] + for b in broadcasts: + read_count = (await session.execute( + select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id) + )).scalar() or 0 - async with aiohttp.ClientSession() as http_session: - # Отправка пользователям - for user in users: - try: - url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/" - if photo_id: - url += "sendPhoto" - data = aiohttp.FormData() - data.add_field('chat_id', user.user_id) - data.add_field('photo', photo_id) - data.add_field('caption', f"📢 Объявление от администрации\n\n{text}") - async with http_session.post(url, data=data, timeout=30) as resp: - if resp.status == 200: - success_count += 1 - else: - url += "sendMessage" - data = aiohttp.FormData() - data.add_field('chat_id', user.user_id) - data.add_field('text', f"📢 Объявление от администрации\n\n{text}") - data.add_field('parse_mode', 'HTML') - async with http_session.post(url, data=data, timeout=30) as resp: - if resp.status == 200: - success_count += 1 - except Exception as e: - error_count += 1 - - # Отправка в чат (если нужно) - if recipients in ['all_and_chat', 'chat_only']: - try: - url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/" - if photo_id: - url += "sendPhoto" - data = aiohttp.FormData() - data.add_field('chat_id', config.ADMIN_CHAT_ID) - data.add_field('photo', photo_id) - data.add_field('caption', f"📢 Объявление от администрации\n\n{text}") - async with http_session.post(url, data=data, timeout=30) as resp: - if resp.status == 200: - chat_sent = True - else: - url += "sendMessage" - data = aiohttp.FormData() - data.add_field('chat_id', config.ADMIN_CHAT_ID) - data.add_field('text', f"📢 Объявление от администрации\n\n{text}") - data.add_field('parse_mode', 'HTML') - async with http_session.post(url, data=data, timeout=30) as resp: - if resp.status == 200: - chat_sent = True - except Exception as e: - logger.error(f"Ошибка отправки в чат: {e}") - - # Логируем в базу - try: - from sqlalchemy import text - async with AsyncSessionLocal() as session: - await session.execute(text(''' - INSERT INTO activity_log (user_id, action_type, action_data, recipients_count) - VALUES (:user_id, 'broadcast', :data, :count) - '''), { - "user_id": config.ADMIN_USER_ID, - "data": f"recipients={recipients}, text_len={len(text)}", - "count": success_count + broadcasts_data.append({ + 'id': b.id, + 'text': b.text[:100] + '...' if len(b.text) > 100 else b.text, + 'sent_at': b.sent_at.strftime('%d.%m.%Y %H:%M') if b.sent_at else '-', + 'total_sent': b.total_sent, + 'read_count': read_count, + 'unread_count': b.total_sent - read_count, + 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0, + 'is_reminder_sent': b.is_reminder_sent, + 'broadcast_type': b.broadcast_type or 'regular', }) - await session.commit() - except Exception as e: - logger.error(f"Ошибка логирования рассылки: {e}") - # Логируем результат - logger.info(f"✅ РАССЫЛКА ЗАВЕРШЕНА: успешно={success_count}, ошибок={error_count}, получатели={recipients}") - - return JSONResponse({ - "success": True, - "message": f"Отправлено: {success_count} (в чат: {'✅' if chat_sent else '❌'})", - "details": { - "users_sent": success_count, - "errors": error_count, - "chat_sent": chat_sent, - "recipients": recipients - } + return templates.TemplateResponse("broadcast.html", { + "request": request, + "username": username, + "broadcasts": broadcasts_data, }) @@ -1089,56 +969,15 @@ def run_web_server(host: str = "0.0.0.0", port: int = 8000): uvicorn.run(app, host=host, port=port) -# ============================================================================ -# V3.1 - УМНЫЕ РАССЫЛКИ -# ============================================================================ - -@app.get("/broadcasts", response_class=HTMLResponse) -async def broadcasts_page(request: Request, username: str = Depends(get_current_admin)): - """Страница умных рассылок""" - from database.models import Broadcast, BroadcastRead - from sqlalchemy import func, desc - - async with AsyncSessionLocal() as session: - # Все рассылки - stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50) - result = await session.execute(stmt) - broadcasts = list(result.scalars().all()) - - # Считаем прочтения для каждой - broadcasts_data = [] - for b in broadcasts: - read_count = (await session.execute( - select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id) - )).scalar() or 0 - - broadcasts_data.append({ - 'id': b.id, - 'text': b.text[:100] + '...' if len(b.text) > 100 else b.text, - 'sent_at': b.sent_at.strftime('%d.%m.%Y %H:%M') if b.sent_at else '-', - 'total_sent': b.total_sent, - 'read_count': read_count, - 'unread_count': b.total_sent - read_count, - 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0, - 'is_reminder_sent': b.is_reminder_sent, - 'broadcast_type': b.broadcast_type, - }) - - return templates.TemplateResponse("broadcasts.html", { - "request": request, - "username": username, - "broadcasts": broadcasts_data, - }) - @app.post("/api/broadcast/send_reminder/{broadcast_id}") async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)): """Отправить напоминание непрочитавшим""" from handlers.smart_broadcast import send_reminder_to_unread - - # Получаем bot из main - import main - sent_count = await send_reminder_to_unread(main.bot, broadcast_id) + from bot_instance import get_bot + + bot = get_bot() + sent_count = await send_reminder_to_unread(bot, broadcast_id) return JSONResponse({ "success": True, @@ -1188,53 +1027,125 @@ async def api_create_broadcast( photo: UploadFile = File(None), recipients: str = Form("all_and_chat"), has_read_button: bool = Form(True), + dry_run: bool = Form(False), # Режим теста БЕЗ отправки username: str = Depends(get_current_admin) ): """Создать и отправить рассылку из веб-панели""" + + # dry_run = тестирование без реальной отправки + if dry_run: + from database.models import User + from services.initiative_group import InitiativeGroupService + + async with AsyncSessionLocal() as session: + if recipients == 'ig_only': + ig_service = InitiativeGroupService(session) + ig_user_ids = await ig_service.get_member_ids(active_only=True) + stmt = select(User).where(User.user_id.in_(ig_user_ids)) + elif recipients == 'all_verified': + stmt = select(User).where(User.verified == True) + else: + stmt = select(User).where(User.verified == True) + + result = await session.execute(stmt) + users = list(result.scalars().all()) + + logger.info(f"🧪 DRY RUN рассылки: получатели={recipients}, пользователей={len(users)}") + return JSONResponse({ + "success": True, + "dry_run": True, + "message": f"Тестовый режим. Рассылка получила бы {len(users)} пользователей", + "recipients_count": len(users) + }) + import aiohttp from database.models import Broadcast from datetime import datetime photo_file_id = None - - # Если есть фото - загружаем + + # Если есть фото - загружаем через Telegram Bot API if photo and photo.filename: try: + # Сохраняем временно temp_path = Path("data") / f"broadcast_{photo.filename}" + content = await photo.read() with open(temp_path, "wb") as f: - content = await photo.read() f.write(content) - + + # Загружаем фото через Telegram API для получения file_id async with aiohttp.ClientSession() as session: url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto" + + # Если бот использует прокси - добавляем его в aiohttp + proxy = config.get_proxy_url() + data = aiohttp.FormData() data.add_field('chat_id', config.ADMIN_USER_ID) - data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename) - data.add_field('caption', 'Test') - async with session.post(url, data=data) as resp: - result = await resp.json() - if result.get('ok'): - photo_file_id = result['result']['photo'][-1]['file_id'] + # Открываем файл и сразу передаём в FormData + with open(temp_path, 'rb') as photo_file: + data.add_field('photo', photo_file, filename=photo.filename) + data.add_field('caption', 'broadcast_test') + data.add_field('parse_mode', 'HTML') + + async with session.post(url, data=data, proxy=proxy) as resp: + text = await resp.text() + + # Проверяем что ответ успешный + if resp.status != 200: + logger.error(f"Ошибка загрузки фото: HTTP {resp.status}, ответ: {text}") + raise Exception(f"Telegram API вернул ошибку: HTTP {resp.status}") + + # Пытаемся распарсить JSON + try: + result = json.loads(text) + except json.JSONDecodeError as e: + logger.error(f"Ошибка парсинга JSON от Telegram API: {e}, ответ: {text}") + raise Exception(f"Telegram API вернул не-JSON ответ: {text[:100]}") + + if result.get('ok'): + photo_file_id = result['result']['photo'][-1]['file_id'] + logger.info(f"✅ Фото загружено, file_id: {photo_file_id}") + else: + error_msg = result.get('description', 'Неизвестная ошибка') + logger.error(f"Ошибка загрузки фото: {error_msg}") + raise Exception(f"Ошибка Telegram API: {error_msg}") + + # Удаляем временный файл + if temp_path.exists(): + temp_path.unlink() + except Exception as e: - logger.error(f"Ошибка загрузки фото: {e}") - - # Получаем бота - import main - bot = main.bot + logger.error(f"❌ Ошибка загрузки фото для рассылки: {e}") + # Не падаем, просто продолжаем без фото + photo_file_id = None + + # Получаем бота из глобального экземпляра + from bot_instance import get_bot + bot = get_bot() + # Отправляем рассылку - from handlers.smart_broadcast import send_smart_broadcast - success = await send_smart_broadcast(bot, text, recipients, photo_file_id) - - if success: + try: + from handlers.smart_broadcast import send_smart_broadcast + success = await send_smart_broadcast(bot, text, recipients, photo_file_id) + + if success: + return JSONResponse({ + "success": True, + "message": f"Рассылка отправлена получателям: {recipients}" + }) + else: + return JSONResponse({ + "error": "Ошибка при отправке рассылки. Проверьте логи бота." + }, status_code=500) + except Exception as e: + logger.error(f"❌ Критическая ошибка при отправке рассылки: {e}") + import traceback + traceback.print_exc() return JSONResponse({ - "success": True, - "message": f"Рассылка отправлена получателям: {recipients}" - }) - else: - return JSONResponse({ - "error": "Ошибка при отправке рассылки" + "error": f"Критическая ошибка: {str(e)}" }, status_code=500) diff --git a/web/templates/broadcast.html b/web/templates/broadcast.html index dcde834..81580a3 100644 --- a/web/templates/broadcast.html +++ b/web/templates/broadcast.html @@ -16,162 +16,216 @@ .broadcast-type-option.active { border-color: #667eea; background: #eef2ff; } .broadcast-type-option h4 { margin-bottom: 10px; } .broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; } - .stats-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-top: 30px; } - .stat-mini { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; } - .stat-mini h3 { font-size: 0.85em; color: #666; margin-bottom: 5px; } - .stat-mini .value { font-size: 1.8em; font-weight: bold; color: #667eea; } - .recent-broadcasts { margin-top: 30px; } - .broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; } - .broadcast-item .text { flex: 1; } - .broadcast-item .meta { text-align: right; color: #666; font-size: 0.9em; } + .nav-tabs .nav-link { color: #495057; } + .nav-tabs .nav-link.active { font-weight: bold; color: #667eea; } + .broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .badge-regular { background: #6c757d; color: white; } .progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; } .progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); } + .readers-table { font-size: 0.9em; } + .readers-table th { background: #f8f9fa; } {% endblock %} {% block content %}
Простая рассылка всем получателям
-С отслеживанием прочтений и статистикой
+ + + +С отслеживанием прочтений и статистикой
+