Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d43606446 | ||
|
|
5ca1ecfce6 | ||
|
|
86ac925b19 |
@@ -1,7 +1,7 @@
|
|||||||
name: Dev images
|
name: Dev images
|
||||||
|
|
||||||
# On every push to the dev branch, build all three images and push them to
|
# On every push to the dev branch, build all three images and push them to
|
||||||
# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI.
|
# GHCR and Docker Hub tagged `dev`.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -22,5 +22,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tag_mode: dev
|
tag_mode: dev
|
||||||
publish_dockerhub: false
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
stages:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
workflow:
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_BRANCH == "dev"'
|
|
||||||
- when: never
|
|
||||||
|
|
||||||
variables:
|
|
||||||
DOCKER_BUILDKIT: "1"
|
|
||||||
DOCKER_DRIVER: overlay2
|
|
||||||
DOCKER_HOST: tcp://docker:2375
|
|
||||||
DOCKER_TLS_CERTDIR: ""
|
|
||||||
GIT_DEPTH: "0"
|
|
||||||
|
|
||||||
docker-dev:
|
|
||||||
stage: docker
|
|
||||||
image: docker:27.5.1
|
|
||||||
services:
|
|
||||||
- name: docker:27.5.1-dind
|
|
||||||
alias: docker
|
|
||||||
interruptible: true
|
|
||||||
parallel:
|
|
||||||
matrix:
|
|
||||||
- TARGET: backend
|
|
||||||
IMAGE: remnawave-minishop-backend
|
|
||||||
- TARGET: worker
|
|
||||||
IMAGE: remnawave-minishop-worker
|
|
||||||
- TARGET: frontend
|
|
||||||
IMAGE: remnawave-minishop-frontend
|
|
||||||
before_script:
|
|
||||||
- apk add --no-cache git
|
|
||||||
- test -n "$DOCKERHUB_USERNAME"
|
|
||||||
- test -n "$DOCKERHUB_TOKEN"
|
|
||||||
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
|
|
||||||
- docker buildx create --name gitlab-builder --driver docker-container --use
|
|
||||||
- docker buildx inspect --bootstrap
|
|
||||||
script:
|
|
||||||
- git fetch origin dev --tags
|
|
||||||
- |
|
|
||||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
|
||||||
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
|
||||||
if [ -z "$provenance" ]; then
|
|
||||||
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
|
||||||
provenance="official"
|
|
||||||
else
|
|
||||||
provenance="custom"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
docker buildx build \
|
|
||||||
--load \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
--file deploy/docker/Dockerfile \
|
|
||||||
--target "$TARGET" \
|
|
||||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
|
||||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
|
||||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
|
||||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
|
||||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
|
||||||
.
|
|
||||||
- git fetch origin dev --tags
|
|
||||||
- |
|
|
||||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
|
||||||
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
|
|
||||||
@@ -139,15 +139,3 @@ IMAGE_TAG=3.1.0 docker compose up -d
|
|||||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||||
|
|
||||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login ghcr.io
|
|
||||||
docker login docker.io
|
|
||||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддержать проект
|
|
||||||
|
|
||||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from bot.services.settings_override_service import (
|
|||||||
current_value,
|
current_value,
|
||||||
update_overrides,
|
update_overrides,
|
||||||
)
|
)
|
||||||
from bot.utils import MessageContent, send_message_via_queue, SUPPORTED_PARAMS
|
from bot.utils import MessageContent, send_message_via_queue
|
||||||
from bot.utils.message_queue import get_queue_manager
|
from bot.utils.message_queue import get_queue_manager
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from bot.utils.ttl_cache import AsyncTTLCache
|
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_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
||||||
BROADCAST_TARGETS = {
|
BROADCAST_TARGETS = {
|
||||||
@@ -172,31 +167,10 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
text = str(payload.get("text") or "").strip()
|
text = str(payload.get("text") or "").strip()
|
||||||
target = str(payload.get("target") or "all").strip().lower()
|
target = str(payload.get("target") or "all").strip().lower()
|
||||||
|
|
||||||
# --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
|
|
||||||
media_type = payload.get("media_type") # например "photo", "video", "document" и т.д.
|
|
||||||
media_file_id = payload.get("media_file_id") # file_id или URL (строка)
|
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
if target not in BROADCAST_TARGETS:
|
|
||||||
target = "all"
|
|
||||||
|
|
||||||
# --- ЛОГИКА ФОРМИРОВАНИЯ MessageContent ---
|
|
||||||
if media_type and media_file_id:
|
|
||||||
# Отправляем медиа: текст становится подписью (caption)
|
|
||||||
if media_type not in SUPPORTED_PARAMS:
|
|
||||||
return _error(400, "unsupported_media_type")
|
|
||||||
content = MessageContent(
|
|
||||||
content_type=media_type,
|
|
||||||
file_id=media_file_id,
|
|
||||||
text=text, # будет использован как caption
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Текстовое сообщение
|
|
||||||
if not text:
|
if not text:
|
||||||
return _error(400, "empty_text")
|
return _error(400, "empty_text")
|
||||||
content = MessageContent(content_type="text", text=text)
|
if target not in BROADCAST_TARGETS:
|
||||||
# --------------------------------------
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
queue_manager = get_queue_manager()
|
||||||
if not queue_manager:
|
if not queue_manager:
|
||||||
@@ -227,11 +201,10 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
failed = 0
|
failed = 0
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
try:
|
try:
|
||||||
# Передаём content (уже с нужным типом), а остальные параметры остаются
|
|
||||||
await send_message_via_queue(
|
await send_message_via_queue(
|
||||||
queue_manager,
|
queue_manager,
|
||||||
int(uid),
|
int(uid),
|
||||||
content,
|
MessageContent(content_type="text", text=text),
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
)
|
)
|
||||||
@@ -245,13 +218,14 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
{
|
{
|
||||||
"user_id": actor_id,
|
"user_id": actor_id,
|
||||||
"event_type": "admin_broadcast_webapp",
|
"event_type": "admin_broadcast_webapp",
|
||||||
"content": f"target={target} sent={sent} failed={failed} "
|
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
|
||||||
f"type={content.content_type} text={text[:120]}",
|
"is_admin_event": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||||
|
|
||||||
|
|
||||||
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||||
"""Return how many users each broadcast audience currently resolves to."""
|
"""Return how many users each broadcast audience currently resolves to."""
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
@@ -266,67 +240,3 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res
|
|||||||
)
|
)
|
||||||
|
|
||||||
return _ok({"counts": counts})
|
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,8 +64,6 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
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/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_post("/api/admin/sync", admin_sync_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/ads", admin_ads_list_route)
|
router.add_get("/api/admin/ads", admin_ads_list_route)
|
||||||
|
|||||||
Generated
+23
-1413
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,6 @@
|
|||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4"
|
||||||
"carta-md": "^4.11.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Textarea } from "$components/ui/index.js";
|
||||||
|
import { Send } from "$components/ui/icons.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
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 { Label } from "$components/ui/primitives.js";
|
||||||
import { Carta, MarkdownEditor } from "carta-md";
|
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||||
import "carta-md/default.css"; // Базовые стили
|
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
const broadcastStore = getContext("broadcastStore");
|
const broadcastStore = getContext("broadcastStore");
|
||||||
@@ -12,8 +11,6 @@
|
|||||||
$: ({
|
$: ({
|
||||||
broadcastTarget,
|
broadcastTarget,
|
||||||
broadcastText,
|
broadcastText,
|
||||||
broadcastMediaType,
|
|
||||||
broadcastMediaFileId,
|
|
||||||
broadcastBusy,
|
broadcastBusy,
|
||||||
broadcastResult,
|
broadcastResult,
|
||||||
broadcastCounts,
|
broadcastCounts,
|
||||||
@@ -22,29 +19,7 @@
|
|||||||
|
|
||||||
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
||||||
|
|
||||||
// Настройка Carta
|
// Append the resolved audience size to each option once counts are loaded.
|
||||||
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) => {
|
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
|
||||||
const count = broadcastCounts?.[option.value];
|
const count = broadcastCounts?.[option.value];
|
||||||
if (count != null) return { ...option, label: `${option.label} (${count})` };
|
if (count != null) return { ...option, label: `${option.label} (${count})` };
|
||||||
@@ -52,46 +27,6 @@
|
|||||||
return option;
|
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(() => {
|
onMount(() => {
|
||||||
broadcastStore.loadCounts();
|
broadcastStore.loadCounts();
|
||||||
});
|
});
|
||||||
@@ -102,10 +37,8 @@
|
|||||||
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
|
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
|
||||||
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
|
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="admin-card-body">
|
<div class="admin-card-body">
|
||||||
<div class="admin-form">
|
<div class="admin-form">
|
||||||
<!-- Аудитория -->
|
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
||||||
<AdminSelect
|
<AdminSelect
|
||||||
@@ -115,336 +48,37 @@
|
|||||||
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
||||||
/>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
|
||||||
<!-- Тип медиа -->
|
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("broadcast_label_media_type", {}, "Тип медиа")}</span>
|
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
||||||
<AdminSelect
|
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||||
value={broadcastMediaType}
|
<Textarea
|
||||||
items={MEDIA_TYPE_OPTIONS}
|
class="admin-textarea"
|
||||||
ariaLabel={at("broadcast_label_media_type", {}, "Тип медиа")}
|
rows="6"
|
||||||
onValueChange={(value) => {
|
|
||||||
broadcastStore.updateField({
|
|
||||||
broadcastMediaType: value,
|
|
||||||
// Очищаем file_id при смене типа
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Label.Root>
|
|
||||||
|
|
||||||
<!-- Блок загрузки медиа (показывается если выбран тип) -->
|
|
||||||
{#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}
|
value={broadcastText}
|
||||||
on:change={(e) => broadcastStore.updateField({
|
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
||||||
broadcastText: e.detail.value
|
|
||||||
})}
|
|
||||||
placeholder={at("broadcast_placeholder", {},
|
|
||||||
"Введите текст сообщения...")}
|
|
||||||
/>
|
/>
|
||||||
<!-- Превью отображается автоматически под редактором -->
|
|
||||||
</div>
|
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
<!-- Кнопка отправки -->
|
|
||||||
<div class="actions-row">
|
|
||||||
<AdminButton
|
<AdminButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onclick={broadcastStore.runBroadcast}
|
onclick={broadcastStore.runBroadcast}
|
||||||
disabled={!canSend}
|
disabled={broadcastBusy || !broadcastText.trim()}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
{broadcastBusy
|
{broadcastBusy
|
||||||
? at("btn_sending", {}, "Отправка...")
|
? at("btn_sending", {}, "Отправка...")
|
||||||
: at("btn_queue", {}, "Поставить в очередь")}
|
: at("btn_queue", {}, "Поставить в очередь")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
|
|
||||||
{#if broadcastResult}
|
{#if broadcastResult}
|
||||||
<span class="admin-muted">
|
<span class="admin-muted"
|
||||||
{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} ·
|
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
|
||||||
{at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed}
|
"broadcast_stat_failed",
|
||||||
</span>
|
{},
|
||||||
|
"Неудач"
|
||||||
|
)}: {broadcastResult.failed}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Исправлено: :global(...) перенесён в конец селектора */
|
|
||||||
.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,5 +1,3 @@
|
|||||||
// frontend/src/lib/admin/stores/broadcastStore.js
|
|
||||||
|
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
export function createBroadcastStore({ api, onToast, at }) {
|
export function createBroadcastStore({ api, onToast, at }) {
|
||||||
@@ -12,10 +10,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
const state = writable({
|
const state = writable({
|
||||||
broadcastTarget: "all",
|
broadcastTarget: "all",
|
||||||
broadcastText: "",
|
broadcastText: "",
|
||||||
// --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
|
|
||||||
broadcastMediaType: "", // например "photo", "video" и т.д.
|
|
||||||
broadcastMediaFileId: "", // file_id или URL
|
|
||||||
// ---------------------------
|
|
||||||
broadcastBusy: false,
|
broadcastBusy: false,
|
||||||
broadcastResult: null,
|
broadcastResult: null,
|
||||||
broadcastCounts: cachedCounts?.counts || null,
|
broadcastCounts: cachedCounts?.counts || null,
|
||||||
@@ -107,46 +101,25 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runBroadcast() {
|
async function runBroadcast() {
|
||||||
// Собираем текущие значения из стора
|
|
||||||
let text = "";
|
let text = "";
|
||||||
let target = "";
|
let target = "";
|
||||||
let mediaType = "";
|
|
||||||
let mediaFileId = "";
|
|
||||||
|
|
||||||
state.update((s) => {
|
state.update((s) => {
|
||||||
text = s.broadcastText;
|
text = s.broadcastText;
|
||||||
target = s.broadcastTarget;
|
target = s.broadcastTarget;
|
||||||
mediaType = s.broadcastMediaType || "";
|
|
||||||
mediaFileId = s.broadcastMediaFileId || "";
|
|
||||||
s.broadcastBusy = true;
|
s.broadcastBusy = true;
|
||||||
s.broadcastResult = null;
|
s.broadcastResult = null;
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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", {
|
const res = await api("/admin/broadcast", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ target, text }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
broadcastText: "",
|
broadcastText: "",
|
||||||
broadcastMediaType: "",
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
|
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
|
||||||
}));
|
}));
|
||||||
onToast(at("broadcast_started", {}, "Рассылка запущена"));
|
onToast(at("broadcast_started", {}, "Рассылка запущена"));
|
||||||
@@ -162,15 +135,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
state.update((s) => ({ ...s, ...fields }));
|
state.update((s) => ({ ...s, ...fields }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дополнительный метод для ручного сброса медиа (например, после загрузки файла)
|
|
||||||
function resetMedia() {
|
|
||||||
state.update((s) => ({
|
|
||||||
...s,
|
|
||||||
broadcastMediaType: "",
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: state.subscribe,
|
subscribe: state.subscribe,
|
||||||
set: state.set,
|
set: state.set,
|
||||||
@@ -178,7 +142,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
runBroadcast,
|
runBroadcast,
|
||||||
updateField,
|
updateField,
|
||||||
loadCounts,
|
loadCounts,
|
||||||
resetMedia,
|
|
||||||
BROADCAST_TARGET_OPTIONS,
|
BROADCAST_TARGET_OPTIONS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,6 @@ export {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Lock,
|
Lock,
|
||||||
Link,
|
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Mail,
|
Mail,
|
||||||
Map,
|
Map,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -33,7 +35,9 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container():
|
|||||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert script.startswith("#!/bin/sh")
|
assert script.startswith("#!/bin/sh")
|
||||||
assert "raw.githubusercontent.com" in script
|
raw_url_template = re.search(r"printf '(https://[^']+)' \"\$repo\"", script)
|
||||||
|
assert raw_url_template is not None
|
||||||
|
assert urlsplit(raw_url_template.group(1)).hostname == "raw.githubusercontent.com"
|
||||||
assert "git clone" not in script
|
assert "git clone" not in script
|
||||||
assert "backend python backend/scripts/import_legacy.py" in script
|
assert "backend python backend/scripts/import_legacy.py" in script
|
||||||
assert "Optional source Remnashop .env path" in script
|
assert "Optional source Remnashop .env path" in script
|
||||||
|
|||||||
Reference in New Issue
Block a user