Compare commits
19 commits
v1.0-pre-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0606d6bde | ||
|
|
b9acc30bf4 | ||
|
|
1abadd9081 | ||
|
|
36c5e51566 | ||
|
|
fca335423e | ||
|
|
8b88344fac | ||
|
|
6d530c9d97 | ||
|
|
3c31a6a529 | ||
|
|
5c437b78b0 | ||
|
|
2924765624 | ||
|
|
b582a579aa | ||
|
|
8cb5ee7036 | ||
|
|
99fd5ebe88 | ||
|
|
401fb3b32a | ||
|
|
0dcea20739 | ||
|
|
0aac30187b | ||
|
|
d4f572cbb4 | ||
|
|
c49003bf39 | ||
|
|
6a4610748e |
34 changed files with 5020 additions and 1771 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ __pycache__/
|
|||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv-ai/
|
||||
venv/
|
||||
venv-google/
|
||||
env/
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ if not ADMIN_USER_ID:
|
|||
SAFETY_MODE = os.getenv('SAFETY_MODE', 'production').lower() # 'production' или 'safe'
|
||||
|
||||
# ===== ДИНАМИЧЕСКИЕ ПУТИ (ПЕРЕНОСИМОСТЬ) =====
|
||||
MUTT_DIR = os.getenv('MUTT_DIR', '/app/mutt' if os.path.exists('/app/mutt') else str(BASE_DIR.parent.parent / 'mutt'))
|
||||
MUTT_DIR = os.getenv('MUTT_DIR', '/home/matrixhasyou/mutt' if os.path.exists('/home/matrixhasyou/mutt') else ('/app/mutt' if os.path.exists('/app/mutt') else str(BASE_DIR.parent.parent / 'mutt')))
|
||||
GOOGLE_CREDS_FILE = os.getenv('GOOGLE_CREDS_FILE', '/app/config/google_creds.json' if os.path.exists('/app/config/google_creds.json') else str(BASE_DIR.parent.parent / 'domovoy_google_creds.json'))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"emergency": [
|
||||
{
|
||||
"name": "Аварийная служба (электрика)",
|
||||
"phone": "54-69-29",
|
||||
"description": "Круглосуточно при замыканиях в щитках, авариях электросетей и ЧП. Федеральный: 8 (8422) 54-69-29",
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"name": "Скорая помощь",
|
||||
"phone": "103",
|
||||
|
|
@ -94,4 +100,4 @@
|
|||
"verified_by_admin": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
data/service_images/emergency_avariyka.png
Normal file
BIN
data/service_images/emergency_avariyka.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -12,14 +12,20 @@ services:
|
|||
- TZ=Europe/Ulyanovsk
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./.env:/app/.env:ro
|
||||
- ./config.py:/app/config.py:ro
|
||||
- ./main.py:/app/main.py
|
||||
- ./logs:/app/logs
|
||||
- ./exports:/app/exports
|
||||
- ./data:/app/data
|
||||
- ./handlers:/app/handlers
|
||||
- ./keyboards:/app/keyboards
|
||||
- ./utils:/app/utils
|
||||
- ./services:/app/services
|
||||
- ../libs/swarmlib:/app/swarmlib:ro
|
||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||
- /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
|
||||
- ./venv-ai:/app/venv-ai:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
|
@ -35,16 +41,28 @@ services:
|
|||
environment:
|
||||
- PYTHONPATH=/app:/app/services
|
||||
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
|
||||
- GOOGLE_API_KEY=AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk
|
||||
- MUTT_DIR=/home/matrixhasyou/mutt
|
||||
- TZ=Europe/Ulyanovsk
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./.env:/app/.env:ro
|
||||
- ./config.py:/app/config.py:ro
|
||||
- ./web:/app/web
|
||||
- ./podcasts:/app/podcasts
|
||||
- ./logs:/app/logs
|
||||
- ./exports:/app/exports
|
||||
- ./data:/app/data
|
||||
- ./handlers:/app/handlers
|
||||
- ./keyboards:/app/keyboards
|
||||
- ./utils:/app/utils
|
||||
- ./services:/app/services
|
||||
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
|
||||
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
|
||||
- ../libs/swarmlib:/app/swarmlib:ro
|
||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
|
||||
- /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
|
||||
- ./venv-ai:/app/venv-ai:ro
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
|
|
|||
|
|
@ -58,104 +58,8 @@ async def process_swarm_task(message: Message, state: FSMContext):
|
|||
# ... (код остается прежним)
|
||||
pass
|
||||
|
||||
# =============================================================================
|
||||
# ЮРИДИЧЕСКИЙ ПОМОЩНИК (NotebookLM)
|
||||
# =============================================================================
|
||||
# (Устаревший блок NotebookLM перенесен в специализированный модуль handlers/legal_advisor.py)
|
||||
|
||||
@router.message(Command('jurist'))
|
||||
@router.message(F.text == '⚖️ Юрист (ЖК РФ)')
|
||||
@admin_only
|
||||
async def cmd_jurist(message: Message, state: FSMContext):
|
||||
"""Активация юридического помощника"""
|
||||
await message.answer(
|
||||
"⚖️ <b>ЮРИДИЧЕСКИЙ ПОМОЩНИК (ЖК РФ)</b>\n\n"
|
||||
"Напишите ваш вопрос для анализа на основе судебных дел и ЖК РФ.\n\n"
|
||||
"<i>Я проанализирую ваш запрос через NotebookLM.</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
await state.set_state(JuristState.waiting_for_question)
|
||||
|
||||
@router.callback_query(F.data == 'admin_jurist')
|
||||
@admin_callback_only
|
||||
async def cb_admin_jurist(callback: CallbackQuery, state: FSMContext):
|
||||
"""Callback-активация юридического помощника"""
|
||||
await callback.answer()
|
||||
await callback.message.answer(
|
||||
"⚖️ <b>ЮРИДИЧЕСКИЙ ПОМОЩНИК (ЖК РФ)</b>\n\n"
|
||||
"Напишите ваш вопрос для анализа на основе судебных дел и ЖК РФ.\n\n"
|
||||
"<i>Я проанализирую ваш запрос через NotebookLM.</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
await state.set_state(JuristState.waiting_for_question)
|
||||
|
||||
@router.message(JuristState.waiting_for_question)
|
||||
@admin_only
|
||||
async def process_jurist_question(message: Message, state: FSMContext):
|
||||
"""Обработка вопроса к юристу через NotebookLM"""
|
||||
status_msg = await message.answer(
|
||||
"⏳ <i>Отправляю запрос в NotebookLM, анализирую судебные материалы и законы (это займет около 10-15 секунд)...</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Динамический поиск бинарника nlm
|
||||
nlm_bin = "nlm"
|
||||
for p in ["/app/bin/nlm", "/usr/local/bin/nlm", os.path.expanduser("~/.local/bin/nlm"), "/home/matrixhasyou/.local/bin/nlm"]:
|
||||
if os.path.exists(p):
|
||||
nlm_bin = p
|
||||
break
|
||||
|
||||
cmd = [
|
||||
nlm_bin, "query", "notebook",
|
||||
"00ed7d92-1ab2-4159-ac72-1d0c4be21377",
|
||||
message.text
|
||||
]
|
||||
env = os.environ.copy()
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
output = stdout.decode('utf-8').strip()
|
||||
error = stderr.decode('utf-8').strip()
|
||||
|
||||
# Убираем предупреждения о новой версии из вывода nlm
|
||||
if "Update available:" in output:
|
||||
output = output.split("Update available:")[0].strip()
|
||||
|
||||
if proc.returncode == 0:
|
||||
import html
|
||||
import re
|
||||
|
||||
# Заменяем <br>, <br/>, <br /> на переносы строк
|
||||
clean_output = output.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
# Экранируем спецсимволы для parse_mode='HTML'
|
||||
safe_output = html.escape(clean_output)
|
||||
# Форматируем **жирный** текст -> <b>жирный</b>
|
||||
safe_output = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', safe_output, flags=re.DOTALL)
|
||||
# Форматируем [ссылка](url) -> <a href="\2">\1</a>
|
||||
safe_output = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2">\1</a>', safe_output, flags=re.DOTALL)
|
||||
|
||||
if len(safe_output) > 4000:
|
||||
parts = [safe_output[i:i+4000] for i in range(0, len(safe_output), 4000)]
|
||||
await status_msg.edit_text(f"⚖️ <b>Анализ NotebookLM (часть 1):</b>\n\n{parts[0]}", parse_mode='HTML')
|
||||
for part in parts[1:]:
|
||||
await message.answer(part, parse_mode='HTML')
|
||||
else:
|
||||
await status_msg.edit_text(f"⚖️ <b>Анализ NotebookLM:</b>\n\n{safe_output}", parse_mode='HTML')
|
||||
else:
|
||||
await status_msg.edit_text(f"❌ <b>Ошибка запроса к NotebookLM:</b>\n{error or output}")
|
||||
except Exception as e:
|
||||
await status_msg.edit_text(f"❌ <b>Внутренняя ошибка бота:</b> {str(e)}")
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(F.text & ~F.text.startswith('/') & F.chat.type == 'private')
|
||||
|
|
@ -226,16 +130,25 @@ async def cmd_legal_sync(message: Message):
|
|||
|
||||
|
||||
@router.message(Command('admin'))
|
||||
@router.message(F.text.in_(['⚙️ Управление', '⚙️ Админка', '⚙️ АДМИНКА']))
|
||||
@admin_only
|
||||
async def cmd_admin(message: Message):
|
||||
"""Админ-меню (только в личке!)"""
|
||||
# Проверяем что это личка
|
||||
# Если вызвано в группе — тихо удаляем из чата и отправляем админу в ЛС
|
||||
if message.chat.type != 'private':
|
||||
await message.answer(
|
||||
'⚠️ <b>Админ-панель доступна только в личных сообщениях!</b>\n\n'
|
||||
'Напишите боту в личку: /admin',
|
||||
parse_mode='HTML'
|
||||
)
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=message.from_user.id,
|
||||
text='🛡️ <b>Панель администратора</b>\n\nВыберите раздел:',
|
||||
reply_markup=get_admin_main_menu(),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
|
|
@ -246,7 +159,7 @@ async def cmd_admin(message: Message):
|
|||
)
|
||||
|
||||
|
||||
@router.message(F.text == '📊 Статистика')
|
||||
@router.message(F.text.in_(['📊 Статистика', '/stats']))
|
||||
@admin_only
|
||||
async def msg_stats_button(message: Message):
|
||||
"""Кнопка Статистика"""
|
||||
|
|
@ -258,19 +171,19 @@ async def msg_stats_button(message: Message):
|
|||
await message.answer(text, parse_mode='HTML')
|
||||
|
||||
|
||||
@router.message(F.text == '🕵️ Шпионы')
|
||||
@router.message(F.text.in_(['🛡️ Фильтр посторонних', '🕵️ Шпионы', '/spies']))
|
||||
@admin_only
|
||||
async def msg_spies_button(message: Message):
|
||||
"""Кнопка Шпионы"""
|
||||
"""Кнопка Шпионы / Фильтр посторонних"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
spy_detector = SpyDetector(session)
|
||||
spies = await spy_detector.get_spies()
|
||||
|
||||
if not spies:
|
||||
await message.answer('🟢 Шпионов не обнаружено')
|
||||
await message.answer('🟢 Посторонних и шпионов не обнаружено')
|
||||
return
|
||||
|
||||
text = '🚨 <b>ШПИОНЫ</b> 🚨\n\n'
|
||||
text = '🚨 <b>ПОСТОРОННИЕ / ШПИОНЫ</b> 🚨\n\n'
|
||||
for spy in spies:
|
||||
name = spy.full_name or f'user{spy.user_id}'
|
||||
apt = spy.apartment or '?'
|
||||
|
|
@ -279,16 +192,16 @@ async def msg_spies_button(message: Message):
|
|||
await message.answer(text, parse_mode='HTML')
|
||||
|
||||
|
||||
@router.message(F.text == '👥 Пользователи')
|
||||
@router.message(F.text.in_(['👥 Список жителей', '👥 Пользователи', '/users']))
|
||||
@admin_only
|
||||
async def msg_users_button(message: Message):
|
||||
"""Кнопка Пользователи"""
|
||||
"""Кнопка Пользователи / Список жителей"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(User).limit(50)
|
||||
result = await session.execute(stmt)
|
||||
users = list(result.scalars().all())
|
||||
|
||||
text = f'👥 <b>Пользователи</b> (50):\n\n'
|
||||
text = f'👥 <b>Список жителей</b> (50):\n\n'
|
||||
for user in users:
|
||||
status = '✅' if user.verified else '⏳'
|
||||
name = user.full_name or f'user{user.user_id}'
|
||||
|
|
@ -300,7 +213,7 @@ async def msg_users_button(message: Message):
|
|||
await message.answer(text, parse_mode='HTML')
|
||||
|
||||
|
||||
@router.message(F.text == '❓ Помощь')
|
||||
@router.message(F.text.in_(['❓ Справка', '❓ Помощь', '/help']), lambda m: config.is_admin(m.from_user.id))
|
||||
@admin_only
|
||||
async def msg_help_button(message: Message):
|
||||
"""Кнопка Помощь - справка по командам"""
|
||||
|
|
@ -795,6 +708,7 @@ async def cmd_spies(message: Message):
|
|||
|
||||
@router.message(Command('export'))
|
||||
@router.message(Command('экспорт'))
|
||||
@router.message(F.text.in_(['📥 Выгрузка базы', '📥 Экспорт JSON']))
|
||||
@admin_only
|
||||
async def cmd_export(message: Message):
|
||||
"""Экспорт данных"""
|
||||
|
|
|
|||
348
handlers/legal_advisor.py
Normal file
348
handlers/legal_advisor.py
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Legal Advisor Router (Юридический Помощник Домового 2.0).
|
||||
Обрабатывает текстовые и голосовые юридические вопросы жильцов и совета дома.
|
||||
Поддерживает ролевую модель:
|
||||
- Жилец: простой бытовой язык + бланки заявлений.
|
||||
- Юрист (has_ai_access=1): статьи ЖК РФ, судебная практика, досудебные претензии.
|
||||
- Админ (Александр Тырин): стратегический анализ, сопоставление ответов чиновников.
|
||||
Строгий шлюз Zero-Spam: ответы выдаются в личку либо направляются на одобрение админу.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from sqlalchemy import select
|
||||
from config import ADMIN_USER_ID
|
||||
from database.db import AsyncSessionLocal
|
||||
from database.models import User
|
||||
from services.ai_legal_engine import legal_engine
|
||||
|
||||
log = logging.getLogger("LegalAdvisorHandler")
|
||||
router = Router()
|
||||
|
||||
TEMP_VOICE_DIR = Path(__file__).resolve().parent.parent / "temp_voice"
|
||||
TEMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
class LegalQuestionState(StatesGroup):
|
||||
waiting_for_question = State()
|
||||
|
||||
def format_telegram_html(text: str) -> str:
|
||||
"""Безопасное форматирование текста Markdown -> HTML для Telegram."""
|
||||
clean = text.replace("<br>", "\n").replace("<br/>", "\n")
|
||||
safe = html.escape(clean)
|
||||
# **жирный** -> <b>жирный</b>
|
||||
safe = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', safe, flags=re.DOTALL)
|
||||
# *курсив* -> <i>курсив</i>
|
||||
safe = re.sub(r'\*(.*?)\*', r'<i>\1</i>', safe, flags=re.DOTALL)
|
||||
# [ссылка](url) -> <a href="url">ссылка</a>
|
||||
safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', r'<a href="\2">\1</a>', safe, flags=re.DOTALL)
|
||||
return safe
|
||||
|
||||
async def send_split_message(message: Message, header: str, body: str):
|
||||
"""Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
|
||||
# Разбиваем исходный текст по параграфам
|
||||
paragraphs = body.split("\n\n")
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_len = 0
|
||||
|
||||
for p in paragraphs:
|
||||
p_len = len(p) + 2
|
||||
if current_len + p_len > 3200 and current_chunk:
|
||||
chunks.append("\n\n".join(current_chunk))
|
||||
current_chunk = [p]
|
||||
current_len = p_len
|
||||
else:
|
||||
current_chunk.append(p)
|
||||
current_len += p_len
|
||||
|
||||
if current_chunk:
|
||||
chunks.append("\n\n".join(current_chunk))
|
||||
|
||||
if not chunks:
|
||||
chunks = [body]
|
||||
|
||||
for idx, chunk in enumerate(chunks, 1):
|
||||
if idx == 1:
|
||||
if header:
|
||||
part_header = f"{header}\n\n" if len(chunks) == 1 else f"{header} (Часть 1/{len(chunks)}):\n\n"
|
||||
else:
|
||||
part_header = "" if len(chunks) == 1 else f"<i>(Часть 1/{len(chunks)}):</i>\n\n"
|
||||
else:
|
||||
part_header = f"<i>(Продолжение {idx}/{len(chunks)}):</i>\n\n"
|
||||
|
||||
part_text = f"{part_header}{format_telegram_html(chunk)}"
|
||||
# Прикрепляем инлайн-кнопки только к последней части ответа
|
||||
reply_markup = get_legal_nav_keyboard() if idx == len(chunks) else None
|
||||
try:
|
||||
await message.answer(part_text, parse_mode='HTML', reply_markup=reply_markup)
|
||||
except Exception as e:
|
||||
log.warning(f"HTML send failed ({e}), falling back to plain text")
|
||||
if header:
|
||||
plain_header = header if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):"
|
||||
await message.answer(f"{plain_header}\n\n{chunk}", reply_markup=reply_markup)
|
||||
else:
|
||||
plain_header = "" if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):\n\n"
|
||||
await message.answer(f"{plain_header}{chunk}", reply_markup=reply_markup)
|
||||
|
||||
def get_legal_nav_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Инлайн-кнопки навигации после ответа юриста."""
|
||||
kb = [
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Задать следующий вопрос", callback_data="jurist_ask_next"),
|
||||
InlineKeyboardButton(text="🚪 Выход из режима юриста", callback_data="jurist_exit")
|
||||
]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
async def determine_user_role(user_id: int) -> str:
|
||||
"""Определяет роль пользователя в системе."""
|
||||
if user_id == ADMIN_USER_ID:
|
||||
return "admin"
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(User).where(User.user_id == user_id)
|
||||
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if user and (user.is_admin or user.has_ai_access):
|
||||
return "lawyer"
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to fetch user {user_id} role: {e}")
|
||||
return "resident"
|
||||
|
||||
# === КОМАНДЫ АКТИВАЦИИ ===
|
||||
|
||||
@router.message(Command("jurist", "юрист", "вопрос", "help_law"))
|
||||
@router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"]))
|
||||
@router.message(F.text.casefold().startswith(("/юрист", "/jurist")))
|
||||
async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
|
||||
# Защита от спама в группе: в группе тихо удаляем и перенаправляем в ЛС
|
||||
if message.chat.type in ('group', 'supergroup'):
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=message.from_user.id,
|
||||
text="⚖️ <b>Юридический помощник ЖК РФ</b> доступен здесь в личных сообщениях.\nДля активации доступа обратитесь к @ktybdsq.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
role = await determine_user_role(message.from_user.id)
|
||||
if role not in ("admin", "lawyer"):
|
||||
await message.answer(
|
||||
"Для использования юридического помощника обратитесь к @ktybdsq.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем, не задал ли пользователь вопрос прямо в команде: /юрист Какой срок ответа?
|
||||
parts = (message.text or "").split(maxsplit=1)
|
||||
if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"):
|
||||
inline_question = parts[1].strip()
|
||||
if len(inline_question) > 3:
|
||||
status_msg = await message.answer("⏳ <i>Изучаю документы, сметы, отчеты и решения дома № 37...</i>", parse_mode='HTML')
|
||||
result = await legal_engine.query(prompt=inline_question, role=role)
|
||||
await status_msg.delete()
|
||||
if result.get("success"):
|
||||
await send_split_message(message, "", result["text"])
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
else:
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
text = (
|
||||
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
|
||||
"<i>Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома.</i>"
|
||||
)
|
||||
await message.answer(text, parse_mode='HTML')
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
||||
@router.callback_query(F.data.in_(["admin_jurist", "jurist_ai", "main_jurist"]))
|
||||
async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
role = await determine_user_role(callback.from_user.id)
|
||||
if role not in ("admin", "lawyer"):
|
||||
await callback.message.answer(
|
||||
"Для использования юридического помощника обратитесь к @ktybdsq.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
return
|
||||
|
||||
text = (
|
||||
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||
"Напишите ваш вопрос или отправьте голосовое сообщение.\n"
|
||||
"<i>Ответ формируется по сметам, отчетам, договорам и решениям ОСС дома.</i>"
|
||||
)
|
||||
await callback.message.answer(text, parse_mode='HTML')
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
||||
# === КНОПКИ НАВИГАЦИИ ПОСЛЕ ОТВЕТА ЮРИСТА ===
|
||||
|
||||
@router.callback_query(F.data == "jurist_ask_next")
|
||||
async def cb_jurist_ask_next(callback: CallbackQuery, state: FSMContext):
|
||||
"""Пользователь хочет задать следующий вопрос юристу"""
|
||||
await callback.answer()
|
||||
role = await determine_user_role(callback.from_user.id)
|
||||
if role not in ("admin", "lawyer"):
|
||||
await callback.message.answer("🔒 <b>Доступ ограничен.</b>", parse_mode='HTML')
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
await callback.message.answer(
|
||||
"⚖️ <b>Режим юриста активен.</b>\n\n"
|
||||
"Напишите ваш следующий вопрос или отправьте голосовое сообщение:",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
@router.callback_query(F.data == "jurist_exit")
|
||||
async def cb_jurist_exit(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выход из режима юриста с возвратом в главное меню"""
|
||||
await callback.answer("Режим юриста завершён")
|
||||
await state.clear()
|
||||
|
||||
user_id = callback.from_user.id
|
||||
try:
|
||||
from keyboards.inline import get_main_menu
|
||||
from database.models import InitiativeGroup
|
||||
import config
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(User).where(User.user_id == user_id)
|
||||
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True)
|
||||
is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
|
||||
|
||||
is_admin = (user.is_admin if user else False) or (user_id == config.ADMIN_USER_ID)
|
||||
is_verified = user.verified if user else False
|
||||
|
||||
await callback.message.answer(
|
||||
"🚪 <b>Вы вышли из режима юриста.</b>\n\nГлавное меню:",
|
||||
reply_markup=get_main_menu(
|
||||
verified=is_verified,
|
||||
is_ig=is_ig,
|
||||
is_admin=is_admin,
|
||||
is_superadmin=(user_id == config.ADMIN_USER_ID)
|
||||
),
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to show main menu on jurist exit: {e}")
|
||||
await callback.message.answer("🚪 <b>Вы вышли из режима юриста.</b>", parse_mode='HTML')
|
||||
|
||||
# === ОБРАБОТКА ТЕКСТОВЫХ ВОПРОСОВ ===
|
||||
|
||||
@router.message(LegalQuestionState.waiting_for_question, F.text)
|
||||
async def process_text_legal_question(message: Message, state: FSMContext, bot: Bot):
|
||||
user_id = message.from_user.id
|
||||
role = await determine_user_role(user_id)
|
||||
if role not in ("admin", "lawyer"):
|
||||
await message.answer(
|
||||
"Для использования юридического помощника обратитесь к @ktybdsq.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
question = message.text
|
||||
|
||||
status_msg = await message.answer(
|
||||
"⏳ <i>Изучаю документы, сметы, отчеты и решения дома № 37...</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
result = await legal_engine.query(prompt=question, role=role)
|
||||
await status_msg.delete()
|
||||
|
||||
if not result.get("success"):
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
# Защита от спама: если вопрос задан в групповом чате
|
||||
if message.chat.type in ("group", "supergroup"):
|
||||
# Ответ отправляется жильцу в ЛИЧКУ
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=format_telegram_html(result['text']),
|
||||
parse_mode='HTML',
|
||||
reply_markup=get_legal_nav_keyboard()
|
||||
)
|
||||
await message.reply(
|
||||
"💬 <b>Ответ отправлен вам в личные сообщения</b>, чтобы не перегружать общий чат дома.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception:
|
||||
await message.reply(
|
||||
"💬 Пожалуйста, откройте диалог с ботом (@lkmthreeseven_bot) и нажмите /start, чтобы получить ответ.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
else:
|
||||
# В личном диалоге отвечаем напрямую без лишних префиксов
|
||||
await send_split_message(message, "", result["text"])
|
||||
|
||||
# Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
||||
# === ОБРАБОТКА ГОЛОСОВЫХ ВОПРОСОВ (НАТИВНОЕ МУЛЬТИМОДАЛЬНОЕ РАСПОЗНАВАНИЕ) ===
|
||||
|
||||
@router.message(LegalQuestionState.waiting_for_question, F.voice)
|
||||
@router.message(F.voice & F.chat.type == "private")
|
||||
async def process_voice_legal_question(message: Message, state: FSMContext, bot: Bot):
|
||||
user_id = message.from_user.id
|
||||
role = await determine_user_role(user_id)
|
||||
if role not in ("admin", "lawyer"):
|
||||
await message.answer(
|
||||
"Для использования юридического помощника обратитесь к @ktybdsq.",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
status_msg = await message.answer(
|
||||
"🎙 <i>Распознаю аудио и сверяю с документами дома № 37...</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
voice_file_path = None
|
||||
try:
|
||||
# Скачиваем голосовой файл
|
||||
file_info = await bot.get_file(message.voice.file_id)
|
||||
voice_file_path = str(TEMP_VOICE_DIR / f"voice_{user_id}_{int(message.date.timestamp())}.ogg")
|
||||
await bot.download_file(file_info.file_path, destination=voice_file_path)
|
||||
|
||||
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем поиск по базе дома)
|
||||
result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path)
|
||||
|
||||
await status_msg.delete()
|
||||
|
||||
if not result.get("success"):
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
# Отправляем чистый ответ без дублирования вопроса и без лишних плашек
|
||||
await send_split_message(message, "", result["text"])
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Voice processing error: {e}")
|
||||
await status_msg.edit_text(f"❌ <b>Ошибка обработки голосового сообщения:</b> {e}")
|
||||
finally:
|
||||
# Всегда зачищаем временный аудиофайл
|
||||
if voice_file_path and os.path.exists(voice_file_path):
|
||||
try: os.unlink(voice_file_path)
|
||||
except: pass
|
||||
|
|
@ -14,37 +14,26 @@ from database.db import AsyncSessionLocal
|
|||
from database.models import User, InitiativeGroup
|
||||
from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard, get_weather_keyboard
|
||||
from services.ai_service import ai_service
|
||||
from utils.redirect import respond_pm_or_redirect
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
async def safe_edit_or_reply(event, text: str, reply_markup=None, parse_mode='HTML'):
|
||||
async def safe_edit_or_reply(event, text: str, reply_markup=None, photo_path: str = None, parse_mode: str = 'HTML', start_param: str = 'start'):
|
||||
"""
|
||||
Безопасное редактирование сообщения.
|
||||
Если сообщение содержит медиа (фото и т.д.), мы удаляем его и отправляем новое.
|
||||
Безопасный ответ: если вызван в группе — перенаправляет в личные сообщения пользователя
|
||||
и тихо удаляет команду в группе, предотвращая спам.
|
||||
"""
|
||||
if isinstance(event, CallbackQuery):
|
||||
await event.answer()
|
||||
message = event.message
|
||||
if (hasattr(message, 'photo') and message.photo) or \
|
||||
(hasattr(message, 'video') and message.video) or \
|
||||
(hasattr(message, 'document') and message.document) or \
|
||||
(hasattr(message, 'content_type') and message.content_type != 'text'):
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить сообщение с медиа: {e}")
|
||||
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
else:
|
||||
try:
|
||||
await message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка edit_text: {e}. Отправляем новое сообщение.")
|
||||
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
elif isinstance(event, Message):
|
||||
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
await respond_pm_or_redirect(
|
||||
event=event,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
photo_path=photo_path,
|
||||
parse_mode=parse_mode,
|
||||
start_param=start_param
|
||||
)
|
||||
|
||||
@router.message(Command('cancel'), StateFilter('*'))
|
||||
@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
|
||||
|
|
@ -59,9 +48,8 @@ async def global_cancel(message: Message, state: FSMContext):
|
|||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message):
|
||||
"""Команда /start"""
|
||||
"""Команда /start: в ЛС открывает меню, в группе тихо перенаправляет в ЛС"""
|
||||
from utils.ui_styles import fmt_msg, get_level_badge, get_user_level_key
|
||||
if message.chat.type != 'private': return
|
||||
|
||||
user_id = message.from_user.id
|
||||
async with AsyncSessionLocal() as session:
|
||||
|
|
@ -88,7 +76,13 @@ async def cmd_start(message: Message):
|
|||
f"Рад видеть тебя снова, {message.from_user.first_name}!\n"
|
||||
"Чем могу помочь сегодня?")
|
||||
|
||||
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML')
|
||||
menu_kb = get_main_menu(
|
||||
verified=user.verified if user else False,
|
||||
is_ig=is_ig,
|
||||
is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID) if user else (user_id == config.ADMIN_USER_ID),
|
||||
is_superadmin=(user_id == config.ADMIN_USER_ID)
|
||||
)
|
||||
await safe_edit_or_reply(message, welcome_text, reply_markup=menu_kb, parse_mode='HTML', start_param='start')
|
||||
|
||||
@router.callback_query(F.data == 'main_menu')
|
||||
async def cb_main_menu(callback: CallbackQuery):
|
||||
|
|
@ -110,19 +104,19 @@ async def cb_main_menu(callback: CallbackQuery):
|
|||
)
|
||||
|
||||
@router.message(Command('phones'))
|
||||
@router.message(F.text.in_(['📞 Важные телефоны', '📞 Телефоны', 'Телефоны']))
|
||||
@router.callback_query(F.data == 'phones')
|
||||
async def cb_phones(event):
|
||||
text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
|
||||
text = '📞 <b>ЭКСТРЕННЫЕ И ВАЖНЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
|
||||
kb = get_phones_menu()
|
||||
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML')
|
||||
await safe_edit_or_reply(event, text, reply_markup=kb, parse_mode='HTML', start_param='phones')
|
||||
|
||||
@router.message(Command('masters'))
|
||||
@router.message(F.text.lower() == 'мастера')
|
||||
@router.message(F.text.in_(['👷 Мастера', 'Мастера', 'мастера']))
|
||||
async def cmd_masters(message: Message):
|
||||
"""Выдача списка мастеров по команде /masters или тексту 'мастера'"""
|
||||
from database.models import Service
|
||||
from keyboards.inline import get_main_menu
|
||||
from aiogram.types import FSInputFile
|
||||
import os
|
||||
|
||||
user_id = message.from_user.id
|
||||
|
|
@ -141,7 +135,7 @@ async def cmd_masters(message: Message):
|
|||
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
|
||||
|
||||
if not services:
|
||||
await message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML')
|
||||
await safe_edit_or_reply(message, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb, parse_mode='HTML', start_param='masters')
|
||||
return
|
||||
|
||||
text = "📞 <b>СПИСОК ТЕЛЕФОНОВ: МАСТЕРА</b>\n\n"
|
||||
|
|
@ -152,10 +146,7 @@ async def cmd_masters(message: Message):
|
|||
text += "\n"
|
||||
|
||||
photo_path = services[0].image_path if services[0].image_path else None
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
|
||||
else:
|
||||
await message.answer(text, reply_markup=menu_kb, parse_mode='HTML')
|
||||
await safe_edit_or_reply(message, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML', start_param='masters')
|
||||
|
||||
@router.message(F.text & ~F.text.startswith('/'))
|
||||
async def chat_with_ai(message: Message):
|
||||
|
|
@ -185,10 +176,10 @@ async def chat_with_ai(message: Message):
|
|||
async def cb_phone_category(callback: CallbackQuery):
|
||||
"""Выдача телефонов по категориям"""
|
||||
from database.models import Service
|
||||
from aiogram.types import FSInputFile
|
||||
import os
|
||||
|
||||
category_map = {
|
||||
'phones_avariyka': 'avariyka',
|
||||
'phones_emergency': 'emergency',
|
||||
'phones_utility': 'utility',
|
||||
'phones_police': 'police',
|
||||
|
|
@ -199,9 +190,6 @@ async def cb_phone_category(callback: CallbackQuery):
|
|||
if not cat_code: return await callback.answer()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(Service).where(Service.category == cat_code).order_by(Service.name)
|
||||
services = list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
user_stmt = select(User).where(User.user_id == callback.from_user.id)
|
||||
user = (await session.execute(user_stmt)).scalar_one_or_none()
|
||||
ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == callback.from_user.id, InitiativeGroup.is_active == True)
|
||||
|
|
@ -212,23 +200,43 @@ async def cb_phone_category(callback: CallbackQuery):
|
|||
verified = user.verified if user else False
|
||||
menu_kb = get_main_menu(verified=verified, is_ig=is_ig, is_admin=is_admin, is_superadmin=is_superadmin)
|
||||
|
||||
if cat_code == 'avariyka':
|
||||
text = (
|
||||
"⚡ <b>АВАРИЙНАЯ СЛУЖБА (ЭЛЕКТРИКА И ЩИТКИ)</b>\n\n"
|
||||
"📞 Городской: <code>54-69-29</code>\n"
|
||||
"📱 Федеральный: <code>8 (8422) 54-69-29</code>\n\n"
|
||||
"📝 <i>Круглосуточный вызов при замыканиях в щитках, обгорании проводки, авариях электросетей дома ЛКМ37.</i>"
|
||||
)
|
||||
photo_path = 'data/service_images/emergency_avariyka.png'
|
||||
return await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
|
||||
|
||||
stmt = select(Service).where(Service.category == cat_code).order_by(Service.service_id.desc() if cat_code == 'emergency' else Service.name)
|
||||
services = list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
if not services:
|
||||
return await safe_edit_or_reply(callback, "👷 Данные для этой категории еще не заполнены.", reply_markup=menu_kb)
|
||||
|
||||
text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n"
|
||||
category_titles = {
|
||||
'emergency': '🚨 ЭКСТРЕННЫЕ СЛУЖБЫ',
|
||||
'utility': '🔧 КОММУНАЛЬНЫЕ СЛУЖБЫ И ДИСПЕТЧЕР ЖСС',
|
||||
'police': '👮 ПОЛИЦИЯ И УЧАСТКОВЫЙ',
|
||||
'masters': '👷 МАСТЕРА'
|
||||
}
|
||||
title = category_titles.get(cat_code, f'📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}')
|
||||
text = f"<b>{title}</b>\n\n"
|
||||
for s in services:
|
||||
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
|
||||
if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
|
||||
text += "\n"
|
||||
|
||||
# Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
|
||||
photo_path = services[0].image_path if services[0].image_path else None
|
||||
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=menu_kb, parse_mode='HTML')
|
||||
else:
|
||||
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, parse_mode='HTML')
|
||||
# Если есть фото у службы
|
||||
photo_path = None
|
||||
for s in services:
|
||||
if s.image_path and os.path.exists(s.image_path):
|
||||
photo_path = s.image_path
|
||||
break
|
||||
|
||||
await safe_edit_or_reply(callback, text, reply_markup=menu_kb, photo_path=photo_path, parse_mode='HTML')
|
||||
|
||||
@router.message(Command('rules'))
|
||||
@router.callback_query(F.data == 'rules')
|
||||
|
|
@ -334,6 +342,7 @@ class HelpFeedbackState(StatesGroup):
|
|||
waiting_for_reply = State()
|
||||
|
||||
@router.message(Command('help'))
|
||||
@router.message(F.text.in_(['❓ Справка', '❓ Помощь', 'Справка', 'Помощь']))
|
||||
@router.callback_query(F.data == 'help')
|
||||
async def cb_help(event, state: FSMContext):
|
||||
await state.clear()
|
||||
|
|
|
|||
|
|
@ -9,57 +9,62 @@ from utils.ui_styles import LEVEL_EMOJI
|
|||
def get_main_menu(verified: bool = False, is_ig: bool = False, is_admin: bool = False, is_superadmin: bool = False) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Стабильное главное меню LKM37 (Grid 2xN)
|
||||
Строгая фильтрация по ролям:
|
||||
- Гость: база + подтверждение квартиры
|
||||
- Жилец: база + коммунальные сервисы дома
|
||||
- Совет Дома: + Совет Дома
|
||||
- Суперадмин: + Панель управления
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# СТРОКА 1: БАЗА (ВСЕГДА ТУТ)
|
||||
# СТРОКА 1: БАЗА (ДОСТУПНА ВСЕМ)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='📞 Телефоны', callback_data='phones'),
|
||||
InlineKeyboardButton(text='🏢 О доме', callback_data='about_house')
|
||||
InlineKeyboardButton(text='📞 Экстренные службы', callback_data='phones'),
|
||||
InlineKeyboardButton(text='⚖️ Юрист (ЖК РФ)', callback_data='main_jurist')
|
||||
)
|
||||
|
||||
# СТРОКА 2-3: ДЛЯ ВЕРИФИЦИРОВАННЫХ (ФИКСИРОВАННОЕ МЕСТО)
|
||||
if verified:
|
||||
# ДЛЯ ВЕРИФИЦИРОВАННЫХ ЖИЛЬЦОВ
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='📅 График', callback_data='schedule'),
|
||||
InlineKeyboardButton(text='💰 Платежи', callback_data='payments')
|
||||
InlineKeyboardButton(text='⚠️ Отключения', callback_data='schedule'),
|
||||
InlineKeyboardButton(text='💳 Оплата ЖКХ', callback_data='payments')
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='📋 Опросы', callback_data='polls'),
|
||||
InlineKeyboardButton(text='🎉 События', callback_data='events')
|
||||
InlineKeyboardButton(text='🗳️ Опросы дома', callback_data='polls'),
|
||||
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house')
|
||||
)
|
||||
|
||||
# СОВЕТ ДОМА (ИНИЦИАТИВНАЯ ГРУППА)
|
||||
if is_ig:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='👥 Совет Дома', callback_data='ig_menu')
|
||||
)
|
||||
|
||||
# АДМИНИСТРИРОВАНИЕ (ТОЛЬКО ДЛЯ АДМИНОВ / СУПЕРАДМИНА)
|
||||
if is_superadmin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🛡️ Панель управления', callback_data='superadmin_menu')
|
||||
)
|
||||
elif is_admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='⚙️ Управление', callback_data='admin_menu')
|
||||
)
|
||||
|
||||
# СЛУЖЕБНОЕ ВНИЗУ
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🏪 Маркет', callback_data='market'),
|
||||
InlineKeyboardButton(text='🏆 Рейтинг', callback_data='achievements')
|
||||
InlineKeyboardButton(text='👤 Моя квартира', callback_data='my_profile'),
|
||||
InlineKeyboardButton(text='❓ Помощь', callback_data='help')
|
||||
)
|
||||
else:
|
||||
# Для гостей кнопка верификации на видном месте
|
||||
# ДЛЯ НЕВЕРИФИЦИРОВАННЫХ (ГОСТЕЙ)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🛡️ ВЕРИФИКАЦИЯ', callback_data='start_verification')
|
||||
InlineKeyboardButton(text='🏢 Паспорт дома', callback_data='about_house'),
|
||||
InlineKeyboardButton(text='🔑 Подтвердить квартиру', callback_data='start_verification')
|
||||
)
|
||||
|
||||
# СТРОКА 4: ИНИЦИАТИВНАЯ ГРУППА И АДМИН
|
||||
if is_ig or is_admin:
|
||||
buttons = []
|
||||
if is_ig:
|
||||
buttons.append(InlineKeyboardButton(text='🎯 ТИМ-МЕНЮ', callback_data='ig_menu'))
|
||||
if is_admin:
|
||||
buttons.append(InlineKeyboardButton(text='⚙️ АДМИНКА', callback_data='admin_menu'))
|
||||
builder.row(*buttons)
|
||||
|
||||
# СТРОКА 5: СУПЕР-АДМИН (ТЫ)
|
||||
if is_superadmin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='👑 [ROOT: УПРАВЛЕНИЕ]', callback_data='superadmin_menu')
|
||||
InlineKeyboardButton(text='❓ Помощь / Связь с админом', callback_data='help')
|
||||
)
|
||||
|
||||
# СТРОКА 6: СЛУЖЕБНОЕ (НИЗ)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='❓ Помощь', callback_data='help'),
|
||||
InlineKeyboardButton(text='🌦️ Погода', callback_data='weather'),
|
||||
InlineKeyboardButton(text='👤 Профиль', callback_data='my_profile')
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
|
@ -121,14 +126,14 @@ def get_admin_menu() -> InlineKeyboardMarkup:
|
|||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='📊 Статистика', callback_data='admin_stats'),
|
||||
InlineKeyboardButton(text='🕵️ Шпионы', callback_data='admin_spies'),
|
||||
InlineKeyboardButton(text='🛡️ Фильтр посторонних', callback_data='admin_spies'),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='👥 Пользователи', callback_data='admin_users'),
|
||||
InlineKeyboardButton(text='⏳ Непроверенные', callback_data='admin_unverified'),
|
||||
InlineKeyboardButton(text='👥 Список жителей', callback_data='admin_users'),
|
||||
InlineKeyboardButton(text='⏳ Ожидают проверки', callback_data='admin_unverified'),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='📥 Экспорт JSON', callback_data='admin_export'),
|
||||
InlineKeyboardButton(text='📥 Выгрузка базы', callback_data='admin_export'),
|
||||
InlineKeyboardButton(text='📤 Рассылка', callback_data='admin_broadcast'),
|
||||
)
|
||||
builder.row(
|
||||
|
|
@ -158,15 +163,18 @@ def get_phones_menu() -> InlineKeyboardMarkup:
|
|||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🚨 Экстренные', callback_data='phones_emergency'),
|
||||
InlineKeyboardButton(text='🔧 ЖКХ', callback_data='phones_utility'),
|
||||
InlineKeyboardButton(text='⚡ Аварийка (54-69-29)', callback_data='phones_avariyka'),
|
||||
InlineKeyboardButton(text='🚨 Все экстренные', callback_data='phones_emergency'),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='👮 Полиция', callback_data='phones_police'),
|
||||
InlineKeyboardButton(text='👷 Мастера', callback_data='phones_masters'),
|
||||
InlineKeyboardButton(text='🔧 Диспетчер ЖКХ', callback_data='phones_utility'),
|
||||
InlineKeyboardButton(text='👮 Полиция / Участковый', callback_data='phones_police'),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'),
|
||||
InlineKeyboardButton(text='👷 Мастера (сантехник и др.)', callback_data='phones_masters'),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text='🔙 Назад в меню', callback_data='main_menu'),
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
|
|||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
KeyboardButton(text='📞 Телефоны'),
|
||||
KeyboardButton(text='📞 Важные телефоны'),
|
||||
KeyboardButton(text='👷 Мастера'),
|
||||
)
|
||||
builder.row(
|
||||
KeyboardButton(text='❓ Помощь'),
|
||||
KeyboardButton(text='❓ Справка'),
|
||||
)
|
||||
|
||||
# Resize keyboard чтобы не занимал много места
|
||||
|
|
@ -29,12 +29,13 @@ def get_admin_reply_menu() -> ReplyKeyboardMarkup:
|
|||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
KeyboardButton(text='/admin'),
|
||||
KeyboardButton(text='/stats'),
|
||||
KeyboardButton(text='⚙️ Управление'),
|
||||
KeyboardButton(text='📊 Статистика'),
|
||||
)
|
||||
builder.row(
|
||||
KeyboardButton(text='/spies'),
|
||||
KeyboardButton(text='/export'),
|
||||
KeyboardButton(text='🛡️ Фильтр посторонних'),
|
||||
KeyboardButton(text='📥 Выгрузка базы'),
|
||||
)
|
||||
|
||||
return builder.as_markup(resize_keyboard=True)
|
||||
|
||||
|
|
|
|||
61
main.py
61
main.py
|
|
@ -49,6 +49,52 @@ async def on_startup(bot: Bot):
|
|||
await init_db()
|
||||
scheduler = Scheduler(bot)
|
||||
scheduler.start()
|
||||
try:
|
||||
from aiogram.types import (
|
||||
BotCommand, BotCommandScopeDefault,
|
||||
BotCommandScopeAllGroupChats, BotCommandScopeAllPrivateChats,
|
||||
BotCommandScopeChat
|
||||
)
|
||||
|
||||
# 1. Для всех групповых чатов (публичный чат дома) - только базовая справка, без админок
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="phones", description="📞 Экстренные службы (в ЛС)"),
|
||||
BotCommand(command="help", description="❓ Справка (в ЛС)")
|
||||
], scope=BotCommandScopeAllGroupChats())
|
||||
|
||||
# 2. Для всех личных чатов с жильцами
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="start", description="Главное меню дома"),
|
||||
BotCommand(command="phones", description="📞 Экстренные службы"),
|
||||
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
|
||||
BotCommand(command="help", description="❓ Помощь и навигация")
|
||||
], scope=BotCommandScopeAllPrivateChats())
|
||||
|
||||
# 3. Персонально для суперадмина дома (Александр Тырин)
|
||||
if ADMIN_USER_ID:
|
||||
try:
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="admin", description="🛡️ Админ-панель дома"),
|
||||
BotCommand(command="start", description="Главное меню дома"),
|
||||
BotCommand(command="phones", description="📞 Экстренные службы"),
|
||||
BotCommand(command="jurist", description="⚖️ Юрист & Разбор ЖК РФ"),
|
||||
BotCommand(command="stats", description="📊 Статистика дома"),
|
||||
BotCommand(command="help", description="❓ Помощь и навигация")
|
||||
], scope=BotCommandScopeChat(chat_id=ADMIN_USER_ID))
|
||||
except Exception as admin_cmd_err:
|
||||
logger.warning(f"Failed to set admin commands scope: {admin_cmd_err}")
|
||||
|
||||
# 4. Дефолтный fallback
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="start", description="Главное меню дома"),
|
||||
BotCommand(command="phones", description="📞 Экстренные телефоны"),
|
||||
BotCommand(command="help", description="❓ Помощь")
|
||||
], scope=BotCommandScopeDefault())
|
||||
|
||||
logger.info("✅ Telegram BotCommand scopes successfully updated.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set bot commands: {e}")
|
||||
|
||||
try:
|
||||
await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>')
|
||||
await send_domovoy_alert("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.")
|
||||
|
|
@ -59,7 +105,12 @@ async def main():
|
|||
bot = get_bot()
|
||||
dp = Dispatcher(storage=MemoryStorage())
|
||||
|
||||
# Регистрация роутеров МКД
|
||||
# Регистрация роутеров МКД (Юрист в наивысшем приоритете)
|
||||
from handlers.legal_advisor import router as legal_advisor_router
|
||||
from handlers.smart_broadcast import router as smart_broadcast_router
|
||||
from handlers.digest import router as digest_router
|
||||
|
||||
dp.include_router(legal_advisor_router)
|
||||
dp.include_router(admin_router)
|
||||
dp.include_router(users_router)
|
||||
|
||||
|
|
@ -69,16 +120,12 @@ async def main():
|
|||
antispam_router, ratelimit_router, schedule_router, ads_router,
|
||||
payments_router, multicount_router, topics_router, toxicity_router,
|
||||
profile_history_router, achievements_router, thanks_router,
|
||||
events_router, initiative_group_router, mutual_aid_router
|
||||
events_router, initiative_group_router, mutual_aid_router,
|
||||
smart_broadcast_router, digest_router
|
||||
]
|
||||
|
||||
for r in other_routers:
|
||||
dp.include_router(r)
|
||||
|
||||
from handlers.smart_broadcast import router as smart_broadcast_router
|
||||
from handlers.digest import router as digest_router
|
||||
dp.include_router(smart_broadcast_router)
|
||||
dp.include_router(digest_router)
|
||||
|
||||
|
||||
dp.startup.register(on_startup)
|
||||
|
|
|
|||
|
|
@ -26,3 +26,4 @@ google-auth-oauthlib
|
|||
|
||||
|
||||
edge-tts>=7.0.0
|
||||
notebooklm-py>=0.8.5
|
||||
|
|
|
|||
276
services/ai_legal_engine.py
Normal file
276
services/ai_legal_engine.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Интеллектуальное юридическое ядро МКД «Домовой 2.0» (AI Legal Engine)
|
||||
Работает на базе Gemini 2.5 Flash / Pro:
|
||||
- Нативный слух (Gemini Multimodal Audio) без Whisper
|
||||
- Прямой асинхронный REST API (высокая отказоустойчивость без конфликтов pydantic)
|
||||
- Разграничение ролей: resident (соседский помощник), lawyer (претензионно-судебный), admin (глубокий аудит)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("ai_legal_engine")
|
||||
class AILegalEngine:
|
||||
def __init__(self):
|
||||
raw_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk")
|
||||
self.api_key = raw_key.strip("\"'") if raw_key else ""
|
||||
self.client = None
|
||||
self.model_name = "gemini-2.5-flash"
|
||||
self.deep_model_name = "gemini-2.5-flash"
|
||||
self._init_sdk_client()
|
||||
|
||||
def _init_sdk_client(self):
|
||||
if not self.api_key:
|
||||
log.warning("Gemini API Key missing! AI Legal Engine will run in offline mode.")
|
||||
return
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
self.client = genai.Client(api_key=self.api_key)
|
||||
log.info("Gemini SDK Client initialized.")
|
||||
except Exception as e:
|
||||
log.info(f"Using high-performance direct REST engine (SDK not active: {e})")
|
||||
|
||||
def get_role_instructions(self, role: str) -> str:
|
||||
if role == "resident":
|
||||
return (
|
||||
"СТИЛЬ ОТВЕТА: Спокойный, понятный, без бюрократического канцелярита.\n"
|
||||
"ТРЕБОВАНИЯ:\n"
|
||||
"1. Суть ситуации простыми словами (1-2 абзаца).\n"
|
||||
"2. Пошаговый порядок действий (Шаг 1, Шаг 2, Шаг 3).\n"
|
||||
"3. Кто несет ответственность (УК, застройщик, поставщик ресурсов, совет дома).\n"
|
||||
"4. Образец краткого заявления при необходимости."
|
||||
)
|
||||
else:
|
||||
return (
|
||||
"СТИЛЬ ОТВЕТА: Сугубо юридический, строгий, аргументированный.\n"
|
||||
"ТРЕБОВАНИЯ:\n"
|
||||
"1. Правовая квалификация ситуации со ссылками на статьи нормативных актов РФ (ЖК РФ, ГК РФ, 59-ФЗ, ЗоЗПП, ПП РФ № 354, ПП РФ № 491, Госстрой № 170, СанПиН).\n"
|
||||
"2. Оценка рисков, перспектив в надзорных органах и суде.\n"
|
||||
"3. Четкий алгоритм действий (досудебная претензия, жалоба в ГЖИ / прокуратуру, иск).\n"
|
||||
"4. Готовый образец мотивировочной части претензии или заявления при необходимости."
|
||||
)
|
||||
return "Отвечай строго по существу."
|
||||
|
||||
async def _query_rest_api(self, prompt: str, system_instruction: str, model: str, audio_path: str = None) -> str:
|
||||
"""Прямой асинхронный REST запрос к Google Gemini API."""
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}"
|
||||
parts = []
|
||||
|
||||
# Аудио вложение
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
with open(audio_path, "rb") as af:
|
||||
audio_b64 = base64.b64encode(af.read()).decode("utf-8")
|
||||
parts.append({
|
||||
"inlineData": {
|
||||
"mimeType": "audio/ogg",
|
||||
"data": audio_b64
|
||||
}
|
||||
})
|
||||
if not prompt:
|
||||
prompt = "Внимательно прослушай аудиосообщение жильца дома. Точно выдели юридическую суть и дай обоснованный ответ."
|
||||
|
||||
parts.append({"text": prompt})
|
||||
|
||||
payload = {
|
||||
"system_instruction": {
|
||||
"parts": [{"text": system_instruction}]
|
||||
},
|
||||
"contents": [
|
||||
{"parts": parts}
|
||||
],
|
||||
"generationConfig": {
|
||||
"temperature": 0.2 if model == self.deep_model_name else 0.4
|
||||
}
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=45)) as resp:
|
||||
if resp.status != 200:
|
||||
err_text = await resp.text()
|
||||
raise RuntimeError(f"Gemini API HTTP {resp.status}: {err_text[:300]}")
|
||||
data = await resp.json()
|
||||
return data["candidates"][0]["content"]["parts"][0]["text"]
|
||||
|
||||
async def transcribe_audio(self, audio_path: str) -> str:
|
||||
"""Транскрибирует аудиосообщение жильца в текст через Gemini Multimodal Audio (Voice-to-Text)."""
|
||||
if not self.api_key or not audio_path or not os.path.exists(audio_path):
|
||||
return ""
|
||||
|
||||
prompt = (
|
||||
"Дословно расшифруй аудиосообщение на русском языке. "
|
||||
"Не добавляй пояснений, не отвечай на вопрос и не искажай слова жильца. "
|
||||
"Выведи ТОЛЬКО расшифрованный текст."
|
||||
)
|
||||
system_instruction = "Ты — высокоточный модуль транскрибации речи (Speech-to-Text). Верни только произнесенный текст."
|
||||
|
||||
for model_to_use in [self.model_name, "gemini-flash-latest", "gemini-2.5-flash"]:
|
||||
try:
|
||||
transcription = await self._query_rest_api(
|
||||
prompt=prompt,
|
||||
system_instruction=system_instruction,
|
||||
model=model_to_use,
|
||||
audio_path=audio_path
|
||||
)
|
||||
if transcription and transcription.strip():
|
||||
return transcription.strip()
|
||||
except Exception as e:
|
||||
log.warning(f"Voice transcription attempt failed on {model_to_use}: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return ""
|
||||
|
||||
async def query_notebooklm(self, prompt: str) -> dict:
|
||||
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 37)."""
|
||||
import shutil
|
||||
notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710")
|
||||
candidates = [
|
||||
"/usr/local/bin/notebooklm",
|
||||
shutil.which("notebooklm"),
|
||||
"/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm",
|
||||
"notebooklm"
|
||||
]
|
||||
nb_bin = None
|
||||
for b in candidates:
|
||||
if b and os.path.exists(b) and os.access(b, os.X_OK):
|
||||
try:
|
||||
with open(b, "rb") as f:
|
||||
first_line = f.readline()
|
||||
if first_line.startswith(b"#!"):
|
||||
interp = first_line[2:].strip().decode("utf-8", errors="ignore")
|
||||
if not os.path.exists(interp):
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
nb_bin = b
|
||||
break
|
||||
if not nb_bin:
|
||||
nb_bin = "/usr/local/bin/notebooklm" if os.path.exists("/usr/local/bin/notebooklm") else "notebooklm"
|
||||
|
||||
cmd = [
|
||||
nb_bin, "ask",
|
||||
"-n", notebook_id,
|
||||
prompt
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = "/root" if os.path.exists("/root/.notebooklm") else os.environ.get("HOME", "/root")
|
||||
env["SSL_CERT_FILE"] = "/etc/ssl/certs/ca-certificates.crt"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env
|
||||
)
|
||||
try:
|
||||
# Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300.0)
|
||||
except asyncio.TimeoutError:
|
||||
try: proc.kill()
|
||||
except: pass
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Таймаут обработки запроса к блокноту документов дома (более 300 сек)",
|
||||
"source": "notebooklm"
|
||||
}
|
||||
|
||||
out_str = stdout.decode('utf-8', errors='ignore').strip()
|
||||
err_str = stderr.decode('utf-8', errors='ignore').strip()
|
||||
|
||||
if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str:
|
||||
lines = [
|
||||
l for l in out_str.splitlines()
|
||||
if not l.startswith("Continuing conversation")
|
||||
and not l.startswith("Resumed conversation")
|
||||
]
|
||||
cleaned = "\n".join(lines).strip()
|
||||
if cleaned.startswith("Answer:"):
|
||||
cleaned = cleaned[len("Answer:"):].strip()
|
||||
return {"success": True, "text": cleaned, "source": "notebooklm"}
|
||||
else:
|
||||
log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:250]}, err={err_str[:250]}")
|
||||
return {"success": False, "error": err_str or out_str, "source": "notebooklm"}
|
||||
except Exception as e:
|
||||
log.error(f"NotebookLM exec exception: {e}")
|
||||
return {"success": False, "error": str(e), "source": "notebooklm"}
|
||||
|
||||
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
|
||||
"""
|
||||
Канонический метод запроса юридического помощника МКД «Домовой 2.0»:
|
||||
- Все ответы формируются СТРОГО на основе документов дома № 37 из блокнота NotebookLM.
|
||||
- Никаких общих абстрактных ответов или галлюцинаций Gemini!
|
||||
- При отправке аудиосообщения: Gemini выполняет ИСКЛЮЧИТЕЛЬНО распознавание голоса в текст (STT),
|
||||
после чего текст вопроса направляется в NotebookLM.
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
transcribed_text = None
|
||||
|
||||
# 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст
|
||||
if audio_path:
|
||||
log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...")
|
||||
transcribed_text = await self.transcribe_audio(audio_path)
|
||||
if not transcribed_text:
|
||||
return {
|
||||
"success": False,
|
||||
"text": "❌ <b>Не удалось распознать голосовое сообщение.</b> Пожалуйста, напишите ваш вопрос текстом.",
|
||||
"role": role,
|
||||
"latency_sec": round((datetime.now() - start_time).total_seconds(), 2)
|
||||
}
|
||||
log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'")
|
||||
prompt = transcribed_text
|
||||
|
||||
if not prompt or not prompt.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"text": "❌ Вопрос пустой. Пожалуйста, сформулируйте юридический вопрос.",
|
||||
"role": role,
|
||||
"latency_sec": 0.0
|
||||
}
|
||||
|
||||
# 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС)
|
||||
nlm_res = await self.query_notebooklm(prompt)
|
||||
latency = round((datetime.now() - start_time).total_seconds(), 2)
|
||||
|
||||
if nlm_res.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"text": nlm_res["text"],
|
||||
"answer": nlm_res["text"],
|
||||
"role": role,
|
||||
"latency_sec": latency,
|
||||
"model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)",
|
||||
"transcribed_question": transcribed_text
|
||||
}
|
||||
else:
|
||||
err_msg = nlm_res.get("error", "Неизвестная ошибка")
|
||||
log.error(f"NotebookLM legal query failed: {err_msg}")
|
||||
# Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы
|
||||
return {
|
||||
"success": False,
|
||||
"text": "❌ <b>База документов дома № 37 временно обновляет сессию.</b>",
|
||||
"role": role,
|
||||
"latency_sec": latency,
|
||||
"error_detail": err_msg
|
||||
}
|
||||
|
||||
# Глобальный синглтон юридического ядра для роутеров бота
|
||||
legal_engine = AILegalEngine()
|
||||
|
||||
if __name__ == "__main__":
|
||||
async def test():
|
||||
engine = AILegalEngine()
|
||||
res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?")
|
||||
print("Success:", res["success"])
|
||||
print("Latency:", res["latency_sec"])
|
||||
print("Model:", res.get("model"))
|
||||
print("Text snippet:\n", res["text"][:300])
|
||||
|
||||
asyncio.run(test())
|
||||
206
services/notebooklm_podcast_generator.py
Normal file
206
services/notebooklm_podcast_generator.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Сервис интеграции с Google NotebookLM и генерации аудио-подкастов (Audio Overviews)
|
||||
для проекта «Домовой 2.0: Юридический Ядерный Щит МКД».
|
||||
|
||||
Возможности:
|
||||
1. Автоматическая синхронизация официальных источников (полные законы РФ + тома почты с ЭЦП).
|
||||
2. Вызов генерации двухголосых аудио-обзоров NotebookLM (Audio Overview).
|
||||
3. Скачивание аудио-подкастов (.m4a/.mp3) в локальное хранилище.
|
||||
4. Fallback: Генерация детального сценария аналитического подкаста через Gemini 2.5 Pro.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
logger = logging.getLogger("notebooklm_podcast")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
NOTEBOOKLM_BIN = "/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm"
|
||||
PODCASTS_DIR = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
|
||||
LEGAL_SOURCES_DIR = Path("/home/matrixhasyou/mutt/legal_sources")
|
||||
MEGA_VOLUMES_DIR = Path("/home/matrixhasyou/mutt/mega_volumes")
|
||||
DEFAULT_NOTEBOOK_NAME = "Разбор ответов строго по ЖК РФ"
|
||||
|
||||
class NotebookLMPodcastGenerator:
|
||||
def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME):
|
||||
self.notebook_name = notebook_name
|
||||
self.cli_bin = NOTEBOOKLM_BIN
|
||||
PODCASTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Проверяет, авторизован ли CLI NotebookLM."""
|
||||
storage_file = Path(os.path.expanduser("~/.notebooklm/profiles/default/storage_state.json"))
|
||||
if not storage_file.exists() and not os.getenv("NOTEBOOKLM_AUTH_JSON"):
|
||||
return False
|
||||
try:
|
||||
res = subprocess.run([self.cli_bin, "list"], capture_output=True, text=True, timeout=10)
|
||||
return res.returncode == 0 and "Not logged in" not in res.stderr and "Not logged in" not in res.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_notebooks(self) -> List[Dict[str, str]]:
|
||||
"""Возвращает список блокнотов из NotebookLM."""
|
||||
if not self.is_authenticated():
|
||||
return []
|
||||
try:
|
||||
res = subprocess.run([self.cli_bin, "list", "--json"], capture_output=True, text=True, timeout=15)
|
||||
if res.returncode == 0:
|
||||
data = json.loads(res.stdout)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка блокнотов: {e}")
|
||||
return []
|
||||
|
||||
def get_or_create_notebook(self) -> Optional[str]:
|
||||
"""Находит существующий блокнот дома или создает новый."""
|
||||
notebooks = self.list_notebooks()
|
||||
for nb in notebooks:
|
||||
if self.notebook_name.lower() in nb.get("title", "").lower():
|
||||
return nb.get("id")
|
||||
|
||||
# Создаем новый
|
||||
try:
|
||||
res = subprocess.run([self.cli_bin, "create", self.notebook_name], capture_output=True, text=True, timeout=20)
|
||||
if res.returncode == 0:
|
||||
# Находим созданный
|
||||
for nb in self.list_notebooks():
|
||||
if self.notebook_name.lower() in nb.get("title", "").lower():
|
||||
return nb.get("id")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания блокнота: {e}")
|
||||
return None
|
||||
|
||||
def upload_source_file(self, file_path: str, notebook_id: Optional[str] = None) -> bool:
|
||||
"""Загружает PDF-файл (закон или том почты) в NotebookLM."""
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"Файл не найден: {file_path}")
|
||||
return False
|
||||
|
||||
args = [self.cli_bin, "source", "add", file_path]
|
||||
if notebook_id:
|
||||
args.extend(["--notebook", notebook_id])
|
||||
|
||||
try:
|
||||
res = subprocess.run(args, capture_output=True, text=True, timeout=180)
|
||||
return res.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки источника {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def sync_all_sources(self) -> Dict[str, Any]:
|
||||
"""Синхронизирует все полные законы и тома мега-PDF с NotebookLM."""
|
||||
if not self.is_authenticated():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "NotebookLM CLI не авторизован. Требуется импорт профиля в ~/.notebooklm/profiles/default/"
|
||||
}
|
||||
|
||||
nb_id = self.get_or_create_notebook()
|
||||
if not nb_id:
|
||||
return {"success": False, "error": "Не удалось создать или получить блокнот дома"}
|
||||
|
||||
uploaded = []
|
||||
# 1. Законы РФ
|
||||
if LEGAL_SOURCES_DIR.exists():
|
||||
for pdf in sorted(LEGAL_SOURCES_DIR.glob("*Полный*.pdf")):
|
||||
if self.upload_source_file(str(pdf), nb_id):
|
||||
uploaded.append(pdf.name)
|
||||
|
||||
# 2. Мега-тома писем
|
||||
if MEGA_VOLUMES_DIR.exists():
|
||||
for pdf in sorted(MEGA_VOLUMES_DIR.glob("*.pdf")):
|
||||
if self.upload_source_file(str(pdf), nb_id):
|
||||
uploaded.append(pdf.name)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"notebook_id": nb_id,
|
||||
"uploaded_sources": uploaded
|
||||
}
|
||||
|
||||
def generate_audio_overview(self, notebook_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Запускает генерацию двухголосного подкаста в NotebookLM."""
|
||||
if not self.is_authenticated():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "NotebookLM не авторизован. Доступен fallback-режим генерации через Gemini 2.5."
|
||||
}
|
||||
|
||||
args = [self.cli_bin, "generate", "audio"]
|
||||
if notebook_id:
|
||||
args.extend(["--notebook", notebook_id])
|
||||
|
||||
try:
|
||||
res = subprocess.run(args, capture_output=True, text=True, timeout=300)
|
||||
if res.returncode == 0:
|
||||
return {"success": True, "output": res.stdout.strip()}
|
||||
return {"success": False, "error": res.stderr.strip() or res.stdout.strip()}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def download_latest_audio(self, output_filename: str = "latest_podcast.m4a") -> Optional[str]:
|
||||
"""Скачивает готовый сгенерированный подкаст."""
|
||||
dest = PODCASTS_DIR / output_filename
|
||||
args = [self.cli_bin, "download", "audio", "--output", str(dest)]
|
||||
try:
|
||||
res = subprocess.run(args, capture_output=True, text=True, timeout=120)
|
||||
if res.returncode == 0 and dest.exists():
|
||||
return str(dest)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка скачивания аудио: {e}")
|
||||
return None
|
||||
|
||||
def generate_fallback_dialogue_script(self, context_summary: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Fallback генератор: при отсутствии сессии NotebookLM формирует
|
||||
полноценный драматический сценарий двухголосного подкаста через Gemini 2.5 Pro.
|
||||
"""
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
api_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk")
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
prompt = f"""Ты — ведущий сценарист и главный аналитик юридического аудио-подкаста дома «ЛенКом 37: Голос Закона».
|
||||
Твоя задача — составить живой, увлекательный, юридически безупречный сценарий подкаста на 7-10 минут (в формате диалога двух экспертов):
|
||||
|
||||
Ведущие:
|
||||
1. Александр (Ведущий / Председатель Совета Дома) — задает острые вопросы жителей, приводит факты из писем госорганов, вскрывает уловки УК и прокуратуры.
|
||||
2. Майя (Юрист-аналитик по жилищному праву) — профессионально, с юмором и железобетонными ссылками на законы (ЖК РФ, 59-ФЗ, ПП 354, Госстрой 170) объясняет, в чем неправы чиновники и как жителям защитить свои права.
|
||||
|
||||
Контекст переписки и правовой базы дома:
|
||||
{context_summary}
|
||||
|
||||
Формат ответа:
|
||||
Заголовок подкаста.
|
||||
Текст реплик с пометками [Александр] и [Майя].
|
||||
В конце — четкие выводы и 3 практических шага для жителей дома.
|
||||
"""
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.4
|
||||
)
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"script": response.text,
|
||||
"engine": "gemini-2.5-flash-fallback"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
generator = NotebookLMPodcastGenerator()
|
||||
print("NotebookLM Auth:", generator.is_authenticated())
|
||||
if not generator.is_authenticated():
|
||||
print("Running fallback test...")
|
||||
res = generator.generate_fallback_dialogue_script(
|
||||
"Судебные тяжбы с УК по перерасчету отопления, игнорирование прокуратурой Заволжского района сроков 59-ФЗ."
|
||||
)
|
||||
print("Generated script snippet:\n", res["script"][:400])
|
||||
|
|
@ -21,7 +21,7 @@ class Scheduler:
|
|||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self.scheduler = AsyncIOScheduler(timezone='Europe/Ulyanovsk')
|
||||
|
||||
def start(self):
|
||||
"""Запуск планировщика"""
|
||||
|
|
@ -141,25 +141,24 @@ class Scheduler:
|
|||
)
|
||||
logger.info('✅ Добавлена задача ежедневного прогноза погоды для админа')
|
||||
|
||||
# Еженедельный прогноз в чат по понедельникам (в 09:00)
|
||||
self.scheduler.add_job(
|
||||
self.monday_chat_weather,
|
||||
CronTrigger.from_crontab('0 9 * * 1'), # Пн в 09:00
|
||||
id='monday_chat_weather',
|
||||
name='Прогноз погоды в чат (Понедельник)',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info('✅ Добавлена задача прогноза погоды в чат по понедельникам')
|
||||
|
||||
# Еженедельный прогноз в чат по четвергам (в 09:00)
|
||||
self.scheduler.add_job(
|
||||
self.thursday_chat_weather,
|
||||
CronTrigger.from_crontab('0 9 * * 4'), # Чт в 09:00
|
||||
id='thursday_chat_weather',
|
||||
name='Прогноз погоды в чат (Четверг)',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info('✅ Добавлена задача прогноза погоды в чат по четвергам')
|
||||
# [ОТКЛЮЧЕНО ПО ТЗ] Еженедельный прогноз в чат по понедельникам и четвергам отключен для группы дома
|
||||
# Жильцы начали активнее общаться, прогноз погоды в общей группе больше не требуется.
|
||||
# Личный прогноз для Александра (daily_admin_weather) выше остается активным.
|
||||
# self.scheduler.add_job(
|
||||
# self.monday_chat_weather,
|
||||
# CronTrigger.from_crontab('0 9 * * 1'),
|
||||
# id='monday_chat_weather',
|
||||
# name='Прогноз погоды в чат (Понедельник)',
|
||||
# replace_existing=True
|
||||
# )
|
||||
# self.scheduler.add_job(
|
||||
# self.thursday_chat_weather,
|
||||
# CronTrigger.from_crontab('0 9 * * 4'),
|
||||
# id='thursday_chat_weather',
|
||||
# name='Прогноз погоды в чат (Четверг)',
|
||||
# replace_existing=True
|
||||
# )
|
||||
logger.info('ℹ️ Прогноз погоды в общий чат дома отключен по требованию председателя')
|
||||
|
||||
# Еженедельный дайджест Александра (суббота в 08:00)
|
||||
self.scheduler.add_job(
|
||||
|
|
@ -550,8 +549,17 @@ class Scheduler:
|
|||
"""Прогноз в чат по понедельникам (на 3 дня)"""
|
||||
try:
|
||||
from services.weather import WeatherService
|
||||
from config import ADMIN_CHAT_ID
|
||||
from config import ADMIN_CHAT_ID, ADMIN_USER_ID
|
||||
forecast = await WeatherService.get_3day_forecast()
|
||||
if not forecast or forecast.startswith("❌"):
|
||||
logger.warning(f"Monday weather forecast failed: {forecast}")
|
||||
await self.bot.send_message(
|
||||
ADMIN_USER_ID,
|
||||
f"⚠️ <b>Внимание:</b> Не удалось сгенерировать прогноз погоды на понедельник для общего чата:\n{forecast}",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
return
|
||||
|
||||
await self.bot.send_message(
|
||||
ADMIN_CHAT_ID,
|
||||
forecast,
|
||||
|
|
@ -565,8 +573,17 @@ class Scheduler:
|
|||
"""Прогноз в чат по четвергам (на остаток недели и выходные)"""
|
||||
try:
|
||||
from services.weather import WeatherService
|
||||
from config import ADMIN_CHAT_ID
|
||||
from config import ADMIN_CHAT_ID, ADMIN_USER_ID
|
||||
forecast = await WeatherService.get_thursday_weekend_broadcast()
|
||||
if not forecast or forecast.startswith("❌"):
|
||||
logger.warning(f"Thursday weather forecast failed: {forecast}")
|
||||
await self.bot.send_message(
|
||||
ADMIN_USER_ID,
|
||||
f"⚠️ <b>Внимание:</b> Не удалось сгенерировать прогноз погоды на четверг для общего чата:\n{forecast}",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
return
|
||||
|
||||
await self.bot.send_message(
|
||||
ADMIN_CHAT_ID,
|
||||
forecast,
|
||||
|
|
|
|||
169
utils/redirect.py
Normal file
169
utils/redirect.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""
|
||||
Универсальный шлюз перенаправления в личные сообщения (Zero-Spam Redirect)
|
||||
Все ответы на команды и меню отправляются строго в ЛС, не засоряя группу.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, FSInputFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_after_delay(msg: Message, delay: int = 15):
|
||||
"""Фоновое удаление временного уведомления через delay секунд"""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await msg.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def respond_pm_or_redirect(
|
||||
event: Message | CallbackQuery,
|
||||
text: str,
|
||||
reply_markup=None,
|
||||
photo_path: str = None,
|
||||
parse_mode: str = 'HTML',
|
||||
start_param: str = 'start'
|
||||
):
|
||||
"""
|
||||
Отправляет контент пользователю строго в личные сообщения.
|
||||
В групповом чате команда тихо удаляется, а пользователю отправляется карточка в ЛС.
|
||||
Если бот не может написать в ЛС первым (пользователь не нажал /start),
|
||||
выводится короткая временная плашка с кнопкой перехода в ЛС, удаляемая через 15 сек.
|
||||
"""
|
||||
bot = event.bot
|
||||
|
||||
# --- Обработка CallbackQuery ---
|
||||
if isinstance(event, CallbackQuery):
|
||||
message = event.message
|
||||
|
||||
# Если инлайн-кнопку нажали в группе
|
||||
if message and message.chat.type in ('group', 'supergroup'):
|
||||
user_id = event.from_user.id
|
||||
try:
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await bot.send_photo(
|
||||
chat_id=user_id,
|
||||
photo=FSInputFile(photo_path),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
await event.answer("Отправил вам в личные сообщения 📩", show_alert=False)
|
||||
except Exception as e:
|
||||
logger.info(f"Cannot PM user {user_id} on callback: {e}")
|
||||
await event.answer(
|
||||
"Пожалуйста, сначала откройте диалог с ботом в личке (@lkmthreeseven_bot)!",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
# Если инлайн-кнопку нажали в ЛС
|
||||
await event.answer()
|
||||
has_media = False
|
||||
if message:
|
||||
has_media = bool(
|
||||
(hasattr(message, 'photo') and message.photo) or
|
||||
(hasattr(message, 'video') and message.video) or
|
||||
(hasattr(message, 'document') and message.document) or
|
||||
(hasattr(message, 'content_type') and message.content_type != 'text')
|
||||
)
|
||||
|
||||
if has_media or (photo_path and os.path.exists(photo_path)):
|
||||
if message:
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await bot.send_photo(
|
||||
chat_id=event.from_user.id,
|
||||
photo=FSInputFile(photo_path),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=event.from_user.id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
except Exception:
|
||||
await message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
return
|
||||
|
||||
# --- Обработка Message ---
|
||||
if isinstance(event, Message):
|
||||
user_id = event.from_user.id
|
||||
|
||||
# Если сообщение получено в личке — штатный ответ
|
||||
if event.chat.type == 'private':
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await event.answer_photo(
|
||||
FSInputFile(photo_path),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
await event.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
return
|
||||
|
||||
# Если сообщение получено в группе или супергруппе:
|
||||
# 1. Пытаемся тихо удалить сообщение-команду жильца
|
||||
try:
|
||||
await event.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Отправляем карточку/меню пользователю в ЛС
|
||||
sent_to_pm = False
|
||||
try:
|
||||
if photo_path and os.path.exists(photo_path):
|
||||
await bot.send_photo(
|
||||
chat_id=user_id,
|
||||
photo=FSInputFile(photo_path),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
sent_to_pm = True
|
||||
except Exception as e:
|
||||
logger.info(f"Cannot PM user {user_id}: {e}")
|
||||
|
||||
# 3. Если в личку отправить не удалось (пользователь не начинал диалог с ботом)
|
||||
if not sent_to_pm:
|
||||
bot_user = await bot.get_me()
|
||||
url = f"https://t.me/{bot_user.username}?start={start_param}"
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[[
|
||||
InlineKeyboardButton(text="💬 Открыть в личке", url=url)
|
||||
]])
|
||||
user_mention = event.from_user.mention_html()
|
||||
hint = await bot.send_message(
|
||||
chat_id=event.chat.id,
|
||||
text=f"👋 {user_mention}, чтобы не спамить в общий чат, перейдите в диалог с ботом:",
|
||||
reply_markup=kb,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
asyncio.create_task(delete_after_delay(hint, 15))
|
||||
190
web/app.py
190
web/app.py
|
|
@ -115,40 +115,7 @@ async def upload_to_tg(content, filename, content_type):
|
|||
file_id = msg["photo"][-1]["file_id"] if file_type == "photo" else msg["document"]["file_id"]
|
||||
return file_id, file_type
|
||||
|
||||
@app.post("/api/emails/sync_ai")
|
||||
async def api_sync_emails_to_ai(username: str = Depends(get_current_admin)):
|
||||
sync_script = "/app/domovoy_drive_sync.py" if os.path.exists("/app/domovoy_drive_sync.py") else str(Path(config.MUTT_DIR).parent / "domovoy_drive_sync.py")
|
||||
python_path = sys.executable
|
||||
|
||||
try:
|
||||
# Запуск синхронизации
|
||||
process = subprocess.run(
|
||||
[python_path, sync_script],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
|
||||
if process.returncode == 0:
|
||||
# Уведомляем админа в ТГ
|
||||
msg = "🤖 <b>AI Синхронизация Успешна!</b>\n\n📄 Архив почты обновлен на Google Drive.\n👉 Не забудь нажать <b>'Sync'</b> в NotebookLM!"
|
||||
|
||||
# Отправка через API Telegram (упрощенно)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||
params = {
|
||||
"chat_id": config.ADMIN_USER_ID,
|
||||
"text": msg,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
# Используем прокси если нужно
|
||||
proxy = f"http://{config.PROXY_HOST}:{config.PROXY_PORT}" if config.USE_PROXY else None
|
||||
await session.get(url, params=params, proxy=proxy)
|
||||
|
||||
return {"success": True, "message": "Синхронизация завершена, уведомление отправлено."}
|
||||
else:
|
||||
return {"success": False, "error": process.stderr or process.stdout}
|
||||
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||
|
||||
# ============================================================================
|
||||
# ГЛАВНАЯ ПАНЕЛЬ (DASHBOARD)
|
||||
|
|
@ -679,7 +646,16 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
|
|||
latest_emails.append({"date": "-", "domain": "other", "subject": f.stem})
|
||||
|
||||
master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf"
|
||||
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc) if master_pdf_path.exists() else None
|
||||
parts = list(Path(config.MUTT_DIR).glob("ALL_EMAILS_CONSOLIDATED_part*.pdf"))
|
||||
has_master_pdf = master_pdf_path.exists() or bool(parts)
|
||||
pdf_size_mb = 0.0
|
||||
last_update = None
|
||||
if master_pdf_path.exists():
|
||||
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
|
||||
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc)
|
||||
elif parts:
|
||||
pdf_size_mb = round(sum(p.stat().st_size for p in parts) / (1024 * 1024), 2)
|
||||
last_update = datetime.fromtimestamp(max(p.stat().st_mtime for p in parts), tz=timezone.utc)
|
||||
|
||||
# Drive sync status
|
||||
sync_time_file = Path(config.MUTT_DIR) / "last_ai_sync.txt"
|
||||
|
|
@ -705,10 +681,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
|
|||
total_attachments = sum(int(m) for m in matches)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
pdf_size_mb = 0.0
|
||||
if master_pdf_path.exists():
|
||||
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
|
||||
|
||||
last_email_date = None
|
||||
if audit_data:
|
||||
|
|
@ -735,15 +707,17 @@ async def api_emails_run_full_sync(username: str = Depends(get_current_admin)):
|
|||
return JSONResponse(status_code=404, content={"message": "Master sync script not found"})
|
||||
|
||||
try:
|
||||
# Run master sync script in background
|
||||
subprocess.Popen([python_path, master_script], start_new_session=True)
|
||||
# Run master sync script in background with logging
|
||||
log_path = Path(config.MUTT_DIR) / "master_sync.log"
|
||||
log_file = open(log_path, "a")
|
||||
subprocess.Popen([python_path, master_script], stdout=log_file, stderr=log_file, start_new_session=True)
|
||||
return JSONResponse(content={"message": "Полная синхронизация (Сбор -> PDF -> G-Drive) запущена в фоне. Следите за уведомлениями в Telegram."})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||
|
||||
@app.post("/api/emails/sync_ai")
|
||||
async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
|
||||
sync_script = "/app/domovoy_drive_sync.py" if os.path.exists("/app/domovoy_drive_sync.py") else str(Path(config.MUTT_DIR).parent / "domovoy_drive_sync.py")
|
||||
sync_script = "/home/matrixhasyou/domovoy_drive_sync.py" if os.path.exists("/home/matrixhasyou/domovoy_drive_sync.py") else "/app/domovoy_drive_sync.py"
|
||||
python_path = sys.executable
|
||||
master_pdf = str(Path(config.MUTT_DIR) / "TOTAL_ARCHIVE_2025-2026.pdf")
|
||||
|
||||
|
|
@ -751,8 +725,10 @@ async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
|
|||
return JSONResponse(status_code=404, content={"message": "Sync script not found"})
|
||||
|
||||
try:
|
||||
# Run sync script in background
|
||||
subprocess.Popen([python_path, sync_script, master_pdf], start_new_session=True)
|
||||
# Run sync script in background with logging
|
||||
log_path = Path(config.MUTT_DIR) / "master_sync.log"
|
||||
log_file = open(log_path, "a")
|
||||
subprocess.Popen([python_path, sync_script, master_pdf], stdout=log_file, stderr=log_file, start_new_session=True)
|
||||
return JSONResponse(content={"message": "Синхронизация с Google Drive запущена в фоне. Это займет около минуты."})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||
|
|
@ -762,11 +738,14 @@ async def api_emails_pipeline_status(username: str = Depends(get_current_admin))
|
|||
import time
|
||||
status_file = Path(config.MUTT_DIR) / "pipeline_status.json"
|
||||
master_pdf_path = Path(config.MUTT_DIR) / "ALL_EMAILS_CONSOLIDATED.pdf"
|
||||
parts = list(Path(config.MUTT_DIR).glob("ALL_EMAILS_CONSOLIDATED_part*.pdf"))
|
||||
|
||||
# Calculate fallback values
|
||||
pdf_size_mb = 0.0
|
||||
if master_pdf_path.exists():
|
||||
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
|
||||
elif parts:
|
||||
pdf_size_mb = round(sum(p.stat().st_size for p in parts) / (1024 * 1024), 2)
|
||||
|
||||
total_emails = 0
|
||||
total_inbound = 0
|
||||
|
|
@ -910,6 +889,131 @@ async def api_sudrf_status(username: str = Depends(get_current_admin)):
|
|||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||
|
||||
@app.get("/legal", response_class=HTMLResponse)
|
||||
async def legal_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
import fitz
|
||||
legal_dir = Path("/home/matrixhasyou/mutt/legal_sources")
|
||||
volumes_dir = Path("/home/matrixhasyou/mutt/mega_volumes")
|
||||
podcasts_dir = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
|
||||
|
||||
laws = []
|
||||
if legal_dir.exists():
|
||||
for f in sorted(legal_dir.glob("*.pdf")):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
size_formatted = f"{size_mb:.2f} МБ" if size_mb >= 1 else f"{f.stat().st_size / 1024:.1f} КБ"
|
||||
try:
|
||||
doc = fitz.open(f)
|
||||
pages = len(doc)
|
||||
except Exception:
|
||||
pages = 0
|
||||
title = f.stem.replace("_", " ").replace("Полный", "").strip()
|
||||
laws.append({
|
||||
"filename": f.name,
|
||||
"title": title,
|
||||
"pages": pages,
|
||||
"size_formatted": size_formatted
|
||||
})
|
||||
|
||||
mega_volumes = []
|
||||
if volumes_dir.exists():
|
||||
for f in sorted(volumes_dir.glob("*.pdf")):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
mega_volumes.append({
|
||||
"name": f.name,
|
||||
"size_formatted": f"{size_mb:.1f} МБ"
|
||||
})
|
||||
|
||||
latest_podcast = None
|
||||
if podcasts_dir.exists():
|
||||
podcast_files = sorted(podcasts_dir.glob("*.*"), key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
if podcast_files:
|
||||
p = podcast_files[0]
|
||||
latest_podcast = {
|
||||
"name": p.name,
|
||||
"size": f"{p.stat().st_size / (1024 * 1024):.1f} МБ",
|
||||
"date": datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m.%Y %H:%M")
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="legal.html",
|
||||
context={
|
||||
"username": username,
|
||||
"laws": laws,
|
||||
"mega_volumes": mega_volumes,
|
||||
"latest_podcast": latest_podcast
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/api/legal/download/{filename}")
|
||||
async def api_download_law(filename: str, username: str = Depends(get_current_admin)):
|
||||
fpath = Path("/home/matrixhasyou/mutt/legal_sources") / filename
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл закона не найден")
|
||||
return FileResponse(fpath, filename=filename, media_type="application/pdf")
|
||||
|
||||
@app.get("/api/legal/download_volume/{filename}")
|
||||
async def api_download_volume(filename: str, username: str = Depends(get_current_admin)):
|
||||
fpath = Path("/home/matrixhasyou/mutt/mega_volumes") / filename
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Файл тома переписки не найден")
|
||||
return FileResponse(fpath, filename=filename, media_type="application/pdf")
|
||||
|
||||
@app.get("/api/legal/podcasts/{filename}")
|
||||
async def api_download_podcast(filename: str, username: str = Depends(get_current_admin)):
|
||||
fpath = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") / filename
|
||||
if not fpath.exists() or not fpath.is_file():
|
||||
raise HTTPException(status_code=404, detail="Подкаст не найден")
|
||||
return FileResponse(fpath, filename=filename)
|
||||
|
||||
@app.post("/api/legal/ask_ai")
|
||||
async def api_legal_ask_ai(request: Request, username: str = Depends(get_current_admin)):
|
||||
try:
|
||||
body = await request.json()
|
||||
query = body.get("query", "").strip()
|
||||
deep = body.get("deep", False)
|
||||
if not query:
|
||||
return JSONResponse(content={"success": False, "error": "Текст вопроса пуст"})
|
||||
|
||||
from services.ai_legal_engine import AILegalEngine
|
||||
engine = AILegalEngine()
|
||||
role = "lawyer" if deep else "resident"
|
||||
res = await engine.query(prompt=query, role=role)
|
||||
return JSONResponse(content=res)
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"success": False, "error": str(e)})
|
||||
|
||||
@app.post("/api/legal/notebooklm_sync")
|
||||
async def api_legal_notebooklm_sync(username: str = Depends(get_current_admin)):
|
||||
try:
|
||||
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
|
||||
generator = NotebookLMPodcastGenerator()
|
||||
res = generator.sync_all_sources()
|
||||
if res.get("success"):
|
||||
return JSONResponse(content={"message": f"Синхронизация успешна! Загружено файлов: {len(res.get('uploaded_sources', []))}"})
|
||||
return JSONResponse(content={"message": f"Статус: {res.get('error', 'Сессия требует настройки')}"})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||
|
||||
@app.post("/api/legal/generate_podcast")
|
||||
async def api_legal_generate_podcast(username: str = Depends(get_current_admin)):
|
||||
try:
|
||||
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
|
||||
generator = NotebookLMPodcastGenerator()
|
||||
if generator.is_authenticated():
|
||||
res = generator.generate_audio_overview()
|
||||
return JSONResponse(content={"message": "Генерация Audio Overview в NotebookLM запущена!"})
|
||||
else:
|
||||
# Fallback генерация сценария через Gemini 2.5 Pro
|
||||
res = generator.generate_fallback_dialogue_script(
|
||||
"Анализ судебных споров дома с УК и ГЖИ по отоплению, перерасчетам и отпискам прокуратуры."
|
||||
)
|
||||
return JSONResponse(content={"message": f"Сгенерирован сценарий подкаста через Gemini 2.5 Pro: {len(res.get('script', ''))} симв."})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=500, content={"message": str(e)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
|
|
|||
524
web/archive/app_full.py
Normal file
524
web/archive/app_full.py
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
"""
|
||||
Web Admin Interface - Full Functional Admin Panel
|
||||
Веб-интерфейс с полным функционалом управления ботом
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, Query
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update, delete
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import config
|
||||
import aiohttp
|
||||
|
||||
from database.db import AsyncSessionLocal, get_db
|
||||
from database.models import (
|
||||
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
||||
Achievement, Thank, Event, EventParticipant, InitiativeGroup,
|
||||
ProfileHistory, Announcement, Service, VerificationRequest, SpyLog
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Приложение
|
||||
app = FastAPI(
|
||||
title="Домовой Бот - Полная Админ Панель",
|
||||
description="Веб-интерфейс с полным функционалом",
|
||||
version="2.1.3"
|
||||
)
|
||||
|
||||
# Шаблоны
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
|
||||
# Статика
|
||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||
|
||||
# Безопасность
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# АВТОРИЗАЦИЯ
|
||||
# =============================================================================
|
||||
|
||||
def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
"""Проверка админа"""
|
||||
correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN)
|
||||
correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD)
|
||||
|
||||
if not (correct_username and correct_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
)
|
||||
|
||||
return credentials.username
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ГЛАВНАЯ СТРАНИЦА
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Главная страница - дашборд"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
users_count = await session.execute(select(func.count(User.user_id)))
|
||||
users_count = users_count.scalar()
|
||||
|
||||
messages_count = await session.execute(select(func.count(Message.id)))
|
||||
messages_count = messages_count.scalar()
|
||||
|
||||
verified_count = await session.execute(
|
||||
select(func.count(User.user_id)).where(User.verified == True)
|
||||
)
|
||||
verified_count = verified_count.scalar()
|
||||
|
||||
ig_count = await session.execute(
|
||||
select(func.count(InitiativeGroup.id)).where(InitiativeGroup.is_active == True)
|
||||
)
|
||||
ig_count = ig_count.scalar()
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"username": username,
|
||||
"stats": {
|
||||
"users": users_count,
|
||||
"messages": messages_count,
|
||||
"verified": verified_count,
|
||||
"ig_members": ig_count
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ПОЛЬЗОВАТЕЛИ - API
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/users")
|
||||
async def api_get_users(
|
||||
username: str = Depends(get_current_admin),
|
||||
search: str = Query(None),
|
||||
apartment: str = Query(None),
|
||||
verified: bool = Query(None),
|
||||
limit: int = Query(100)
|
||||
):
|
||||
"""Получить список пользователей с фильтрами"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(User)
|
||||
|
||||
if search:
|
||||
stmt = stmt.where(
|
||||
(User.full_name.ilike(f"%{search}%")) |
|
||||
(User.username.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if apartment:
|
||||
stmt = stmt.where(User.apartment.ilike(f"%{apartment}%"))
|
||||
|
||||
if verified is not None:
|
||||
stmt = stmt.where(User.verified == verified)
|
||||
|
||||
stmt = stmt.order_by(User.user_id.desc()).limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
users = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"user_id": u.user_id,
|
||||
"full_name": u.full_name,
|
||||
"username": u.username,
|
||||
"apartment": u.apartment,
|
||||
"verified": u.verified,
|
||||
"rating": u.rating,
|
||||
"message_count": u.message_count,
|
||||
"is_admin": u.is_admin,
|
||||
"spy_score": u.spy_score,
|
||||
"toxicity_score": u.toxicity_score
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/verify")
|
||||
async def api_verify_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:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
user.verified = True
|
||||
user.verification_date = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
# Уведомить пользователя
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
await http_session.post(
|
||||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": user_id,
|
||||
"text": f"✅ <b>Верификация подтверждена!</b>\n\nТеперь у вас есть доступ ко всем функциям бота.",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"success": True, "message": f"Пользователь {user_id} верифицирован"}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/unverify")
|
||||
async def api_unverify_user(
|
||||
user_id: int,
|
||||
reason: str = Form(None),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Снять верификацию с пользователя"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
user.verified = False
|
||||
await session.commit()
|
||||
|
||||
# Уведомить пользователя
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
await http_session.post(
|
||||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": user_id,
|
||||
"text": f"⚠️ <b>Верификация отозвана</b>\n\nПричина: {reason or 'Без указания причины'}\n\nУ вас есть 7 дней на повторную верификацию.",
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"success": True, "message": f"Верификация пользователя {user_id} отозвана"}
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/set_admin")
|
||||
async def api_set_admin(
|
||||
user_id: int,
|
||||
is_admin: bool = Form(True),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Назначить/снять права админа"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
user = await session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
user.is_admin = is_admin
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": f"Пользователь {user_id} {'назначен' if is_admin else 'снят с должности'} админа"}
|
||||
|
||||
|
||||
@app.get("/api/initiative_group")
|
||||
async def api_get_ig_members(username: str = Depends(get_current_admin)):
|
||||
"""Получить список участников ИГ"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(InitiativeGroup).where(InitiativeGroup.is_active == True)
|
||||
result = await session.execute(stmt)
|
||||
members = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"members": [
|
||||
{
|
||||
"user_id": m.user_id,
|
||||
"role": m.role,
|
||||
"joined_at": m.joined_at.isoformat(),
|
||||
"name": m.user.full_name if m.user else f"user{m.user_id}",
|
||||
"apartment": m.user.apartment if m.user else None
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/initiative_group/add")
|
||||
async def api_add_ig_member(
|
||||
user_id: int = Form(...),
|
||||
role: str = Form("member"),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Добавить в ИГ"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Проверяем есть ли уже
|
||||
existing = await session.get(InitiativeGroup, user_id)
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
existing.role = role
|
||||
else:
|
||||
member = InitiativeGroup(user_id=user_id, role=role)
|
||||
session.add(member)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": f"Пользователь {user_id} добавлен в ИГ"}
|
||||
|
||||
|
||||
@app.post("/api/initiative_group/remove")
|
||||
async def api_remove_ig_member(
|
||||
user_id: int = Form(...),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Удалить из ИГ"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
member = await session.get(InitiativeGroup, user_id)
|
||||
if member:
|
||||
member.is_active = False
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": f"Пользователь {user_id} удалён из ИГ"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ТЕЛЕФОНЫ - API
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/phones")
|
||||
async def api_get_phones(username: str = Depends(get_current_admin)):
|
||||
"""Получить список телефонов"""
|
||||
import os
|
||||
phones_file = "data/phones.json"
|
||||
|
||||
if os.path.exists(phones_file):
|
||||
with open(phones_file, 'r', encoding='utf-8') as f:
|
||||
phones_data = json.load(f)
|
||||
return {"success": True, "data": phones_data}
|
||||
else:
|
||||
return {"success": False, "data": {}}
|
||||
|
||||
|
||||
@app.post("/api/phones/update")
|
||||
async def api_update_phones(
|
||||
request: Request,
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Обновить телефоны"""
|
||||
import os
|
||||
data = await request.json()
|
||||
phones_file = "data/phones.json"
|
||||
|
||||
try:
|
||||
with open(phones_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return {"success": True, "message": "Телефоны обновлены"}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ОБЪЯВЛЕНИЯ - API
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/announcements")
|
||||
async def api_get_announcements(username: str = Depends(get_current_admin)):
|
||||
"""Получить список объявлений"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(Announcement).order_by(Announcement.created_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
announcements = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"announcements": [
|
||||
{
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"text": a.text,
|
||||
"category": a.category,
|
||||
"is_active": a.is_active,
|
||||
"created_at": a.created_at.isoformat()
|
||||
}
|
||||
for a in announcements
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/announcements/create")
|
||||
async def api_create_announcement(
|
||||
title: str = Form(...),
|
||||
text: str = Form(...),
|
||||
category: str = Form("general"),
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Создать объявление"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
announcement = Announcement(
|
||||
title=title,
|
||||
text=text,
|
||||
category=category,
|
||||
created_by=username
|
||||
)
|
||||
session.add(announcement)
|
||||
await session.commit()
|
||||
|
||||
return {"success": True, "message": "Объявление создано"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ВЫПОЛНЕНИЕ КОМАНД БОТА
|
||||
# =============================================================================
|
||||
|
||||
@app.post("/api/command")
|
||||
async def api_command(
|
||||
request: Request,
|
||||
username: str = Depends(get_current_admin)
|
||||
):
|
||||
"""Выполнить команду бота"""
|
||||
data = await request.json()
|
||||
command = data.get('command', '')
|
||||
args = data.get('args', '')
|
||||
|
||||
if not command:
|
||||
return JSONResponse({"success": False, "error": "Нет команды"})
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
full_command = f"/{command}"
|
||||
if args:
|
||||
full_command += f" {args}"
|
||||
|
||||
result = await http_session.post(
|
||||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": config.ADMIN_CHAT_ID,
|
||||
"text": full_command
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"Команда /{command} отправлена"
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/api/commands")
|
||||
async def api_commands_list(username: str = Depends(get_current_admin)):
|
||||
"""Список доступных команд"""
|
||||
commands = {
|
||||
"admin": [
|
||||
{"cmd": "admin", "desc": "Админ-панель"},
|
||||
{"cmd": "stats", "desc": "Статистика чата"},
|
||||
{"cmd": "spies", "desc": "Подозрительные пользователи"},
|
||||
{"cmd": "users", "desc": "Список жильцов"},
|
||||
{"cmd": "verify", "desc": "Заявки на верификацию"},
|
||||
{"cmd": "export", "desc": "Экспорт данных"},
|
||||
{"cmd": "rating", "desc": "Рейтинг пользователей"},
|
||||
],
|
||||
"initiative_group": [
|
||||
{"cmd": "ig_add", "desc": "Добавить в ИГ"},
|
||||
{"cmd": "ig_remove", "desc": "Удалить из ИГ"},
|
||||
{"cmd": "ig_list", "desc": "Список ИГ"},
|
||||
{"cmd": "ig_stats", "desc": "Статистика ИГ"},
|
||||
{"cmd": "ig_broadcast", "desc": "Рассылка для ИГ"},
|
||||
],
|
||||
"events": [
|
||||
{"cmd": "event_add", "desc": "Добавить событие"},
|
||||
{"cmd": "events", "desc": "Список событий"},
|
||||
{"cmd": "participants", "desc": "Участники события"},
|
||||
],
|
||||
"security": [
|
||||
{"cmd": "unverify", "desc": "Отозвать верификацию"},
|
||||
{"cmd": "unverified_list", "desc": "Список не верифицированных"},
|
||||
{"cmd": "ban", "desc": "Забанить пользователя"},
|
||||
{"cmd": "unban", "desc": "Разбанить"},
|
||||
],
|
||||
"phones": [
|
||||
{"cmd": "add_phone", "desc": "Добавить телефон"},
|
||||
{"cmd": "edit_phone", "desc": "Редактировать телефон"},
|
||||
{"cmd": "remove_phone", "desc": "Удалить телефон"},
|
||||
]
|
||||
}
|
||||
return JSONResponse({"commands": commands})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# СТРАНИЦЫ
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница пользователей"""
|
||||
return templates.TemplateResponse("users.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
@app.get("/initiative_group", response_class=HTMLResponse)
|
||||
async def ig_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница ИГ"""
|
||||
return templates.TemplateResponse("initiative_group.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
@app.get("/phones", response_class=HTMLResponse)
|
||||
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница телефонов"""
|
||||
return templates.TemplateResponse("phones.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
@app.get("/announcements", response_class=HTMLResponse)
|
||||
async def announcements_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница объявлений"""
|
||||
return templates.TemplateResponse("announcements.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
@app.get("/commands", response_class=HTMLResponse)
|
||||
async def commands_page(request: Request, username: str = Depends(get_current_admin)):
|
||||
"""Страница команд"""
|
||||
return templates.TemplateResponse("commands.html", {
|
||||
"request": request,
|
||||
"username": username
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ЗАПУСК
|
||||
# =============================================================================
|
||||
|
||||
def run_web_server():
|
||||
"""Запустить веб-сервер"""
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=config.WEB_ADMIN_HOST,
|
||||
port=config.WEB_ADMIN_PORT,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_web_server()
|
||||
1547
web/archive/app_reference.py
Normal file
1547
web/archive/app_reference.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,389 +1,21 @@
|
|||
/* GLASSMORPHISM DARK UI FOR DOMOVOY WEB PANEL */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
/* CLEAN MINIMALIST THEME FOR DOMOVOY WEB PANEL */
|
||||
:root {
|
||||
--bg-main: #06060c;
|
||||
--bg-gradient: radial-gradient(circle at 50% 0%, #150f30 0%, #06060c 80%);
|
||||
--panel-bg: rgba(18, 18, 29, 0.45);
|
||||
--panel-bg-hover: rgba(26, 26, 42, 0.6);
|
||||
--panel-border: rgba(255, 255, 255, 0.08);
|
||||
--panel-border-hover: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--color-primary: #8a2be2;
|
||||
--color-primary-glow: rgba(138, 43, 226, 0.35);
|
||||
--color-success: #00ffcc;
|
||||
--color-success-glow: rgba(0, 255, 204, 0.25);
|
||||
--color-warning: #ffb300;
|
||||
--color-warning-glow: rgba(255, 179, 0, 0.25);
|
||||
--color-danger: #ff2d55;
|
||||
--color-danger-glow: rgba(255, 45, 85, 0.25);
|
||||
--color-info: #00bfff;
|
||||
--color-info-glow: rgba(0, 191, 255, 0.25);
|
||||
|
||||
--color-text-main: #f5f5f7;
|
||||
--color-text-muted: #8e8e93;
|
||||
--color-text-dark: #3a3a3c;
|
||||
|
||||
--font-main: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--shadow-neon: 0 8px 32px 0 rgba(0, 0, 0, 0.4), 0 0 16px 0 var(--color-primary-glow);
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
--bg-main: #f4f4f4;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-header: #ece9d8;
|
||||
--border-color: #707680;
|
||||
--border-light: #cccccc;
|
||||
--text-main: #111111;
|
||||
--text-muted: #555555;
|
||||
--link-color: #0000cc;
|
||||
--link-hover: #cc0000;
|
||||
--badge-bg: #e8e8e8;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-main) !important;
|
||||
background-image: var(--bg-gradient) !important;
|
||||
background-attachment: fixed !important;
|
||||
color: var(--color-text-main) !important;
|
||||
font-family: var(--font-main) !important;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.5;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Сайдбар */
|
||||
.sidebar {
|
||||
background: rgba(10, 10, 18, 0.3) !important;
|
||||
backdrop-filter: blur(20px) !important;
|
||||
-webkit-backdrop-filter: blur(20px) !important;
|
||||
border-right: 1px solid var(--panel-border) !important;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: var(--color-text-muted) !important;
|
||||
border-left: 3px solid transparent !important;
|
||||
transition: var(--transition-smooth) !important;
|
||||
padding: 0.75rem 1.5rem !important;
|
||||
font-weight: 500 !important;
|
||||
text-transform: uppercase !important;
|
||||
font-family: var(--font-main) !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: var(--color-text-main) !important;
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
border-left-color: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: var(--color-success) !important;
|
||||
background: rgba(0, 255, 204, 0.05) !important;
|
||||
border-left-color: var(--color-success) !important;
|
||||
text-shadow: 0 0 10px var(--color-success-glow) !important;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i, .sidebar .nav-link bi {
|
||||
margin-right: 0.5rem !important;
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
|
||||
/* Карточки */
|
||||
.card {
|
||||
background: var(--panel-bg) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
-webkit-backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid var(--panel-border) !important;
|
||||
border-radius: 16px !important;
|
||||
transition: var(--transition-smooth) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--panel-bg-hover) !important;
|
||||
border-color: var(--panel-border-hover) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4) !important;
|
||||
transform: translateY(-2px) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border-bottom: 1px solid var(--panel-border) !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
padding: 1rem 1.25rem !important;
|
||||
color: var(--color-text-main) !important;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem !important;
|
||||
}
|
||||
|
||||
/* Переопределение старых рамок и фонов */
|
||||
.border-success, .border-warning, .border-cyan, .border-danger {
|
||||
border-color: var(--panel-border) !important;
|
||||
}
|
||||
|
||||
.card.border-success:hover, .card.border-warning:hover, .card.border-cyan:hover {
|
||||
border-color: var(--panel-border-hover) !important;
|
||||
}
|
||||
|
||||
.card.border-danger {
|
||||
border-color: rgba(255, 45, 85, 0.3) !important;
|
||||
}
|
||||
|
||||
.card.border-danger:hover {
|
||||
border-color: var(--color-danger) !important;
|
||||
box-shadow: 0 8px 30px rgba(255, 45, 85, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Таблицы */
|
||||
.table {
|
||||
color: var(--color-text-main) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.table > :not(caption) > * > * {
|
||||
background-color: transparent !important;
|
||||
color: var(--color-text-main) !important;
|
||||
border-bottom: 1px solid var(--panel-border) !important;
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
color: var(--color-text-muted) !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase !important;
|
||||
font-size: 0.8rem !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
border-bottom: 2px solid var(--panel-border) !important;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover td {
|
||||
background-color: rgba(255, 255, 255, 0.02) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.btn {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: var(--transition-smooth) !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
}
|
||||
|
||||
.btn-matrix, .btn-outline-success {
|
||||
background-color: transparent !important;
|
||||
border: 1px solid var(--color-success) !important;
|
||||
color: var(--color-success) !important;
|
||||
}
|
||||
|
||||
.btn-matrix:hover, .btn-outline-success:hover, .btn-outline-success:active, .btn-outline-success:focus {
|
||||
background-color: var(--color-success) !important;
|
||||
color: #000 !important;
|
||||
border-color: var(--color-success) !important;
|
||||
box-shadow: 0 0 15px var(--color-success-glow) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--color-primary) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
box-shadow: 0 0 15px var(--color-primary-glow) !important;
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
border-color: var(--color-danger) !important;
|
||||
color: var(--color-danger) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
background-color: var(--color-danger) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 0 15px var(--color-danger-glow) !important;
|
||||
}
|
||||
|
||||
.btn-outline-warning {
|
||||
border-color: var(--color-warning) !important;
|
||||
color: var(--color-warning) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-warning:hover {
|
||||
background-color: var(--color-warning) !important;
|
||||
color: #000 !important;
|
||||
box-shadow: 0 0 15px var(--color-warning-glow) !important;
|
||||
}
|
||||
|
||||
.btn-outline-info {
|
||||
border-color: var(--color-info) !important;
|
||||
color: var(--color-info) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.btn-outline-info:hover {
|
||||
background-color: var(--color-info) !important;
|
||||
color: #000 !important;
|
||||
box-shadow: 0 0 15px var(--color-info-glow) !important;
|
||||
}
|
||||
|
||||
/* Формы */
|
||||
.form-control, .form-select {
|
||||
background-color: rgba(255, 255, 255, 0.03) !important;
|
||||
border: 1px solid var(--panel-border) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
border-radius: 8px !important;
|
||||
transition: var(--transition-smooth) !important;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: var(--color-text-main) !important;
|
||||
box-shadow: 0 0 8px var(--color-primary-glow) !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--color-text-muted) !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
.bg-black {
|
||||
background-color: rgba(10, 10, 15, 0.5) !important;
|
||||
border: 1px solid var(--panel-border) !important;
|
||||
}
|
||||
|
||||
/* Ссылки */
|
||||
a {
|
||||
color: var(--color-success);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #fff;
|
||||
text-shadow: 0 0 8px var(--color-success-glow);
|
||||
}
|
||||
|
||||
/* Баджи */
|
||||
.badge {
|
||||
border-radius: 6px !important;
|
||||
font-weight: 500 !important;
|
||||
padding: 0.4em 0.6em !important;
|
||||
}
|
||||
|
||||
.bg-success {
|
||||
background-color: rgba(0, 255, 204, 0.15) !important;
|
||||
color: var(--color-success) !important;
|
||||
border: 1px solid var(--color-success) !important;
|
||||
}
|
||||
|
||||
.bg-danger {
|
||||
background-color: rgba(255, 45, 85, 0.15) !important;
|
||||
color: var(--color-danger) !important;
|
||||
border: 1px solid var(--color-danger) !important;
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: rgba(255, 179, 0, 0.15) !important;
|
||||
color: var(--color-warning) !important;
|
||||
border: 1px solid var(--color-warning) !important;
|
||||
}
|
||||
|
||||
.bg-info {
|
||||
background-color: rgba(0, 191, 255, 0.15) !important;
|
||||
color: var(--color-info) !important;
|
||||
border: 1px solid var(--color-info) !important;
|
||||
}
|
||||
|
||||
/* Утилиты и кастомный контент */
|
||||
hr {
|
||||
border-color: var(--panel-border) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
/* Кастомный Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(10, 10, 15, 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Дополнения для соответствия Swarm Dashboard */
|
||||
.glass-panel {
|
||||
background: var(--panel-bg) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
-webkit-backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid var(--panel-border) !important;
|
||||
border-radius: 16px !important;
|
||||
padding: 24px !important;
|
||||
transition: var(--transition-smooth) !important;
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
background: var(--panel-bg-hover) !important;
|
||||
border-color: var(--panel-border-hover) !important;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
@keyframes neonPulseSuccess {
|
||||
0%, 100% { box-shadow: 0 0 8px var(--color-success-glow); }
|
||||
50% { box-shadow: 0 0 18px rgba(0, 255, 204, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes neonPulseDanger {
|
||||
0%, 100% { box-shadow: 0 0 8px var(--color-danger-glow); }
|
||||
50% { box-shadow: 0 0 18px rgba(255, 45, 85, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes neonPulsePrimary {
|
||||
0%, 100% { box-shadow: 0 0 8px var(--color-primary-glow); }
|
||||
50% { box-shadow: 0 0 18px rgba(138, 43, 226, 0.6); }
|
||||
}
|
||||
|
||||
.glow-success {
|
||||
animation: neonPulseSuccess 3s infinite alternate !important;
|
||||
}
|
||||
|
||||
.glow-danger {
|
||||
animation: neonPulseDanger 3s infinite alternate !important;
|
||||
}
|
||||
|
||||
.glow-primary {
|
||||
animation: neonPulsePrimary 3s infinite alternate !important;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #fff 0%, var(--color-text-muted) 100%) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
.text-gradient-neon {
|
||||
background: linear-gradient(135deg, #00ffcc 0%, #8a2be2 100%) !important;
|
||||
-webkit-background-clip: text !important;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,192 +6,344 @@
|
|||
<title>{% block title %}LKM37 CORE{% endblock %}</title>
|
||||
<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">
|
||||
<link href="/static/css/matrix.css" rel="stylesheet">
|
||||
<style>
|
||||
.sidebar { min-height: 100vh; position: sticky; top: 0; overflow-y: auto; }
|
||||
.content { padding: 2rem; }
|
||||
:root {
|
||||
--bg-main: #f4f4f4;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-header: #ece9d8;
|
||||
--border-color: #707680;
|
||||
--border-light: #cccccc;
|
||||
--text-main: #111111;
|
||||
--text-muted: #555555;
|
||||
--link-color: #0000cc;
|
||||
--link-hover: #cc0000;
|
||||
--badge-bg: #e8e8e8;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
background-color: var(--bg-header);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.sidebar-header h4 {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.sidebar-header small {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 4px;
|
||||
padding-left: 6px;
|
||||
border-left: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-main) !important;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: #eaeaea;
|
||||
border-color: var(--border-light);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link.active {
|
||||
background-color: var(--bg-header);
|
||||
border-color: var(--border-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
.nav-link i {
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
padding: 16px 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.card, .panel {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card-header, .panel-header {
|
||||
background-color: var(--bg-header);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.card-body, .panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
background-color: var(--bg-panel);
|
||||
font-size: 12px;
|
||||
}
|
||||
table th, table td {
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
table th {
|
||||
background-color: var(--bg-header);
|
||||
border-color: var(--border-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
table tr:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.btn-unix {
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
background-color: #e8e8e8;
|
||||
color: #000000;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-unix:hover {
|
||||
background-color: #d8d8d8;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-unix-primary {
|
||||
background-color: #002b49;
|
||||
color: #ffffff;
|
||||
border-color: #001828;
|
||||
}
|
||||
.btn-unix-primary:hover {
|
||||
background-color: #003d66;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: inherit;
|
||||
background-color: #e8e8e8;
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
color: #111111;
|
||||
}
|
||||
pre {
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--badge-bg);
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.badge-success { background-color: #e2f0d9 !important; border-color: #70ad47 !important; color: #385723 !important; }
|
||||
.badge-warning { background-color: #fff2cc !important; border-color: #ffc000 !important; color: #806000 !important; }
|
||||
.badge-danger { background-color: #fce4d6 !important; border-color: #ed7d31 !important; color: #c65911 !important; }
|
||||
.badge-info { background-color: #e6f2ff !important; border-color: #66a3ff !important; color: #004080 !important; }
|
||||
|
||||
/* Глобальные переопределения Bootstrap и утилит для светлого стиля */
|
||||
.card, .panel {
|
||||
background-color: var(--bg-panel) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
.card-header, .panel-header {
|
||||
background-color: var(--bg-header) !important;
|
||||
border-bottom: 1px solid var(--border-color) !important;
|
||||
color: var(--text-main) !important;
|
||||
font-weight: bold !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.bg-black, .bg-dark {
|
||||
background-color: #ffffff !important;
|
||||
color: var(--text-main) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
.text-white {
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
.text-matrix, .text-cyan, .text-success, .text-info {
|
||||
color: var(--text-main) !important;
|
||||
}
|
||||
.btn-matrix {
|
||||
background-color: #e8e8e8 !important;
|
||||
color: #000000 !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 12px !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
.btn-matrix:hover {
|
||||
background-color: #d8d8d8 !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
input, select, textarea, .form-control, .form-select {
|
||||
background-color: #ffffff !important;
|
||||
color: var(--text-main) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 12px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus, .form-control:focus, .form-select:focus {
|
||||
background-color: #ffffff !important;
|
||||
color: var(--text-main) !important;
|
||||
border-color: #0000cc !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-main) !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
.shadow, .shadow-sm, .shadow-lg, .shadow-danger {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
{% block extra_style %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="matrix-canvas-container" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; opacity: 0.1;">
|
||||
<canvas id="matrix-canvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar (ВОССТАНОВЛЕННОЕ ПОЛНОЕ МЕНЮ) -->
|
||||
<div class="col-md-2 sidebar p-0">
|
||||
<div class="p-3 text-center border-bottom" style="border-color: var(--panel-border) !important;">
|
||||
<h4 class="mb-0" style="color: var(--color-success); text-shadow: 0 0 15px var(--color-success-glow); font-weight: 700; letter-spacing: 1px;">LKM37</h4>
|
||||
<small class="text-success opacity-50" style="font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.5px;">CORE SYSTEM v4.5</small>
|
||||
</div>
|
||||
<nav class="nav flex-column mt-3">
|
||||
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
|
||||
<i class="bi bi-terminal"></i> ДАШБОРД
|
||||
</a>
|
||||
|
||||
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Жильцы</small></div>
|
||||
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users">
|
||||
<i class="bi bi-people"></i> ЖИЛЬЦЫ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
|
||||
<i class="bi bi-shield-lock"></i> ВЕРИФИКАЦИЯ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
|
||||
<i class="bi bi-telephone"></i> ТЕЛЕФОНЫ
|
||||
</a>
|
||||
|
||||
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Контент</small></div>
|
||||
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
|
||||
<i class="bi bi-broadcast"></i> РАССЫЛКА
|
||||
</a>
|
||||
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
|
||||
<i class="bi bi-clock-history"></i> ПОСТЫ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
|
||||
<i class="bi bi-file-earmark-text"></i> ДАЙДЖЕСТЫ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
|
||||
<i class="bi bi-folder2-open"></i> ФАЙЛЫ
|
||||
</a>
|
||||
|
||||
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Сервисы</small></div>
|
||||
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
|
||||
<i class="bi bi-calendar-event"></i> ГРАФИКИ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
|
||||
<i class="bi bi-shop"></i> ОБЪЯВЛЕНИЯ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
|
||||
<i class="bi bi-check2-square"></i> ОПРОСЫ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_events %}{% endblock %}" href="/events">
|
||||
<i class="bi bi-calendar3"></i> СОБЫТИЯ
|
||||
</a>
|
||||
|
||||
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Юрист</small></div>
|
||||
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
|
||||
<i class="bi bi-bank text-danger"></i> ЖАТВА ГАС
|
||||
</a>
|
||||
|
||||
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Система</small></div>
|
||||
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
|
||||
<i class="bi bi-cpu"></i> ИНФРАСТРУКТУРА
|
||||
</a>
|
||||
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
|
||||
<i class="bi bi-download"></i> ЭКСПОРТ
|
||||
</a>
|
||||
<a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
|
||||
<i class="bi bi-envelope-at"></i> ПОЧТА
|
||||
</a>
|
||||
</nav>
|
||||
<div class="layout-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h4>МКД ЛЕНКОМ 37</h4>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-10 content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
|
||||
<i class="bi bi-terminal"></i> Дашборд
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Правовая база</div>
|
||||
<a class="nav-link {% block nav_legal %}{% endblock %}" href="/legal">
|
||||
<i class="bi bi-shield-check"></i> Законы РФ & ИИ-Юрист
|
||||
</a>
|
||||
<a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
|
||||
<i class="bi bi-envelope-check"></i> Реестр писем & ЭЦП
|
||||
</a>
|
||||
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
|
||||
<i class="bi bi-bank"></i> Суды ГАС Правосудие
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Жильцы & Доступ</div>
|
||||
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users">
|
||||
<i class="bi bi-people"></i> Жильцы (AI доступ)
|
||||
</a>
|
||||
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
|
||||
<i class="bi bi-patch-check"></i> Верификация
|
||||
</a>
|
||||
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
|
||||
<i class="bi bi-telephone"></i> Телефоны
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Коммуникация</div>
|
||||
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
|
||||
<i class="bi bi-broadcast"></i> Оповещения (Zero-Spam)
|
||||
</a>
|
||||
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
|
||||
<i class="bi bi-clock-history"></i> Запланированные посты
|
||||
</a>
|
||||
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
|
||||
<i class="bi bi-newspaper"></i> Дайджесты
|
||||
</a>
|
||||
|
||||
<div class="nav-section-title">Сервисы & Инфра</div>
|
||||
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
|
||||
<i class="bi bi-folder2-open"></i> Файлы и архивы
|
||||
</a>
|
||||
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
|
||||
<i class="bi bi-calendar3"></i> Графики
|
||||
</a>
|
||||
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
|
||||
<i class="bi bi-shop"></i> Объявления
|
||||
</a>
|
||||
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
|
||||
<i class="bi bi-check2-square"></i> Опросы
|
||||
</a>
|
||||
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
|
||||
<i class="bi bi-cpu"></i> Инфраструктура
|
||||
</a>
|
||||
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
|
||||
<i class="bi bi-download"></i> Экспорт базы
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const canvas = document.getElementById('matrix-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
let width = canvas.width = window.innerWidth;
|
||||
let height = canvas.height = window.innerHeight;
|
||||
|
||||
const particles = [];
|
||||
const properties = {
|
||||
bgColor: '#06060c',
|
||||
particleColor: 'rgba(138, 43, 226, 0.25)',
|
||||
lineColor: 'rgba(0, 255, 204, 0.05)',
|
||||
particleRadius: 2.5,
|
||||
particleCount: 70,
|
||||
maxVelocity: 0.4,
|
||||
lineLength: 140
|
||||
};
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
width = canvas.width = window.innerWidth;
|
||||
height = canvas.height = window.innerHeight;
|
||||
});
|
||||
|
||||
class Particle {
|
||||
constructor() {
|
||||
this.x = Math.random() * width;
|
||||
this.y = Math.random() * height;
|
||||
this.velocityPosition = {
|
||||
x: (Math.random() - 0.5) * properties.maxVelocity,
|
||||
y: (Math.random() - 0.5) * properties.maxVelocity
|
||||
}
|
||||
}
|
||||
|
||||
position() {
|
||||
if (this.x + this.velocityPosition.x > width || this.x + this.velocityPosition.x < 0) {
|
||||
this.velocityPosition.x = -this.velocityPosition.x;
|
||||
}
|
||||
if (this.y + this.velocityPosition.y > height || this.y + this.velocityPosition.y < 0) {
|
||||
this.velocityPosition.y = -this.velocityPosition.y;
|
||||
}
|
||||
this.x += this.velocityPosition.x;
|
||||
this.y += this.velocityPosition.y;
|
||||
}
|
||||
|
||||
reDraw() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, properties.particleRadius, 0, Math.PI * 2);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = properties.particleColor;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawLines() {
|
||||
let x1, y1, x2, y2, length, opacity;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
x1 = particles[i].x;
|
||||
y1 = particles[i].y;
|
||||
x2 = particles[j].x;
|
||||
y2 = particles[j].y;
|
||||
length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
|
||||
if (length < properties.lineLength) {
|
||||
opacity = 1 - (length / properties.lineLength);
|
||||
ctx.strokeStyle = `rgba(0, 255, 204, ${opacity * 0.06})`;
|
||||
ctx.lineWidth = '0.5';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
for (let i = 0; i < properties.particleCount; i++) {
|
||||
particles.push(new Particle());
|
||||
}
|
||||
loop();
|
||||
}
|
||||
|
||||
function loop() {
|
||||
ctx.fillStyle = properties.bgColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
particles[i].position();
|
||||
particles[i].reDraw();
|
||||
}
|
||||
drawLines();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
{% block extra_script %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,24 +7,24 @@
|
|||
{% block extra_style %}
|
||||
<style>
|
||||
#preview { max-width: 400px; margin-top: 1rem; display: none; }
|
||||
#preview img { max-width: 100%; border-radius: 8px; }
|
||||
.smart-options { display: none; background: #f0f7ff; padding: 20px; border-radius: 8px; margin-top: 15px; border: 2px solid #667eea; }
|
||||
#preview img { max-width: 100%; border: 1px solid var(--border-color); }
|
||||
.smart-options { display: none; background: #ffffff; padding: 16px; border: 1px solid var(--border-color); margin-top: 12px; }
|
||||
.smart-options.active { display: block; }
|
||||
.broadcast-type-selector { display: flex; gap: 15px; margin-bottom: 20px; }
|
||||
.broadcast-type-option { flex: 1; padding: 20px; border: 2px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.3s; text-align: center; }
|
||||
.broadcast-type-option:hover { border-color: #667eea; background: #f8f9ff; }
|
||||
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; }
|
||||
.broadcast-type-option h4 { margin-bottom: 10px; }
|
||||
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; }
|
||||
.nav-tabs .nav-link { color: #495057; }
|
||||
.nav-tabs .nav-link.active { font-weight: bold; color: #667eea; }
|
||||
.broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||
.badge-regular { background: #6c757d; color: white; }
|
||||
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; }
|
||||
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); }
|
||||
.readers-table { font-size: 0.9em; }
|
||||
.readers-table th { background: #f8f9fa; }
|
||||
.broadcast-type-selector { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.broadcast-type-option { flex: 1; padding: 14px; border: 1px solid var(--border-color); background: #ffffff; cursor: pointer; text-align: center; }
|
||||
.broadcast-type-option:hover { background: #f0f0f0; }
|
||||
.broadcast-type-option.active { border: 2px solid #0000cc; background: #ece9d8; font-weight: bold; }
|
||||
.broadcast-type-option h4 { margin-bottom: 6px; font-size: 14px; }
|
||||
.broadcast-type-option .icon { font-size: 20px; margin-bottom: 6px; }
|
||||
.nav-tabs .nav-link { color: var(--text-main); border-radius: 0; }
|
||||
.nav-tabs .nav-link.active { font-weight: bold; border-color: var(--border-color) var(--border-color) #ffffff; }
|
||||
.broadcast-item { background: white; padding: 12px; border: 1px solid var(--border-color); margin-bottom: 10px; }
|
||||
.badge-smart { background: #e2f0d9; border: 1px solid #70ad47; color: #385723; font-weight: bold; }
|
||||
.badge-regular { background: #e8e8e8; border: 1px solid var(--border-color); color: #111111; }
|
||||
.progress-mini { width: 100px; height: 8px; background: #e8e8e8; border: 1px solid var(--border-light); display: inline-block; vertical-align: middle; margin-left: 5px; }
|
||||
.progress-mini-fill { height: 100%; background: #385723; }
|
||||
.readers-table { font-size: 12px; }
|
||||
.readers-table th { background: var(--bg-header); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -76,8 +76,8 @@
|
|||
<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">
|
||||
<label class="form-label">🖼️ Использовать готовое фото (Артефакт):</label>
|
||||
<select class="form-select" id="broadcastArtifactPhoto">
|
||||
<option value="">-- Не выбрано (или загрузить новое) --</option>
|
||||
{% for a in artifacts if a.file_type == 'photo' %}
|
||||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||||
|
|
@ -87,8 +87,8 @@
|
|||
</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">
|
||||
<label class="form-label">📄 Использовать готовый документ (Артефакт):</label>
|
||||
<select class="form-select" id="broadcastArtifactDocument">
|
||||
<option value="">-- Не выбрано (или загрузить новое) --</option>
|
||||
{% for a in artifacts if a.file_type == 'document' %}
|
||||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}LKM37 Dashboard{% endblock %}
|
||||
{% block title %}Дашборд - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_dashboard %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
|
||||
>_ ТЕРМИНАЛ_УПРАВЛЕНИЯ_ЛКМ37
|
||||
</h2>
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Панель управления МКД ЛенКом 37</h4>
|
||||
<div class="small text-muted">Сводные показатели системы и текущий статус сервисов</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="row g-3">
|
||||
<!-- КАРТОЧКА: ЖИЛЬЦЫ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">ЖИЛЬЦЫ</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-people-fill display-4 mb-2"></i>
|
||||
<h5 class="card-title opacity-50">СУБЪЕКТЫ</h5>
|
||||
<h2 class="display-6">{{ users_count }}</h2>
|
||||
<i class="bi bi-people mb-2" style="font-size: 28px; color: #555555;"></i>
|
||||
<div class="small text-muted">Всего зарегистрировано</div>
|
||||
<div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ users_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- КАРТОЧКА: ВЕРИФИКАЦИЯ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 {% if pending_count > 0 %}border-danger shadow-danger{% endif %}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">ВЕРИФИКАЦИЯ</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shield-lock-fill display-4 mb-2"></i>
|
||||
<h5 class="card-title opacity-50">ВЕРИФИКАЦИЯ</h5>
|
||||
<h2 class="display-6">{{ pending_count }}</h2>
|
||||
<i class="bi bi-shield-lock mb-2" style="font-size: 28px; color: #555555;"></i>
|
||||
<div class="small text-muted">Заявок на проверку</div>
|
||||
<div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ pending_count }}</div>
|
||||
{% if pending_count > 0 %}
|
||||
<span class="badge bg-danger">ТРЕБУЕТ ВНИМАНИЯ</span>
|
||||
<div class="mt-2">
|
||||
<span class="badge-status badge-danger">ТРЕБУЕТ ВНИМАНИЯ</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -40,54 +43,79 @@
|
|||
|
||||
<!-- КАРТОЧКА: ОБЪЯВЛЕНИЯ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-warning">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">ОБЪЯВЛЕНИЯ</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-shop display-4 mb-2"></i>
|
||||
<h5 class="card-title opacity-50">МАРКЕТ</h5>
|
||||
<h2 class="display-6 text-warning">{{ active_ads }}</h2>
|
||||
<i class="bi bi-shop mb-2" style="font-size: 28px; color: #555555;"></i>
|
||||
<div class="small text-muted">Активные публикации</div>
|
||||
<div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ active_ads }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- КАРТОЧКА: АКТИВНОСТЬ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100 border-cyan">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">СООБЩЕНИЯ</div>
|
||||
<div class="card-body text-center">
|
||||
<i class="bi bi-activity display-4 mb-2"></i>
|
||||
<h5 class="card-title opacity-50">СООБЩЕНИЯ</h5>
|
||||
<h2 class="display-6" style="color: var(--matrix-cyan);">{{ messages_count }}</h2>
|
||||
<i class="bi bi-activity mb-2" style="font-size: 28px; color: #555555;"></i>
|
||||
<div class="small text-muted">Активность чата</div>
|
||||
<div style="font-size: 24px; font-weight: bold; margin-top: 4px;">{{ messages_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="row mt-3 g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">СИСТЕМНЫЙ_СТАТУС</div>
|
||||
<div class="card h-100">
|
||||
<div class="card-header">СИСТЕМНЫЙ СТАТУС</div>
|
||||
<div class="card-body">
|
||||
<div class="p-3 bg-black border border-success font-monospace small">
|
||||
[OK] Database: PostgreSQL (Synology NAS)<br>
|
||||
[OK] Telegram: @lkmthreeseven_bot<br>
|
||||
[OK] Proxy: Active (127.0.0.1:10808)<br>
|
||||
[OK] Node: {{ username }}@server<br>
|
||||
<hr class="border-success">
|
||||
[INFO] Всего верифицировано: {{ verified_count }} жильцов.<br>
|
||||
[INFO] Активных опросов: {{ active_polls }}<br>
|
||||
[INFO] Предстоящих событий: {{ upcoming_events }}
|
||||
</div>
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 220px; font-weight: bold;">База данных:</td>
|
||||
<td>PostgreSQL (Synology NAS 192.168.10.105:5433) <span class="badge-status badge-success">OK</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Telegram-бот:</td>
|
||||
<td>@lkmthreeseven_bot (DomovoyKontrolBot) <span class="badge-status badge-success">ONLINE</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Юридический модуль:</td>
|
||||
<td>Google NotebookLM (аккаунт corteskelsey814, 14 источников) <span class="badge-status badge-success">READY</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Текущий узел:</td>
|
||||
<td>{{ username }}@DockerServer (192.168.10.116)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Верифицировано жильцов:</td>
|
||||
<td>{{ verified_count }} чел.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Активных опросов:</td>
|
||||
<td>{{ active_polls }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Предстоящих событий:</td>
|
||||
<td>{{ upcoming_events }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">БЫСТРЫЕ_ДЕЙСТВИЯ</div>
|
||||
<div class="card-body">
|
||||
<a href="/broadcast" class="btn btn-matrix w-100 mb-2">НОВАЯ РАССЫЛКА</a>
|
||||
<a href="/verification" class="btn btn-outline-success w-100 btn-sm mb-2">ПРОВЕРИТЬ ЗАЯВКИ</a>
|
||||
<form action="/api/digest/generate" method="post">
|
||||
<button type="submit" class="btn btn-outline-info w-100 btn-sm">ДАЙДЖЕСТ (ТЕСТ)</button>
|
||||
<div class="card h-100">
|
||||
<div class="card-header">ДЕЙСТВИЯ</div>
|
||||
<div class="card-body d-flex flex-column gap-2">
|
||||
<a href="/broadcast" class="btn-unix btn-unix-primary text-center">Новая рассылка</a>
|
||||
<a href="/verification" class="btn-unix text-center">Проверить заявки жильцов</a>
|
||||
<a href="/legal" class="btn-unix text-center">База законов и NotebookLM</a>
|
||||
<form action="/api/digest/generate" method="post" class="mt-auto">
|
||||
<button type="submit" class="btn-unix w-100">Тестовая генерация дайджеста</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,108 +1,98 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Настройки дайджеста - Домовой Бот{% endblock %}
|
||||
|
||||
{% block nav_digest_settings %}active{% endblock %}
|
||||
|
||||
{% block extra_style %}
|
||||
<style>
|
||||
.digest-settings-container { max-width: 900px; }
|
||||
.digest-header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
||||
.digest-header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
|
||||
.digest-back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; transition: var(--transition-smooth); }
|
||||
.digest-back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
|
||||
.digest-card { background: var(--panel-bg); border: 1px solid var(--panel-border); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
|
||||
.digest-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
|
||||
.digest-card h3 { margin-bottom: 15px; color: var(--color-success); text-shadow: 0 0 5px var(--color-success-glow); }
|
||||
.digest-form-group { margin-bottom: 20px; }
|
||||
.digest-form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: var(--color-text-main); }
|
||||
.digest-form-group input[type="time"], .digest-form-group select { width: 100%; padding: 10px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-size: 1em; transition: var(--transition-smooth); }
|
||||
.digest-form-group input[type="time"]:focus, .digest-form-group select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
|
||||
.digest-checkbox-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; color: var(--color-text-main); }
|
||||
.digest-checkbox-group input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--color-success); cursor: pointer; }
|
||||
.digest-btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 1em; background: var(--color-success); color: #000; font-weight: 600; transition: var(--transition-smooth); }
|
||||
.digest-btn:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}Настройки дайджеста - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_digests %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="digest-settings-container">
|
||||
<div class="digest-header">
|
||||
<a href="/digests" class="digest-back-link">← Назад к дайджестам</a>
|
||||
<h1>⚙️ Настройки дайджеста</h1>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Настройки генерации дайджеста</h4>
|
||||
<div class="small text-muted">Конфигурация автоматического сбора событий и публикации</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/digests" class="btn-unix">← Назад к дайджестам</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="digest-card">
|
||||
<h3>📅 Расписание</h3>
|
||||
<div class="digest-form-group">
|
||||
<label>Автоматическая генерация</label>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="autoGenerate" checked>
|
||||
<label for="autoGenerate">Включено (каждое воскресенье в 19:00)</label>
|
||||
<div class="row g-3" style="max-width: 900px;">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">РАСПИСАНИЕ И АВТОМАТИЗАЦИЯ</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="autoGenerate" checked>
|
||||
<label class="form-check-label" for="autoGenerate">
|
||||
<b>Автоматическая генерация:</b> каждое воскресенье в 19:00 (время Ульяновск)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="digest-form-group">
|
||||
<label>Время генерации</label>
|
||||
<input type="time" value="19:00" disabled>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">РАЗДЕЛЫ ДАЙДЖЕСТА</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includeEvents" checked>
|
||||
<label class="form-check-label" for="includeEvents">События и встречи дома</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includePolls" checked>
|
||||
<label class="form-check-label" for="includePolls">Опросы и голосования собственников</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includeAds" checked>
|
||||
<label class="form-check-label" for="includeAds">Доска объявлений</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includeTopUsers" checked>
|
||||
<label class="form-check-label" for="includeTopUsers">Топ активных жильцов</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includeSchedule" checked>
|
||||
<label class="form-check-label" for="includeSchedule">График плановых отключений ЖКХ</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="includeVerifications" checked>
|
||||
<label class="form-check-label" for="includeVerifications">Статистика новых верификаций</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="includeStats" checked>
|
||||
<label class="form-check-label" for="includeStats">Сводная статистика активности чата</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="digest-card">
|
||||
<h3>📊 Содержимое дайджеста</h3>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeEvents" checked>
|
||||
<label for="includeEvents">🔔 События</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includePolls" checked>
|
||||
<label for="includePolls">📊 Опросы</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeAds" checked>
|
||||
<label for="includeAds">📢 Объявления</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeTopUsers" checked>
|
||||
<label for="includeTopUsers">🏆 Топ активных жильцов</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeSchedule" checked>
|
||||
<label for="includeSchedule">⏰ Запланированные отключения</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeVerifications" checked>
|
||||
<label for="includeVerifications">✅ Верификации</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="includeStats" checked>
|
||||
<label for="includeStats">📈 Статистика чата</label>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">РЕЖИМ ПУБЛИКАЦИИ</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="sendPreview" checked>
|
||||
<label class="form-check-label" for="sendPreview">Отправлять превью председателю на утверждение перед рассылкой</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="autoSend">
|
||||
<label class="form-check-label" for="autoSend">Публиковать напрямую без ручного подтверждения</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="saveArchive" checked>
|
||||
<label class="form-check-label" for="saveArchive">Сохранять выпуск в архив базы данных</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="digest-card">
|
||||
<h3>📬 Отправка</h3>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="sendPreview" checked>
|
||||
<label for="sendPreview">Отправлять превью админу для утверждения</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="autoSend">
|
||||
<label for="autoSend">Публиковать автоматически (без утверждения)</label>
|
||||
</div>
|
||||
<div class="digest-checkbox-group">
|
||||
<input type="checkbox" id="saveArchive" checked>
|
||||
<label for="saveArchive">Сохранять в архив</label>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button class="btn-unix btn-unix-primary" onclick="saveSettings()">Сохранить настройки</button>
|
||||
</div>
|
||||
|
||||
<button class="digest-btn" onclick="saveSettings()">💾 Сохранить настройки</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
function saveSettings() {
|
||||
alert('✅ Настройки сохранены!');
|
||||
alert('Настройки успешно сохранены');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,275 +1,250 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Еженедельные дайджесты - Домовой Бот{% endblock %}
|
||||
|
||||
{% block title %}Еженедельные дайджесты - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_digests %}active{% endblock %}
|
||||
|
||||
{% block extra_style %}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, var(--color-primary) 0%, rgba(10, 10, 15, 0.7) 100%); border: 1px solid var(--panel-border); color: white; padding: 30px; border-radius: 16px; margin-bottom: 30px; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
|
||||
.header h1 { font-size: 2em; margin-bottom: 10px; text-shadow: 0 0 10px var(--color-primary-glow); }
|
||||
.back-link { color: var(--color-success); text-decoration: none; opacity: 0.9; }
|
||||
.back-link:hover { opacity: 1; text-shadow: 0 0 8px var(--color-success-glow); }
|
||||
.tabs { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.tab { padding: 12px 24px; background: rgba(255, 255, 255, 0.05); color: var(--color-text-muted); border: 1px solid var(--panel-border); border-radius: 8px 8px 0 0; cursor: pointer; font-size: 1em; transition: var(--transition-smooth); }
|
||||
.tab:hover { color: var(--color-text-main); background: rgba(255, 255, 255, 0.08); }
|
||||
.tab.active { background: var(--color-primary); color: white; border-color: var(--color-primary); box-shadow: 0 0 10px var(--color-primary-glow); }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.pending-card { background: var(--panel-bg); padding: 25px; border-radius: 16px; margin-bottom: 20px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); border-left: 5px solid var(--color-primary); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); transition: var(--transition-smooth); }
|
||||
.pending-card:hover { border-color: var(--panel-border-hover); background: var(--panel-bg-hover); }
|
||||
.pending-card h3 { color: var(--color-success); margin-bottom: 10px; text-shadow: 0 0 5px var(--color-success-glow); }
|
||||
.pending-card .meta { color: var(--color-text-muted); font-size: 0.9em; margin-bottom: 15px; }
|
||||
.pending-card .text { background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); border: 1px solid var(--panel-border); padding: 15px; border-radius: 8px; margin: 15px 0; white-space: pre-wrap; max-height: 300px; overflow-y: auto; font-family: var(--font-mono); }
|
||||
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 0.95em; margin-right: 10px; transition: var(--transition-smooth); }
|
||||
.btn-approve { background: var(--color-success); color: #000; font-weight: 600; }
|
||||
.btn-approve:hover { background: #00cca3; box-shadow: 0 0 10px var(--color-success-glow); }
|
||||
.btn-reject { background: var(--color-danger); color: white; }
|
||||
.btn-reject:hover { background: #e0244a; box-shadow: 0 0 10px var(--color-danger-glow); }
|
||||
.btn-edit { background: var(--color-warning); color: #000; font-weight: 600; }
|
||||
.btn-edit:hover { background: #d99800; box-shadow: 0 0 10px var(--color-warning-glow); }
|
||||
.btn-send { background: var(--color-info); color: #000; font-weight: 600; }
|
||||
.btn-send:hover { background: #0099cc; box-shadow: 0 0 10px var(--color-info-glow); }
|
||||
table { width: 100%; background: var(--panel-bg); border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid var(--panel-border); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||
th { background: rgba(138, 43, 226, 0.2); color: var(--color-success); padding: 15px; text-align: left; border-bottom: 2px solid var(--panel-border); font-weight: 600; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }
|
||||
td { padding: 15px; border-bottom: 1px solid var(--panel-border); color: var(--color-text-main); }
|
||||
tr:hover { background: rgba(255, 255, 255, 0.02); }
|
||||
.badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-size: 0.85em; font-weight: 500; border: 1px solid transparent; }
|
||||
.badge-pending { background: rgba(255, 179, 0, 0.15); color: var(--color-warning); border-color: var(--color-warning); }
|
||||
.badge-approved { background: rgba(0, 255, 204, 0.15); color: var(--color-success); border-color: var(--color-success); }
|
||||
.badge-rejected { background: rgba(255, 45, 85, 0.15); color: var(--color-danger); border-color: var(--color-danger); }
|
||||
.badge-sent { background: rgba(0, 191, 255, 0.15); color: var(--color-info); border-color: var(--color-info); }
|
||||
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; backdrop-filter: blur(8px); }
|
||||
.modal.active { display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: var(--bg-main); border: 1px solid var(--panel-border); color: var(--color-text-main); padding: 30px; border-radius: 16px; max-width: 900px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 15px var(--color-primary-glow); }
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--panel-border); padding-bottom: 15px; }
|
||||
.close-btn { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--color-text-muted); }
|
||||
.close-btn:hover { color: var(--color-text-main); }
|
||||
textarea { width: 100%; min-height: 300px; padding: 15px; border: 1px solid var(--panel-border); border-radius: 8px; background: rgba(0, 0, 0, 0.3); color: var(--color-text-main); font-family: var(--font-mono); font-size: 0.95em; transition: var(--transition-smooth); }
|
||||
textarea:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 8px var(--color-primary-glow); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h1>📰 Еженедельные дайджесты</h1>
|
||||
<p>Утверждение, редактирование и архив</p>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Еженедельные дайджесты дома</h4>
|
||||
<div class="small text-muted">Утверждение проектов, ручная корректировка и архив публикаций</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-unix btn-unix-primary" onclick="generateNow()">
|
||||
<i class="bi bi-arrow-repeat"></i> Сгенерировать сейчас
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('pending')">⏳ Ожидают утверждения</button>
|
||||
<button class="tab" onclick="showTab('archive')">📚 Архив</button>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn-unix active" id="tabBtnPending" onclick="showTab('pending')">Ожидают утверждения ({{ pending_digests|length }})</button>
|
||||
<button class="btn-unix" id="tabBtnArchive" onclick="showTab('archive')">Архив дайджестов</button>
|
||||
</div>
|
||||
|
||||
<div id="pending" class="tab-content active">
|
||||
{% if pending_digests|length > 0 %}
|
||||
{% for digest in pending_digests %}
|
||||
<div class="pending-card">
|
||||
<h3>📰 Дайджест #{{ digest.week_number }}/{{ digest.year }}</h3>
|
||||
<div class="meta">
|
||||
Период: {{ digest.period_label }} | Создан: {{ digest.created_at }} | Статус: <span class="badge badge-pending">{{ digest.status_emoji }} {{ digest.status }}</span>
|
||||
</div>
|
||||
<div class="text">{{ digest.digest_text }}</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button class="btn btn-approve" onclick="approveDigest({{ digest.id }})">✅ Утвердить</button>
|
||||
<button class="btn btn-edit" onclick="editDigest({{ digest.id }})">✏️ Редактировать</button>
|
||||
<button class="btn btn-reject" onclick="rejectDigest({{ digest.id }})">❌ Отклонить</button>
|
||||
<button class="btn btn-send" onclick="sendDigest({{ digest.id }})" id="sendBtn{{ digest.id }}" style="display:none;">📨 Отправить</button>
|
||||
</div>
|
||||
<!-- ВКЛАДКА: НА УТВЕРЖДЕНИИ -->
|
||||
<div id="pending" class="tab-content-panel">
|
||||
{% if pending_digests|length > 0 %}
|
||||
{% for digest in pending_digests %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Дайджест #{{ digest.week_number }}/{{ digest.year }} (Период: {{ digest.period_label }})</span>
|
||||
<span class="badge-status badge-warning">{{ digest.status_emoji }} {{ digest.status.upper() }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="small text-muted mb-2">Создан: {{ digest.created_at }}</div>
|
||||
<pre style="white-space: pre-wrap; max-height: 250px; background-color: #ffffff; border: 1px solid var(--border-color);">{{ digest.digest_text }}</pre>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn-unix btn-unix-primary" onclick="approveDigest({{ digest.id }})">Утвердить</button>
|
||||
<button class="btn-unix" onclick="editDigest({{ digest.id }})">Редактировать</button>
|
||||
<button class="btn-unix" onclick="rejectDigest({{ digest.id }})">Отклонить</button>
|
||||
<button class="btn-unix" onclick="sendDigest({{ digest.id }})" id="sendBtn{{ digest.id }}" style="display:none;">Отправить жильцам</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
|
||||
<h2>📭 Нет ожидающих утверждения дайджестов</h2>
|
||||
<p style="margin-top: 10px;">Следующий дайджест будет сгенерирован в воскресенье в 09:00</p>
|
||||
<button class="btn btn-send" style="margin-top: 20px;" onclick="generateNow()">🔄 Сгенерировать сейчас</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="archive" class="tab-content">
|
||||
{% if archive_digests|length > 0 %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Неделя</th>
|
||||
<th>Период</th>
|
||||
<th>Статус</th>
|
||||
<th>Отправлен</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for digest in archive_digests %}
|
||||
<tr>
|
||||
<td>#{{ digest.week_number }}/{{ digest.year }}</td>
|
||||
<td>{{ digest.period_label }}</td>
|
||||
<td><span class="badge badge-{{ digest.status }}">{{ digest.status_emoji }} {{ digest.status }}</span></td>
|
||||
<td>{{ digest.sent_at }}</td>
|
||||
<td>
|
||||
<button class="btn btn-edit" onclick="viewDigest({{ digest.id }})">👁️ Просмотр</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 50px; background: white; border-radius: 8px;">
|
||||
<h2>📚 Архив пуст</h2>
|
||||
<p style="margin-top: 10px;">Здесь будут отображаться все дайджесты</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card p-4 text-center">
|
||||
<div class="mb-2"><i class="bi bi-inbox text-muted" style="font-size: 32px;"></i></div>
|
||||
<div style="font-weight: bold;">Нет дайджестов, ожидающих утверждения</div>
|
||||
<div class="small text-muted mt-1">Автоматическая генерация запускается по расписанию каждое воскресенье в 19:00</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для редактирования -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>✏️ Редактирование дайджеста</h2>
|
||||
<button class="close-btn" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<textarea id="editText"></textarea>
|
||||
<div style="margin-top: 15px;">
|
||||
<button class="btn btn-approve" onclick="saveEdit()">💾 Сохранить</button>
|
||||
<button class="btn btn-reject" onclick="closeEditModal()">Отмена</button>
|
||||
<!-- ВКЛАДКА: АРХИВ -->
|
||||
<div id="archive" class="tab-content-panel" style="display: none;">
|
||||
{% if archive_digests|length > 0 %}
|
||||
<div class="card">
|
||||
<div class="card-header">АРХИВ ВЫПУСКОВ</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 120px;">Выпуск</th>
|
||||
<th>Период</th>
|
||||
<th style="width: 140px;">Статус</th>
|
||||
<th style="width: 180px;">Отправлен</th>
|
||||
<th style="width: 100px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for digest in archive_digests %}
|
||||
<tr>
|
||||
<td><b>#{{ digest.week_number }}/{{ digest.year }}</b></td>
|
||||
<td>{{ digest.period_label }}</td>
|
||||
<td>
|
||||
<span class="badge-status {% if digest.status == 'approved' or digest.status == 'sent' %}badge-success{% elif digest.status == 'rejected' %}badge-danger{% else %}badge-warning{% endif %}">
|
||||
{{ digest.status_emoji }} {{ digest.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ digest.sent_at or '—' }}</td>
|
||||
<td>
|
||||
<button class="btn-unix" onclick="viewDigest({{ digest.id }})">Открыть</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card p-4 text-center">
|
||||
<div class="small text-muted">Архив пока пуст</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра -->
|
||||
<div id="viewModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="viewTitle">📰 Дайджест</h2>
|
||||
<button class="close-btn" onclick="closeViewModal()">×</button>
|
||||
<!-- Модальное окно для редактирования -->
|
||||
<div id="editModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1050; align-items: center; justify-content: center;">
|
||||
<div class="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>РЕДАКТИРОВАНИЕ ДАЙДЖЕСТА</span>
|
||||
<button class="btn-unix" onclick="closeEditModal()">×</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<textarea id="editText" class="form-control" style="width: 100%; min-height: 350px; font-family: inherit; font-size: 12px;"></textarea>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn-unix btn-unix-primary" onclick="saveEdit()">Сохранить изменения</button>
|
||||
<button class="btn-unix" onclick="closeEditModal()">Отмена</button>
|
||||
</div>
|
||||
<div id="viewText" style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentDigestId = null;
|
||||
<!-- Модальное окно для просмотра -->
|
||||
<div id="viewModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); z-index: 1050; align-items: center; justify-content: center;">
|
||||
<div class="card" style="width: 90%; max-width: 800px; max-height: 90vh; overflow-y: auto; margin: 40px auto; border: 2px solid var(--border-color);">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span id="viewTitle">ПРОСМОТР ДАЙДЖЕСТА</span>
|
||||
<button class="btn-unix" onclick="closeViewModal()">×</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre id="viewText" style="white-space: pre-wrap; background-color: #ffffff; border: 1px solid var(--border-color);"></pre>
|
||||
<div class="mt-3">
|
||||
<button class="btn-unix" onclick="closeViewModal()">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function showTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
}
|
||||
<script>
|
||||
let currentDigestId = null;
|
||||
|
||||
function approveDigest(id) {
|
||||
if (confirm('Утвердить этот дайджест?')) {
|
||||
fetch(`/api/digest/${id}/approve`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
// Показываем кнопку "Отправить"
|
||||
document.getElementById(`sendBtn${id}`).style.display = 'inline-block';
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
function showTab(tabName) {
|
||||
document.getElementById('pending').style.display = tabName === 'pending' ? 'block' : 'none';
|
||||
document.getElementById('archive').style.display = tabName === 'archive' ? 'block' : 'none';
|
||||
document.getElementById('tabBtnPending').className = tabName === 'pending' ? 'btn-unix active' : 'btn-unix';
|
||||
document.getElementById('tabBtnArchive').className = tabName === 'archive' ? 'btn-unix active' : 'btn-unix';
|
||||
}
|
||||
|
||||
function rejectDigest(id) {
|
||||
const reason = prompt('Причина отклонения:');
|
||||
if (reason) {
|
||||
fetch(`/api/digest/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function editDigest(id) {
|
||||
currentDigestId = id;
|
||||
fetch(`/api/digest/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('editText').value = data.digest.text;
|
||||
document.getElementById('editModal').classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
const newText = document.getElementById('editText').value;
|
||||
fetch(`/api/digest/${currentDigestId}/update_text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: newText })
|
||||
})
|
||||
function approveDigest(id) {
|
||||
if (confirm('Утвердить этот дайджест?')) {
|
||||
fetch(`/api/digest/${id}/approve`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
closeEditModal();
|
||||
const sendBtn = document.getElementById(`sendBtn${id}`);
|
||||
if (sendBtn) sendBtn.style.display = 'inline-block';
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
document.getElementById('editModal').classList.remove('active');
|
||||
}
|
||||
function rejectDigest(id) {
|
||||
const reason = prompt('Причина отклонения:');
|
||||
if (reason) {
|
||||
fetch(`/api/digest/${id}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function viewDigest(id) {
|
||||
fetch(`/api/digest/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('viewTitle').textContent = `📰 Дайджест #${data.digest.week}/${data.digest.year}`;
|
||||
document.getElementById('viewText').textContent = data.digest.text;
|
||||
document.getElementById('viewModal').classList.add('active');
|
||||
});
|
||||
}
|
||||
function editDigest(id) {
|
||||
currentDigestId = id;
|
||||
fetch(`/api/digest/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('editText').value = data.digest.text;
|
||||
document.getElementById('editModal').style.display = 'flex';
|
||||
});
|
||||
}
|
||||
|
||||
function closeViewModal() {
|
||||
document.getElementById('viewModal').classList.remove('active');
|
||||
}
|
||||
function saveEdit() {
|
||||
const newText = document.getElementById('editText').value;
|
||||
fetch(`/api/digest/${currentDigestId}/update_text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: newText })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
closeEditModal();
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function sendDigest(id) {
|
||||
if (confirm('Отправить дайджест всем верифицированным жильцам?')) {
|
||||
fetch(`/api/digest/${id}/send`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
function closeEditModal() {
|
||||
document.getElementById('editModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function generateNow() {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Генерация...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/digest/generate', { method: 'POST' });
|
||||
const result = await r.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('✅ ' + result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Сгенерировать сейчас';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('❌ Ошибка: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 Сгенерировать сейчас';
|
||||
}
|
||||
function viewDigest(id) {
|
||||
fetch(`/api/digest/${id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('viewTitle').textContent = `Дайджест #${data.digest.week}/${data.digest.year}`;
|
||||
document.getElementById('viewText').textContent = data.digest.text;
|
||||
document.getElementById('viewModal').style.display = 'flex';
|
||||
});
|
||||
}
|
||||
|
||||
function closeViewModal() {
|
||||
document.getElementById('viewModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function sendDigest(id) {
|
||||
if (confirm('Отправить дайджест всем верифицированным жильцам?')) {
|
||||
fetch(`/api/digest/${id}/send`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function generateNow() {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
const oldText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Генерация...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/digest/generate', { method: 'POST' });
|
||||
const result = await r.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Успешно: ' + result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = oldText;
|
||||
}
|
||||
</script>
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = oldText;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,72 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Email Audit - LKM37 Admin{% endblock %}
|
||||
{% block title %}Реестр писем и аудит - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_emails %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ЗАГОЛОВОК -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
|
||||
>_ АУДИТ_ВЗАИМОДЕЙСТВИЯ_С_ОРГАНАМИ
|
||||
</h2>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Реестр официальной переписки и судебный аудит</h4>
|
||||
<div class="small text-muted">Архив писем, обращений и ответов надзорных органов дома № 37</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<span class="badge bg-success p-2">АВТОМАТИЗАЦИЯ_CRON: ACTIVE (04:00)</span>
|
||||
<div>
|
||||
<span class="badge-status badge-success">CRON: ACTIVE (04:00)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ (ВИДЖЕТЫ) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- КАРТОЧКА 1: ВСЕГО ПИСЕМ -->
|
||||
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark border-success h-100 shadow">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ВСЕГО ПИСЕМ</span>
|
||||
<i class="bi bi-envelope-open"></i>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-success text-uppercase small font-monospace mb-0">>_ ВСЕГО_ПИСЕМ</h6>
|
||||
<i class="bi bi-envelope-open text-success fs-4"></i>
|
||||
</div>
|
||||
<h2 class="display-6 font-monospace my-2 text-white" id="statTotalEmails">{{ total_emails }}</h2>
|
||||
<div class="small text-muted">
|
||||
Вх: <span id="statInboundEmails" class="text-success fw-bold">{{ total_inbound }}</span> /
|
||||
Исх: <span id="statOutboundEmails" class="text-info fw-bold">{{ total_outbound }}</span>
|
||||
<div style="font-size: 22px; font-weight: bold;" id="statTotalEmails">{{ total_emails }}</div>
|
||||
<div class="small text-muted mt-1">
|
||||
Входящие: <b id="statInboundEmails">{{ total_inbound }}</b> / Исходящие: <b id="statOutboundEmails">{{ total_outbound }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- КАРТОЧКА 2: ОБЪЕМ АРХИВА -->
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark border-info h-100 shadow">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ОБЪЕМ АРХИВА</span>
|
||||
<i class="bi bi-file-earmark-pdf"></i>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-info text-uppercase small font-monospace mb-0">>_ ОБЪЕМ_АРХИВА</h6>
|
||||
<i class="bi bi-file-earmark-pdf text-info fs-4"></i>
|
||||
</div>
|
||||
<h2 class="display-6 font-monospace my-2 text-white" id="statPdfSize">{{ pdf_size_mb }} MB</h2>
|
||||
<div class="small text-muted text-truncate" id="statLastUpdate">
|
||||
<div style="font-size: 22px; font-weight: bold;" id="statPdfSize">{{ pdf_size_mb }} MB</div>
|
||||
<div class="small text-muted mt-1 text-truncate" id="statLastUpdate">
|
||||
Создан: {{ fmt_local(last_update) if last_update else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- КАРТОЧКА 3: ВСЕГО ВЛОЖЕНИЙ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark border-warning h-100 shadow">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ВЛОЖЕНИЯ</span>
|
||||
<i class="bi bi-paperclip"></i>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-warning text-uppercase small font-monospace mb-0">>_ ВШИТЫХ_ВЛОЖЕНИЙ</h6>
|
||||
<i class="bi bi-paperclip text-warning fs-4"></i>
|
||||
</div>
|
||||
<h2 class="display-6 font-monospace my-2 text-white" id="statAttachments">{{ total_attachments }}</h2>
|
||||
<div class="small text-muted">Интегрировано в листы PDF</div>
|
||||
<div style="font-size: 22px; font-weight: bold;" id="statAttachments">{{ total_attachments }}</div>
|
||||
<div class="small text-muted mt-1">Интегрировано в PDF</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- КАРТОЧКА 4: ПОСЛЕДНЯЯ СИНХРОНИЗАЦИЯ -->
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-dark border-primary h-100 shadow">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>DRIVE СИНХРОНИЗАЦИЯ</span>
|
||||
<i class="bi bi-cloud-check"></i>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-primary text-uppercase small font-monospace mb-0">>_ DRIVE_SYNC</h6>
|
||||
<i class="bi bi-google-play text-primary fs-4"></i>
|
||||
</div>
|
||||
<h2 class="display-6 font-monospace my-2 text-white" id="statLastSync">
|
||||
<div style="font-size: 22px; font-weight: bold;" id="statLastSync">
|
||||
{% if last_sync_time %}{{ last_sync_time.strftime('%d.%m.%Y') }}{% else %}-{% endif %}
|
||||
</h2>
|
||||
<div class="small text-muted text-truncate" id="statLastSyncTime">
|
||||
</div>
|
||||
<div class="small text-muted mt-1 text-truncate" id="statLastSyncTime">
|
||||
Время: {{ fmt_local(last_sync_time) if last_sync_time else 'Неизвестно' }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,114 +74,149 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- ЛЕВАЯ КОЛОНКА: УПРАВЛЕНИЕ И СТАТУС -->
|
||||
<div class="row g-3">
|
||||
<!-- ЛЕВАЯ КОЛОНКА -->
|
||||
<div class="col-md-4">
|
||||
<!-- КАРТОЧКА УПРАВЛЕНИЯ -->
|
||||
<div class="card mb-4 shadow">
|
||||
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace">
|
||||
[CONTROL] УПРАВЛЕНИЕ_АРХИВОМ
|
||||
</div>
|
||||
<div class="card-body text-center py-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">УПРАВЛЕНИЕ АРХИВОМ</div>
|
||||
<div class="card-body text-center py-3">
|
||||
{% if has_master_pdf %}
|
||||
<i class="bi bi-file-earmark-pdf-fill display-2 text-danger mb-3"></i>
|
||||
<h6 class="text-white">ALL_EMAILS_CONSOLIDATED.pdf</h6>
|
||||
<p class="small opacity-50 mb-4">Архив готов к использованию в NotebookLM</p>
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-file-earmark-pdf text-danger" style="font-size: 36px;"></i>
|
||||
</div>
|
||||
<div style="font-weight: bold;">ALL_EMAILS_CONSOLIDATED.pdf</div>
|
||||
<div class="small text-muted mb-3">Архив готов к работе в NotebookLM</div>
|
||||
|
||||
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2">
|
||||
<i class="bi bi-lightning-charge-fill"></i> ТУРБО СИНХРОНИЗАЦИЯ
|
||||
<button id="fullSyncBtn" class="btn-unix btn-unix-primary w-100 mb-2">
|
||||
<i class="bi bi-arrow-repeat"></i> Запустить синхронизацию
|
||||
</button>
|
||||
|
||||
<div class="dropdown w-100">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle w-100" type="button" data-bs-toggle="dropdown">
|
||||
ДОПОЛНИТЕЛЬНО
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark w-100">
|
||||
<li><a href="/static/email_archive/TOTAL_ARCHIVE_2025-2026.pdf" class="dropdown-item" download>СКАЧАТЬ АРХИВ</a></li>
|
||||
<li><button id="syncAiBtn" class="dropdown-item">ТОЛЬКО UPLOAD В DRIVE</button></li>
|
||||
</ul>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/static/email_archive/TOTAL_ARCHIVE_2025-2026.pdf" class="btn-unix w-50 text-center" download>Скачать PDF</a>
|
||||
<button id="syncAiBtn" class="btn-unix w-50">В Google Drive</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<i class="bi bi-file-earmark-x display-2 opacity-20 mb-3"></i>
|
||||
<p class="text-warning">Архив еще не сформирован</p>
|
||||
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2">
|
||||
<i class="bi bi-lightning-charge-fill"></i> ЗАПУСТИТЬ ПЕРВЫЙ СБОР
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-file-earmark-x text-muted" style="font-size: 36px;"></i>
|
||||
</div>
|
||||
<div class="small text-muted mb-3">Архив еще не сформирован</div>
|
||||
<button id="fullSyncBtn" class="btn-unix btn-unix-primary w-100">
|
||||
<i class="bi bi-arrow-repeat"></i> Запустить первый сбор
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- СТАТУС ПАЙПЛАЙНА (ПОСТОЯННЫЙ) -->
|
||||
<div class="card border-primary mb-4 shadow" id="pipelineStatusCard">
|
||||
<div class="card-header bg-dark border-bottom border-primary d-flex justify-content-between align-items-center text-primary font-monospace">
|
||||
<span>[PIPELINE] МОНИТОРИНГ_ПРОЦЕССА</span>
|
||||
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" style="display: none;" role="status"></span>
|
||||
<!-- СТАТУС ПАЙПЛАЙНА -->
|
||||
<div class="card mb-3" id="pipelineStatusCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>МОНИТОРИНГ ПРОЦЕССА</span>
|
||||
<span class="spinner-border spinner-border-sm" id="pipelineSpinner" style="display: none;" role="status"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-3 bg-black border border-secondary font-monospace small">
|
||||
[STEP] Шаг: <span id="pipelineStep" class="text-info fw-bold">Ожидание</span> <br>
|
||||
[TIME] Обновлено: <span id="pipelineTime">-</span> <br>
|
||||
<div class="progress my-3" style="height: 12px; background-color: #111;">
|
||||
<div id="pipelineProgress" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
[DATA] Новых писем: <span id="pipelineNewEmails" class="text-success fw-bold">0</span> <br>
|
||||
[DATA] Всего в архиве: <span id="pipelineTotalEmails" class="fw-bold">0</span> <br>
|
||||
[DATA] Размер файла: <span id="pipelineSize" class="fw-bold">0.00 MB</span>
|
||||
</div>
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 120px; font-weight: bold;">Шаг:</td>
|
||||
<td><span id="pipelineStep">Ожидание</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Обновлено:</td>
|
||||
<td><span id="pipelineTime">-</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Прогресс:</td>
|
||||
<td>
|
||||
<div class="progress" style="height: 10px; background-color: #e8e8e8; border-radius: 0;">
|
||||
<div id="pipelineProgress" class="progress-bar bg-primary" role="progressbar" style="width: 0%; border-radius: 0;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Новых писем:</td>
|
||||
<td><span id="pipelineNewEmails">0</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">В архиве:</td>
|
||||
<td><span id="pipelineTotalEmails">0</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Размер:</td>
|
||||
<td><span id="pipelineSize">0.00 MB</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-dark border-bottom border-secondary text-warning font-monospace">
|
||||
[LOG] ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-3 bg-black border border-warning font-monospace small">
|
||||
{% if last_log %}
|
||||
[TIME] Начат: {{ fmt_local(last_log.start_time) }} <br>
|
||||
[TIME] Завершен: {{ fmt_local(last_log.end_time) if last_log.end_time else 'В ПРОЦЕССЕ...' }} <br>
|
||||
[STATUS] Статус: <span class="{% if last_log.status == 'success' %}text-success{% elif last_log.status == 'error' %}text-danger{% else %}text-warning{% endif %} fw-bold">{{ last_log.status.upper() }}</span> <br>
|
||||
[DATA] Новых писем: {{ last_log.new_emails_count }}
|
||||
{% else %}
|
||||
[INFO] Запуски еще не производились.
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">ПОСЛЕДНИЙ ЗАПУСК</div>
|
||||
<div class="card-body p-2">
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
{% if last_log %}
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Начат:</td>
|
||||
<td>{{ fmt_local(last_log.start_time) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Завершен:</td>
|
||||
<td>{{ fmt_local(last_log.end_time) if last_log.end_time else 'В процессе...' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Статус:</td>
|
||||
<td>
|
||||
<span class="badge-status {% if last_log.status == 'success' %}badge-success{% elif last_log.status == 'error' %}badge-danger{% else %}badge-warning{% endif %}">
|
||||
{{ last_log.status.upper() }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Новых писем:</td>
|
||||
<td>{{ last_log.new_emails_count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted text-center py-2">Запуски еще не производились</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ПРАВАЯ КОЛОНКА: ТАБЛИЦА ВЕДОМСТВ -->
|
||||
<div class="col-md-8">
|
||||
<div class="card h-100 shadow">
|
||||
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace">
|
||||
[STATS] СТАТИСТИКА_ПО_ВЕДОМСТВАМ_И_ОРГАНАМ
|
||||
</div>
|
||||
<div class="card h-100">
|
||||
<div class="card-header">СТАТИСТИКА ПО ОРГАНАМ И ВЕДОМСТВАМ</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 700px; overflow-y: auto;">
|
||||
<table class="table table-dark table-hover mb-0 align-middle">
|
||||
<thead class="sticky-top bg-dark">
|
||||
<div class="table-responsive" style="max-height: 650px; overflow-y: auto;">
|
||||
<table class="table mb-0">
|
||||
<thead style="position: sticky; top: 0; z-index: 1;">
|
||||
<tr>
|
||||
<th>Ведомство / Домен</th>
|
||||
<th class="text-center" style="width: 100px;">Вх.</th>
|
||||
<th class="text-center" style="width: 100px;">Исх.</th>
|
||||
<th style="width: 180px;">Последнее письмо</th>
|
||||
<th class="text-center" style="width: 80px;">Вх.</th>
|
||||
<th class="text-center" style="width: 80px;">Исх.</th>
|
||||
<th style="width: 170px;">Последнее письмо</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain in audit_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-success fw-bold">{{ domain.domain }}</span>
|
||||
<br><small class="opacity-50 text-white">{{ domain.description }}</small>
|
||||
<b>{{ domain.domain }}</b>
|
||||
<div class="small text-muted">{{ domain.description }}</div>
|
||||
</td>
|
||||
<td class="text-center font-monospace text-success fw-bold">{{ domain.inbound_count }}</td>
|
||||
<td class="text-center font-monospace text-info fw-bold">{{ domain.outbound_count }}</td>
|
||||
<td class="font-monospace">{{ domain.last_email_date.strftime('%d.%m.%Y %H:%M') if domain.last_email_date else '-' }}</td>
|
||||
<td class="text-center"><b>{{ domain.inbound_count }}</b></td>
|
||||
<td class="text-center"><b>{{ domain.outbound_count }}</b></td>
|
||||
<td>{{ domain.last_email_date.strftime('%d.%m.%Y %H:%M') if domain.last_email_date else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center opacity-50 py-4">Данных пока нет. Запустите экспорт.</td>
|
||||
<td colspan="4" class="text-center text-muted py-3">Данных пока нет. Запустите экспорт.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -200,32 +228,30 @@
|
|||
</div>
|
||||
|
||||
<!-- СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ -->
|
||||
<div class="row mt-4">
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-dark border-bottom border-secondary text-info font-monospace">
|
||||
[FEED] СВЕЖИЕ_ПОСТУПЛЕНИЯ_В_АРХИВ (ПОСЛЕДНИЕ 10)
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ (ПОСЛЕДНИЕ 10)</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0" style="font-size: 0.85rem;">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px;">Дата</th>
|
||||
<th style="width: 200px;">Ведомство</th>
|
||||
<th style="width: 170px;">Дата</th>
|
||||
<th style="width: 220px;">Ведомство</th>
|
||||
<th>Тема письма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for email in latest_emails %}
|
||||
<tr>
|
||||
<td><code class="text-info font-monospace">{{ email.date }}</code></td>
|
||||
<td><span class="badge bg-secondary font-monospace">{{ email.domain }}</span></td>
|
||||
<td class="text-truncate text-white" style="max-width: 500px;">{{ email.subject }}</td>
|
||||
<td><code>{{ email.date }}</code></td>
|
||||
<td><span class="badge-status">{{ email.domain }}</span></td>
|
||||
<td class="text-truncate" style="max-width: 550px;">{{ email.subject }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center opacity-50 py-3">Новых файлов пока нет</td>
|
||||
<td colspan="3" class="text-center text-muted py-3">Новых файлов пока нет</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -270,7 +296,6 @@ async function checkPipelineStatus() {
|
|||
|
||||
if (data.last_sync_time && data.last_sync_time !== '-') {
|
||||
if (widgetLastSync) {
|
||||
// Извлекаем только дату из YYYY-MM-DD HH:MM:SS
|
||||
const parts = data.last_sync_time.split(' ');
|
||||
if (parts[0]) {
|
||||
const dateParts = parts[0].split('-');
|
||||
|
|
@ -287,12 +312,9 @@ async function checkPipelineStatus() {
|
|||
time.textContent = data.updated_at || '-';
|
||||
|
||||
if (data.status === 'running') {
|
||||
card.className = "card border-primary mb-4 shadow";
|
||||
step.className = "text-info fw-bold";
|
||||
step.textContent = data.step;
|
||||
step.innerHTML = '<b>' + data.step + '</b>';
|
||||
spinner.style.display = 'inline-block';
|
||||
|
||||
// Динамический прогресс
|
||||
let width = '10%';
|
||||
if (data.step.includes('Сбор')) width = '35%';
|
||||
else if (data.step.includes('Консолидация')) width = '70%';
|
||||
|
|
@ -300,30 +322,22 @@ async function checkPipelineStatus() {
|
|||
progress.style.width = width;
|
||||
progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary";
|
||||
|
||||
// Опрашиваем часто при работе
|
||||
setTimeout(checkPipelineStatus, 2000);
|
||||
} else if (data.status === 'success') {
|
||||
card.className = "card border-success mb-4 shadow";
|
||||
step.className = "text-success fw-bold";
|
||||
step.innerHTML = '✅ Завершен успешно';
|
||||
step.innerHTML = '<span class="badge-status badge-success">Завершен успешно</span>';
|
||||
spinner.style.display = 'none';
|
||||
progress.style.width = '100%';
|
||||
progress.className = "progress-bar bg-success";
|
||||
|
||||
// Опрашиваем реже в простое
|
||||
setTimeout(checkPipelineStatus, 15000);
|
||||
} else if (data.status === 'error' || data.status === 'stuck') {
|
||||
card.className = "card border-danger mb-4 shadow";
|
||||
step.className = "text-danger fw-bold";
|
||||
step.innerHTML = '❌ ' + (data.step || 'Ошибка выполнения');
|
||||
step.innerHTML = '<span class="badge-status badge-danger">' + (data.step || 'Ошибка') + '</span>';
|
||||
spinner.style.display = 'none';
|
||||
progress.style.width = '100%';
|
||||
progress.className = "progress-bar bg-danger";
|
||||
|
||||
setTimeout(checkPipelineStatus, 15000);
|
||||
} else {
|
||||
card.className = "card border-secondary mb-4 shadow";
|
||||
step.className = "text-muted";
|
||||
step.textContent = "Неактивен";
|
||||
spinner.style.display = 'none';
|
||||
progress.style.width = '0%';
|
||||
|
|
@ -351,7 +365,6 @@ document.getElementById('fullSyncBtn').addEventListener('click', async function(
|
|||
const response = await fetch('/api/emails/run_full_sync', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
alert(result.message);
|
||||
// Запускаем мониторинг статуса
|
||||
setTimeout(checkPipelineStatus, 1000);
|
||||
} catch (e) {
|
||||
alert('Ошибка: ' + e);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Экспорт - Домовой Бот{% endblock %}
|
||||
{% block title %}Резервное копирование и экспорт - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_export %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ЭКСПОРТ_ДАННЫХ</h2>
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Резервное копирование и экспорт базы</h4>
|
||||
<div class="small text-muted">Формирование дампов базы данных и синхронизация с локальным Git-сервером (Forgejo)</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-success">
|
||||
<div class="card-header border-success">
|
||||
<i class="bi bi-file-earmark-json"></i> ЭКСПОРТ В JSON & GITEA
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i> ЭКСПОРТ ДАННЫХ В JSON И GIT
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Экспорт ВСЕХ пользователей и сообщений. Файл будет автоматически отправлен в Gitea.</p>
|
||||
<button class="btn btn-matrix w-100" onclick="exportJSON()">
|
||||
<i class="bi bi-cloud-arrow-up"></i> ЗАПУСТИТЬ ПОЛНЫЙ ЭКСПОРТ
|
||||
<p class="small text-muted mb-3">
|
||||
Экспорт всех учетных записей жильцов, истории сообщений и настроек. Файл формируется в формате JSON и автоматически фиксируется коммитом в Git.
|
||||
</p>
|
||||
<button class="btn-unix btn-unix-primary w-100" onclick="exportJSON()">
|
||||
<i class="bi bi-arrow-up-circle"></i> Запустить полный экспорт сейчас
|
||||
</button>
|
||||
<div id="exportResult" class="mt-3"></div>
|
||||
</div>
|
||||
|
|
@ -23,62 +28,70 @@
|
|||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card border-success">
|
||||
<div class="card-header border-success">
|
||||
<i class="bi bi-graph-up"></i> СТАТИСТИКА ВЫГРУЗОК
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history"></i> СТАТИСТИКА ВЫГРУЗОК
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="h4 text-matrix mb-3">
|
||||
Всего выгрузок: <span class="badge bg-success">{{ export_stats.total }}</span>
|
||||
<table class="table mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-weight: bold; width: 180px;">Всего выгрузок:</td>
|
||||
<td><span class="badge-status badge-success">{{ export_stats.total }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Последняя выгрузка:</td>
|
||||
<td>{{ fmt_local(export_stats.last_date) if export_stats.last_date else 'Никогда' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold;">Авторасписание:</td>
|
||||
<td>Каждое воскресенье в 03:00 (cron)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="small text-muted">
|
||||
Архивы сохраняются локально в каталог <code>exports/</code> и отправляются в репозиторий на Synology NAS.
|
||||
</div>
|
||||
<p>Последняя выгрузка:<br>
|
||||
<span class="text-success">{{ fmt_local(export_stats.last_date) if export_stats.last_date else 'Никогда' }}</span></p>
|
||||
<hr class="border-success">
|
||||
<p class="text-muted small">
|
||||
Автоматический экспорт выполняется раз в неделю (вс, 03:00) и пушится в репозиторий.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
async function exportJSON() {
|
||||
const resultDiv = document.getElementById('exportResult');
|
||||
const btn = document.querySelector('button[onclick="exportJSON()"]');
|
||||
|
||||
resultDiv.innerHTML = '<div class="alert alert-info"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Gitea...</div>';
|
||||
btn.disabled = true;
|
||||
async function exportJSON() {
|
||||
const resultDiv = document.getElementById('exportResult');
|
||||
const btn = document.querySelector('button[onclick="exportJSON()"]');
|
||||
|
||||
resultDiv.innerHTML = '<div class="alert alert-info py-2 small"><div class="spinner-border spinner-border-sm me-2"></div>Сбор данных и отправка в Git...</div>';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/export/json');
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ <b>Успех!</b><br>
|
||||
Файл: <code>${data.filename}</code><br>
|
||||
Сообщений: ${data.messages}<br>
|
||||
Статус Git: ${data.git ? '🚀 Отправлено в Gitea' : '❌ Ошибка Git'}<br>
|
||||
<br>
|
||||
<a href="/exports/${data.filename}" class="btn btn-outline-success btn-sm w-100 mb-2">
|
||||
<i class="bi bi-download"></i> СКАЧАТЬ ФАЙЛ ЛОКАЛЬНО
|
||||
try {
|
||||
const response = await fetch('/api/export/json');
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="alert alert-success py-2 small">
|
||||
<b>Успешно!</b><br>
|
||||
Файл: <code>${data.filename}</code><br>
|
||||
Сообщений: ${data.messages}<br>
|
||||
Статус Git: ${data.git ? 'Синхронизировано в Forgejo' : 'Ошибка Git'}<br>
|
||||
<div class="mt-2">
|
||||
<a href="/exports/${data.filename}" class="btn-unix">
|
||||
Скачать файл локально
|
||||
</a>
|
||||
<small>Страница обновится через 10 сек...</small>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => location.reload(), 10000);
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${data.error || 'Неизвестно'}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка сети: ${error.message}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => location.reload(), 10000);
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Ошибка: ${data.error || 'Неизвестная ошибка'}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger py-2 small">Сетевая ошибка: ${error.message}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Менеджер артефактов - LKM37{% endblock %}
|
||||
{% block title %}Файлы и архивы - МКД ЛенКом 37{% 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_CORE)
|
||||
</h2>
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Менеджер файлов и медиа-артефактов</h4>
|
||||
<div class="small text-muted">Хранилище документов, системных архивов и медиа-файлов для рассылок</div>
|
||||
</div>
|
||||
|
||||
<!-- Секция загрузки артефактов -->
|
||||
<div class="row mb-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="card bg-black border-success shadow-sm">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-cloud-upload"></i> ЗАГРУЗКА МЕДИА-ФАЙЛА (РЕГИСТРАЦИЯ В TELEGRAM)
|
||||
</div>
|
||||
<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">
|
||||
<p class="small text-muted mb-3">
|
||||
Загрузите изображение или документ. Бот сохранит его в Telegram и сгенерирует постоянный <code>file_id</code> для безопасного использования в постах и рассылках.
|
||||
</p>
|
||||
<form id="uploadForm" class="row g-2 align-items-center">
|
||||
<div class="col-md-9">
|
||||
<input type="file" id="fileInput" class="form-control bg-dark border-success text-success">
|
||||
<input type="file" id="fileInput" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn btn-matrix w-100">
|
||||
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn-unix btn-unix-primary w-100">
|
||||
<span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
|
||||
ЗАГРУЗИТЬ
|
||||
Загрузить в бота
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -32,26 +37,26 @@
|
|||
</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> ОБНОВИТЬ
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>ЗАРЕГИСТРИРОВАННЫЕ МЕДИА-АРТЕФАКТЫ TELEGRAM</span>
|
||||
<button class="btn-unix" onclick="loadFiles()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Обновить список
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr class="text-info opacity-75" style="font-size: 0.8em;">
|
||||
<th>ИМЯ ФАЙЛА</th>
|
||||
<th>ТИП</th>
|
||||
<th>TELEGRAM_FILE_ID</th>
|
||||
<th class="text-end">ДЕЙСТВИЯ</th>
|
||||
<tr>
|
||||
<th>Имя файла</th>
|
||||
<th style="width: 120px;">Тип</th>
|
||||
<th>Telegram File ID</th>
|
||||
<th class="text-end" style="width: 120px;">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="artifactsTable">
|
||||
<!-- Заполняется через API -->
|
||||
<!-- Заполняется через JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -59,31 +64,27 @@
|
|||
</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">
|
||||
<div class="card-header">СИСТЕМНЫЕ АРХИВЫ И ЛОГИ СЕРВЕРА</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr class="text-muted" style="font-size: 0.8em;">
|
||||
<th>ИМЯ ФАЙЛА</th>
|
||||
<th>КАТЕГОРИЯ</th>
|
||||
<th>РАЗМЕР</th>
|
||||
<th class="text-end">ДЕЙСТВИЯ</th>
|
||||
<tr>
|
||||
<th>Имя файла</th>
|
||||
<th style="width: 140px;">Категория</th>
|
||||
<th style="width: 120px;">Размер</th>
|
||||
<th class="text-end" style="width: 100px;">Скачать</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="systemFilesTable">
|
||||
<!-- Заполняется через API -->
|
||||
<!-- Заполняется через JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', loadFiles);
|
||||
|
||||
|
|
@ -109,12 +110,12 @@ async function loadFiles() {
|
|||
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><b>${f.filename}</b></td>
|
||||
<td><span class="badge-status">${f.file_type}</span></td>
|
||||
<td><code 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 class="btn-unix" onclick="copyId('${f.file_id}')">
|
||||
Копировать ID
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -128,12 +129,12 @@ async function loadFiles() {
|
|||
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><b>${f.filename}</b></td>
|
||||
<td><span class="badge-status">${f.file_type}</span></td>
|
||||
<td>${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 href="${f.path}" class="btn-unix" download>
|
||||
Скачать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -147,12 +148,7 @@ async function loadFiles() {
|
|||
|
||||
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);
|
||||
alert('File ID скопирован в буфер обмена');
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
|
|
@ -176,7 +172,7 @@ async function uploadFile() {
|
|||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Файл успешно зарегистрирован в Telegram!\nFILE_ID: ' + result.file_id);
|
||||
alert('Файл успешно зарегистрирован в Telegram!\nFile ID: ' + result.file_id);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка при регистрации: ' + result.error);
|
||||
|
|
|
|||
|
|
@ -1,102 +1,112 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Инфраструктура - Домовой Бот{% endblock %}
|
||||
{% block title %}Инфраструктура - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_infra %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-cpu"></i> Мониторинг Инфраструктуры</h2>
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Мониторинг инфраструктуры и сервисов</h4>
|
||||
<div class="small text-muted">Состояние сетевых узлов, замеры интернет-канала и статус фоновых пайплайнов</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="card bg-dark border-success">
|
||||
<div class="card-header border-success text-success">
|
||||
<i class="bi bi-speedometer2"></i> ТЕСТ СКОРОСТИ ИНТЕРНЕТА (150 Mbps План)
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>ТЕСТЫ СКОРОСТИ ИНТЕРНЕТ-КАНАЛА (ТАРИФ 150 МБИТ/С)</span>
|
||||
<button id="runSpeedtestBtn" class="btn-unix">
|
||||
<i class="bi bi-play-fill"></i> Запустить замер сейчас
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-dark table-hover small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата и Время</th>
|
||||
<th>Download (Mbps)</th>
|
||||
<th>Upload (Mbps)</th>
|
||||
<th>Ping (ms)</th>
|
||||
<th>Сервер</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата и время</th>
|
||||
<th>Download (Мбит/с)</th>
|
||||
<th>Upload (Мбит/с)</th>
|
||||
<th>Ping (мс)</th>
|
||||
<th>Сервер замера</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for test in speedtests %}
|
||||
<tr>
|
||||
<td>{{ test.timestamp }}</td>
|
||||
<td><b>{{ test.download }}</b></td>
|
||||
<td><b>{{ test.upload }}</b></td>
|
||||
<td>{{ test.ping }}</td>
|
||||
<td class="text-muted">{{ test.server_name }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-3">Данных замеров пока нет</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">СЕТЕВЫЕ УЗЛЫ И ХОСТЫ</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
{% for test in speedtests %}
|
||||
<tr>
|
||||
<td>{{ test.timestamp }}</td>
|
||||
<td class="text-info">{{ test.download }}</td>
|
||||
<td class="text-warning">{{ test.upload }}</td>
|
||||
<td>{{ test.ping }}</td>
|
||||
<td class="opacity-50">{{ test.server_name }}</td>
|
||||
<td style="width: 140px;"><b>NanoPi R3S</b></td>
|
||||
<td>192.168.10.1 (Роутер / Шлюз)</td>
|
||||
<td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Synology NAS</b></td>
|
||||
<td>192.168.10.105 (Хранилище / PostgreSQL)</td>
|
||||
<td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>DockerServer</b></td>
|
||||
<td>192.168.10.116 (Domovoy / Maya Bot)</td>
|
||||
<td class="text-end"><span class="badge-status badge-success">ONLINE</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-info">
|
||||
<div class="card-header text-info">УЗЛЫ СЕТИ</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush bg-transparent">
|
||||
<li class="list-group-item bg-transparent text-white border-secondary">
|
||||
<span class="badge bg-success">ONLINE</span> NanoPi R3S (192.168.10.1)
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent text-white border-secondary">
|
||||
<span class="badge bg-success">ONLINE</span> Synology NAS (192.168.10.105)
|
||||
</li>
|
||||
<li class="list-group-item bg-transparent text-white border-secondary">
|
||||
<span class="badge bg-success">ONLINE</span> DockerServer (192.168.10.116)
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>ГИС ЖКХ & СБОРКА АРХИВА</span>
|
||||
<span id="gisStatusBadge" class="badge-status">СТАТУС: ПРОВЕРКА</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-primary">
|
||||
<div class="card-header text-primary">ГИС ЖКХ & ЮРИДИЧЕСКИЙ АРХИВ</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<button id="runGisHarvestBtn" class="btn btn-outline-primary flex-grow-1 me-2">
|
||||
<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button id="runGisHarvestBtn" class="btn-unix btn-unix-primary flex-grow-1">
|
||||
<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ
|
||||
</button>
|
||||
<button id="refreshGisStatusBtn" class="btn-unix">
|
||||
Обновить статус
|
||||
</button>
|
||||
<div id="gisStatusBadge" class="badge bg-secondary p-2">СТАТУС: НЕИЗВЕСТНО</div>
|
||||
</div>
|
||||
<div class="mb-2 d-flex justify-content-between">
|
||||
<span class="small text-muted">ПОСЛЕДНИЕ ЛОГИ ЖНЕЦА:</span>
|
||||
<button id="refreshGisStatusBtn" class="btn btn-sm btn-link p-0 text-primary">ОБНОВИТЬ СТАТУС</button>
|
||||
</div>
|
||||
<pre id="gisLogs" class="bg-black text-success p-2 small border border-secondary" style="height: 200px; overflow-y: scroll; font-size: 0.7rem;"></pre>
|
||||
<div class="small opacity-50 mt-2">Выполняет: Выкачка (Overwrite) -> Теггирование -> Склейка -> Google Drive</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card bg-dark border-warning">
|
||||
<div class="card-header text-warning">ДЕЙСТВИЯ</div>
|
||||
<div class="card-body">
|
||||
<button id="runSpeedtestBtn" class="btn btn-outline-warning w-100 mb-2">
|
||||
<i class="bi bi-play-fill"></i> ЗАПУСТИТЬ ТЕСТ СКОРОСТИ СЕЙЧАС
|
||||
</button>
|
||||
<div class="small text-muted mb-1">Лог выполнения процесса:</div>
|
||||
<pre id="gisLogs" style="height: 160px; overflow-y: scroll; background-color: #ffffff; border: 1px solid var(--border-color); font-size: 11px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
document.getElementById('runSpeedtestBtn').addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> ТЕСТИРУЮ (60с)...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Замер (до 60 сек)...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/infra/run_speedtest', { method: 'POST' });
|
||||
|
|
@ -107,16 +117,16 @@ document.getElementById('runSpeedtestBtn').addEventListener('click', async funct
|
|||
alert('Ошибка: ' + e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-play-fill"></i> ЗАПУСТИТЬ ТЕСТ СКОРОСТИ СЕЙЧАС';
|
||||
btn.innerHTML = '<i class="bi bi-play-fill"></i> Запустить замер сейчас';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('runGisHarvestBtn').addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
if (!confirm('Запустить тотальную перекачку ГИС ЖКХ и сборку PDF? Это займет значительное время.')) return;
|
||||
if (!confirm('Запустить сборку ГИС ЖКХ? Это займет некоторое время.')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> ЖАТВА В ПРОЦЕССЕ...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Процесс запущен...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/infra/run_gis_harvest', { method: 'POST' });
|
||||
|
|
@ -126,7 +136,7 @@ document.getElementById('runGisHarvestBtn').addEventListener('click', async func
|
|||
alert('Ошибка: ' + e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-cloud-download"></i> ЗАПУСТИТЬ ЖАТВУ И PDF-СБОРКУ';
|
||||
btn.innerHTML = '<i class="bi bi-cloud-download"></i> Запустить сборку ГИС ЖКХ';
|
||||
refreshGisStatus();
|
||||
}
|
||||
});
|
||||
|
|
@ -140,24 +150,23 @@ async function refreshGisStatus() {
|
|||
const data = await response.json();
|
||||
|
||||
if (data.alive) {
|
||||
badge.className = 'badge bg-success p-2';
|
||||
badge.innerText = 'СТАТУС: РАБОТАЕТ';
|
||||
badge.className = 'badge-status badge-success';
|
||||
badge.innerText = 'РАБОТАЕТ';
|
||||
} else {
|
||||
badge.className = 'badge bg-danger p-2';
|
||||
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
|
||||
badge.className = 'badge-status';
|
||||
badge.innerText = 'ОЖИДАНИЕ';
|
||||
}
|
||||
|
||||
logs.innerText = data.log;
|
||||
logs.innerText = data.log || 'Лог чист';
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
} catch (e) {
|
||||
badge.className = 'badge bg-warning p-2';
|
||||
badge.innerText = 'ОШИБКА API';
|
||||
badge.className = 'badge-status badge-danger';
|
||||
badge.innerText = 'ОШИБКА';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refreshGisStatusBtn').addEventListener('click', refreshGisStatus);
|
||||
window.addEventListener('load', refreshGisStatus);
|
||||
// Авто-обновление раз в 30 секунд
|
||||
setInterval(refreshGisStatus, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
217
web/templates/legal.html
Normal file
217
web/templates/legal.html
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Нормативно-правовая база & ИИ-Юрист | LKM37{% endblock %}
|
||||
{% block nav_legal %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="m-0" style="font-size: 18px; font-weight: bold;">НОРМАТИВНО-ПРАВОВАЯ БАЗА РФ & ИИ-ЮРИСТ</h3>
|
||||
<div>
|
||||
<button class="btn-unix btn-unix-primary" onclick="syncNotebookLM()">
|
||||
<i class="bi bi-arrow-repeat"></i> Синхронизация с NotebookLM
|
||||
</button>
|
||||
<button class="btn-unix" onclick="generatePodcast()">
|
||||
<i class="bi bi-mic"></i> Сгенерировать подкаст (Audio Overview)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Левая колонка: Реестр полных законов РФ -->
|
||||
<div class="col-md-7">
|
||||
<div class="panel">
|
||||
<div class="panel-header d-flex justify-content-between align-items-center">
|
||||
<span>ПОЛНЫЕ ОФИЦИАЛЬНЫЕ КОДЕКСЫ И НОРМАТИВНЫЕ АКТЫ РФ (100% ТЕКСТ)</span>
|
||||
<span class="badge-status badge-success">{{ laws|length }} документов</span>
|
||||
</div>
|
||||
<div class="panel-body p-0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 45px;">№</th>
|
||||
<th>Наименование нормативно-правового акта</th>
|
||||
<th style="width: 75px;">Стр.</th>
|
||||
<th style="width: 85px;">Размер</th>
|
||||
<th style="width: 100px;">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for law in laws %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>
|
||||
<strong>{{ law.title }}</strong><br>
|
||||
<small style="color: var(--text-muted);">{{ law.filename }}</small>
|
||||
</td>
|
||||
<td>{{ law.pages }}</td>
|
||||
<td>{{ law.size_formatted }}</td>
|
||||
<td>
|
||||
<a href="/api/legal/download/{{ law.filename }}" class="btn-unix" download>
|
||||
<i class="bi bi-download"></i> PDF
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Карточка томов переписки -->
|
||||
<div class="panel">
|
||||
<div class="panel-header d-flex justify-content-between align-items-center">
|
||||
<span>СВОДНЫЕ ТОМА ПЕРЕПИСКИ С ГОСОРГАНАМИ & ШТАМПЫ ЭЦП</span>
|
||||
<span class="badge-status badge-success">{{ mega_volumes|length }} тома</span>
|
||||
</div>
|
||||
<div class="panel-body p-0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Том переписки</th>
|
||||
<th>Размер</th>
|
||||
<th>Оглавление TOC</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for vol in mega_volumes %}
|
||||
<tr>
|
||||
<td><strong>{{ vol.name }}</strong></td>
|
||||
<td>{{ vol.size_formatted }}</td>
|
||||
<td><span class="badge-status badge-success">Вшито в PDF</span></td>
|
||||
<td>
|
||||
<a href="/api/legal/download_volume/{{ vol.name }}" class="btn-unix" download>
|
||||
<i class="bi bi-download"></i> Скачать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Правая колонка: ИИ-Консультант и Генератор Подкастов -->
|
||||
<div class="col-md-5">
|
||||
<!-- Интерактивный AI-Юрист -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<i class="bi bi-cpu"></i> АВТОНОМНЫЙ ИИ-КОНСУЛЬТАНТ (GEMINI 2.5)
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
|
||||
Контекст: Полный свод ЖК РФ, ГК РФ, 59-ФЗ, ПП 354, ПП 491, Госстрой 170 + 610 писем дома с верифицированными ЭЦП.
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label style="font-weight: bold; font-size: 11px;">Вопрос / Фабула ситуации:</label>
|
||||
<textarea id="ai-query" class="form-control" rows="4" style="font-family: inherit; font-size: 12px; border: 1px solid var(--border-color); border-radius: 0;" placeholder="Например: УК отказалась делать перерасчет за отопление в подъезде 4, утверждая что перерыв не превышал допустимой нормы. На какие пункты ПП 354 и Госстроя 170 сослаться в претензии?"></textarea>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<input type="checkbox" id="ai-deep-mode">
|
||||
<label for="ai-deep-mode" style="font-size: 11px; cursor: pointer;">Глубокий анализ (Gemini 2.5 Pro)</label>
|
||||
</div>
|
||||
<button class="btn-unix btn-unix-primary" onclick="askAiLawyer()">
|
||||
<i class="bi bi-send"></i> Сформировать позицию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="ai-response-box" style="display: none; margin-top: 12px; border-top: 1px dashed var(--border-color); padding-top: 10px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong style="font-size: 11px;">ОТВЕТ ИИ-ЮРИСТА:</strong>
|
||||
<span id="ai-model-tag" class="badge-status badge-success">Flash</span>
|
||||
</div>
|
||||
<div id="ai-response-text" style="background-color: #fafafa; border: 1px solid var(--border-light); padding: 8px; font-size: 11px; white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Аудио-подкасты NotebookLM -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<i class="bi bi-soundwave"></i> АУДИО-ПОДКАСТЫ ДОМА (AUDIO OVERVIEWS)
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
|
||||
Автоматическая генерация аналитических подкастов диалога двух экспертов по делам дома.
|
||||
</p>
|
||||
<div id="podcast-status-box">
|
||||
{% if latest_podcast %}
|
||||
<div class="mb-2">
|
||||
<strong>Последний подкаст:</strong> {{ latest_podcast.name }}<br>
|
||||
<small style="color: var(--text-muted);">Размер: {{ latest_podcast.size }} | Дата: {{ latest_podcast.date }}</small>
|
||||
</div>
|
||||
<audio controls style="width: 100%; margin-bottom: 8px;">
|
||||
<source src="/api/legal/podcasts/{{ latest_podcast.name }}" type="audio/mp4">
|
||||
Ваш браузер не поддерживает аудио.
|
||||
</audio>
|
||||
{% else %}
|
||||
<p style="font-size: 11px; font-style: italic; color: var(--text-muted);">
|
||||
Подкасты еще не генерировались. Нажмите кнопку генерации выше.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function askAiLawyer() {
|
||||
const query = document.getElementById('ai-query').value.trim();
|
||||
if (!query) {
|
||||
alert('Пожалуйста, введите текст вопроса');
|
||||
return;
|
||||
}
|
||||
const deep = document.getElementById('ai-deep-mode').checked;
|
||||
const resBox = document.getElementById('ai-response-box');
|
||||
const resText = document.getElementById('ai-response-text');
|
||||
const tag = document.getElementById('ai-model-tag');
|
||||
|
||||
resBox.style.display = 'block';
|
||||
resText.innerText = 'Анализ правовой базы и судебной практики... Пожалуйста, подождите.';
|
||||
tag.innerText = deep ? 'Gemini 2.5 Pro' : 'Gemini 2.5 Flash';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/legal/ask_ai', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({query: query, deep: deep})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
resText.innerText = data.answer;
|
||||
} else {
|
||||
resText.innerText = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
|
||||
}
|
||||
} catch (e) {
|
||||
resText.innerText = 'Сетевая ошибка обращения к ИИ-Юристу: ' + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNotebookLM() {
|
||||
if (!confirm('Запустить полную синхронизацию законов и томов переписки с Google NotebookLM?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/legal/notebooklm_sync', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
alert(data.message || 'Синхронизация завершена!');
|
||||
} catch (e) {
|
||||
alert('Ошибка запуска синхронизации: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePodcast() {
|
||||
if (!confirm('Запустить генерацию Audio Overview подкаста в NotebookLM?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/legal/generate_podcast', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
alert(data.message || 'Задача генерации подкаста запущена!');
|
||||
} catch (e) {
|
||||
alert('Ошибка запуска генерации подкаста: ' + e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,138 +1,138 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Телефоны и службы - Домовой Бот{% endblock %}
|
||||
|
||||
{% block title %}Телефоны и службы - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_phones %}active{% endblock %}
|
||||
|
||||
{% block extra_style %}
|
||||
.service-image-preview { max-width: 200px; max-height: 200px; object-fit: contain; }
|
||||
.service-image-preview { max-width: 120px; max-height: 80px; object-fit: contain; border: 1px solid var(--border-light); }
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">>_ ТЕЛЕФОНЫ_И_СЛУЖБЫ</h2>
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Телефоны экстренных и обслуживающих служб</h4>
|
||||
<div class="small text-muted">Справочник полезных контактов, аварийных служб и мастеров для жителей дома</div>
|
||||
</div>
|
||||
|
||||
<!-- Добавить службу -->
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header border-success">
|
||||
<h5 class="mb-0"><i class="bi bi-plus-lg"></i> Добавить службу</h5>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle"></i> ДОБАВЛЕНИЕ НОВОЙ СЛУЖБЫ
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="addServiceForm" class="row g-3">
|
||||
<form id="addServiceForm" class="row g-2 align-items-center">
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control bg-black border-success text-success" id="serviceName" placeholder="Название" required>
|
||||
<input type="text" class="form-control" id="serviceName" placeholder="Название службы" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control bg-black border-success text-success" id="servicePhone" placeholder="Телефон" required>
|
||||
<input type="text" class="form-control" id="servicePhone" placeholder="Телефон" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select bg-black border-success text-success" id="serviceCategory">
|
||||
<option value="emergency">🚨 Экстренные</option>
|
||||
<option value="utility">🔧 ЖКХ</option>
|
||||
<option value="masters">👷 Мастера</option>
|
||||
<option value="police">👮 Полиция</option>
|
||||
<option value="other">📋 Другое</option>
|
||||
<select class="form-select" id="serviceCategory">
|
||||
<option value="emergency">Экстренные службы</option>
|
||||
<option value="utility" selected>ЖКХ и аварийные</option>
|
||||
<option value="masters">Мастера и ремонт</option>
|
||||
<option value="police">Полиция и охрана</option>
|
||||
<option value="other">Прочие контакты</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control bg-black border-success text-success" id="serviceDesc" placeholder="Описание">
|
||||
<input type="text" class="form-control" id="serviceDesc" placeholder="Описание / часы работы">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-matrix w-100">Добавить</button>
|
||||
<button type="submit" class="btn-unix btn-unix-primary w-100">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Загрузить картинку -->
|
||||
<div class="card mb-4 border-success">
|
||||
<div class="card-header border-success">
|
||||
<h5 class="mb-0"><i class="bi bi-image"></i> Загрузить картинку для службы</h5>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-image"></i> ЗАГРУЗКА ИЗОБРАЖЕНИЯ ДЛЯ КАТЕГОРИИ
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="uploadImageForm" class="row g-3" enctype="multipart/form-data">
|
||||
<form id="uploadImageForm" class="row g-2 align-items-center" enctype="multipart/form-data">
|
||||
<div class="col-md-3">
|
||||
<select class="form-select bg-black border-success text-success" id="imageCategory" required>
|
||||
<option value="emergency">🚨 Экстренные</option>
|
||||
<option value="utility">🔧 ЖКХ</option>
|
||||
<option value="masters">👷 Мастера</option>
|
||||
<option value="police">👮 Полиция</option>
|
||||
<option value="other">📋 Другое</option>
|
||||
<select class="form-select" id="imageCategory" required>
|
||||
<option value="emergency">Экстренные службы</option>
|
||||
<option value="utility" selected>ЖКХ и аварийные</option>
|
||||
<option value="masters">Мастера и ремонт</option>
|
||||
<option value="police">Полиция и охрана</option>
|
||||
<option value="other">Прочие контакты</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="file" class="form-control bg-black border-success text-success" id="imageFile" accept="image/*" required>
|
||||
<small class="text-muted">JPG, PNG до 5MB</small>
|
||||
<div class="col-md-5">
|
||||
<input type="file" class="form-control" id="imageFile" accept="image/*" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-matrix w-100">Загрузить</button>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn-unix w-100">Загрузить картинку</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="uploadResult" class="mt-3"></div>
|
||||
<div id="uploadResult" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список служб -->
|
||||
<div class="card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="text-success opacity-75">СУЩЕСТВУЮЩИЕ СЛУЖБЫ:</h5>
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Название</th>
|
||||
<th>Телефон</th>
|
||||
<th>Описание</th>
|
||||
<th>Картинка</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in services %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if service.category == 'emergency' %}
|
||||
<span class="badge bg-danger">🚨 Экстренные</span>
|
||||
{% elif service.category == 'utility' %}
|
||||
<span class="badge bg-info">🔧 ЖКХ</span>
|
||||
{% elif service.category == 'masters' %}
|
||||
<span class="badge bg-success">👷 Мастера</span>
|
||||
{% elif service.category == 'police' %}
|
||||
<span class="badge bg-warning text-dark">👮 Полиция</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Другое</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ service.name }}</td>
|
||||
<td><a href="tel:{{ service.phone }}">{{ service.phone }}</a></td>
|
||||
<td>{{ service.description or '—' }}</td>
|
||||
<td>
|
||||
{% if service.image_path %}
|
||||
<img src="/static/service_images/{{ service.image_path.split('/')[-1] }}"
|
||||
class="service-image-preview" alt="Картинка">
|
||||
<br><small class="text-muted">✅</small>
|
||||
{% else %}
|
||||
<span class="text-muted">❌ Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteService({{ service.service_id }})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not services %}
|
||||
<p class="text-center text-muted">Служб нет</p>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-header">СПИСОК КОНТАКТОВ В БАЗЕ ДАННЫХ</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 150px;">Категория</th>
|
||||
<th>Название</th>
|
||||
<th style="width: 160px;">Телефон</th>
|
||||
<th>Описание</th>
|
||||
<th style="width: 130px;">Изображение</th>
|
||||
<th class="text-end" style="width: 90px;">Удалить</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in services %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if service.category == 'emergency' %}
|
||||
<span class="badge-status badge-danger">Экстренные</span>
|
||||
{% elif service.category == 'utility' %}
|
||||
<span class="badge-status badge-warning">ЖКХ</span>
|
||||
{% elif service.category == 'masters' %}
|
||||
<span class="badge-status badge-success">Мастера</span>
|
||||
{% elif service.category == 'police' %}
|
||||
<span class="badge-status badge-info">Полиция</span>
|
||||
{% else %}
|
||||
<span class="badge-status">Другое</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><b>{{ service.name }}</b></td>
|
||||
<td><a href="tel:{{ service.phone }}"><b>{{ service.phone }}</b></a></td>
|
||||
<td>{{ service.description or '—' }}</td>
|
||||
<td>
|
||||
{% if service.image_path %}
|
||||
<img src="/static/service_images/{{ service.image_path.split('/')[-1] }}"
|
||||
class="service-image-preview" alt="Превью">
|
||||
{% else %}
|
||||
<span class="text-muted small">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn-unix" onclick="deleteService({{ service.service_id }})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-3">Службы пока не добавлены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
// Добавление службы
|
||||
document.getElementById('addServiceForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
|
|
@ -146,11 +146,10 @@
|
|||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await r.json();
|
||||
if (result.success) { alert('✅ Служба добавлена'); location.reload(); }
|
||||
else { alert('❌ Ошибка: ' + (result.error || 'Неизвестная')); }
|
||||
if (result.success) { alert('Служба добавлена'); location.reload(); }
|
||||
else { alert('Ошибка: ' + (result.error || 'Неизвестная')); }
|
||||
});
|
||||
|
||||
// Загрузка картинки
|
||||
document.getElementById('uploadImageForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
|
|
@ -158,7 +157,7 @@
|
|||
formData.append('file', document.getElementById('imageFile').files[0]);
|
||||
|
||||
const resultDiv = document.getElementById('uploadResult');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">Загрузка...</div>';
|
||||
resultDiv.innerHTML = '<div class="alert alert-info py-1 small">Загрузка...</div>';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/service/upload_image', {
|
||||
|
|
@ -168,20 +167,21 @@
|
|||
const result = await r.json();
|
||||
|
||||
if (result.success) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br>Файл: ${result.file_path}</div>`;
|
||||
resultDiv.innerHTML = `<div class="alert alert-success py-1 small">${result.message}</div>`;
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error}</div>`;
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${result.error}</div>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger py-1 small">Ошибка: ${err.message}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteService(id) {
|
||||
if (!confirm('Удалить службу?')) return;
|
||||
if (!confirm('Удалить эту службу?')) return;
|
||||
const r = await fetch(`/api/service/${id}/delete`, { method: 'POST' });
|
||||
const result = await r.json();
|
||||
if (result.success) { alert('✅ Удалено'); location.reload(); }
|
||||
if (result.success) { alert('Удалено'); location.reload(); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@
|
|||
|
||||
<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">
|
||||
<label class="form-label">🖼️ Готовое фото (Артефакт):</label>
|
||||
<select class="form-select" id="artifactPhotoId">
|
||||
<option value="">-- Не выбрано --</option>
|
||||
{% for a in artifacts if a.file_type == 'photo' %}
|
||||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||||
|
|
@ -39,8 +39,8 @@
|
|||
</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">
|
||||
<label class="form-label">📄 Готовый документ (Артефакт):</label>
|
||||
<select class="form-select" id="artifactDocumentId">
|
||||
<option value="">-- Не выбрано --</option>
|
||||
{% for a in artifacts if a.file_type == 'document' %}
|
||||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||||
|
|
|
|||
|
|
@ -1,39 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Жатва ГАС Правосудие - LKM37{% endblock %}
|
||||
{% block title %}ГАС Правосудие - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_sudrf %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4"><i class="bi bi-bank text-danger"></i> Жатва ГАС Правосудие</h2>
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Сбор материалов судебных дел (ГАС «Правосудие»)</h4>
|
||||
<div class="small text-muted">Автоматизированная выгрузка карточек дел, определений и решений для базы NotebookLM</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row g-3">
|
||||
<!-- Инструкция и Статус -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card bg-dark border-danger h-100">
|
||||
<div class="card-header border-danger text-danger">
|
||||
<i class="bi bi-info-circle"></i> ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
ИНСТРУКЦИЯ И УПРАВЛЕНИЕ
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<p class="text-white">Этот модуль предназначен для автоматизированного сбора поданных обращений, заявлений и ответов судов с портала ГАС «Правосудие» (ej.sudrf.ru) для последующей склейки в PDF и загрузки в Google Drive (NotebookLM).</p>
|
||||
<p class="small text-muted">
|
||||
Модуль собирает электронные документы судебных процессов по дому (ej.sudrf.ru), преобразует вложения в PDF и загружает в единый аналитический контур.
|
||||
</p>
|
||||
|
||||
<h5 class="text-danger mt-4"><i class="bi bi-1-circle"></i> Шаг 1: Подготовка сессии</h5>
|
||||
<p class="small text-muted">Нажмите кнопку «Запустить Жатву ГАС Правосудие» ниже. Система откроет скрытый браузер Chromium на виртуальном рабочем столе.</p>
|
||||
<div class="mb-3">
|
||||
<div style="font-weight: bold;">Шаг 1: Подготовка сессии</div>
|
||||
<div class="small text-muted">Нажмите кнопку «Запустить сессию» ниже. Будет открыт браузер на виртуальном рабочем столе.</div>
|
||||
</div>
|
||||
|
||||
<h5 class="text-danger mt-3"><i class="bi bi-2-circle"></i> Шаг 2: Авторизация ЕСИА</h5>
|
||||
<p class="small text-muted">Откройте VNC-браузер по ссылке справа и войдите в свой личный кабинет ГАС «Правосудие» через Госуслуги (потребуется пройти 2FA/SMS).</p>
|
||||
<div class="mb-3">
|
||||
<div style="font-weight: bold;">Шаг 2: Вход через Госуслуги (ЕСИА)</div>
|
||||
<div class="small text-muted">Перейдите по ссылке VNC и подтвердите вход по СМС в личном кабинете.</div>
|
||||
</div>
|
||||
|
||||
<h5 class="text-danger mt-3"><i class="bi bi-3-circle"></i> Шаг 3: Автоматический парсинг</h5>
|
||||
<p class="small text-muted">Как только вход будет обнаружен, скрипт перехватит сессию, соберет список дел, сделает скриншоты, скачает вложения и загрузит сводные PDF на Google Drive.</p>
|
||||
<div>
|
||||
<div style="font-weight: bold;">Шаг 3: Автоматический экспорт</div>
|
||||
<div class="small text-muted">После авторизации скрипт перехватит сессию, скачает дела и сформирует PDF.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-top border-secondary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-white">Статус парсера:</span>
|
||||
<div id="sudrfStatusBadge" class="badge bg-secondary p-2">НЕИЗВЕСТНО</div>
|
||||
<div class="mt-3 pt-3 border-top" style="border-color: var(--border-light) !important;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>Статус процесса:</span>
|
||||
<div id="sudrfStatusBadge" class="badge-status">ПРОВЕРКА</div>
|
||||
</div>
|
||||
<button id="runSudrfParserBtn" class="btn btn-danger w-100 py-2">
|
||||
<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ
|
||||
<button id="runSudrfParserBtn" class="btn-unix btn-unix-primary w-100 py-2">
|
||||
<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -41,41 +52,36 @@
|
|||
</div>
|
||||
|
||||
<!-- VNC и Логи -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card bg-dark border-info h-100">
|
||||
<div class="card-header border-info text-info">
|
||||
<i class="bi bi-display"></i> СЕССИЯ И ЛОГИ СКАНИРОВАНИЯ
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>VNC-ЭКРАН И ЖУРНАЛ СКАНИРОВАНИЯ</span>
|
||||
<button id="refreshSudrfStatusBtn" class="btn-unix">
|
||||
<i class="bi bi-arrow-clockwise"></i> Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn btn-outline-info w-100 py-3 mb-2">
|
||||
<i class="bi bi-window-fullscreen"></i> ОТКРЫТЬ VNC ЭКРАН (noVNC)
|
||||
<a href="http://192.168.10.116:6080/vnc.html" target="_blank" class="btn-unix w-100 text-center py-2 mb-2">
|
||||
<i class="bi bi-display"></i> Открыть VNC экран (noVNC)
|
||||
</a>
|
||||
<div class="alert alert-warning py-2 mb-3 text-warning border-warning" style="font-size: 0.75rem; background: transparent;">
|
||||
<i class="bi bi-exclamation-triangle"></i> <strong>Внимание:</strong> Если жатва не запущена, VNC-экран будет пустым (черным). Браузер Chromium автоматически откроется на экране сразу после нажатия кнопки «Запустить жатву».
|
||||
<div class="small text-muted mb-2">
|
||||
<i>Экран браузера отображается в noVNC сразу после запуска сессии.</i>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="small text-muted">ЛОГИ В РЕАЛЬНОМ ВРЕМЕНИ:</span>
|
||||
<button id="refreshSudrfStatusBtn" class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre id="sudrfLogs" class="bg-black text-danger p-3 small border border-secondary flex-grow-1" style="min-height: 250px; overflow-y: scroll; font-family: monospace; font-size: 0.75rem;"></pre>
|
||||
<div class="small text-muted mb-1">Журнал работы:</div>
|
||||
<pre id="sudrfLogs" class="flex-grow-1 mb-0" style="min-height: 220px; overflow-y: scroll; background-color: #ffffff; border: 1px solid var(--border-color); font-size: 11px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
document.getElementById('runSudrfParserBtn').addEventListener('click', async function() {
|
||||
const btn = this;
|
||||
if (!confirm('Запустить сборку судебного архива ГАС Правосудие? Потребуется войти через Госуслуги во VNC.')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> СЕССИЯ ЗАПУЩЕНА...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сессия запускается...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sudrf/run', { method: 'POST' });
|
||||
|
|
@ -85,7 +91,7 @@ document.getElementById('runSudrfParserBtn').addEventListener('click', async fun
|
|||
alert('Ошибка при запуске: ' + e);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-play-circle-fill"></i> ЗАПУСТИТЬ ЖАТВУ ГАС ПРАВОСУДИЕ';
|
||||
btn.innerHTML = '<i class="bi bi-play-circle"></i> Запустить сборку ГАС Правосудие';
|
||||
refreshSudrfStatus();
|
||||
}
|
||||
});
|
||||
|
|
@ -99,25 +105,23 @@ async function refreshSudrfStatus() {
|
|||
const data = await response.json();
|
||||
|
||||
if (data.alive) {
|
||||
badge.className = 'badge bg-success p-2';
|
||||
badge.innerText = 'СТАТУС: РАБОТАЕТ';
|
||||
badge.className = 'badge-status badge-success';
|
||||
badge.innerText = 'РАБОТАЕТ';
|
||||
} else {
|
||||
badge.className = 'badge bg-danger p-2';
|
||||
badge.innerText = 'СТАТУС: ОСТАНОВЛЕН';
|
||||
badge.className = 'badge-status';
|
||||
badge.innerText = 'ОЖИДАНИЕ';
|
||||
}
|
||||
|
||||
logs.innerText = data.log || "Лог пуст или еще не создан.";
|
||||
logs.innerText = data.log || 'Лог чист';
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
} catch (e) {
|
||||
badge.className = 'badge bg-warning p-2';
|
||||
badge.className = 'badge-status badge-warning';
|
||||
badge.innerText = 'ОШИБКА API';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refreshSudrfStatusBtn').addEventListener('click', refreshSudrfStatus);
|
||||
|
||||
window.addEventListener('load', refreshSudrfStatus);
|
||||
// Автообновление логов каждые 15 секунд
|
||||
setInterval(refreshSudrfStatus, 15000);
|
||||
setInterval(refreshSudrfStatus, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,74 +1,90 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Пользователи - LKM37{% endblock %}
|
||||
{% block title %}Реестр жильцов - МКД ЛенКом 37{% endblock %}
|
||||
{% block nav_users %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4" style="color: var(--matrix-green);">
|
||||
>_ РЕЕСТР_СУБЪЕКТОВ
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1" style="font-size: 16px; font-weight: bold;">Реестр жильцов и доступ к ИИ</h4>
|
||||
<div class="small text-muted">Управление верификацией и статусом доступа к юридическому модулю</div>
|
||||
</div>
|
||||
{% if current_filter %}
|
||||
<span class="badge bg-info ms-2">
|
||||
{% if current_filter == 'verified' %}✅ ВЕРИФИЦИРОВАНЫ
|
||||
{% elif current_filter == 'unverified' %}⏳ ОЖИДАНИЕ
|
||||
{% elif current_filter == 'ig' %}🔵 TEAM
|
||||
{% elif current_filter == 'active_unverified' %}🔥 АКТИВНЫ
|
||||
{% else %}ВСЕ{% endif %}
|
||||
</span>
|
||||
<a href="/users" class="btn btn-sm btn-outline-secondary ms-2 text-success border-success">СБРОС</a>
|
||||
<div>
|
||||
<span class="badge-status badge-info">
|
||||
ФИЛЬТР:
|
||||
{% if current_filter == 'verified' %}ВЕРИФИЦИРОВАНЫ
|
||||
{% elif current_filter == 'unverified' %}ОЖИДАНИЕ
|
||||
{% elif current_filter == 'ig' %}ИНИЦИАТИВНАЯ ГРУППА
|
||||
{% elif current_filter == 'active_unverified' %}АКТИВНЫЕ
|
||||
{% else %}ВСЕ{% endif %}
|
||||
</span>
|
||||
<a href="/users" class="btn-unix ms-2">Сбросить фильтр</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Статистика (ПЛОСКИЕ ПЕРЕМЕННЫЕ) -->
|
||||
<style>
|
||||
.filter-card { cursor: pointer; transition: var(--transition-smooth) !important; }
|
||||
.filter-card:hover { transform: translateY(-4px) !important; }
|
||||
.filter-card.active-filter { box-shadow: 0 0 15px currentColor !important; border-width: 2px !important; }
|
||||
</style>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Статистика и фильтры -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-black border-success text-success filter-card {% if not current_filter %}active-filter{% endif %}" onclick="location.href='/users'">
|
||||
<div class="card h-100" style="cursor: pointer;" onclick="location.href='/users'">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ВСЕГО</span>
|
||||
<i class="bi bi-people"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3>{{ total_count }}</h3>
|
||||
<small>⚡️ ВСЕГО</small>
|
||||
<div style="font-size: 22px; font-weight: bold;">{{ total_count }}</div>
|
||||
<div class="small text-muted">Все пользователи</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-black border-success text-success filter-card {% if current_filter == 'verified' %}active-filter{% endif %}" onclick="location.href='/users?filter=verified'">
|
||||
<div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=verified'">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ВЕРИФИЦИРОВАНЫ</span>
|
||||
<i class="bi bi-patch-check"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3>{{ verified_count }}</h3>
|
||||
<small>✅ ВЕРИФИЦИРОВАНЫ</small>
|
||||
<div style="font-size: 22px; font-weight: bold;">{{ verified_count }}</div>
|
||||
<div class="small text-muted">С подтверждением</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-black border-info text-info filter-card {% if current_filter == 'ig' %}active-filter{% endif %}" onclick="location.href='/users?filter=ig'">
|
||||
<div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=ig'">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>СОВЕТ ДОМА / ИГ</span>
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3>{{ ig_count }}</h3>
|
||||
<small>🔵 ИНИЦИАТИВНАЯ ГРУППА</small>
|
||||
<div style="font-size: 22px; font-weight: bold;">{{ ig_count }}</div>
|
||||
<div class="small text-muted">Инициативная группа</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card bg-black border-warning text-warning filter-card {% if current_filter == 'active_unverified' %}active-filter{% endif %}" onclick="location.href='/users?filter=active_unverified'">
|
||||
<div class="card h-100" style="cursor: pointer;" onclick="location.href='/users?filter=active_unverified'">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<span>ГОСТИ</span>
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3>{{ active_unverified }}</h3>
|
||||
<small>🔥 АКТИВНЫЕ ГОСТИ</small>
|
||||
<div style="font-size: 22px; font-weight: bold;">{{ active_unverified }}</div>
|
||||
<div class="small text-muted">Без верификации</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Поиск -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body bg-black border-success">
|
||||
<form method="GET" action="/users" class="row g-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body p-2">
|
||||
<form method="GET" action="/users" class="row g-2 align-items-center">
|
||||
<div class="col-md-10">
|
||||
<input type="text" class="form-control bg-dark border-success text-success" name="search" placeholder="Поиск по ID, имени, квартире..." value="{{ search }}">
|
||||
<input type="text" class="form-control" name="search" placeholder="Поиск по ID, имени, квартире..." value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-matrix w-100">ПОИСК</button>
|
||||
<button type="submit" class="btn-unix btn-unix-primary w-100">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -76,20 +92,19 @@
|
|||
|
||||
<!-- Таблица -->
|
||||
<div class="card">
|
||||
<div class="card-header border-success">СПИСОК_СУБЪЕКТОВ</div>
|
||||
<div class="card-header">СПИСОК СОБСТВЕННИКОВ И ЖИТЕЛЕЙ</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr class="text-success opacity-50">
|
||||
<th>ID</th>
|
||||
<th>ИМЯ</th>
|
||||
<th>НИК</th>
|
||||
<th>КВАРТИРА</th>
|
||||
<th>СТАТУС</th>
|
||||
<th>ЮРИСТ</th>
|
||||
<th>РЕЙТИНГ</th>
|
||||
<th>ДЕЙСТВИЯ</th>
|
||||
<tr>
|
||||
<th style="width: 100px;">ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Username</th>
|
||||
<th>Квартира</th>
|
||||
<th>Статус</th>
|
||||
<th style="width: 110px;">Доступ Юрист</th>
|
||||
<th>Рейтинг</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -97,23 +112,32 @@
|
|||
<tr>
|
||||
<td><code>{{ user.user_id }}</code></td>
|
||||
<td>{{ user.first_name }} {{ user.last_name or '' }}</td>
|
||||
<td>@{{ user.username or '—' }}</td>
|
||||
<td>{{ user.apartment or '—' }}</td>
|
||||
<td>
|
||||
{% if user.verified %}<span class="text-success">V</span>{% else %}<span class="text-danger">X</span>{% endif %}
|
||||
{% if user.user_id in ig_user_ids %}<span class="text-info ms-1">IG</span>{% endif %}
|
||||
{% if user.username %}
|
||||
<a href="https://t.me/{{ user.username }}" target="_blank">@{{ user.username }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><b>{{ user.apartment or '—' }}</b></td>
|
||||
<td>
|
||||
<div class="form-check form-switch">
|
||||
{% if user.verified %}
|
||||
<span class="badge-status badge-success">Верифицирован</span>
|
||||
{% else %}
|
||||
<span class="badge-status badge-warning">Не подтвержден</span>
|
||||
{% endif %}
|
||||
{% if user.user_id in ig_user_ids %}
|
||||
<span class="badge-status badge-info ms-1">ИГ</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check form-switch d-inline-block">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
{% if user.has_ai_access %}checked{% endif %}
|
||||
onclick="toggleAI({{ user.user_id }})">
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.rating }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-success" onclick="editUser({{ user.user_id }})"><i class="bi bi-pencil"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
|||
Loading…
Reference in a new issue