🔄 feat: Unify Regular and Smart broadcast UI into single page with type selector
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
f7aaf96cfa
commit
fa8c1d53d9
2 changed files with 402 additions and 242 deletions
92
web/app.py
92
web/app.py
|
|
@ -1146,6 +1146,98 @@ async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/broadcasts/list")
|
||||||
|
async def api_get_broadcasts_list(limit: int = 10, 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(limit)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
broadcasts = list(result.scalars().all())
|
||||||
|
|
||||||
|
total = (await session.execute(select(func.count(Broadcast.id)))).scalar() or 0
|
||||||
|
|
||||||
|
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,
|
||||||
|
'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,
|
||||||
|
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
|
||||||
|
'broadcast_type': b.broadcast_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"total": total,
|
||||||
|
"broadcasts": broadcasts_data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/broadcast/create")
|
||||||
|
async def api_create_broadcast(
|
||||||
|
text: str = Form(...),
|
||||||
|
photo: UploadFile = File(None),
|
||||||
|
recipients: str = Form("all_and_chat"),
|
||||||
|
has_read_button: bool = Form(True),
|
||||||
|
username: str = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
"""Создать и отправить рассылку из веб-панели"""
|
||||||
|
import aiohttp
|
||||||
|
from database.models import Broadcast
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
photo_file_id = None
|
||||||
|
|
||||||
|
# Если есть фото - загружаем
|
||||||
|
if photo and photo.filename:
|
||||||
|
try:
|
||||||
|
temp_path = Path("data") / f"broadcast_{photo.filename}"
|
||||||
|
with open(temp_path, "wb") as f:
|
||||||
|
content = await photo.read()
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
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_file_id = result['result']['photo'][-1]['file_id']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка загрузки фото: {e}")
|
||||||
|
|
||||||
|
# Получаем бота
|
||||||
|
import main
|
||||||
|
bot = main.bot
|
||||||
|
|
||||||
|
# Отправляем рассылку
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/broadcast/readers/{broadcast_id}")
|
@app.get("/api/broadcast/readers/{broadcast_id}")
|
||||||
async def api_get_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
|
async def api_get_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
|
||||||
"""Получить список прочитавших"""
|
"""Получить список прочитавших"""
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,181 @@
|
||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
{% block title %}Рассылки - Домовой Бот{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Рассылки - Домовой Бот</title>
|
{% block nav_broadcast %}active{% endblock %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
{% block extra_style %}
|
||||||
<style>
|
<style>
|
||||||
.sidebar { min-height: 100vh; background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); }
|
|
||||||
.sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 1rem; }
|
|
||||||
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: white; background-color: rgba(255,255,255,0.1); }
|
|
||||||
.content { padding: 2rem; }
|
|
||||||
#preview { max-width: 400px; margin-top: 1rem; display: none; }
|
#preview { max-width: 400px; margin-top: 1rem; display: none; }
|
||||||
#preview img { max-width: 100%; border-radius: 8px; }
|
#preview img { max-width: 100%; border-radius: 8px; }
|
||||||
</style>
|
.smart-options { display: none; background: #f0f7ff; padding: 20px; border-radius: 8px; margin-top: 15px; border: 2px solid #667eea; }
|
||||||
</head>
|
.smart-options.active { display: block; }
|
||||||
<body>
|
.broadcast-type-selector { display: flex; gap: 15px; margin-bottom: 20px; }
|
||||||
<div class="container-fluid">
|
.broadcast-type-option { flex: 1; padding: 20px; border: 2px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.3s; text-align: center; }
|
||||||
<div class="row">
|
.broadcast-type-option:hover { border-color: #667eea; background: #f8f9ff; }
|
||||||
<div class="col-md-2 sidebar p-0">
|
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; }
|
||||||
<div class="p-3 text-white text-center">
|
.broadcast-type-option h4 { margin-bottom: 10px; }
|
||||||
<h4><i class="bi bi-house-door"></i> Домовой Бот</h4>
|
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; }
|
||||||
</div>
|
.stats-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px; margin-top: 30px; }
|
||||||
<nav class="nav flex-column">
|
.stat-mini { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; }
|
||||||
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Дашборд</a>
|
.stat-mini h3 { font-size: 0.85em; color: #666; margin-bottom: 5px; }
|
||||||
<a class="nav-link" href="/users"><i class="bi bi-people"></i> Пользователи</a>
|
.stat-mini .value { font-size: 1.8em; font-weight: bold; color: #667eea; }
|
||||||
<a class="nav-link" href="/verification"><i class="bi bi-shield-check"></i> Верификация</a>
|
.recent-broadcasts { margin-top: 30px; }
|
||||||
<a class="nav-link" href="/phones"><i class="bi bi-telephone"></i> Телефоны</a>
|
.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; }
|
||||||
<a class="nav-link active" href="/broadcast"><i class="bi bi-broadcast"></i> Рассылки</a>
|
.broadcast-item .text { flex: 1; }
|
||||||
<a class="nav-link" href="/scheduled_posts"><i class="bi bi-calendar2-plus"></i> Запланированные посты</a>
|
.broadcast-item .meta { text-align: right; color: #666; font-size: 0.9em; }
|
||||||
<a class="nav-link" href="/polls"><i class="bi bi-bar-chart"></i> Опросы</a>
|
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||||
<a class="nav-link" href="/events"><i class="bi bi-calendar-event"></i> События</a>
|
.badge-regular { background: #6c757d; color: white; }
|
||||||
<a class="nav-link" href="/schedules"><i class="bi bi-clock"></i> Расписания</a>
|
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; }
|
||||||
<a class="nav-link" href="/export"><i class="bi bi-download"></i> Экспорт</a>
|
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); }
|
||||||
</nav>
|
</style>
|
||||||
</div>
|
{% endblock %}
|
||||||
<div class="col-md-10 content">
|
|
||||||
<h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылка объявлений</h2>
|
|
||||||
|
|
||||||
<div class="card">
|
{% block content %}
|
||||||
|
<h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылки</h2>
|
||||||
|
|
||||||
|
<!-- Выбор типа рассылки -->
|
||||||
|
<div class="card mb-4">
|
||||||
<div class="card-header bg-primary text-white">
|
<div class="card-header bg-primary text-white">
|
||||||
<h5 class="mb-0"><i class="bi bi-send"></i> Создать рассылку</h5>
|
<h5 class="mb-0"><i class="bi bi-send"></i> Создать рассылку</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<!-- Тип рассылки -->
|
||||||
|
<label class="form-label fw-bold">Тип рассылки:</label>
|
||||||
|
<div class="broadcast-type-selector">
|
||||||
|
<div class="broadcast-type-option active" onclick="selectBroadcastType('regular')" id="typeRegular">
|
||||||
|
<div class="icon">📢</div>
|
||||||
|
<h4>Обычная</h4>
|
||||||
|
<p class="text-muted small">Простая рассылка всем получателям</p>
|
||||||
|
</div>
|
||||||
|
<div class="broadcast-type-option" onclick="selectBroadcastType('smart')" id="typeSmart">
|
||||||
|
<div class="icon">✨</div>
|
||||||
|
<h4>Умная</h4>
|
||||||
|
<p class="text-muted small">С отслеживанием прочтений и статистикой</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="broadcastForm" enctype="multipart/form-data">
|
<form id="broadcastForm" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" id="broadcastType" value="regular">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Текст объявления</label>
|
<label class="form-label">Текст рассылки</label>
|
||||||
<textarea class="form-control" id="broadcastText" rows="5"
|
<textarea class="form-control" id="broadcastText" rows="5"
|
||||||
placeholder="Введите текст объявления..." required></textarea>
|
placeholder="Введите текст рассылки..." required></textarea>
|
||||||
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i></small>
|
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Картинка (необязательно)</label>
|
<label class="form-label">Картинка (необязательно)</label>
|
||||||
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*"
|
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
|
||||||
onchange="previewImage()">
|
|
||||||
<small class="text-muted">JPG, PNG до 5MB</small>
|
<small class="text-muted">JPG, PNG до 5MB</small>
|
||||||
<div id="preview">
|
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
|
||||||
<img id="previewImg" alt="Предпросмотр">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Кому отправить:</label>
|
<label class="form-label">Кому отправить:</label>
|
||||||
<select class="form-select" id="broadcastRecipients">
|
<select class="form-select" id="broadcastRecipients">
|
||||||
<option value="all_verified">✅ Всем верифицированным</option>
|
<option value="all_and_chat">✅ Всем верифицированным + в чат (по умолчанию)</option>
|
||||||
<option value="ig_only">🔵 Только Инициативной Группе</option>
|
<option value="all_verified">✅ Только верифицированным в личку</option>
|
||||||
<option value="chat_only">📢 Только в общий чат</option>
|
<option value="ig_only">👥 Только Инициативной Группе</option>
|
||||||
<option value="all_and_chat">✅ Всем + в чат (по умолчанию)</option>
|
<option value="chat_only">💬 Только в общий чат</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="text-muted">Выберите получателей рассылки</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info">
|
<!-- Опции умной рассылки -->
|
||||||
|
<div class="smart-options" id="smartOptions">
|
||||||
|
<h5 class="mb-3"><i class="bi bi-stars"></i> Настройки умной рассылки</h5>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="hasReadButton" checked>
|
||||||
|
<label class="form-check-label" for="hasReadButton">
|
||||||
|
Добавить кнопку "✅ Прочитал"
|
||||||
|
</label>
|
||||||
|
<div class="text-muted small">Жильцы смогут отметить что прочитали сообщение</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="trackReads" checked>
|
||||||
|
<label class="form-check-label" for="trackReads">
|
||||||
|
Отслеживать прочтения
|
||||||
|
</label>
|
||||||
|
<div class="text-muted small">В статистике будет видно кто прочитал, а кто нет</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
<i class="bi bi-info-circle"></i>
|
<i class="bi bi-info-circle"></i>
|
||||||
<b>Получатели:</b> <span id="recipientsInfo">Все верифицированные пользователи + общий чат</span>
|
<b>После отправки умной рассылки вы сможете:</b>
|
||||||
|
<ul class="mb-0 mt-2">
|
||||||
|
<li>Посмотреть кто прочитал сообщение</li>
|
||||||
|
<li>Увидеть кто НЕ прочитал</li>
|
||||||
|
<li>Отправить напоминание непрочитавшим</li>
|
||||||
|
<li>Просмотреть общую статистику в разделе <a href="/broadcasts" target="_blank">Умные рассылки →</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg">
|
<button type="submit" class="btn btn-primary btn-lg mt-3">
|
||||||
<i class="bi bi-send"></i> Отправить рассылку
|
<i class="bi bi-send"></i> <span id="submitBtnText">Отправить рассылку</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="result" class="mt-4"></div>
|
<div id="result" class="mt-4"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<!-- Краткая статистика -->
|
||||||
|
<div class="stats-summary" id="statsSummary" style="display:none;">
|
||||||
|
<div class="stat-mini">
|
||||||
|
<h3>Всего рассылок</h3>
|
||||||
|
<div class="value" id="statTotal">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini">
|
||||||
|
<h3>Умных рассылок</h3>
|
||||||
|
<div class="value" id="statSmart">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-mini">
|
||||||
|
<h3>Средний % прочтений</h3>
|
||||||
|
<div class="value" id="statReadPercent">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Последние рассылки -->
|
||||||
|
<div class="recent-broadcasts" id="recentBroadcasts" style="display:none;">
|
||||||
|
<h5 class="mb-3">📊 Последние рассылки</h5>
|
||||||
|
<div id="broadcastsList"></div>
|
||||||
|
<a href="/broadcasts" class="btn btn-outline-primary mt-2">
|
||||||
|
<i class="bi bi-arrow-right"></i> Полная статистика умных рассылок
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5>Как использовать:</h5>
|
<h5>Как использовать:</h5>
|
||||||
<ol>
|
<ol>
|
||||||
|
<li>Выберите тип: <b>Обычная</b> или <b>Умная</b> рассылка</li>
|
||||||
<li>Введите текст объявления</li>
|
<li>Введите текст объявления</li>
|
||||||
<li>Прикрепите картинку (необязательно)</li>
|
<li>Прикрепите картинку (необязательно)</li>
|
||||||
<li>Выберите получателей</li>
|
<li>Выберите получателей</li>
|
||||||
|
<li>Для умной рассылки настройте опции отслеживания</li>
|
||||||
<li>Нажмите "Отправить рассылку"</li>
|
<li>Нажмите "Отправить рассылку"</li>
|
||||||
<li>Бот отправит выбранным получателям</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<!-- IG Broadcast Limit -->
|
{% block extra_script %}
|
||||||
<div class="card mt-4">
|
<script>
|
||||||
<div class="card-header bg-info text-white">
|
// Выбор типа рассылки
|
||||||
<h5 class="mb-0"><i class="bi bi-people"></i> Настройка рассылки ИГ</h5>
|
function selectBroadcastType(type) {
|
||||||
</div>
|
document.getElementById('broadcastType').value = type;
|
||||||
<div class="card-body">
|
document.getElementById('typeRegular').classList.toggle('active', type === 'regular');
|
||||||
<form id="igLimitForm">
|
document.getElementById('typeSmart').classList.toggle('active', type === 'smart');
|
||||||
<div class="mb-3">
|
document.getElementById('smartOptions').classList.toggle('active', type === 'smart');
|
||||||
<label class="form-label">Минимальный интервал между рассылками ИГ (секунды)</label>
|
document.getElementById('submitBtnText').textContent = type === 'smart' ? '✨ Отправить умную рассылку' : '📢 Отправить рассылку';
|
||||||
<input type="number" class="form-control" id="igBroadcastLimit" value="0" min="0" step="60">
|
}
|
||||||
<small class="text-muted">
|
|
||||||
<ul class="mb-0">
|
|
||||||
<li><code>0</code> — Без ограничений</li>
|
|
||||||
<li><code>3600</code> — 1 раз в час</li>
|
|
||||||
<li><code>86400</code> — 1 раз в сутки</li>
|
|
||||||
</ul>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-save"></i> Сохранить настройку
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div id="igLimitResult" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Загрузка настройки лимита ИГ
|
|
||||||
async function loadIgLimit() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings/ig_broadcast_limit');
|
|
||||||
const result = await r.json();
|
|
||||||
if (result.success) {
|
|
||||||
document.getElementById('igBroadcastLimit').value = parseInt(result.value) || 0;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Ошибка загрузки настройки:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохранение настройки лимита ИГ
|
// Предпросмотр изображения
|
||||||
document.getElementById('igLimitForm').addEventListener('submit', async (e) => {
|
function previewImage() {
|
||||||
e.preventDefault();
|
|
||||||
const limit = parseInt(document.getElementById('igBroadcastLimit').value);
|
|
||||||
const resultDiv = document.getElementById('igLimitResult');
|
|
||||||
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-info">💾 Сохранение...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings/ig_broadcast_limit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ limit: limit })
|
|
||||||
});
|
|
||||||
const result = await r.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-success">✅ Настройка сохранена!</div>';
|
|
||||||
} else {
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-danger">❌ Ошибка: ' + result.error + '</div>';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-danger">❌ Ошибка: ' + err.message + '</div>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Загружаем настройку при загрузке страницы
|
|
||||||
document.addEventListener('DOMContentLoaded', loadIgLimit);
|
|
||||||
|
|
||||||
// Обновление информации о получателях
|
|
||||||
document.getElementById('broadcastRecipients').addEventListener('change', function() {
|
|
||||||
const info = {
|
|
||||||
'all_verified': 'Все верифицированные пользователи (в личку)',
|
|
||||||
'ig_only': 'Только участники Инициативной Группы (в личку)',
|
|
||||||
'chat_only': 'Только в общий чат (одно сообщение)',
|
|
||||||
'all_and_chat': 'Все верифицированные (в личку) + общий чат'
|
|
||||||
};
|
|
||||||
document.getElementById('recipientsInfo').textContent = info[this.value] || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
function previewImage() {
|
|
||||||
const file = document.getElementById('broadcastPhoto').files[0];
|
const file = document.getElementById('broadcastPhoto').files[0];
|
||||||
const preview = document.getElementById('preview');
|
const preview = document.getElementById('preview');
|
||||||
const previewImg = document.getElementById('previewImg');
|
const previewImg = document.getElementById('previewImg');
|
||||||
|
|
@ -196,57 +190,131 @@
|
||||||
} else {
|
} else {
|
||||||
preview.style.display = 'none';
|
preview.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('broadcastForm').addEventListener('submit', async (e) => {
|
// Обновление информации о получателях
|
||||||
|
document.getElementById('broadcastRecipients').addEventListener('change', function() {
|
||||||
|
const info = {
|
||||||
|
'all_verified': 'Все верифицированные пользователи (в личку)',
|
||||||
|
'ig_only': 'Только участники Инициативной Группы (в личку)',
|
||||||
|
'chat_only': 'Только в общий чат (одно сообщение)',
|
||||||
|
'all_and_chat': 'Все верифицированные (в личку) + общий чат'
|
||||||
|
};
|
||||||
|
// Можно добавить отображение info где-то на странице
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправка формы
|
||||||
|
document.getElementById('broadcastForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const type = document.getElementById('broadcastType').value;
|
||||||
const text = document.getElementById('broadcastText').value;
|
const text = document.getElementById('broadcastText').value;
|
||||||
const photo = document.getElementById('broadcastPhoto').files[0];
|
const photo = document.getElementById('broadcastPhoto').files[0];
|
||||||
const recipients = document.getElementById('broadcastRecipients').value;
|
const recipients = document.getElementById('broadcastRecipients').value;
|
||||||
const resultDiv = document.getElementById('result');
|
const resultDiv = document.getElementById('result');
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
alert('Введите текст объявления!');
|
alert('Введите текст рассылки!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientNames = {
|
const typeLabel = type === 'smart' ? 'умную' : '';
|
||||||
'all_verified': 'всем верифицированным',
|
if (!confirm(`Отправить${typeLabel ? ' ' + typeLabel : ''} рассылку выбранным получателям?`)) return;
|
||||||
'ig_only': 'Инициативной Группе',
|
|
||||||
'chat_only': 'в общий чат',
|
|
||||||
'all_and_chat': 'всем верифицированным + в чат'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!confirm(`Отправить рассылку ${recipientNames[recipients] || 'всем'}?`)) return;
|
|
||||||
|
|
||||||
resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>';
|
resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>';
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('text', text);
|
formData.append('text', text);
|
||||||
formData.append('recipients', recipients);
|
formData.append('recipients', recipients);
|
||||||
|
formData.append('broadcast_type', type);
|
||||||
|
|
||||||
|
if (type === 'smart') {
|
||||||
|
formData.append('has_read_button', document.getElementById('hasReadButton').checked);
|
||||||
|
formData.append('track_reads', document.getElementById('trackReads').checked);
|
||||||
|
}
|
||||||
|
|
||||||
if (photo) {
|
if (photo) {
|
||||||
formData.append('photo', photo);
|
formData.append('photo', photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/broadcast/send', {
|
const endpoint = type === 'smart' ? '/api/broadcast/create' : '/api/broadcast/send';
|
||||||
method: 'POST',
|
const r = await fetch(endpoint, { method: 'POST', body: formData });
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
const result = await r.json();
|
const result = await r.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}</div>`;
|
let message = `✅ ${result.message}`;
|
||||||
|
if (type === 'smart') {
|
||||||
|
message += '<br><br>📊 <a href="/broadcasts">Посмотреть статистику прочтений →</a>';
|
||||||
|
}
|
||||||
|
resultDiv.innerHTML = `<div class="alert alert-success">${message}</div>`;
|
||||||
document.getElementById('broadcastForm').reset();
|
document.getElementById('broadcastForm').reset();
|
||||||
document.getElementById('preview').style.display = 'none';
|
document.getElementById('preview').style.display = 'none';
|
||||||
|
selectBroadcastType('regular');
|
||||||
|
|
||||||
|
// Загружаем статистику
|
||||||
|
loadBroadcastStats();
|
||||||
} else {
|
} else {
|
||||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
|
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
</body>
|
// Загрузка статистики рассылок
|
||||||
</html>
|
async function loadBroadcastStats() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/broadcasts/list?limit=5');
|
||||||
|
const result = await r.json();
|
||||||
|
|
||||||
|
if (result.success && result.broadcasts) {
|
||||||
|
const broadcasts = result.broadcasts;
|
||||||
|
|
||||||
|
// Показываем блок статистики
|
||||||
|
document.getElementById('statsSummary').style.display = 'grid';
|
||||||
|
document.getElementById('recentBroadcasts').style.display = 'block';
|
||||||
|
|
||||||
|
// Заполняем цифры
|
||||||
|
document.getElementById('statTotal').textContent = result.total || broadcasts.length;
|
||||||
|
const smartCount = broadcasts.filter(b => b.broadcast_type === 'smart').length;
|
||||||
|
document.getElementById('statSmart').textContent = smartCount;
|
||||||
|
|
||||||
|
const avgRead = broadcasts.length > 0
|
||||||
|
? (broadcasts.reduce((sum, b) => sum + (b.read_percent || 0), 0) / broadcasts.length).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
document.getElementById('statReadPercent').textContent = avgRead + '%';
|
||||||
|
|
||||||
|
// Список последних
|
||||||
|
const listDiv = document.getElementById('broadcastsList');
|
||||||
|
listDiv.innerHTML = broadcasts.map(b => `
|
||||||
|
<div class="broadcast-item">
|
||||||
|
<div class="text">
|
||||||
|
<span class="badge ${b.broadcast_type === 'smart' ? 'badge-smart' : 'badge-regular'}">
|
||||||
|
${b.broadcast_type === 'smart' ? '✨ Умная' : '📢 Обычная'}
|
||||||
|
</span>
|
||||||
|
${b.text.substring(0, 80)}${b.text.length > 80 ? '...' : ''}
|
||||||
|
${b.broadcast_type === 'smart' ? `
|
||||||
|
<div class="progress-mini">
|
||||||
|
<div class="progress-mini-fill" style="width: ${b.read_percent}%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted ms-2">${b.read_percent}% прочитали</small>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div>${b.sent_at || '-'}</div>
|
||||||
|
<div>Отпр: ${b.total_sent || 0}</div>
|
||||||
|
${b.broadcast_type === 'smart' ? `<div><a href="/broadcasts">📊 Статистика</a></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки статистики:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем статистику при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', loadBroadcastStats);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue