🔄 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:
Admin 2026-04-06 23:29:32 +04:00
parent f7aaf96cfa
commit fa8c1d53d9
2 changed files with 402 additions and 242 deletions

View file

@ -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)):
"""Получить список прочитавших""" """Получить список прочитавших"""

View file

@ -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: &lt;b&gt;жирный&lt;/b&gt;, &lt;i&gt;курсив&lt;/i&gt;</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 %}