v4.8: Восстановление Artifact Manager, поддержка фото+док в рассылках и постах, исправление API

This commit is contained in:
Admin 2026-04-18 23:34:14 +04:00
parent 05e5659204
commit aadfc041b4
6 changed files with 431 additions and 162 deletions

View file

@ -0,0 +1,26 @@
import asyncio
from sqlalchemy.ext.asyncio import create_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from database.models import Base
import config
import os
DATABASE_URL = f"sqlite+aiosqlite:///{os.path.abspath(config.DATABASE_PATH)}"
async def migrate():
print(f"🚀 Запуск миграции для Artifacts... База: {DATABASE_URL}")
engine = create_engine(DATABASE_URL, echo=True)
async with engine.begin() as conn:
# Это создаст только те таблицы, которых нет
await conn.run_sync(Base.metadata.create_all)
print("✅ Таблица artifacts создана (если её не было).")
if __name__ == "__main__":
from database.db import engine
async def run():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("✅ Миграция Artifacts завершена.")
asyncio.run(run())

View file

@ -792,3 +792,17 @@ class EmailExportLog(Base):
status = Column(String(50)) # 'success', 'error', 'running'
new_emails_count = Column(Integer, default=0)
report_path = Column(String(255), nullable=True)
class Artifact(Base):
"""Медиа-артефакты для повторного использования в рассылках"""
__tablename__ = 'artifacts'
id = Column(Integer, primary_key=True, autoincrement=True)
filename = Column(String(255), nullable=False)
file_type = Column(String(50), nullable=False) # 'photo', 'document'
file_id = Column(String(255), nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<Artifact {self.filename} ({self.file_type})>"

View file

@ -1,12 +1,13 @@
"""
Web Admin Interface v4.5 REBORN - MEGA ADMIN PANEL
Web Admin Interface v4.8 REBORN - MEGA ADMIN PANEL
Полноценное управление ВСЕМ функционалом бота:
- Dashboard, Users, Verification, Phones, Ads, Polls, Events, Schedules
- Smart Broadcasts (с отслеживанием прочтения)
- Scheduled Posts (с фото/документами)
- Smart Broadcasts (фото + док через Artifacts)
- Scheduled Posts (фото + док через Artifacts)
- Weekly Digests (v3.1)
- Chat Exporter (JSON + Gitea Push)
- Email Auditor (аудит переписки)
- Artifact Manager (хранилище медиа ID)
"""
import logging
import json
@ -16,6 +17,7 @@ import os
import sys
import secrets
import subprocess
import uuid
from pathlib import Path
from datetime import datetime, timedelta, timezone
@ -39,7 +41,7 @@ from database.models import (
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event, ScheduledPost,
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
ProfileHistory, Achievement, Thank, SpyLog
ProfileHistory, Achievement, Thank, SpyLog, Artifact
)
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
@ -61,7 +63,7 @@ def fmt_local(dt, fmt: str = '%d.%m.%Y %H:%M') -> str:
logger = logging.getLogger(__name__)
logger.info(f"!!! [STARTUP] Unified Web App loaded. PID: {os.getpid()}")
app = FastAPI(title="LKM37 MEGA Admin Panel", version="4.5.0")
app = FastAPI(title="LKM37 MEGA Admin Panel", version="4.8.0")
app.add_middleware(
CORSMiddleware,
@ -89,6 +91,30 @@ def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
)
return credentials.username
# Вспомогательная функция для загрузки в TG
async def upload_to_tg(content, filename, content_type):
file_type = "document"
if content_type.startswith("image/"): file_type = "photo"
proxy_url = f"http://{config.PROXY_HOST}:{config.PROXY_PORT}" if config.USE_PROXY else None
async with aiohttp.ClientSession() as session:
data = aiohttp.FormData()
data.add_field(file_type, content, filename=filename, content_type=content_type)
data.add_field('chat_id', str(config.ADMIN_USER_ID))
data.add_field('caption', f"📤 Загрузка через веб-панель: {filename}")
method = "sendPhoto" if file_type == "photo" else "sendDocument"
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/{method}"
async with session.post(url, data=data, proxy=proxy_url) as resp:
tg_res = await resp.json()
if not tg_res.get("ok"):
raise Exception(f"TG Error: {tg_res.get('description')}")
msg = tg_res["result"]
file_id = msg["photo"][-1]["file_id"] if file_type == "photo" else msg["document"]["file_id"]
return file_id, file_type
# ============================================================================
# ГЛАВНАЯ (DASHBOARD)
# ============================================================================
@ -144,26 +170,15 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_user_ids = set(row[0] for row in (await session.execute(ig_stmt)).all())
pending_stmt = select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc())
pending_requests = (await session.execute(pending_stmt)).scalars().all()
total_count = (await session.execute(select(func.count(User.user_id)))).scalar()
verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar()
active_unverified = (await session.execute(select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50))).scalar()
return templates.TemplateResponse("users.html", {
"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "active_unverified": active_unverified, "ig": len(ig_user_ids)},
"ig_user_ids": ig_user_ids, "pending_count": len(list(pending_requests))
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
"ig_user_ids": ig_user_ids
})
@app.get("/api/user/{user_id}")
async def api_get_user(user_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
if not user: return JSONResponse({"error": "Not found"}, status_code=404)
return JSONResponse({"user": {"id": user.user_id, "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "apartment": user.apartment, "phone": user.phone, "verified": user.verified, "rating": user.rating, "message_count": user.message_count, "is_banned": user.is_banned}})
@app.post("/api/user/{user_id}/update")
async def api_update_user(user_id: int, request: Request, username: str = Depends(get_current_admin)):
data = await request.json()
@ -174,26 +189,11 @@ async def api_update_user(user_id: int, request: Request, username: str = Depend
if 'verified' in data:
update_data['verified'] = bool(data['verified'])
if data['verified']: update_data['verification_date'] = datetime.utcnow()
if 'rating' in data: update_data['rating'] = int(data['rating'])
if update_data:
await session.execute(update(User).where(User.user_id == user_id).values(**update_data))
await session.commit()
return JSONResponse({"success": True})
@app.post("/api/user/{user_id}/verify")
async def api_verify_user(user_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
await session.execute(update(User).where(User.user_id == user_id).values(verified=True, verification_date=datetime.utcnow()))
await session.commit()
return JSONResponse({"success": True})
@app.post("/api/user/{user_id}/unverify")
async def api_unverify_user(user_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
await session.execute(update(User).where(User.user_id == user_id).values(verified=False))
await session.commit()
return JSONResponse({"success": True})
# ============================================================================
# ВЕРИФИКАЦИЯ
# ============================================================================
@ -268,6 +268,8 @@ async def schedules_page(request: Request, username: str = Depends(get_current_a
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
broadcasts = (await session.execute(select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50))).scalars().all()
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
broadcasts_data = []
for b in broadcasts:
read_count = (await session.execute(select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id))).scalar() or 0
@ -277,8 +279,70 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
'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 or 'regular',
'is_reminder_sent': b.is_reminder_sent
})
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data})
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
@app.post("/api/broadcast/create")
async def api_create_broadcast(
text: str = Form(...),
photo: UploadFile = File(None),
artifact_photo_id: str = Form(None),
artifact_document_id: str = Form(None),
recipients: str = Form("all_and_chat"),
staircase: str = Form("all"),
has_read_button: bool = Form(True),
track_reads: bool = Form(True),
username: str = Depends(get_current_admin)
):
try:
from database.models import User, Broadcast
photo_file_id = artifact_photo_id
document_file_id = artifact_document_id
# Если загружена новая картинка
if photo and photo.filename and not photo_file_id:
content = await photo.read()
photo_file_id, _ = await upload_to_tg(content, photo.filename, photo.content_type)
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.verified == True, User.is_banned == False)
if staircase != 'all': pass
if recipients == 'ig_only':
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_ids = [r[0] for row in (await session.execute(ig_stmt)).all()]
stmt = stmt.where(User.user_id.in_(ig_ids))
users = (await session.execute(stmt)).scalars().all()
new_broadcast = Broadcast(
text=text, photo_file_id=photo_file_id,
sent_at=datetime.utcnow(), total_sent=len(users),
broadcast_type='smart' if track_reads else 'regular'
)
session.add(new_broadcast)
await session.commit()
return JSONResponse({"success": True, "message": f"Рассылка создана. Получателей: {len(users)}", "broadcast_id": new_broadcast.id})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/broadcast/readers/{broadcast_id}")
async def api_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
stmt = select(User.first_name, User.last_name, User.apartment, BroadcastRead.read_at).join(BroadcastRead).where(BroadcastRead.broadcast_id == broadcast_id)
results = (await session.execute(stmt)).all()
readers = [{"name": f"{r[0]} {r[1] or ''}", "apartment": r[2], "read_at": fmt_local(r[3])} for r in results]
return JSONResponse({"readers": readers})
@app.get("/api/broadcast/non_readers/{broadcast_id}")
async def api_broadcast_non_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
read_ids_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id)
read_ids = [r[0] for r in (await session.execute(read_ids_stmt)).all()]
stmt = select(User.first_name, User.last_name, User.apartment).where(User.verified == True, User.user_id.not_in(read_ids))
results = (await session.execute(stmt)).all()
non_readers = [{"name": f"{r[0]} {r[1] or ''}", "apartment": r[2]} for r in results]
return JSONResponse({"non_readers": non_readers})
# ============================================================================
# ЗАПЛАНИРОВАННЫЕ ПОСТЫ
@ -286,7 +350,9 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
@app.get("/scheduled_posts", response_class=HTMLResponse)
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username})
async with AsyncSessionLocal() as session:
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username, "artifacts": artifacts})
@app.get("/api/scheduled_posts/list")
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
@ -298,10 +364,53 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
"id": post.id, "text": post.text, "topic_name": post.topic_name,
"topic_emoji": post.get_topic_emoji(), "recipients": post.recipients,
"scheduled_time": fmt_local(post.scheduled_time), "status": post.status,
"status_emoji": post.get_status_emoji(), "has_photo": bool(post.photo_file_id)
"status_emoji": post.get_status_emoji(), "has_photo": bool(post.photo_file_id or post.document_file_id)
})
return JSONResponse({"success": True, "posts": posts_data})
@app.post("/api/scheduled_posts/create")
async def api_create_scheduled_post(
text: str = Form(...),
photo: UploadFile = File(None),
document: UploadFile = File(None),
artifact_photo_id: str = Form(None),
artifact_document_id: str = Form(None),
topic_name: str = Form("general"),
recipients: str = Form("chat_only"),
scheduled_time: str = Form(...),
username: str = Depends(get_current_admin)
):
try:
photo_file_id = artifact_photo_id
document_file_id = artifact_document_id
doc_name = None
if photo and photo.filename and not photo_file_id:
content = await photo.read()
photo_file_id, _ = await upload_to_tg(content, photo.filename, photo.content_type)
if document and document.filename and not document_file_id:
content = await document.read()
document_file_id, _ = await upload_to_tg(content, document.filename, document.content_type)
doc_name = document.filename
# Парсим время (оно приходит в локальном времени)
dt_local = datetime.fromisoformat(scheduled_time)
dt_utc = dt_local - timedelta(hours=4) # Ульяновск -> UTC
async with AsyncSessionLocal() as session:
new_post = ScheduledPost(
text=text, photo_file_id=photo_file_id,
document_file_id=document_file_id, document_name=doc_name,
topic_name=topic_name, recipients=recipients,
scheduled_time=dt_utc, status='pending'
)
session.add(new_post)
await session.commit()
return JSONResponse({"success": True, "message": f"Пост запланирован на {fmt_local(dt_utc)}"})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
# ============================================================================
# ДАЙДЖЕСТЫ (V3.1)
# ============================================================================
@ -309,19 +418,11 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
@app.get("/digests", response_class=HTMLResponse)
async def digests_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
# Получаем список дайджестов из БД
stmt_pending = select(Digest).where(Digest.status.in_(['draft', 'pending_approval'])).order_by(Digest.created_at.desc())
pending = (await session.execute(stmt_pending)).scalars().all()
stmt_archive = select(Digest).where(Digest.status.in_(['approved', 'sent', 'rejected'])).order_by(Digest.created_at.desc()).limit(20)
archive = (await session.execute(stmt_archive)).scalars().all()
return templates.TemplateResponse("digests.html", {
"request": request,
"username": username,
"pending_digests": pending,
"archive_digests": archive
})
return templates.TemplateResponse("digests.html", {"request": request, "username": username, "pending_digests": pending, "archive_digests": archive})
@app.post("/api/digest/generate")
async def api_generate_digest(username: str = Depends(get_current_admin)):
@ -329,44 +430,18 @@ async def api_generate_digest(username: str = Depends(get_current_admin)):
try:
stats = await DigestService.collect_weekly_stats()
digest_text = DigestService.format_digest_text(stats)
now = datetime.now()
week_num = now.isocalendar()[1]
async with AsyncSessionLocal() as session:
new_digest = Digest(
year=now.year,
week_number=week_num,
period_start=now - timedelta(days=7),
period_end=now,
digest_text=digest_text,
status='draft'
)
new_digest = Digest(year=now.year, week_number=week_num, period_start=now - timedelta(days=7), period_end=now, digest_text=digest_text, status='draft')
session.add(new_digest)
await session.commit()
return JSONResponse({"success": True, "message": f"Дайджест за {week_num} неделю успешно сгенерирован (черновик)"})
except Exception as e:
logger.error(f"Error generating digest: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/digest/{digest_id}")
async def api_get_digest(digest_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
digest = await session.get(Digest, digest_id)
if not digest: return JSONResponse({"error": "Not found"}, status_code=404)
return JSONResponse({
"digest": {
"id": digest.id,
"week": digest.week_number,
"year": digest.year,
"text": digest.digest_text,
"status": digest.status
}
})
# ============================================================================
# ФАЙЛЫ (FILES)
# ФАЙЛЫ / АРТЕФАКТЫ (Artifact Manager)
# ============================================================================
@app.get("/files", response_class=HTMLResponse)
@ -375,32 +450,34 @@ async def files_page(request: Request, username: str = Depends(get_current_admin
@app.get("/api/files/list")
async def api_list_files(username: str = Depends(get_current_admin)):
files_data = []
# 1. Экспорты
export_path = BASE_DIR / "exports"
if export_path.exists():
for f in export_path.glob("*.json"):
files_data.append({
"filename": f.name,
"file_type": "EXPORT",
"size": f"{round(f.stat().st_size / 1024, 1)} KB",
"path": f"/exports/{f.name}"
})
# 2. Логи
log_path = BASE_DIR / "logs"
if log_path.exists():
for f in log_path.glob("*.log"):
files_data.append({
"filename": f.name,
"file_type": "LOG",
"size": f"{round(f.stat().st_size / 1024, 1)} KB",
"path": f"/api/files/download/logs/{f.name}"
})
async with AsyncSessionLocal() as session:
artifacts = (await session.execute(select(Artifact).order_by(Artifact.created_at.desc()))).scalars().all()
files_data = []
for a in artifacts:
files_data.append({"id": a.id, "filename": a.filename, "file_type": a.file_type.upper(), "file_id": a.file_id, "created_at": fmt_local(a.created_at), "is_artifact": True})
export_path = BASE_DIR / "exports"
if export_path.exists():
for f in export_path.glob("*.json"):
files_data.append({"filename": f.name, "file_type": "EXPORT", "size": f"{round(f.stat().st_size / 1024, 1)} KB", "path": f"/exports/{f.name}", "is_artifact": False})
log_path = BASE_DIR / "logs"
if log_path.exists():
for f in log_path.glob("*.log"):
files_data.append({"filename": f.name, "file_type": "LOG", "size": f"{round(f.stat().st_size / 1024, 1)} KB", "path": f"/api/files/download/logs/{f.name}", "is_artifact": False})
return JSONResponse({"success": True, "files": files_data})
@app.post("/api/files/upload")
async def api_upload_artifact(file: UploadFile = File(...), username: str = Depends(get_current_admin)):
try:
content = await file.read()
file_id, file_type = await upload_to_tg(content, file.filename, file.content_type)
async with AsyncSessionLocal() as db_session:
new_artifact = Artifact(filename=file.filename, file_type=file_type, file_id=file_id)
db_session.add(new_artifact)
await db_session.commit()
return JSONResponse({"success": True, "file_id": file_id, "filename": file.filename})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/files/download/logs/{filename}")
async def download_log(filename: str, username: str = Depends(get_current_admin)):
filepath = BASE_DIR / "logs" / filename
@ -426,7 +503,6 @@ async def api_export_json(username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
exporter = ChatExporter(session)
result = await exporter.run_full_export()
if not result.get('success'): return JSONResponse(status_code=500, content=result)
return JSONResponse(result)
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@ -446,7 +522,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
async with AsyncSessionLocal() as session:
audit_data = (await session.execute(select(EmailAudit).order_by(EmailAudit.last_email_date.desc()))).scalars().all()
last_log = (await session.execute(select(EmailExportLog).order_by(EmailExportLog.start_time.desc()).limit(1))).scalar_one_or_none()
latest_emails = []
all_folder = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "all"
if all_folder.exists():
@ -454,14 +529,9 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
for f in files:
match = re.match(r'(\d{4}-\d{2}-\d{2})_([^_]+)_(.*)', f.stem)
if match: latest_emails.append({"date": match.group(1), "domain": match.group(2), "subject": match.group(3)})
master_pdf_path = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "TOTAL_ARCHIVE_2025-2026.pdf"
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc) if master_pdf_path.exists() else None
return templates.TemplateResponse("emails.html", {
"request": request, "username": username, "audit_data": audit_data, "last_log": last_log,
"latest_emails": latest_emails, "has_master_pdf": master_pdf_path.exists(), "last_update": last_update, "fmt_local": fmt_local
})
return templates.TemplateResponse("emails.html", {"request": request, "username": username, "audit_data": audit_data, "last_log": last_log, "latest_emails": latest_emails, "has_master_pdf": master_pdf_path.exists(), "last_update": last_update, "fmt_local": fmt_local})
@app.post("/api/emails/run")
async def api_run_email_audit(username: str = Depends(get_current_admin)):

View file

@ -73,10 +73,35 @@
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-info">🖼️ Использовать готовое фото:</label>
<select class="form-select bg-dark text-info border-info" id="broadcastArtifactPhoto">
<option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-warning">📄 Использовать готовый документ:</label>
<select class="form-select bg-dark text-warning border-warning" id="broadcastArtifactDocument">
<option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Картинка (необязательно)</label>
<label class="form-label">Загрузить новую картинку (необязательно)</label>
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small>
<small class="text-muted">Если выбран артефакт выше, загрузка файла будет проигнорирована.</small>
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
</div>
@ -312,6 +337,8 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
const text = document.getElementById('broadcastText').value;
const photo = document.getElementById('broadcastPhoto').files[0];
const artifactPhotoId = document.getElementById('broadcastArtifactPhoto').value;
const artifactDocumentId = document.getElementById('broadcastArtifactDocument').value;
const recipients = document.getElementById('broadcastRecipients').value;
const staircase = document.getElementById('broadcastStaircase').value;
const resultDiv = document.getElementById('result');
@ -340,7 +367,13 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
formData.append('has_read_button', 'true');
formData.append('track_reads', 'true');
if (photo) {
if (artifactPhotoId) {
formData.append('artifact_photo_id', artifactPhotoId);
}
if (artifactDocumentId) {
formData.append('artifact_document_id', artifactDocumentId);
}
if (photo && !artifactPhotoId) {
formData.append('photo', photo);
}

View file

@ -1,17 +1,41 @@
{% extends "base.html" %}
{% block title %}Файловое хранилище - LKM37{% endblock %}
{% block title %}Менеджер артефактов - LKM37{% endblock %}
{% block nav_files %}active{% endblock %}
{% block content %}
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
>_ ХРАНИЛИЩЕ_АРТЕФАКТОВ (FILES)
>_ ЦЕНТРАЛЬНЫЙ_АРХИВАННЫХ (FILES_CORE)
</h2>
<div class="card mb-4">
<div class="card-header border-success d-flex justify-content-between align-items-center">
<span>СПИСОКОСТУПНЫХАЙЛОВ</span>
<button class="btn btn-outline-success btn-sm" onclick="loadFiles()">
<!-- Секция загрузки артефактов -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card bg-black border-success shadow-sm">
<div class="card-body">
<h5 class="text-success mb-3"><i class="bi bi-cloud-upload"></i> РЕГИСТРАЦИЯ НОВОГО АРТЕФАКТА В TELEGRAM</h5>
<p class="small text-muted mb-3">Загрузите файл сюда, и бот отправит его вам в личку, чтобы получить постоянный <code>file_id</code>. Вы сможете использовать этот ID в рассылках и постах многократно.</p>
<form id="uploadForm" class="row g-3">
<div class="col-md-9">
<input type="file" id="fileInput" class="form-control bg-dark border-success text-success">
</div>
<div class="col-md-3">
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn btn-matrix w-100">
<span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
ЗАГРУЗИТЬ
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Таблица артефактов -->
<div class="card mb-4 border-info">
<div class="card-header border-info bg-info bg-opacity-10 d-flex justify-content-between align-items-center">
<span class="text-info fw-bold">💠 АРТЕФАКТЫ TELEGRAM (ДЛЯ РАССЫЛОК)</span>
<button class="btn btn-outline-info btn-sm" onclick="loadFiles()">
<i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ
</button>
</div>
@ -19,37 +43,41 @@
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr class="text-success opacity-50" style="font-size: 0.8em;">
<tr class="text-info opacity-75" style="font-size: 0.8em;">
<th>ИМЯ ФАЙЛА</th>
<th>КАТЕГОРИЯ</th>
<th>РАЗМЕР</th>
<th>ТИП</th>
<th>TELEGRAM_FILE_ID</th>
<th class="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody id="filesTable">
<tr>
<td colspan="4" class="text-center p-5">
<div class="spinner-border text-success" role="status"></div>
<div class="mt-2">Сканирование секторов...</div>
</td>
</tr>
<tbody id="artifactsTable">
<!-- Заполняется через API -->
</tbody>
</table>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card border-info">
<div class="card-header border-info text-info">ИНФОРМАЦИЯ</div>
<div class="card-body small">
<ul class="mb-0">
<li>Категория <b>EXPORT</b>: JSON-выгрузки истории чата.</li>
<li>Категория <b>LOG</b>: Системные логи бота и веб-панели.</li>
<li>Все файлы доступны для прямого скачивания на локальный хост.</li>
</ul>
</div>
<!-- Таблица системных файлов -->
<div class="card border-secondary">
<div class="card-header border-secondary bg-secondary bg-opacity-10">
<span class="text-secondary fw-bold">📁 СИСТЕМНЫЕ ФАЙЛЫ (ЛОГИ И ЭКСПОРТЫ)</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr class="text-muted" style="font-size: 0.8em;">
<th>ИМЯ ФАЙЛА</th>
<th>КАТЕГОРИЯ</th>
<th>РАЗМЕР</th>
<th class="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody id="systemFilesTable">
<!-- Заполняется через API -->
</tbody>
</table>
</div>
</div>
</div>
@ -60,36 +88,104 @@
document.addEventListener('DOMContentLoaded', loadFiles);
async function loadFiles() {
const table = document.getElementById('filesTable');
const artifactsTable = document.getElementById('artifactsTable');
const systemFilesTable = document.getElementById('systemFilesTable');
try {
const response = await fetch('/api/files/list');
const result = await response.json();
if (!result.success) throw new Error(result.error);
table.innerHTML = '';
if (result.files.length === 0) {
table.innerHTML = '<tr><td colspan="4" class="text-center p-4">Файлы не найдены</td></tr>';
return;
artifactsTable.innerHTML = '';
systemFilesTable.innerHTML = '';
const artifacts = result.files.filter(f => f.is_artifact);
const systemFiles = result.files.filter(f => !f.is_artifact);
if (artifacts.length === 0) {
artifactsTable.innerHTML = '<tr><td colspan="4" class="text-center p-3 text-muted">Артефакты не зарегистрированы</td></tr>';
} else {
artifacts.forEach(f => {
artifactsTable.innerHTML += `
<tr>
<td class="font-monospace text-info">${f.filename}</td>
<td><span class="badge bg-info text-dark">${f.file_type}</span></td>
<td><code class="text-success small" style="word-break: break-all;">${f.file_id}</code></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-info" onclick="copyId('${f.file_id}')">
<i class="bi bi-copy"></i> ID
</button>
</td>
</tr>
`;
});
}
result.files.forEach(f => {
const badgeClass = f.file_type === 'EXPORT' ? 'bg-primary' : 'bg-secondary';
table.innerHTML += `
<tr>
<td class="font-monospace">${f.filename}</td>
<td><span class="badge ${badgeClass}">${f.file_type}</span></td>
<td class="text-muted">${f.size}</td>
<td class="text-end">
<a href="${f.path}" class="btn btn-sm btn-matrix" download>
<i class="bi bi-download"></i> СКАЧАТЬ
</a>
</td>
</tr>
`;
});
if (systemFiles.length === 0) {
systemFilesTable.innerHTML = '<tr><td colspan="4" class="text-center p-3 text-muted">Файлы не найдены</td></tr>';
} else {
systemFiles.forEach(f => {
systemFilesTable.innerHTML += `
<tr>
<td class="font-monospace">${f.filename}</td>
<td><span class="badge bg-secondary">${f.file_type}</span></td>
<td class="text-muted">${f.size}</td>
<td class="text-end">
<a href="${f.path}" class="btn btn-sm btn-outline-secondary" download>
<i class="bi bi-download"></i>
</a>
</td>
</tr>
`;
});
}
} catch (error) {
table.innerHTML = `<tr><td colspan="4" class="text-center text-danger p-4">Ошибка: ${error.message}</td></tr>`;
console.error(error);
}
}
function copyId(id) {
navigator.clipboard.writeText(id);
const toast = document.createElement('div');
toast.className = 'position-fixed bottom-0 end-0 p-3';
toast.style.zIndex = '11';
toast.innerHTML = `<div class="alert alert-success">FILE_ID скопирован!</div>`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const btn = document.getElementById('uploadBtn');
const spinner = document.getElementById('uploadSpinner');
if (!fileInput.files[0]) return alert('Выберите файл для загрузки');
btn.disabled = true;
spinner.classList.remove('d-none');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert('Файл успешно зарегистрирован в Telegram!\nFILE_ID: ' + result.file_id);
location.reload();
} else {
alert('Ошибка при регистрации: ' + result.error);
}
} catch (error) {
alert('Сетевая ошибка: ' + error.message);
} finally {
btn.disabled = false;
spinner.classList.add('d-none');
}
}
</script>

View file

@ -28,21 +28,42 @@
<small class="text-muted">Поддерживается HTML: &lt;b&gt;жирный&lt;/b&gt;, &lt;i&gt;курсив&lt;/i&gt;, &lt;emoji&gt;</small>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-info">🖼️ Готовое фото (Артефакт):</label>
<select class="form-select bg-dark text-info border-info" id="artifactPhotoId">
<option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-warning">📄 Готовый документ (Артефакт):</label>
<select class="form-select bg-dark text-warning border-warning" id="artifactDocumentId">
<option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Картинка (необязательно)</label>
<label class="form-label">Загрузить новую картинку (необязательно)</label>
<input type="file" class="form-control" id="postPhoto" accept="image/*"
onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small>
<small class="text-muted">Если выбран артефакт фото выше, загрузка будет проигнорирована.</small>
<div id="preview">
<img id="previewImg" alt="Предпросмотр">
</div>
</div>
<div class="mb-3">
<label class="form-label">📄 Документ (необязательно)</label>
<label class="form-label">📄 Загрузить новый документ (необязательно)</label>
<input type="file" class="form-control" id="postDocument" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
onchange="previewDocument()">
<small class="text-muted">PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, ZIP, RAR до 50MB</small>
<small class="text-muted">Если выбран артефакт документа выше, загрузка будет проигнорирована.</small>
<div id="documentPreview" class="mt-2" style="display:none;">
<span class="badge bg-success">📄 <span id="documentName"></span></span>
<button type="button" class="btn btn-sm btn-danger ms-2" onclick="clearDocument()">✕ Удалить</button>
@ -180,6 +201,8 @@
const text = document.getElementById('postText').value;
const photo = document.getElementById('postPhoto').files[0];
const artifactPhotoId = document.getElementById('artifactPhotoId').value;
const artifactDocumentId = document.getElementById('artifactDocumentId').value;
const topic = document.getElementById('postTopic').value;
const recipients = document.getElementById('postRecipients').value;
const date = document.getElementById('postDate').value;
@ -203,11 +226,18 @@
formData.append('topic_name', topic);
formData.append('recipients', recipients);
formData.append('scheduled_time', scheduledTime);
if (photo) {
if (artifactPhotoId) {
formData.append('artifact_photo_id', artifactPhotoId);
}
if (artifactDocumentId) {
formData.append('artifact_document_id', artifactDocumentId);
}
if (photo && !artifactPhotoId) {
formData.append('photo', photo);
}
const doc = document.getElementById('postDocument').files[0];
if (doc) {
if (doc && !artifactDocumentId) {
formData.append('document', doc);
}