feat: add route to upload media and edit frontend component

This commit is contained in:
austnv
2026-06-16 15:53:27 +03:00
parent 0304b6fc2b
commit 86c739b696
6 changed files with 2164 additions and 22 deletions
@@ -7,6 +7,11 @@ from collections import defaultdict
from bot.utils.ttl_cache import AsyncTTLCache
import tempfile
import os
from aiogram.types import InputFile, Message
from aiogram.exceptions import TelegramBadRequest
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
BROADCAST_TARGETS = {
@@ -261,3 +266,67 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res
)
return _ok({"counts": counts})
async def admin_upload_media_route(request: web.Request) -> web.Response:
"""
Эндпоинт для загрузки медиафайла администратором.
Принимает multipart/form-data с полем 'file'.
Возвращает file_id, полученный от Telegram Bot.
"""
actor_id = _require_admin_user_id(request)
# Проверяем, что это multipart-запрос
if request.content_type and not request.content_type.startswith("multipart/form-data"):
return _error(400, "invalid_content_type")
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "file":
return _error(400, "file_required")
# Сохраняем файл во временный файл
with tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") as tmp:
while True:
chunk = await field.read_chunk()
if not chunk:
break
tmp.write(chunk)
tmp_path = tmp.name
try:
# Получаем бота из app
bot = request.app.get("bot")
if bot is None:
return _error(503, "bot_unavailable")
# Отправляем файл в личный чат администратора, чтобы получить file_id
# Используем send_document, так как он подходит для любых файлов
with open(tmp_path, "rb") as f:
input_file = InputFile(f)
sent_msg: Message = await bot.send_document(
chat_id=actor_id,
document=input_file,
# Не отправляем лишний текст, чтобы не привлекать внимание
)
file_id = sent_msg.document.file_id
# Удаляем сообщение, чтобы не засорять чат
await bot.delete_message(chat_id=actor_id, message_id=sent_msg.message_id)
# Возвращаем file_id
return _ok({"file_id": file_id})
except TelegramBadRequest as e:
logger.warning("Failed to upload media to Telegram: %s", e)
return _error(400, f"telegram_error: {str(e)}")
except Exception as e:
logger.error("Unexpected error during media upload: %s", e, exc_info=True)
return _error(500, "upload_failed")
finally:
# Удаляем временный файл
try:
os.unlink(tmp_path)
except OSError:
pass
@@ -64,6 +64,8 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/upload-media", admin_upload_media_route)
router.add_post("/api/admin/sync", admin_sync_route)
router.add_get("/api/admin/ads", admin_ads_list_route)
@@ -1,9 +1,10 @@
<script>
import { Textarea } from "$components/ui/index.js";
import { Send } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { Label } from "$components/ui/primitives.js";
import { Send, Upload, Link as LinkIcon } from "$components/ui/icons.js";
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { Label } from "$components/ui/primitives.js";
import { Carta, MarkdownEditor } from "carta-md";
import "carta-md/default.css"; // Базовые стили
export let at;
const broadcastStore = getContext("broadcastStore");
@@ -11,6 +12,8 @@
$: ({
broadcastTarget,
broadcastText,
broadcastMediaType,
broadcastMediaFileId,
broadcastBusy,
broadcastResult,
broadcastCounts,
@@ -19,7 +22,29 @@
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
// Append the resolved audience size to each option once counts are loaded.
// Настройка Carta
const carta = new Carta({
sanitizer: (html) => html, // Можно подключить DOMPurify для безопасности
});
// Опции типов медиа
const MEDIA_TYPE_OPTIONS = [
{ value: "", label: at("media_type_none", {}, "Без медиа") },
{ value: "photo", label: "📷 Фото" },
{ value: "video", label: "🎬 Видео" },
{ value: "document", label: "📄 Документ" },
{ value: "audio", label: "🎵 Аудио" },
{ value: "animation", label: "🎞️ Анимация (GIF)" },
{ value: "voice", label: "🎤 Голосовое" },
{ value: "sticker", label: "😊 Стикер" },
{ value: "video_note", label: "📹 Видео-заметка" },
];
// Локальное состояние для вкладок медиа
let mediaInputMode = "url"; // 'url' | 'file'
let selectedFile = null;
let isUploading = false;
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
const count = broadcastCounts?.[option.value];
if (count != null) return { ...option, label: `${option.label} (${count})` };
@@ -27,6 +52,46 @@
return option;
});
$: showFileIdInput = broadcastMediaType && broadcastMediaType !== "";
$: canSend = !broadcastBusy && (
(broadcastText && broadcastText.trim()) ||
(broadcastMediaType && broadcastMediaFileId && broadcastMediaFileId.trim())
);
// Обработчик загрузки файла
async function handleFileUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
selectedFile = file;
isUploading = true;
try {
// TODO: заменить на реальный эндпоинт загрузки
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/admin/upload-media", {
method: "POST",
body: formData,
});
if (!response.ok) throw new Error("Upload failed");
const data = await response.json();
broadcastStore.updateField({
broadcastMediaFileId: data.file_id || data.url,
});
} catch (error) {
console.error("Upload error:", error);
// Можно показать уведомление об ошибке
} finally {
isUploading = false;
// Сбрасываем input, чтобы можно было загрузить тот же файл снова
event.target.value = "";
}
}
onMount(() => {
broadcastStore.loadCounts();
});
@@ -37,8 +102,10 @@
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
</header>
<div class="admin-card-body">
<div class="admin-form">
<!-- Аудитория -->
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
<AdminSelect
@@ -48,37 +115,335 @@
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
/>
</Label.Root>
<!-- Тип медиа -->
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
<Textarea
class="admin-textarea"
rows="6"
value={broadcastText}
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
<span>{at("broadcast_label_media_type", {}, "Тип медиа")}</span>
<AdminSelect
value={broadcastMediaType}
items={MEDIA_TYPE_OPTIONS}
ariaLabel={at("broadcast_label_media_type", {}, "Тип медиа")}
onValueChange={(value) => {
broadcastStore.updateField({
broadcastMediaType: value,
// Очищаем file_id при смене типа
broadcastMediaFileId: "",
});
}}
/>
</Label.Root>
<div style="display:flex; gap:8px; align-items:center;">
<!-- Блок загрузки медиа (показывается если выбран тип) -->
{#if showFileIdInput}
<div class="media-upload-block">
<!-- Переключатель режимов -->
<div class="media-tabs">
<button
class="media-tab {mediaInputMode === 'url' ? 'active' : ''}"
on:click={() => mediaInputMode = 'url'}
>
<LinkIcon size={14} />
{at("media_tab_url", {}, "По ссылке")}
</button>
<button
class="media-tab {mediaInputMode === 'file' ? 'active' : ''}"
on:click={() => mediaInputMode = 'file'}
>
<Upload size={14} />
{at("media_tab_file", {}, "Загрузить файл")}
</button>
</div>
<!-- Режим: ссылка -->
{#if mediaInputMode === 'url'}
<Label.Root class="admin-field-label">
<span>{at("broadcast_label_file_id", {}, "File ID или URL")}</span>
<small>
{at("broadcast_hint_file_id", {},
"Введите file_id из Telegram или прямую ссылку на файл")}
</small>
<input
class="admin-input"
type="text"
placeholder="https://example.com/image.jpg или file_id"
value={broadcastMediaFileId}
on:input={(e) => broadcastStore.updateField({
broadcastMediaFileId: e.target.value
})}
/>
</Label.Root>
{/if}
<!-- Режим: загрузка файла -->
{#if mediaInputMode === 'file'}
<div class="file-upload-area">
<label class="file-upload-label">
<Upload size={20} />
<span>
{selectedFile
? selectedFile.name
: at("media_upload_hint", {}, "Выберите файл или перетащите сюда")}
</span>
<input
type="file"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
on:change={handleFileUpload}
disabled={isUploading}
/>
</label>
{#if isUploading}
<div class="upload-progress">
{at("media_uploading", {}, "Загрузка...")}
</div>
{/if}
{#if broadcastMediaFileId && selectedFile}
<div class="upload-success">
{at("media_uploaded", {}, "Файл загружен")}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Markdown-редактор -->
<Label.Root class="admin-field-label">
<span>
{at("broadcast_label_text", {}, "Текст сообщения")}
<span class="badge-markdown">Markdown</span>
</span>
<small>
{#if broadcastMediaType}
{at("broadcast_hint_caption", {}, "Этот текст будет подписью (caption) к медиа")}
{:else}
{at("broadcast_hint_markdown", {},
"Поддерживается Markdown и HTML-разметка Telegram")}
{/if}
</small>
<!-- Контейнер редактора с превью -->
<div class="markdown-editor-wrapper">
<MarkdownEditor
{carta}
value={broadcastText}
on:change={(e) => broadcastStore.updateField({
broadcastText: e.detail.value
})}
placeholder={at("broadcast_placeholder", {},
"Введите текст сообщения...")}
/>
<!-- Превью отображается автоматически под редактором -->
</div>
</Label.Root>
<!-- Кнопка отправки -->
<div class="actions-row">
<AdminButton
variant="primary"
onclick={broadcastStore.runBroadcast}
disabled={broadcastBusy || !broadcastText.trim()}
disabled={!canSend}
>
<Send size={14} />
{broadcastBusy
? at("btn_sending", {}, "Отправка...")
: at("btn_queue", {}, "Поставить в очередь")}
</AdminButton>
{#if broadcastResult}
<span class="admin-muted"
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
"broadcast_stat_failed",
{},
"Неудач"
)}: {broadcastResult.failed}</span
>
<span class="admin-muted">
{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} ·
{at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed}
</span>
{/if}
</div>
</div>
</div>
</div>
<style>
/* --- Стили для медиа-блока --- */
.media-upload-block {
margin: 12px 0;
padding: 12px 16px;
background: var(--bg-subtle, #f8f9fa);
border-radius: 8px;
border: 1px solid var(--border, #e9ecef);
}
.media-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
background: var(--bg-surface, #f1f3f5);
border-radius: 6px;
padding: 4px;
}
.media-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #6c757d);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.media-tab:hover {
background: var(--bg-hover, #e9ecef);
}
.media-tab.active {
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
/* --- Стили для загрузки файлов --- */
.file-upload-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-upload-label {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
border: 2px dashed var(--border, #dee2e6);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary, #6c757d);
justify-content: center;
}
.file-upload-label:hover {
border-color: var(--primary, #0d6efd);
background: var(--bg-subtle, #f8f9fa);
}
.file-upload-label input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.upload-progress {
color: var(--text-secondary, #6c757d);
font-size: 13px;
text-align: center;
}
.upload-success {
color: var(--success, #198754);
font-size: 13px;
text-align: center;
}
/* --- Стили для Markdown-редактора --- */
.markdown-editor-wrapper {
margin-top: 6px;
border: 1px solid var(--border, #dee2e6);
border-radius: 8px;
overflow: hidden;
}
.markdown-editor-wrapper :global(.carta-editor) {
min-height: 180px;
padding: 12px 16px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
border: none;
outline: none;
resize: vertical;
width: 100%;
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
}
.markdown-editor-wrapper :global(.carta-editor:focus) {
box-shadow: inset 0 0 0 2px var(--primary, #0d6efd);
}
.markdown-editor-wrapper :global(.carta-preview) {
padding: 16px;
border-top: 1px solid var(--border, #dee2e6);
background: var(--bg-subtle, #f8f9fa);
font-size: 14px;
line-height: 1.7;
max-height: 300px;
overflow-y: auto;
}
.markdown-editor-wrapper :global(.carta-preview) h1,
.markdown-editor-wrapper :global(.carta-preview) h2,
.markdown-editor-wrapper :global(.carta-preview) h3 {
margin: 12px 0 8px;
}
.markdown-editor-wrapper :global(.carta-preview) code {
background: var(--bg-code, #e9ecef);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.markdown-editor-wrapper :global(.carta-preview) pre {
background: var(--bg-code-block, #1e1e1e);
color: #d4d4d4;
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
}
/* --- Общие стили --- */
.badge-markdown {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
background: var(--primary-subtle, #e7f1ff);
color: var(--primary, #0d6efd);
margin-left: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.actions-row {
display: flex;
gap: 16px;
align-items: center;
margin-top: 8px;
flex-wrap: wrap;
}
.admin-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border, #dee2e6);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background: var(--bg-white, #ffffff);
color: var(--text-primary, #212529);
}
.admin-input:focus {
border-color: var(--primary, #0d6efd);
outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.1);
}
</style>
@@ -1,3 +1,5 @@
// frontend/src/lib/admin/stores/broadcastStore.js
import { writable } from "svelte/store";
export function createBroadcastStore({ api, onToast, at }) {
@@ -10,6 +12,10 @@ export function createBroadcastStore({ api, onToast, at }) {
const state = writable({
broadcastTarget: "all",
broadcastText: "",
// --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
broadcastMediaType: "", // например "photo", "video" и т.д.
broadcastMediaFileId: "", // file_id или URL
// ---------------------------
broadcastBusy: false,
broadcastResult: null,
broadcastCounts: cachedCounts?.counts || null,
@@ -101,25 +107,46 @@ export function createBroadcastStore({ api, onToast, at }) {
}
async function runBroadcast() {
// Собираем текущие значения из стора
let text = "";
let target = "";
let mediaType = "";
let mediaFileId = "";
state.update((s) => {
text = s.broadcastText;
target = s.broadcastTarget;
mediaType = s.broadcastMediaType || "";
mediaFileId = s.broadcastMediaFileId || "";
s.broadcastBusy = true;
s.broadcastResult = null;
return s;
});
try {
// Формируем payload
const payload = {
target,
text: text.trim(),
};
// Добавляем медиа, только если есть и тип, и file_id
if (mediaType && mediaFileId && mediaFileId.trim()) {
payload.media_type = mediaType;
payload.media_file_id = mediaFileId.trim();
}
const res = await api("/admin/broadcast", {
method: "POST",
body: JSON.stringify({ target, text }),
body: JSON.stringify(payload),
});
if (res?.ok) {
state.update((s) => ({
...s,
broadcastText: "",
broadcastMediaType: "",
broadcastMediaFileId: "",
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
}));
onToast(at("broadcast_started", {}, "Рассылка запущена"));
@@ -135,6 +162,15 @@ export function createBroadcastStore({ api, onToast, at }) {
state.update((s) => ({ ...s, ...fields }));
}
// Дополнительный метод для ручного сброса медиа (например, после загрузки файла)
function resetMedia() {
state.update((s) => ({
...s,
broadcastMediaType: "",
broadcastMediaFileId: "",
}));
}
return {
subscribe: state.subscribe,
set: state.set,
@@ -142,6 +178,7 @@ export function createBroadcastStore({ api, onToast, at }) {
runBroadcast,
updateField,
loadCounts,
resetMedia,
BROADCAST_TARGET_OPTIONS,
};
}
}
+1666
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -18,5 +18,8 @@
"bench:bot": "python scripts/perf_benchmarks.py",
"test": "pytest -q",
"check": "npm run lint && npm test && npm run build:webapp"
},
"dependencies": {
"carta-md": "^4.11.2"
}
}