v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- Refactored max_bot from nested packages to flat module structure
- Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets)
- Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT)
- Q1: Broadcast preview modal with 10s confirmation timer
- Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary)
- Q4: bot_settings showNotification(), editable max_bot_id
- Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret
- Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists()
- Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py
- Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings
- Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py
- Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
from fastapi import Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Request, Depends, HTTPException
from fastapi.responses import RedirectResponse
def get_current_user(request: Request) -> dict | None:
@@ -11,6 +11,6 @@ def require_role(*roles: str):
if not user:
return RedirectResponse(url="/service/login", status_code=302)
if roles and user["role"] not in roles:
return HTMLResponse(content="Доступ запрещён", status_code=403)
raise HTTPException(status_code=403, detail="Доступ запрещён")
return user
return dependency
+163 -154
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from datetime import datetime
from app.config import get_settings
from app.services.docs_permissions import load_permissions, save_permissions, discover_docs, can_access_doc, get_allowed_docs
from app.dependencies import get_current_user, require_role
@@ -15,6 +16,22 @@ router = APIRouter(prefix="/service/documents", tags=["documents"])
from app.templating import templates
def now_str():
return datetime.now().strftime("%d.%m.%Y %H:%M")
def _doc_ctx(request, user, **kw):
original_user = request.session.get("original_user")
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
c = {"request": request, "user": user, "now": now_str(),
"role_permissions": getattr(request.state, 'role_permissions', {}),
"role_override_active": original_user is not None,
"original_role": original_user["role"] if original_user else None,
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else []}
c.update(kw)
return c
def transliterate_slug(text: str) -> str:
translit_map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
@@ -41,157 +58,7 @@ async def documents_list(
user: dict = Depends(get_current_user),
):
docs = get_allowed_docs(user["role"])
return templates.TemplateResponse("documents/list.html", {
"request": request, "user": user, "docs": docs,
})
@router.get("/{slug}")
async def document_view(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "view"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
html_content = render_markdown(content)
return templates.TemplateResponse("documents/view.html", {
"request": request, "user": user, "doc": doc, "content": html_content,
"can_edit": can_access_doc(user["role"], slug, "edit"),
"can_download": user["role"] == "owner",
})
@router.get("/{slug}/edit")
async def document_edit(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return templates.TemplateResponse("documents/edit.html", {
"request": request, "user": user, "doc": doc, "content": content,
})
@router.post("/{slug}/edit")
async def document_update(
slug: str,
request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
@router.get("/tech-access")
async def tech_access_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
perms = discover_docs()
editable_docs = [
doc for slug, doc in perms.items()
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
]
return templates.TemplateResponse("documents/tech_access.html", {
"request": request, "user": user, "docs": editable_docs, "perms": perms,
})
@router.post("/tech-access")
async def tech_access_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
continue
technician_view = form.get(f"{slug}_technician_view") == "on"
perms[slug]["permissions"]["technician"] = {"view": technician_view}
save_permissions(perms)
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
@router.get("/{slug}/download")
async def document_download(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
from fastapi.responses import FileResponse
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
return templates.TemplateResponse("documents/list.html", _doc_ctx(request, user, docs=docs))
@router.get("/admin/permissions")
@@ -204,9 +71,7 @@ async def permissions_page(
return HTMLResponse(content="Доступ запрещён", status_code=403)
perms = discover_docs()
return templates.TemplateResponse("documents/permissions.html", {
"request": request, "user": user, "perms": perms,
})
return templates.TemplateResponse("documents/permissions.html", _doc_ctx(request, user, perms=perms))
@router.post("/admin/permissions")
@@ -280,3 +145,147 @@ async def document_create(
save_permissions(perms)
return RedirectResponse(url=f"/service/documents/{slug}/edit", status_code=302)
@router.get("/tech-access")
async def tech_access_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
perms = discover_docs()
editable_docs = [
doc for slug, doc in perms.items()
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
]
return templates.TemplateResponse("documents/tech_access.html", _doc_ctx(request, user, docs=editable_docs, perms=perms))
@router.post("/tech-access")
async def tech_access_update(
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "engineer":
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
form = await request.form()
perms = discover_docs()
for slug in perms.keys():
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
continue
technician_view = form.get(f"{slug}_technician_view") == "on"
perms[slug]["permissions"]["technician"] = {"view": technician_view}
save_permissions(perms)
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
@router.get("/{slug}")
async def document_view(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "view"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
html_content = render_markdown(content)
return templates.TemplateResponse("documents/view.html", _doc_ctx(request, user,
doc=doc, content=html_content,
can_edit=can_access_doc(user["role"], slug, "edit"),
can_download=user["role"] == "owner",
))
@router.get("/{slug}/edit")
async def document_edit(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return templates.TemplateResponse("documents/edit.html", _doc_ctx(request, user, doc=doc, content=content))
@router.post("/{slug}/edit")
async def document_update(
slug: str,
request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if not can_access_doc(user["role"], slug, "edit"):
return HTMLResponse(content="Доступ запрещён", status_code=403)
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
@router.get("/{slug}/download")
async def document_download(
slug: str,
request: Request,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
if user["role"] != "owner":
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
from fastapi.responses import FileResponse
docs_dir = settings.DOCS_DIR
perms = discover_docs()
doc = perms.get(slug)
if not doc:
return HTMLResponse(content="Документ не найден", status_code=404)
filepath = os.path.join(docs_dir, doc["filename"])
if not os.path.exists(filepath):
return HTMLResponse(content="Файл не найден", status_code=404)
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
+54 -1
View File
@@ -1,4 +1,5 @@
import os
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
@@ -63,7 +64,7 @@ SEED_COEFFICIENTS = [
async def seed_initial_data():
from sqlalchemy import text, select, func
from app.models.models import FormulaCoefficient, User
from app.models.models import FormulaCoefficient, User, RoleMenuPermission
async with async_session() as db:
try:
@@ -74,6 +75,24 @@ async def seed_initial_data():
db.add(FC(key=key, value=value, description=desc, formula_ref=ref))
await db.commit()
# Seed default role menu permissions if table is empty
perm_count = await db.execute(select(func.count(RoleMenuPermission.id)))
if perm_count.scalar() == 0:
DEFAULT_PERMISSIONS = {
"engineer": [
"charts", "customers", "objects", "sla", "questionnaire", "passports",
"tasks", "reports", "incidents", "tech_access", "docs",
],
"technician": [
"tasks", "reports", "incidents", "checklist",
],
}
for role, keys in DEFAULT_PERMISSIONS.items():
for key in keys:
db.add(RoleMenuPermission(role=role, menu_key=key, visible=True))
await db.commit()
await db.execute(text("DELETE FROM users WHERE login = 'admin'"))
await db.commit()
except Exception:
@@ -91,6 +110,7 @@ async def lifespan(app: FastAPI):
from app.calculations.engine import reload_coefficients
await reload_coefficients(db)
discover_docs()
yield
@@ -116,6 +136,32 @@ class RoleGuardMiddleware(BaseHTTPMiddleware):
app.add_middleware(RoleGuardMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY, max_age=settings.SESSION_TTL)
class PermissionsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if "session" not in request.scope:
request.state.role_permissions = {}
return await call_next(request)
from app.models.models import RoleMenuPermission
from sqlalchemy import select
user = request.session.get("user")
if user and user.get("role") in ("engineer", "technician"):
from app.database import async_session
async with async_session() as db:
result = await db.execute(
select(RoleMenuPermission).where(RoleMenuPermission.role == user["role"])
)
request.state.role_permissions = {
row.menu_key: row.visible for row in result.scalars().all()
}
else:
request.state.role_permissions = {}
response = await call_next(request)
return response
app.add_middleware(PermissionsMiddleware)
app.include_router(documents_router)
from app.routers.service_pages import router as service_pages_router
@@ -210,8 +256,15 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), user:
shs_alert = f"SHS ниже 70 ({stats['shs']['shs_score']}) — {stats['shs']['shs_status']}"
now = __import__("datetime").datetime.now().strftime("%d.%m.%Y %H:%M")
original_user = request.session.get("original_user")
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
return templates.TemplateResponse("dashboard.html", {
"request": request, "stats": stats, "shs_alert": shs_alert, "now": now, "user": user,
"active_page": "dashboard",
"role_permissions": getattr(request.state, 'role_permissions', {}),
"role_override_active": original_user is not None,
"original_role": original_user["role"] if original_user else None,
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else [],
})
+17
View File
@@ -17,6 +17,7 @@ class User(Base):
full_name = Column(String(255), nullable=False)
phone = Column(String(50), default="")
email = Column(String(255), default="")
max_user_id = Column(BigInteger, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, nullable=False, server_default="NOW()")
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
@@ -495,3 +496,19 @@ class QuickMenuItem(Base):
updated_at = Column(DateTime, nullable=False, server_default="NOW()", onupdate="NOW()")
creator = relationship("User")
class RoleMenuPermission(Base):
__tablename__ = "role_menu_permissions"
id = Column(Integer, primary_key=True, autoincrement=True)
role = Column(String(20), nullable=False)
menu_key = Column(String(50), nullable=False)
visible = Column(Boolean, nullable=False, default=True)
__table_args__ = (
CheckConstraint("role IN ('engineer','technician')", name="ck_role_menu_role"),
)
+110 -335
View File
@@ -6,6 +6,7 @@ from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, or_, and_, Integer, text
from sqlalchemy.orm import selectinload, joinedload
import httpx
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.auth import audit_log
@@ -1135,45 +1136,6 @@ async def cases_delete(request: Request, db: AsyncSession = Depends(get_db), use
return RedirectResponse(url="/service/cases", status_code=302)
# ===================== BOT SETTINGS =====================
@router.get("/service/bot-settings")
async def bot_settings(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки бота"))
@router.get("/service/bot-settings/responses")
async def bot_responses(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_responses.html", ctx(request, user=user, active_page="bot-responses", title="Шаблоны ответов бота"))
@router.get("/service/bot-settings/features")
async def bot_features(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_features.html", ctx(request, user=user, active_page="bot-features", title="Функции бота"))
@router.get("/service/bot-settings/categories")
async def bot_categories(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_categories.html", ctx(request, user=user, active_page="bot-categories", title="Категории обращений"))
@router.get("/service/bot-settings/kb")
async def bot_kb(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний бота"))
@router.get("/service/bot-settings/conversations")
async def bot_conversations(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_conversations.html", ctx(request, user=user, active_page="bot-conversations", title="Диалоги бота"))
@router.get("/service/bot-settings/users")
async def bot_users(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_users.html", ctx(request, user=user, active_page="bot-users", title="Пользователи бота"))
@router.get("/service/bot-settings/test-runner")
async def bot_test_runner(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_test_runner.html", ctx(request, user=user, active_page="bot-test-runner", title="Тестовый прогон бота"))
@router.get("/service/bot-settings/analytics")
async def bot_analytics(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_analytics.html", ctx(request, user=user, active_page="bot-analytics", title="Аналитика бота"))
@router.get("/service/portal-settings")
async def portal_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/portal_settings.html", ctx(request, user=user, active_page="portal-settings", title="Настройка портала"))
@@ -1184,6 +1146,40 @@ async def role_settings_page(request: Request, db: AsyncSession = Depends(get_db
return templates.TemplateResponse("pages/role_settings.html", ctx(request, user=user, active_page="role-settings", title="Настройка Ролей"))
# ===================== CHAT-BOT MAX PAGES =====================
@router.get("/service/bot-settings")
async def bot_settings_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_settings.html", ctx(request, user=user, active_page="bot-settings", title="Настройки чат-бота MAX"))
@router.get("/service/bot-kb")
async def bot_kb_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_kb.html", ctx(request, user=user, active_page="bot-kb", title="База знаний чат-бота"))
@router.get("/service/bot-test")
async def bot_test_page_redirect():
return RedirectResponse(url="/service/bot-settings", status_code=302)
@router.get("/service/bot-test-content")
async def bot_test_content(request: Request, user: dict = Depends(get_current_user)):
if not user:
return HTMLResponse("Not authenticated")
return templates.TemplateResponse("pages/bot_test.html", {"request": request})
@router.get("/service/bot-consent")
async def bot_consent_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_consent.html", ctx(request, user=user, active_page="bot-consent", title="Согласия ФЗ-152 и рассылка"))
@router.get("/service/bot-tickets")
async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)):
return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки от чат-бота"))
# ---- Role permissions API ----
@router.get("/service/api/role-permissions")
async def api_role_permissions_get(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@@ -1242,332 +1238,111 @@ async def api_role_reset(request: Request, user: dict = Depends(get_current_user
return JSONResponse({"ok": True, "role": request.session["user"]["role"]})
# ===================== BOT API (proxied from max_bot) =====================
# ===================== BOT API PROXY =====================
BOT_SERVICE_URL = "http://127.0.0.1:8002"
import aiohttp
MAX_BOT_URL = "http://127.0.0.1:8002"
async def _proxy(method, path, json_body=None):
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.request(method, f"{MAX_BOT_URL}{path}", json=json_body) as resp:
return JSONResponse(await resp.json())
except aiohttp.ClientError as e:
return JSONResponse({"error": f"Bot service unavailable: {e}", "ok": False}, status_code=503)
except Exception as e:
return JSONResponse({"error": str(e), "ok": False}, status_code=500)
async def _proxy(method: str, path: str, body=None):
async with httpx.AsyncClient() as client:
if body is not None:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}", json=body)
else:
resp = await client.request(method, f"{BOT_SERVICE_URL}{path}")
return JSONResponse(resp.json(), status_code=resp.status_code)
@router.get("/service/api/bot/settings")
async def api_bot_settings(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async def proxy_bot_settings_get(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/settings")
@router.post("/service/api/bot/settings/save")
async def api_bot_settings_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
async def proxy_bot_settings_save(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/settings/save", body)
@router.get("/service/api/bot/templates")
async def api_bot_templates(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/templates")
@router.post("/service/api/bot/templates/save")
async def api_bot_templates_save(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/templates/save", body)
@router.get("/service/api/bot/features")
async def api_bot_features(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/features")
@router.post("/service/api/bot/features/toggle")
async def api_bot_features_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/features/toggle", body)
@router.get("/service/api/bot/categories")
async def api_bot_categories(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/categories")
@router.post("/service/api/bot/categories/create")
async def api_bot_categories_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/create", body)
@router.post("/service/api/bot/categories/edit")
async def api_bot_categories_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/edit", body)
@router.post("/service/api/bot/categories/delete")
async def api_bot_categories_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/categories/delete", body)
@router.get("/service/api/bot/kb")
async def api_bot_kb(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.get("/api/bot/kb")
async def proxy_bot_kb_list(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/kb")
@router.post("/service/api/bot/kb/create")
async def api_bot_kb_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/kb")
async def proxy_bot_kb_create(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/create", body)
return await _proxy("POST", "/api/bot/kb", body)
@router.post("/service/api/bot/kb/edit")
async def api_bot_kb_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.delete("/api/bot/kb/{card_id}")
async def proxy_bot_kb_delete(card_id: int, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("DELETE", f"/api/bot/kb/{card_id}")
@router.get("/api/bot/tickets")
async def proxy_bot_tickets_list(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/tickets")
@router.patch("/api/bot/tickets/{ticket_id}")
async def proxy_bot_tickets_update(ticket_id: int, request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/edit", body)
return await _proxy("PATCH", f"/api/bot/tickets/{ticket_id}", body)
@router.post("/service/api/bot/kb/delete")
async def api_bot_kb_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.put("/api/bot/kb/{card_id}")
async def proxy_bot_kb_update(card_id: int, request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/kb/delete", body)
return await _proxy("PUT", f"/api/bot/kb/{card_id}", body)
@router.get("/service/api/bot/conversations")
async def api_bot_conversations(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/conversations")
@router.get("/service/api/bot/users")
async def api_bot_users(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20),
sort_by: str = Query("created_at"), sort_dir: str = Query("desc"),
consent: str = Query(None)):
params = f"?page={page}&limit={limit}&sort_by={sort_by}&sort_dir={sort_dir}"
if consent:
params += f"&consent={consent}"
return await _proxy("GET", f"/api/bot/users{params}")
@router.get("/api/bot/users/consented")
async def proxy_bot_users_consented(user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
return await _proxy("GET", "/api/bot/users/consented")
@router.get("/service/api/bot/analytics")
async def api_bot_analytics(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/analytics")
@router.post("/service/api/bot/test-run")
async def api_bot_test_run(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/test-run", body)
@router.get("/service/api/bot/broadcast/recipients")
async def api_bot_broadcast_recipients(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
q: str = Query(None), page: int = Query(1), limit: int = Query(200)):
params = f"?page={page}&limit={limit}"
if q:
params += f"&q={q}"
return await _proxy("GET", f"/api/bot/broadcast/recipients{params}")
@router.get("/service/api/bot/broadcast/history")
async def api_bot_broadcast_history(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
page: int = Query(1), limit: int = Query(20)):
return await _proxy("GET", f"/api/bot/broadcast/history?page={page}&limit={limit}")
@router.post("/service/api/bot/broadcast")
async def api_bot_broadcast(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/broadcast")
async def proxy_bot_broadcast(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/broadcast", body)
@router.get("/service/bot-settings/handoffs")
async def bot_handoffs_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_handoffs.html", ctx(request, user=user, active_page="bot-handoffs", title="Handoff-диалоги"))
@router.get("/service/bot-settings/handoffs/poll")
async def bot_handoffs_poll(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/handoffs")
@router.post("/service/bot-settings/handoffs/{handoff_id}/take")
async def bot_handoff_take(handoff_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/take")
@router.get("/service/bot-settings/notifications")
async def bot_notifications_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_notifications.html", ctx(request, user=user, active_page="bot-notifications", title="Уведомления бота"))
@router.get("/service/bot-settings/notifications/data")
async def bot_notifications_data(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/notifications")
@router.post("/service/bot-settings/notifications/{notif_id}/read")
async def bot_notification_read(notif_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/notifications/{notif_id}/read")
# ===================== BOT RISK QUESTIONS =====================
@router.get("/service/bot-settings/risk-questions")
async def bot_risk_questions_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_risk_questions.html", ctx(request, user=user, active_page="bot-risk-questions", title="Вопросы риск-инжиниринга"))
@router.get("/service/bot-settings/storage")
async def bot_storage_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_storage.html", ctx(request, user=user, active_page="bot-storage", title="Яндекс.Диск"))
@router.get("/service/bot-settings/broadcasts")
async def bot_broadcasts_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_broadcasts.html", ctx(request, user=user, active_page="bot-broadcasts", title="Рассылки"))
@router.get("/service/api/bot/risk-questions")
async def api_bot_risk_questions(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/risk-questions")
@router.post("/service/api/bot/risk-questions/create")
async def api_bot_risk_questions_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/broadcast/preview")
async def proxy_bot_broadcast_preview(request: Request, user: dict = Depends(get_current_user)):
if not user:
return JSONResponse({"ok": False, "error": "Not authenticated"}, status_code=401)
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/create", body)
return await _proxy("POST", "/api/bot/broadcast/preview", body)
@router.post("/service/api/bot/risk-questions/edit")
async def api_bot_risk_questions_edit(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
@router.post("/api/bot/webhook")
async def proxy_bot_webhook(request: Request):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/edit", body)
headers = {"Content-Type": "application/json"}
secret = request.headers.get("X-Max-Bot-Api-Secret")
if secret:
headers["X-Max-Bot-Api-Secret"] = secret
async with httpx.AsyncClient() as client:
resp = await client.post(f"{BOT_SERVICE_URL}/webhook", json=body, headers=headers)
return JSONResponse(resp.json(), status_code=resp.status_code)
@router.post("/service/api/bot/risk-questions/toggle")
async def api_bot_risk_questions_toggle(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/toggle", body)
@router.post("/service/api/bot/risk-questions/delete")
async def api_bot_risk_questions_delete(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/risk-questions/delete", body)
# ===================== BOT TICKETS =====================
@router.get("/service/bot-settings/tickets")
async def bot_tickets_page(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return templates.TemplateResponse("pages/bot_tickets.html", ctx(request, user=user, active_page="bot-tickets", title="Заявки"))
@router.get("/service/api/bot/tickets")
async def api_bot_tickets(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner")),
status: str = Query(None), priority: str = Query(None)):
params = ""
if status: params += f"status={status}&"
if priority: params += f"priority={priority}"
return await _proxy("GET", f"/api/bot/tickets?{params}")
@router.get("/service/api/bot/tickets/{ticket_id}")
async def api_bot_ticket_get(ticket_id: int, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", f"/api/bot/tickets/{ticket_id}")
@router.post("/service/api/bot/tickets/create")
async def api_bot_tickets_create(request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", "/api/bot/tickets/create", body)
@router.post("/service/api/bot/tickets/{ticket_id}/message")
async def api_bot_tickets_message(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/message", body)
@router.post("/service/api/bot/tickets/{ticket_id}/status")
async def api_bot_tickets_status(ticket_id: int, request: Request, db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
body = await request.json()
return await _proxy("POST", f"/api/bot/tickets/{ticket_id}/status", body)
@router.get("/service/api/bot/engineers")
async def api_bot_engineers(db: AsyncSession = Depends(get_db), user: dict = Depends(require_role("owner"))):
return await _proxy("GET", "/api/bot/engineers")
# ===================== BOT STORAGE (Yandex Disk) =====================
async def _get_yandex_client(db: AsyncSession):
from app.yandex_disk import YandexDiskClient
result = await db.execute(text("SELECT value FROM bot_settings WHERE key = 'yandex_disk_token'"))
row = result.scalar_one_or_none()
if not row:
return None
return YandexDiskClient(row)
@router.get("/service/api/bot/storage/list")
async def api_bot_storage_list(path: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
items = await client.list_folder(path)
return JSONResponse(items)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/search")
async def api_bot_storage_search(query: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
items = await client.search(query)
return JSONResponse(items)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/public-link")
async def api_bot_storage_public_link(path: str = Query(""), expiry: int = Query(30),
db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
url = await client.get_public_url(path, expiry)
return JSONResponse({"url": url})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
@router.get("/service/api/bot/storage/download")
async def api_bot_storage_download(path: str = Query(""), db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
client = await _get_yandex_client(db)
if not client:
return JSONResponse({"error": "Yandex Disk not configured"}, status_code=400)
try:
info = await client.get_file_info(path)
dl_url = info.get("public_url", "")
if not dl_url:
dl_info = await client._request("GET", "/resources/download", params={"path": path})
dl_url = dl_info.get("href", "")
return JSONResponse({"url": dl_url})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
finally:
await client.close()
# ===================== HANDOFF CLOSE =====================
@router.post("/service/api/bot/handoffs/{handoff_id}/close")
async def api_bot_handoff_close(handoff_id: int, db: AsyncSession = Depends(get_db),
user: dict = Depends(require_role("owner"))):
return await _proxy("POST", f"/api/bot/handoffs/{handoff_id}/close")
+1 -5
View File
@@ -98,11 +98,7 @@ body {
border-color: var(--accent);
}
.theme-label {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
/* EasyMDE dark theme overrides */
[data-theme="dark"] .EasyMDEContainer .editor-toolbar {
+72
View File
@@ -52,8 +52,14 @@ img { max-width:100%; height:auto; }
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-body);
}
[data-theme="dark"] .login-page {
background: linear-gradient(135deg, #0a0e1a 0%, #0d1a30 50%, #0a0e1a 100%);
}
[data-theme="light"] .login-page {
background: linear-gradient(135deg, #e8ecf1 0%, #f0f4f8 50%, #e8ecf1 100%);
}
.login-box {
background: var(--bg-card);
@@ -965,6 +971,72 @@ table .actions button {
color: var(--text-muted) !important;
}
/* ---------- ROLE SWITCHER ---------- */
.role-switcher {
position: relative;
}
.role-btn {
display: flex;
align-items: center;
gap: 4px;
background: var(--accent-dim);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
transition: all 0.2s;
white-space: nowrap;
}
.role-btn:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.role-btn-label { font-weight: 600; text-transform: capitalize; }
.role-btn-arrow { font-size: 10px; }
.role-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 160px;
box-shadow: var(--shadow);
z-index: 100;
overflow: hidden;
}
.role-dropdown.open { display: block; }
.role-dropdown-item {
display: block;
padding: 8px 14px;
font-size: 13px;
color: var(--text-primary);
text-decoration: none;
text-transform: capitalize;
transition: background 0.15s;
}
.role-dropdown-item:hover { background: var(--accent-dim); }
.role-dropdown-item.active { font-weight: 700; color: var(--accent); }
.role-dropdown-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.role-dropdown-reset { color: var(--danger); }
.role-dropdown-reset:hover { background: rgba(239,68,68,0.1) !important; }
/* ---------- USER NAME ---------- */
.navbar__user {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
/* ---------- DOCUMENTS ---------- */
.container {
max-width: 1200px;
+38
View File
@@ -0,0 +1,38 @@
(function() {
var ROLE_NAMES = { owner: 'Владелец', engineer: 'Инженер', technician: 'Техник' };
var EMOJIS = { owner: '👑', engineer: '🔧', technician: '🛠️' };
function closeDropdown() {
var dd = document.querySelector('.role-dropdown');
if (dd) dd.classList.remove('open');
}
function switchRole(role) {
closeDropdown();
fetch('/service/api/role/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role })
}).then(function(r) { return r.json() }).then(function(d) {
if (d.ok) { window.location.reload(); }
});
}
function resetRole() {
closeDropdown();
fetch('/service/api/role/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}).then(function(r) { return r.json() }).then(function(d) {
if (d.ok) { window.location.reload(); }
});
}
// Close dropdown on outside click
document.addEventListener('click', function(e) {
var sw = document.querySelector('.role-switcher');
if (sw && !sw.contains(e.target)) closeDropdown();
});
window.roleSwitcher = { switchRole: switchRole, resetRole: resetRole, ROLE_NAMES: ROLE_NAMES, EMOJIS: EMOJIS };
})();
+3 -6
View File
@@ -9,15 +9,12 @@
function applyTheme(theme) {
var resolved = theme === 'system' ? getSystemTheme() : theme;
document.documentElement.setAttribute('data-theme', resolved);
var label = document.getElementById('theme-label');
if (label) {
var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' };
label.textContent = names[theme] || 'Авто';
}
var names = { system: 'Авто', dark: 'Тёмная', light: 'Светлая' };
var icons = { system: '🖥', dark: '🌙', light: '☀️' };
var btn = document.getElementById('theme-btn');
if (btn) {
var icons = { system: '🖥', dark: '🌙', light: '☀️' };
btn.textContent = icons[theme] || '🖥';
btn.title = names[theme] || 'Авто';
}
}
+5 -11
View File
@@ -26,16 +26,10 @@
{% if role_permissions.get('checklist', True) != False %}<a href="/service/checklist" class="{% if active_page == 'checklist' %}active{% endif %}">Чек-лист</a>{% endif %}
{% if role_permissions.get('tech_access', True) != False %}<a href="/service/documents/tech-access" class="{% if active_page == 'tech-access' %}active{% endif %}">Доступ техников</a>{% endif %}
<div class="sidebar-section">Чат-бот Max</div>
{% if role_permissions.get('bot_analytics', True) != False %}<a href="/service/bot-settings/analytics" class="{% if active_page == 'bot-analytics' %}active{% endif %}">Аналитика</a>{% endif %}
{% if role_permissions.get('bot_users', True) != False %}<a href="/service/bot-settings/users" class="{% if active_page == 'bot-users' %}active{% endif %}">Пользователи бота</a>{% endif %}
{% if role_permissions.get('bot_conversations', True) != False %}<a href="/service/bot-settings/conversations" class="{% if active_page == 'bot-conversations' %}active{% endif %}">Диалоги бота</a>{% endif %}
{% if role_permissions.get('bot_tickets', True) != False %}<a href="/service/bot-settings/tickets" class="{% if active_page == 'bot-tickets' %}active{% endif %}">Заявки</a>{% endif %}
{% if role_permissions.get('bot_handoffs', True) != False %}<a href="/service/bot-settings/handoffs" class="{% if active_page == 'bot-handoffs' %}active{% endif %}">Handoff-диалоги</a>{% endif %}
{% if role_permissions.get('bot_notifications', True) != False %}<a href="/service/bot-settings/notifications" class="{% if active_page == 'bot-notifications' %}active{% endif %}">Уведомления</a>{% endif %}
{% if role_permissions.get('bot_risk_questions', True) != False %}<a href="/service/bot-settings/risk-questions" class="{% if active_page == 'bot-risk-questions' %}active{% endif %}">Вопросы риск-инжиниринга</a>{% endif %}
{% if role_permissions.get('bot_storage', True) != False %}<a href="/service/bot-settings/storage" class="{% if active_page == 'bot-storage' %}active{% endif %}">Яндекс.Диск</a>{% endif %}
{% if role_permissions.get('bot_broadcasts', True) != False %}<a href="/service/bot-settings/broadcasts" class="{% if active_page == 'bot-broadcasts' %}active{% endif %}">Рассылки</a>{% endif %}
<div class="sidebar-section">Чат-бот MAX</div>
{% if role_permissions.get('bot_consent', True) != False %}<a href="/service/bot-consent" class="{% if active_page == 'bot-consent' %}active{% endif %}">Согласия ФЗ-152</a>{% endif %}
{% if role_permissions.get('bot_tickets', True) != False %}<a href="/service/bot-tickets" class="{% if active_page == 'bot-tickets' %}active{% endif %}">Заявки от бота</a>{% endif %}
{% if role_permissions.get('bot_kb', True) != False %}<a href="/service/bot-kb" class="{% if active_page == 'bot-kb' %}active{% endif %}">База знаний</a>{% endif %}
<div class="sidebar-section">Контент</div>
{% if role_permissions.get('blog', True) != False %}<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>{% endif %}
@@ -48,9 +42,9 @@
<div class="sidebar-section">Настройки</div>
<a href="/service/questionnaire-config" class="{% if active_page == 'questionnaire-config' %}active{% endif %}">Настройка опросника</a>
<a href="/service/formulas" class="{% if active_page == 'formulas' %}active{% endif %}">Формулы</a>
<a href="/service/bot-settings" class="{% if active_page == 'bot-settings' %}active{% endif %}">Чат-бот Max</a>
<a href="/service/portal-settings" class="{% if active_page == 'portal-settings' %}active{% endif %}">Настройка портала</a>
<a href="/service/role-settings" class="{% if active_page == 'role-settings' %}active{% endif %}">Настройка Ролей</a>
{% if role_permissions.get('bot_settings', True) != False %}<a href="/service/bot-settings" class="{% if active_page == 'bot-settings' %}active{% endif %}">Настройки чат-бота</a>{% endif %}
<a href="/service/ideas" class="{% if active_page == 'ideas' %}active{% endif %}">Идеи</a>
</nav>
<div class="sidebar-footer">
+39 -3
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<script>(function(){var t=localStorage.getItem('aegisone_service_theme')||'system';if(t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches))document.documentElement.setAttribute('data-theme','dark');else if(t==='light'||(t==='system'&&matchMedia('(prefers-color-scheme:light)').matches))document.documentElement.setAttribute('data-theme','light');})();</script>
<title>{% block title %}Service Portal — AegisOne Engineering{% endblock %}</title>
<link rel="stylesheet" href="/service/static/css/service.css">
<link rel="stylesheet" href="/service/static/css/service-theme.css">
@@ -22,15 +23,50 @@
<a href="/service/dashboard">AegisOne Service</a>
</div>
<ul class="navbar__menu">
<li class="theme-switcher" data-tooltip="Переключить тему">
<button id="theme-btn" onclick="toggleTheme()">🖥</button>
<span class="theme-label" id="theme-label">Авто</span>
{% if available_roles|length > 1 %}
<li class="role-switcher">
<button class="role-btn" onclick="document.querySelector('.role-dropdown').classList.toggle('open')">
<span class="role-btn-label">{{ role_override_active and '⚡' or '' }} {{ user.role }}</span>
<span class="role-btn-arrow"></span>
</button>
<div class="role-dropdown">
{% for r in available_roles %}
<a href="#" class="role-dropdown-item {% if r == user.role %}active{% endif %}" onclick="event.preventDefault(); roleSwitcher.switchRole('{{ r }}')">
{{ r }}
</a>
{% endfor %}
{% if role_override_active %}
<div class="role-dropdown-divider"></div>
<a href="#" class="role-dropdown-item role-dropdown-reset" onclick="event.preventDefault(); roleSwitcher.resetRole()">
← Сбросить ({{ original_role }})
</a>
{% endif %}
</div>
</li>
{% endif %}
<li class="theme-switcher">
<button id="theme-btn" onclick="toggleTheme()" title="Авто">🖥</button>
</li>
</ul>
</nav>
{% endif %}
{% block content %}{% endblock %}
<div class="toast-container" id="toast-container"></div>
<script src="/service/static/js/theme.js"></script>
<script src="/service/static/js/role-switcher.js"></script>
<script>
function showNotification(msg, type){
type = type || 'success';
var container = document.getElementById('toast-container');
if(!container){ container = document.createElement('div'); container.className='toast-container'; container.id='toast-container'; document.body.appendChild(container); }
var t = document.createElement('div');
t.className = 'toast toast-' + type;
t.textContent = msg;
container.appendChild(t);
setTimeout(function(){ t.style.opacity='0'; t.style.transition='opacity 0.3s'; setTimeout(function(){ t.remove() },300) }, 3000);
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+1 -143
View File
@@ -3,78 +3,7 @@
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="active">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users">Сотрудники</a>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/assignments">Назначение сотрудников</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/ceo">CEO дашборд</a>
<a href="/service/coefficients">Коэффициенты</a>
<a href="/service/charts">Графики</a>
<a href="/service/ideas">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/documents/tech-access">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks">Задачи</a>
<a href="/service/reports">Отчёты</a>
<a href="/service/incidents">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog">Управление блогом</a>
{% endif %}
<a href="/blog/">Публичный блог</a>
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div>
@@ -182,75 +111,4 @@ document.addEventListener('DOMContentLoaded', applyDashSettings);
.card-link { text-decoration:none; color:inherit; display:block; transition:transform 0.2s, box-shadow 0.2s; }
.card-link:hover { transform:translateY(-2px); box-shadow:var(--shadow); cursor:pointer; }
</style>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
+1
View File
@@ -24,5 +24,6 @@
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</form>
<p class="login-box__footnote">* Сервисный портал для сотрудников AegisOne Engineering</p>
<div style="text-align:right;margin-top:12px;font-size:11px;color:var(--text-muted);">v{{ app_version }}</div>
</div>
{% endblock %}
+1 -142
View File
@@ -3,78 +3,7 @@
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="{% if active_page == 'dashboard' %}active{% endif %}">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users" class="{% if active_page == 'users' %}active{% endif %}">Сотрудники</a>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/assignments" class="{% if active_page == 'assignments' %}active{% endif %}">Назначение сотрудников</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/ceo" class="{% if active_page == 'ceo' %}active{% endif %}">CEO дашборд</a>
<a href="/service/coefficients" class="{% if active_page == 'coefficients' %}active{% endif %}">Коэффициенты</a>
<a href="/service/coefficients/test" class="{% if active_page == 'coefficients-test' %}active{% endif %}">Тест формул</a>
<a href="/service/charts" class="{% if active_page == 'charts' %}active{% endif %}">Графики</a>
<a href="/service/ideas" class="{% if active_page == 'ideas' %}active{% endif %}">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers" class="{% if active_page == 'customers' %}active{% endif %}">Клиенты</a>
<a href="/service/objects" class="{% if active_page == 'objects' %}active{% endif %}">Объекты</a>
<a href="/service/sla" class="{% if active_page == 'sla' %}active{% endif %}">SLA контракты</a>
<a href="/service/documents/tech-access" class="{% if active_page == 'tech-access' %}active{% endif %}">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks" class="{% if active_page == 'tasks' %}active{% endif %}">Задачи</a>
<a href="/service/reports" class="{% if active_page == 'reports' %}active{% endif %}">Отчёты</a>
<a href="/service/incidents" class="{% if active_page == 'incidents' %}active{% endif %}">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire" class="{% if active_page == 'questionnaire' %}active{% endif %}">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config" class="{% if active_page == 'questionnaire-config' %}active{% endif %}">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports" class="{% if active_page == 'passports' %}active{% endif %}">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist" class="{% if active_page == 'checklist' %}active{% endif %}">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog" class="{% if active_page == 'blog' %}active{% endif %}">Управление блогом</a>
{% endif %}
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases" class="{% if active_page == 'cases' %}active{% endif %}">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/" class="{% if active_page == 'docs' %}active{% endif %}">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions" class="{% if active_page == 'docs-manage' %}active{% endif %}">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div>
@@ -91,75 +20,5 @@
{% block page_content %}{% endblock %}
</main>
</div>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}
{% block extra_scripts %}{% endblock %}
@@ -1,241 +0,0 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.broadcast-layout { display:flex; gap:24px; margin-top:16px; flex-wrap:wrap; }
.broadcast-left { flex:1; min-width:300px; }
.broadcast-right { flex:1; min-width:300px; }
.msg-preview { background:#e3f2fd; border-radius:12px; padding:12px 16px; margin-top:8px;
white-space:pre-wrap; word-break:break-word; line-height:1.5; font-size:14px; }
.msg-preview.empty { color:#999; font-style:italic; }
.recipient-item { display:flex; align-items:center; gap:8px; padding:6px 8px;
border-bottom:1px solid #eee; cursor:pointer; }
.recipient-item:hover { background:#f8f9fa; }
.recipient-item .name { flex:1; }
.recipient-item .phone { color:#666; font-size:13px; }
.recipient-list { max-height:300px; overflow-y:auto; border:1px solid #ddd; border-radius:8px;
margin-top:8px; }
.recipient-list .select-all { padding:8px; border-bottom:1px solid #eee; background:#f8f9fa;
font-weight:600; cursor:pointer; user-select:none; }
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:1000;
align-items:center; justify-content:center; }
.modal-overlay.active { display:flex; }
.modal-box { background:#fff; border-radius:12px; padding:24px; max-width:600px;
width:90%; max-height:80vh; overflow-y:auto; }
.modal-box h3 { margin:0 0 16px; }
.modal-box .actions { display:flex; gap:12px; justify-content:flex-end; margin-top:20px; }
.broadcast-history { margin-top:24px; }
.broadcast-history table { width:100%; }
.broadcast-history td, .broadcast-history th { padding:8px 12px; text-align:left; border-bottom:1px solid #eee; }
.broadcast-history .text-col { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
</style>
<div class="card">
<div class="card-header"><h2>Рассылки</h2></div>
<div class="broadcast-layout">
<div class="broadcast-left">
<div class="form-group">
<label>Текст сообщения</label>
<textarea id="broadcast-text" rows="5" style="width:100%;" oninput="updatePreview()" placeholder="Введите текст рассылки... Markdown поддерживается"></textarea>
</div>
<div>
<label>📱 Предпросмотр сообщения</label>
<div id="live-preview" class="msg-preview empty">Текст сообщения появится здесь...</div>
</div>
</div>
<div class="broadcast-right">
<div class="form-group">
<label>Получатели</label>
<input type="text" id="recipient-search" placeholder="Поиск..." style="width:100%;margin-bottom:8px;" oninput="loadRecipients()">
</div>
<div class="recipient-list" id="recipient-list">
<div class="select-all" onclick="toggleSelectAll()">
<span id="select-all-checkbox"></span> Выбрать всех согласных
<span style="float:right;color:#666;font-weight:400;" id="recipient-count"></span>
</div>
<div id="recipient-items"></div>
</div>
<div style="display:flex;gap:12px;margin-top:12px;">
<button class="btn btn-primary" onclick="previewBroadcast()">👁 Предпросмотр</button>
<button class="btn btn-primary" onclick="sendBroadcast()">📨 Отправить</button>
</div>
</div>
</div>
</div>
<div class="card broadcast-history">
<div class="card-header"><h3>История рассылок</h3></div>
<div class="table-wrap"><table>
<thead><tr><th>ID</th><th>Текст</th><th>Получателей</th><th>Дата</th></tr></thead>
<tbody id="history-list"></tbody>
</table></div>
</div>
<div class="modal-overlay" id="preview-modal">
<div class="modal-box">
<h3>👁 Предпросмотр рассылки</h3>
<div style="background:#e3f2fd;border-radius:12px;padding:16px;white-space:pre-wrap;line-height:1.5;" id="preview-text"></div>
<div style="margin-top:16px;padding:12px;background:#f8f9fa;border-radius:8px;">
<strong>Получателей:</strong> <span id="preview-count"></span>
<div style="margin-top:8px;max-height:150px;overflow-y:auto;" id="preview-recipients"></div>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="closePreview()">Отмена</button>
<button class="btn btn-primary" id="preview-send-btn" onclick="confirmSend()">📨 Отправить</button>
</div>
</div>
</div>
<script>
var selectedUserIds = new Set();
var allRecipients = [];
var currentDraftId = null;
function updatePreview(){
var text = document.getElementById('broadcast-text').value.trim();
var preview = document.getElementById('live-preview');
if(text){
preview.textContent = text;
preview.className = 'msg-preview';
} else {
preview.textContent = 'Текст сообщения появится здесь...';
preview.className = 'msg-preview empty';
}
}
function loadRecipients(){
var q = document.getElementById('recipient-search').value.trim();
var url = '/service/api/bot/broadcast/recipients?limit=200';
if(q) url += '&q=' + encodeURIComponent(q);
fetch(url).then(function(r){ return r.json() }).then(function(d){
allRecipients = d.users || [];
renderRecipients();
});
}
function renderRecipients(){
var container = document.getElementById('recipient-items');
var countEl = document.getElementById('recipient-count');
container.innerHTML = '';
countEl.textContent = allRecipients.length + ' чел.';
allRecipients.forEach(function(u){
var div = document.createElement('div');
div.className = 'recipient-item';
var checked = selectedUserIds.has(u.max_user_id) ? '☑' : '☐';
var name = (u.first_name || '') + ' ' + (u.last_name || '');
if(!name.trim()) name = 'ID ' + u.max_user_id;
div.innerHTML = '<span class="cb">' + checked + '</span>' +
'<span class="name">' + name + '</span>' +
'<span class="phone">' + (u.phone || '') + '</span>';
div.onclick = function(){ toggleRecipient(u.max_user_id); };
container.appendChild(div);
});
updateSelectAllCheckbox();
}
function toggleRecipient(id){
if(selectedUserIds.has(id)){
selectedUserIds['delete'](id);
} else {
selectedUserIds.add(id);
}
renderRecipients();
}
function toggleSelectAll(){
if(selectedUserIds.size === allRecipients.length){
selectedUserIds.clear();
} else {
selectedUserIds = new Set(allRecipients.map(function(u){ return u.max_user_id; }));
}
renderRecipients();
}
function updateSelectAllCheckbox(){
var cb = document.getElementById('select-all-checkbox');
if(allRecipients.length > 0 && selectedUserIds.size === allRecipients.length){
cb.textContent = '☑';
} else {
cb.textContent = '☐';
}
}
function previewBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'preview', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
if(d.error){ alert(d.error); return; }
currentDraftId = d.draft_id;
document.getElementById('preview-text').textContent = d.text;
document.getElementById('preview-count').textContent = d.recipient_count + ' чел.';
var recEl = document.getElementById('preview-recipients');
recEl.innerHTML = d.recipients.join(', ');
document.getElementById('preview-modal').className = 'modal-overlay active';
});
}
function closePreview(){
document.getElementById('preview-modal').className = 'modal-overlay';
}
function confirmSend(){
var text = document.getElementById('broadcast-text').value.trim();
if(!currentDraftId){ alert('Сначала сделайте предпросмотр'); return; }
if(!confirm('Отправить рассылку?')) return;
var userIds = Array.from(selectedUserIds);
document.getElementById('preview-send-btn').disabled = true;
fetch('/service/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, user_ids: userIds, action: 'send', draft_id: currentDraftId})
}).then(function(r){ return r.json() }).then(function(d){
closePreview();
if(d.ok){
alert('Рассылка отправлена: ' + d.sent_to + ' из ' + d.total_recipients + ' получателей');
document.getElementById('broadcast-text').value = '';
document.getElementById('live-preview').textContent = 'Текст сообщения появится здесь...';
document.getElementById('live-preview').className = 'msg-preview empty';
selectedUserIds.clear();
renderRecipients();
currentDraftId = null;
loadHistory();
} else {
alert('Ошибка: ' + (d.error || 'неизвестная'));
}
document.getElementById('preview-send-btn').disabled = false;
});
}
function sendBroadcast(){
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ alert('Введите текст рассылки'); return; }
var userIds = Array.from(selectedUserIds);
if(userIds.length === 0 && !confirm('Не выбрано ни одного получателя. Отправить всем согласным?')) return;
previewBroadcast();
}
function loadHistory(){
fetch('/service/api/bot/broadcast/history').then(function(r){ return r.json() }).then(function(d){
var tbody = document.getElementById('history-list');
tbody.innerHTML = '';
(d.broadcasts || []).forEach(function(b){
var tr = document.createElement('tr');
tr.innerHTML = '<td>' + b.id + '</td>' +
'<td class="text-col" title="' + b.text.replace(/"/g,'&quot;') + '">' + b.text + '</td>' +
'<td>' + b.recipient_count + '</td>' +
'<td>' + b.sent_at + '</td>';
tbody.appendChild(tr);
});
if(!d.broadcasts || d.broadcasts.length === 0){
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#999;">Рассылок ещё не было</td></tr>';
}
});
}
loadRecipients();
loadHistory();
</script>
{% endblock %}
@@ -0,0 +1,220 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.consent-table { width:100%; border-collapse:collapse; }
.consent-table th, .consent-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.consent-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.consent-table td { vertical-align:middle; }
.consent-yes { color:#155724; font-weight:600; }
.consent-no { color:#721c24; font-weight:600; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
.broadcast-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; }
.broadcast-card h3 { margin-bottom:12px; }
.broadcast-card textarea { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px; min-height:100px; resize:vertical; margin-bottom:10px; box-sizing:border-box; }
.broadcast-actions { display:flex; gap:10px; flex-wrap:wrap; }
.recipient-count { font-size:12px; color:var(--text-muted); margin-bottom:8px; }
.preview-area { display:none; margin-top:12px; padding:12px; border:1px solid var(--border); border-radius:8px; background:var(--bg-input); white-space:pre-wrap; font-size:14px; line-height:1.5; }
.preview-area.open { display:block; }
.preview-area .preview-text { margin-bottom:10px; }
.preview-area .preview-actions { display:flex; gap:10px; }
.confirm-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:1000; align-items:center; justify-content:center; }
.confirm-overlay.open { display:flex; }
.confirm-modal { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:24px; width:500px; max-width:90vw; }
.confirm-modal h3 { margin-bottom:8px; }
.confirm-modal .confirm-text { padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg-input); max-height:200px; overflow-y:auto; margin:12px 0; white-space:pre-wrap; font-size:13px; }
.confirm-modal .confirm-count { font-size:12px; color:var(--text-muted); margin-bottom:12px; }
.confirm-modal .confirm-timer { font-size:24px; font-weight:700; text-align:center; color:var(--accent); margin:12px 0; }
.confirm-modal .confirm-actions { display:flex; gap:10px; justify-content:center; }
.confirm-modal .confirm-actions .btn { min-width:140px; }
.timer-danger { color:var(--danger) !important; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Согласия ФЗ-152</h2>
<span id="consent-count" style="font-size:13px;color:var(--text-muted);">0 пользователей</span>
</div>
<div class="filter-bar">
<input type="text" id="consent-search" placeholder="Поиск по имени, телефону..." oninput="filterConsents()">
</div>
<table class="consent-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Телефон</th>
<th>Email</th>
<th>Организация</th>
<th>Username</th>
<th>Согласие</th>
<th>Дата</th>
</tr>
</thead>
<tbody id="consent-table-body">
<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="broadcast-card">
<h3>Рассылка пользователям MAX</h3>
<div class="recipient-count" id="recipient-count">Получателей: 0</div>
<textarea id="broadcast-text" placeholder="Введите текст сообщения..." oninput="clearPreview()"></textarea>
<div class="broadcast-actions">
<button class="btn" onclick="showPreview()">Предпросмотр</button>
<button class="btn btn-primary" id="send-btn" onclick="openConfirm()" disabled>Отправить всем</button>
</div>
<div class="preview-area" id="preview-area">
<div class="preview-text" id="preview-text"></div>
<div class="preview-actions">
<button class="btn btn-primary" onclick="openConfirm()">Отправить</button>
<button class="btn" onclick="hidePreview()">Редактировать</button>
</div>
</div>
</div>
<div class="confirm-overlay" id="confirm-overlay">
<div class="confirm-modal">
<h3>Подтверждение рассылки</h3>
<div class="confirm-count" id="confirm-count">Получателей: 0</div>
<div class="confirm-text" id="confirm-text"></div>
<div class="confirm-timer" id="confirm-timer">10</div>
<div class="confirm-actions">
<button class="btn btn-primary" id="confirm-yes" onclick="executeBroadcast()">Подтвердить</button>
<button class="btn" onclick="closeConfirm()">Отмена</button>
</div>
</div>
</div>
<script>
var allConsents = [];
var confirmTimer = null;
var confirmSeconds = 10;
function loadConsents(){
var tbody = document.getElementById('consent-table-body');
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/users/consented').then(function(r){ return r.json() }).then(function(data){
allConsents = data;
document.getElementById('consent-count').textContent = data.length + ' пользователей';
document.getElementById('recipient-count').textContent = 'Получателей: ' + data.length;
document.getElementById('send-btn').disabled = data.length === 0;
renderConsents(data);
}).catch(function(err){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Ошибка загрузки</td></tr>';
showNotification('Ошибка загрузки согласий', 'error');
});
}
function renderConsents(list){
var tbody = document.getElementById('consent-table-body');
if(!list.length){
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:var(--text-muted);">Нет данных</td></tr>';
return;
}
var html = '';
list.forEach(function(u){
var name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—';
var dateStr = u.consent_date ? new Date(u.consent_date).toLocaleString() : '—';
html += '<tr>' +
'<td>' + u.id + '</td>' +
'<td>' + escapeHtml(name) + '</td>' +
'<td>' + escapeHtml(u.phone || '—') + '</td>' +
'<td>' + escapeHtml(u.email || '—') + '</td>' +
'<td>' + escapeHtml(u.organization || '—') + '</td>' +
'<td>' + escapeHtml(u.username || '—') + '</td>' +
'<td class="consent-yes">Да</td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterConsents(){
var search = document.getElementById('consent-search').value.toLowerCase();
var filtered = allConsents.filter(function(u){
if(!search) return true;
var name = [u.first_name, u.last_name].filter(Boolean).join(' ').toLowerCase();
var phone = (u.phone || '').toLowerCase();
var email = (u.email || '').toLowerCase();
var username = (u.username || '').toLowerCase();
return name.includes(search) || phone.includes(search) || email.includes(search) || username.includes(search);
});
renderConsents(filtered);
}
function clearPreview(){
document.getElementById('preview-area').classList.remove('open');
}
function showPreview(){
var text = document.getElementById('broadcast-text').value;
if(!text.trim()){ showNotification('Введите текст сообщения', 'warning'); return; }
document.getElementById('preview-text').textContent = text;
document.getElementById('preview-area').classList.add('open');
}
function hidePreview(){
document.getElementById('preview-area').classList.remove('open');
}
function openConfirm(){
if(confirmTimer) return;
var text = document.getElementById('broadcast-text').value.trim();
if(!text){ showNotification('Введите текст сообщения', 'warning'); return; }
closeConfirm();
document.getElementById('confirm-text').textContent = text;
document.getElementById('confirm-count').textContent = 'Получателей: ' + allConsents.length;
document.getElementById('confirm-overlay').classList.add('open');
confirmSeconds = 10;
document.getElementById('confirm-timer').textContent = confirmSeconds;
document.getElementById('confirm-timer').className = 'confirm-timer';
document.getElementById('confirm-yes').disabled = false;
confirmTimer = setInterval(function(){
confirmSeconds--;
document.getElementById('confirm-timer').textContent = confirmSeconds;
if(confirmSeconds <= 3) document.getElementById('confirm-timer').className = 'confirm-timer timer-danger';
if(confirmSeconds <= 0){
clearInterval(confirmTimer);
confirmTimer = null;
closeConfirm();
showNotification('Время подтверждения истекло. Нажмите «Отправить всем» повторно.', 'warning');
}
}, 1000);
}
function closeConfirm(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
document.getElementById('confirm-overlay').classList.remove('open');
}
function executeBroadcast(){
if(confirmTimer){ clearInterval(confirmTimer); confirmTimer = null; }
var text = document.getElementById('confirm-text').textContent;
document.getElementById('confirm-yes').disabled = true;
fetch('/api/bot/broadcast', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, all: true})
}).then(function(r){ return r.json() }).then(function(d){
closeConfirm();
if(d.ok) showNotification('Отправлено: ' + d.sent + ', ошибок: ' + d.errors, d.errors ? 'warning' : 'success');
else showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}).catch(function(){
closeConfirm();
showNotification('Ошибка соединения', 'error');
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadConsents();
</script>
{% endblock %}
+239
View File
@@ -0,0 +1,239 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.kb-table { width:100%; border-collapse:collapse; }
.kb-table th, .kb-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.kb-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.kb-table td { vertical-align:top; }
.kb-table .active-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.kb-table .active-badge.yes { background:#d4edda; color:#155724; }
.kb-table .active-badge.no { background:#f8d7da; color:#721c24; }
.kb-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.4);
z-index:1000; align-items:center; justify-content:center; }
.kb-modal.open { display:flex; }
.kb-modal-content { background:var(--bg-card); border-radius:12px; padding:30px; width:600px; max-width:90vw;
max-height:80vh; overflow-y:auto; }
.kb-modal-content h3 { margin-bottom:20px; }
.form-group { margin-bottom:14px; }
.form-group label { display:block; font-weight:600; margin-bottom:4px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:14px;
}
.form-group textarea { min-height:100px; resize:vertical; }
.modal-actions { display:flex; gap:12px; margin-top:16px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar input, .filter-bar select { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>База знаний чат-бота</h2>
<button class="btn btn-primary" onclick="openModal()">+ Добавить карточку</button>
</div>
<div class="filter-bar">
<input type="text" id="kb-search" placeholder="Поиск по вопросу..." oninput="filterKB()">
<select id="kb-category-filter" onchange="filterKB()">
<option value="">Все категории</option>
</select>
<select id="kb-active-filter" onchange="filterKB()">
<option value="">Все статусы</option>
<option value="true">Активные</option>
<option value="false">Неактивные</option>
</select>
</div>
<table class="kb-table">
<thead>
<tr>
<th>ID</th>
<th>Вопрос</th>
<th>Категория</th>
<th>Статус</th>
<th>Источник</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="kb-table-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="kb-modal" id="kb-modal">
<div class="kb-modal-content">
<h3 id="modal-title">Новая карточка</h3>
<form id="kb-form">
<div class="form-group">
<label>Категория</label>
<select name="category_id" id="kb-category-id"></select>
</div>
<div class="form-group">
<label>Вопрос *</label>
<input type="text" name="question" id="kb-question" required>
</div>
<div class="form-group">
<label>Ответ (оставьте пустым, если нет ответа)</label>
<textarea name="answer" id="kb-answer"></textarea>
</div>
<div class="form-group">
<label>Ключевые слова (через запятую)</label>
<input type="text" name="keywords" id="kb-keywords" placeholder="слово1, слово2, слово3">
</div>
<div class="form-group">
<label>URL источника</label>
<input type="url" name="source_url" id="kb-source-url" placeholder="https://aegisone.ru/...">
</div>
<div class="form-group">
<label><input type="checkbox" name="is_active" id="kb-is-active" checked> Активная карточка</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" onclick="closeModal()">Отмена</button>
</div>
</form>
</div>
</div>
<script>
var categories = {};
var allCards = [];
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){});
function loadKB(){
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
allCards = data;
renderKB(data);
});
}
function renderKB(cards){
var tbody = document.getElementById('kb-table-body');
if(!cards.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет карточек</td></tr>';
return;
}
var html = '';
cards.forEach(function(c){
var catName = categories[c.category_id] || 'Без категории';
var activeClass = c.is_active ? 'yes' : 'no';
var activeLabel = c.is_active ? 'Активна' : 'Неактивна';
var answerPreview = c.answer ? c.answer.substring(0, 50) + '...' : '<em style="color:var(--text-muted)">Нет ответа</em>';
html += '<tr>' +
'<td>' + c.id + '</td>' +
'<td><strong>' + escapeHtml(c.question) + '</strong><br><small style="color:var(--text-muted)">' + answerPreview + '</small></td>' +
'<td>' + catName + '</td>' +
'<td><span class="active-badge ' + activeClass + '">' + activeLabel + '</span></td>' +
'<td style="font-size:12px;">' + (c.source_url ? '<a href="' + c.source_url + '" target="_blank">ссылка</a>' : '—') + '</td>' +
'<td><button class="btn btn-sm" onclick="editCard(' + c.id + ')" title="Редактировать">✏️</button> <button class="btn btn-sm" onclick="deleteCard(' + c.id + ')" style="color:#dc3545;" title="Удалить">🗑</button></td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterKB(){
var search = document.getElementById('kb-search').value.toLowerCase();
var catFilter = document.getElementById('kb-category-filter').value;
var activeFilter = document.getElementById('kb-active-filter').value;
var filtered = allCards.filter(function(c){
if(search && !c.question.toLowerCase().includes(search)) return false;
if(catFilter && String(c.category_id) !== catFilter) return false;
if(activeFilter !== '' && String(c.is_active) !== activeFilter) return false;
return true;
});
renderKB(filtered);
}
function openModal(){
editingId = null;
document.getElementById('kb-modal').classList.add('open');
document.getElementById('modal-title').textContent = 'Новая карточка';
document.getElementById('kb-form').reset();
document.getElementById('kb-is-active').checked = true;
}
function closeModal(){
document.getElementById('kb-modal').classList.remove('open');
}
var editingId = null;
function editCard(id){
var card = allCards.find(function(c){ return c.id === id; });
if(!card) return;
editingId = id;
document.getElementById('modal-title').textContent = 'Редактировать карточку #' + id;
document.getElementById('kb-question').value = card.question || '';
document.getElementById('kb-answer').value = card.answer || '';
document.getElementById('kb-keywords').value = (card.keywords || []).join(', ');
document.getElementById('kb-source-url').value = card.source_url || '';
document.getElementById('kb-is-active').checked = card.is_active;
document.getElementById('kb-category-id').value = card.category_id || '';
document.getElementById('kb-modal').classList.add('open');
}
function deleteCard(id){
if(!confirm('Удалить карточку?')) return;
fetch('/api/bot/kb/' + id, { method: 'DELETE' }).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { showNotification('Карточка удалена', 'success'); loadKB(); }
else showNotification('Ошибка удаления', 'error');
});
}
document.getElementById('kb-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {
category_id: parseInt(document.getElementById('kb-category-id').value) || null,
question: document.getElementById('kb-question').value,
answer: document.getElementById('kb-answer').value || null,
keywords: document.getElementById('kb-keywords').value.split(',').map(function(s){ return s.trim(); }).filter(Boolean),
source_url: document.getElementById('kb-source-url').value || null,
is_active: document.getElementById('kb-is-active').checked,
sort_order: 0,
};
var url = '/api/bot/kb';
var method = 'POST';
if(editingId) { url += '/' + editingId; method = 'PUT'; }
fetch(url, {
method: method, headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) { closeModal(); loadKB(); showNotification(editingId ? 'Карточка обновлена' : 'Карточка создана', 'success'); }
else showNotification('Ошибка сохранения', 'error');
});
});
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Load categories
fetch('/api/bot/kb').then(function(r){ return r.json() }).then(function(data){
// Extract unique categories
var catSet = {};
data.forEach(function(c){
if(c.category_id) catSet[c.category_id] = true;
});
var catSelect = document.getElementById('kb-category-id');
var catFilter = document.getElementById('kb-category-filter');
var catOptions = {
1: 'О компании', 2: 'Аудит безопасности', 3: 'Сервисное обслуживание (SLA)',
4: 'Реагирование на инциденты', 5: 'Технический надзор',
6: 'Документация и нормативы', 7: 'Риск-инжиниринг', 8: 'Отрасли и кейсы'
};
categories = catOptions;
Object.keys(catOptions).forEach(function(id){
var opt1 = document.createElement('option');
opt1.value = id; opt1.textContent = catOptions[id];
catSelect.appendChild(opt1);
var opt2 = document.createElement('option');
opt2.value = id; opt2.textContent = catOptions[id];
catFilter.appendChild(opt2);
});
});
loadKB();
</script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:20px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.bot-settings-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.bot-settings-grid .full-width { grid-column:1/-1; }
.form-group { margin-bottom:16px; }
.form-group label { display:block; font-weight:600; margin-bottom:6px; font-size:13px; color:var(--text-muted); }
.form-group input, .form-group textarea, .form-group select {
width:100%; padding:10px 14px; border:1px solid var(--border);
border-radius:8px; font-size:14px; background:var(--bg-input); color:var(--text-primary);
transition:border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus {
outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(0,150,200,0.1);
}
.form-group textarea { min-height:100px; resize:vertical; }
.form-actions { grid-column:1/-1; display:flex; gap:12px; margin-top:8px; }
.form-actions .btn { min-width:160px; }
.hint { font-size:12px; color:var(--text-muted); margin-top:4px; }
.test-iframe { width:100%; height:650px; border:none; border-radius:8px; }
</style>
<div class="card">
<div class="card-header"><h2>Настройки чат-бота MAX — «София»</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('main')">Основные</div>
<div class="tab" onclick="switchTab('test')">Тестовый прогон</div>
</div>
<div class="tab-content active" id="tab-main">
<form id="bot-settings-form">
<div class="bot-settings-grid">
<div class="form-group full-width">
<label>Имя ассистента</label>
<input type="text" name="assistant_name" id="assistant_name" placeholder="Например: София">
</div>
<div class="form-group full-width">
<label>Роль ассистента</label>
<textarea name="assistant_role" id="assistant_role" placeholder="Описание роли бота..."></textarea>
<div class="hint">Будет использована для формирования системного промпта YandexGPT</div>
</div>
<div class="form-group">
<label>MAX-бот ID</label>
<input type="text" name="max_bot_id" id="max_bot_id" placeholder="Токен бота">
<div class="hint">Токен чат-бота из платформы MAX</div>
</div>
<div class="form-group">
<label>Email поддержки</label>
<input type="email" name="support_email" id="support_email" placeholder="zakaz@aegisone.ru">
<div class="hint">На этот email приходят уведомления о новых заявках</div>
</div>
<div class="form-group full-width">
<label>Тема письма</label>
<input type="text" name="email_subject" id="email_subject" placeholder="Обращение через Виртуального помощника">
<div class="hint">Тема email-уведомления об эскалации</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
</div>
</div>
</form>
</div>
<div class="tab-content" id="tab-test">
<p style="margin-bottom:12px;color:var(--text-muted);font-size:13px;">Симуляция диалога с ботом. Отправляет запросы напрямую на вебхук бота.</p>
<iframe src="/service/bot-test-content" class="test-iframe" id="test-iframe"></iframe>
</div>
</div>
<div class="card" style="margin-top:20px;">
<div class="card-header"><h2>Общие настройки портала</h2></div>
<div class="hint" style="margin-bottom:16px;">
Yandex GPT и Yandex Disk настраиваются в разделе
<a href="/service/portal-settings">Настройка портала</a>.
Бот использует эти данные автоматически.
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab-bar .tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab-bar .tab[onclick*="' + name + '"]').classList.add('active');
if(name === 'test'){
document.getElementById('test-iframe').src = '/service/bot-test-content';
}
}
document.getElementById('bot-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input, textarea').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка: ' + (d.error || 'сохранения'), 'error');
}).catch(function(){
showNotification('Ошибка соединения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['assistant_name','assistant_role','support_email','email_subject','max_bot_id'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
if(document.getElementById('max_bot_id') && !document.getElementById('max_bot_id').value){
document.getElementById('max_bot_id').value = 'f9LHodD0cOKkk03DYXj906MdIeRs...';
}
});
</script>
{% endblock %}
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter','Segoe UI',sans-serif; background:#1B2838; color:#E8EDF2; font-size:13px; height:100vh; display:flex; flex-direction:column; }
.test-container { display:flex; flex:1; gap:12px; padding:12px; overflow:hidden; }
.test-chat { flex:1; border:1px solid #253248; border-radius:10px; display:flex; flex-direction:column; overflow:hidden; }
.test-chat-header { background:#0A1628; padding:10px 14px; font-weight:600; border-bottom:1px solid #253248; font-size:12px; }
.test-chat-messages { flex:1; overflow-y:auto; padding:10px 14px; }
.test-chat-input { display:flex; border-top:1px solid #253248; }
.test-chat-input input { flex:1; border:none; padding:10px 14px; font-size:13px; outline:none; background:#1B2838; color:#E8EDF2; }
.test-chat-input button { padding:10px 16px; border:none; background:#00ADEF; color:#fff; cursor:pointer; font-weight:600; }
.test-log { width:300px; border:1px solid #253248; border-radius:10px; padding:10px; overflow-y:auto; font-family:monospace; font-size:11px; line-height:1.5; background:#0A1628; }
.test-log .log-entry { margin-bottom:4px; padding:3px 0; border-bottom:1px solid #1e3044; }
.test-log .log-entry .time { color:#6B8299; }
.test-log .log-entry .event { color:#00ADEF; font-weight:600; }
.msg-bot { background:#1e3044; padding:7px 12px; border-radius:8px 8px 8px 0; margin-bottom:6px; max-width:85%; }
.msg-user { background:#00ADEF; color:#fff; padding:7px 12px; border-radius:8px 8px 0 8px; margin-bottom:6px; max-width:85%; margin-left:auto; }
.test-scenarios { display:flex; gap:6px; padding:8px 12px; flex-wrap:wrap; border-bottom:1px solid #253248; }
.test-scenarios button { font-size:11px; padding:4px 10px; background:#253248; color:#E8EDF2; border:1px solid #3a4a60; border-radius:4px; cursor:pointer; }
.test-scenarios button:hover { background:#00ADEF; color:#fff; }
</style>
</head>
<body>
<div class="test-scenarios">
<button onclick="runScenario('greeting')">Приветствие</button>
<button onclick="runScenario('consent_yes')">Согласие → заявка</button>
<button onclick="runScenario('consent_no')">Отказ согласия</button>
<button onclick="runScenario('question')">Общий вопрос</button>
<button onclick="clearTest()">Очистить</button>
</div>
<div class="test-container">
<div class="test-chat">
<div class="test-chat-header">Чат с ботом (симуляция)</div>
<div class="test-chat-messages" id="test-messages"></div>
<div class="test-chat-input">
<input type="text" id="test-input" placeholder="Введите сообщение..." onkeypress="if(event.key==='Enter') sendTestMessage()">
<button onclick="sendTestMessage()">Отправить</button>
</div>
</div>
<div class="test-log" id="test-log"></div>
</div>
<script>
var testUserId = 999999001;
function log(event, detail){
var logDiv = document.getElementById('test-log');
var time = new Date().toLocaleTimeString();
var entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = '<span class="time">[' + time + ']</span> <span class="event">' + event + '</span> ' + (detail || '');
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function addMessage(text, from){
var msgs = document.getElementById('test-messages');
var div = document.createElement('div');
div.className = from === 'bot' ? 'msg-bot' : 'msg-user';
div.textContent = text;
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight;
}
function sendTestMessage(){
var input = document.getElementById('test-input');
var text = input.value.trim();
if(!text) return;
input.value = '';
addMessage(text, 'user');
log('message_created', 'Пользователь: ' + text);
simulateWebhook('message_created', text);
}
function runScenario(scenario){
clearTest();
switch(scenario){
case 'greeting':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'consent_yes':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Здравствуйте! Я хочу оставить заявку.', 'user');
log('message_created', 'Пользователь: хочу оставить заявку');
simulateWebhook('message_created', 'Здравствуйте! Я хочу оставить заявку.');
}, 1000);
break;
case 'consent_no':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
break;
case 'question':
log('bot_started', 'Пользователь начал диалог');
simulateWebhook('bot_started', null);
setTimeout(function(){
addMessage('Какие услуги вы предоставляете?', 'user');
log('message_created', 'Пользователь: какие услуги?');
simulateWebhook('message_created', 'Какие услуги вы предоставляете?');
}, 1000);
break;
}
}
function simulateWebhook(type, text){
var payload = {update_type: type};
if(type === 'bot_started'){
payload.user = {user_id: testUserId, first_name: 'Тест', last_name: 'Пользователь'};
payload.chat_id = testUserId;
} else if(type === 'message_created'){
payload.sender = {user_id: testUserId};
payload.chat_id = testUserId;
payload.body = {text: text, attachments: []};
}
log('webhook', 'Отправка на /webhook (' + type + ')');
fetch('/api/bot/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Max-Bot-Api-Secret': 'aegisone_bot_secret_2026',
},
body: JSON.stringify(payload)
}).then(function(r){ return r.json() }).then(function(d){
log('response', JSON.stringify(d));
if(d && d.ok) log('success', 'Событие обработано');
else log('error', 'Ошибка: ' + JSON.stringify(d));
}).catch(function(err){
log('error', 'Сетевая ошибка: ' + err);
});
}
function clearTest(){
document.getElementById('test-messages').innerHTML = '';
document.getElementById('test-log').innerHTML = '';
document.getElementById('test-input').value = '';
log('info', 'Тест очищен');
}
</script>
</body>
</html>
@@ -0,0 +1,179 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tickets-table { width:100%; border-collapse:collapse; }
.tickets-table th, .tickets-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.tickets-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.tickets-table td { vertical-align:middle; }
.status-badge { display:inline-block; padding:3px 10px; border-radius:12px; font-size:12px; font-weight:600; }
.status-nova { background:#cce5ff; color:#004085; }
.status-v-rabote { background:#fff3cd; color:#856404; }
.status-zakryta { background:#d4edda; color:#155724; }
.status-select { padding:4px 8px; border-radius:6px; border:1px solid var(--border); font-size:12px; }
.ticket-detail-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; display:none; }
.ticket-detail-card.open { display:block; }
.ticket-detail-card h3 { margin-bottom:12px; }
.ticket-detail-card .field { margin-bottom:8px; }
.ticket-detail-card .field label { font-weight:600; font-size:12px; color:var(--text-muted); display:block; }
.ticket-detail-card .field .value { font-size:14px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar select, .filter-bar input { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Заявки от чат-бота</h2>
<span id="ticket-count" style="font-size:13px;color:var(--text-muted);">0 заявок</span>
</div>
<div class="filter-bar">
<select id="status-filter" onchange="filterTickets()">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
<input type="text" id="ticket-search" placeholder="Поиск..." oninput="filterTickets()">
<button class="btn btn-sm" onclick="loadTickets()">Обновить</button>
</div>
<table class="tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Приоритет</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tickets-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="ticket-detail-card" id="ticket-detail">
<h3 id="ticket-detail-title">Заявка #</h3>
<div class="field"><label>Описание</label><div class="value" id="ticket-detail-desc"></div></div>
<div class="field"><label>Приоритет</label><div class="value" id="ticket-detail-priority"></div></div>
<div class="field"><label>Статус</label>
<select id="ticket-detail-status" onchange="updateTicketStatus()">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
</div>
<div class="field"><label>Дата</label><div class="value" id="ticket-detail-date"></div></div>
<button class="btn" onclick="closeDetail()">Закрыть</button>
</div>
<script>
var allTickets = [];
var currentTicketId = null;
function loadTickets(){
var tbody = document.getElementById('tickets-body');
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/tickets').then(function(r){ return r.json() }).then(function(data){
allTickets = data;
document.getElementById('ticket-count').textContent = data.length + ' заявок';
renderTickets(data);
});
}
function renderTickets(tickets){
var tbody = document.getElementById('tickets-body');
if(!tickets.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
return;
}
var html = '';
tickets.forEach(function(t){
var statusClass = 'status-nova';
if(t.status === 'В работе') statusClass = 'status-v-rabote';
else if(t.status === 'Закрыта') statusClass = 'status-zakryta';
var dateStr = t.created_at ? new Date(t.created_at).toLocaleString() : '';
html += '<tr>' +
'<td>' + t.id + '</td>' +
'<td><a href="javascript:void(0)" onclick="showDetail(' + t.id + ')">' + escapeHtml(t.title) + '</a></td>' +
'<td>' + t.priority + '</td>' +
'<td><span class="status-badge ' + statusClass + '">' + t.status + '</span></td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'<td>' +
' <select class="status-select" onchange="changeStatus(' + t.id + ', this.value)">' +
' <option value="">Сменить статус</option>' +
' <option value="Новая">Новая</option>' +
' <option value="В работе">В работе</option>' +
' <option value="Закрыта">Закрыта</option>' +
' </select>' +
'</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterTickets(){
var statusFilter = document.getElementById('status-filter').value;
var search = document.getElementById('ticket-search').value.toLowerCase();
var filtered = allTickets.filter(function(t){
if(statusFilter && t.status !== statusFilter) return false;
if(search && !t.title.toLowerCase().includes(search) && !(t.description || '').toLowerCase().includes(search)) return false;
return true;
});
renderTickets(filtered);
}
function showDetail(id){
currentTicketId = id;
var ticket = allTickets.find(function(t){ return t.id === id; });
if(!ticket) return;
document.getElementById('ticket-detail-title').textContent = 'Заявка #' + ticket.id;
document.getElementById('ticket-detail-desc').textContent = ticket.description || '—';
document.getElementById('ticket-detail-priority').textContent = ticket.priority;
document.getElementById('ticket-detail-status').value = ticket.status;
document.getElementById('ticket-detail-date').textContent = ticket.created_at ? new Date(ticket.created_at).toLocaleString() : '—';
document.getElementById('ticket-detail').classList.add('open');
}
function closeDetail(){
document.getElementById('ticket-detail').classList.remove('open');
currentTicketId = null;
}
function changeStatus(id, status){
if(!status) return;
updateTicketStatusApi(id, status);
}
function updateTicketStatus(){
if(currentTicketId){
var status = document.getElementById('ticket-detail-status').value;
updateTicketStatusApi(currentTicketId, status);
}
}
function updateTicketStatusApi(id, status){
fetch('/api/bot/tickets/' + id, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok){
loadTickets();
if(currentTicketId === id) showDetail(id);
} else {
showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadTickets();
</script>
{% endblock %}
@@ -0,0 +1,281 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.formula-test-panel { margin-top: 16px; }
.formula-description {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
}
.formula-code {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 8px;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--accent);
}
.formula-inputs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.formula-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: var(--radius);
font-weight: 600;
font-size: 14px;
}
.formula-result-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.formula-result-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
}
.accordion { margin-bottom:8px; }
.accordion-header {
display:flex; justify-content:space-between; align-items:center;
padding:10px 14px; background:var(--bg-input); border:1px solid var(--border);
border-radius:var(--radius); cursor:pointer; user-select:none;
}
.accordion-header:hover { background:var(--bg-secondary); }
.accordion-header-left { display:flex; align-items:center; gap:8px; }
.accordion-arrow { font-size:10px; color:var(--text-muted); width:12px; }
.accordion-title { font-weight:600; font-size:13px; }
.accordion-badge {
font-size:11px; background:var(--accent); color:#fff;
border-radius:10px; padding:1px 8px;
}
.accordion-header-right { display:flex; align-items:center; gap:8px; }
.accordion-formula, .accordion-action {
cursor:pointer; font-size:16px; opacity:0.7;
}
.accordion-formula:hover, .accordion-action:hover { opacity:1; }
.accordion-body { display:none; padding:12px 0 4px; }
</style>
<div class="card">
<div class="card-header"><h2>Формулы</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('test')">Тест формул</div>
<div class="tab" onclick="switchTab('coeffs')">Коэффициенты</div>
</div>
<div class="tab-content active" id="tab-test">
<div class="form-group">
<label>Выберите формулу для тестирования</label>
<select id="formula-select" onchange="switchFormula(this.value)" style="max-width:400px">
{% for f in formulas %}
<option value="{{ f.key }}" {% if f.key == selected %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% for f in formulas %}
<div id="formula-{{ f.key }}" class="formula-test-panel" {% if f.key != selected %}style="display:none"{% endif %}>
<div class="formula-description">
<div style="margin-bottom:8px"><strong>Формула:</strong></div>
<div class="formula-code">{{ f.formula }}</div>
<div style="margin-top:8px"><strong>Результат:</strong> {{ f.result }}</div>
</div>
{% if f.sandbox %}
<form class="formula-sandbox-form" onsubmit="return testFormula(event, '{{ f.key }}')">
<h3 style="margin:16px 0 12px;font-size:14px;">Параметры расчёта</h3>
<div class="formula-inputs">
{% for inp in f.sandbox.inputs %}
<div class="form-group">
<label>{{ inp.label }}</label>
{% if inp.type == "checkbox" %}
<input type="checkbox" name="{{ inp.key }}" value="1" style="width:20px;height:20px;accent-color:var(--accent)">
{% elif inp.type == "select" %}
<select name="{{ inp.key }}">
{% for ok, ol in inp.options.items() %}
<option value="{{ ok }}" {% if ok == inp.default %}selected{% endif %}>{{ ol }}</option>
{% endfor %}
</select>
{% else %}
<input type="number" name="{{ inp.key }}" value="{{ inp.default|default(0) }}" step="any">
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Рассчитать</button>
<div class="formula-result" id="result-{{ f.key }}" style="display:none"></div>
</form>
{% else %}
<div class="empty-state" style="padding:24px 0">
<p>Для этой формулы пока нет тестовой песочницы.</p>
</div>
{% endif %}
<h3 style="margin:20px 0 10px;font-size:13px;color:var(--text-muted)">Связанные коэффициенты</h3>
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr></thead>
<tbody>{% for c in f.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
{% endfor %}
</div>
<div class="tab-content" id="tab-coeffs">
{% if groups|length == 0 %}
<div class="card-body"><p class="empty">Нет коэффициентов</p></div>
{% else %}
{% for gkey, gdata in groups.items() %}
<div class="accordion">
<div class="accordion-header" onclick="toggleAccordion(this)">
<div class="accordion-header-left">
<span class="accordion-arrow"></span>
<span class="accordion-title">{{ gdata.info.name }}</span>
<span class="accordion-badge">{{ gdata.coeffs|length }}</span>
</div>
<div class="accordion-header-right">
<span class="accordion-formula" title="Формула" onclick="event.stopPropagation(); showFormulaPopover(this, '{{ gkey }}')" data-formula="{{ gdata.info.formula }}" data-result="{{ gdata.info.result }}">📐</span>
</div>
</div>
<div class="accordion-body">
<div class="table-wrap"><table><thead><tr><th>Ключ</th><th>Значение</th><th>Описание</th><th>Формула</th><th>Активен</th><th class="actions"></th></tr></thead>
<tbody>{% for c in gdata.coeffs %}<tr>
<td><code>{{ c.key }}</code></td>
<td><strong>{{ c.value }}</strong></td>
<td><small>{{ c.description }}</small></td>
<td><small>{{ c.formula_ref }}</small></td>
<td><form method="post" action="/service/coefficients/toggle"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-{{ 'success' if c.is_active else 'secondary' }} btn-sm">{{ '✓' if c.is_active else '✕' }}</button></form></td>
<td class="actions"><button class="btn btn-secondary btn-sm" onclick="editCoeff({{ c.id }},'{{ c.key|e }}','{{ c.value }}','{{ c.description|e }}','{{ c.formula_ref|e }}')"></button>
<form method="post" action="/service/coefficients/delete" style="display:inline" onsubmit="return confirm('Удалить коэффициент?')"><input type="hidden" name="id" value="{{ c.id }}"><button class="btn btn-danger btn-sm"></button></form>
</td>
</tr>{% endfor %}</tbody></table>
</div>
</div>
</div>
{% endfor %}
<div style="margin-top:16px;">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-coeff').classList.add('open')">+ Добавить коэффициент</button>
</div>
{% endif %}
</div>
</div>
<!-- Coefficient edit modal -->
<div class="modal-overlay" id="modal-coeff"><div class="modal">
<button type="button" class="modal-close" onclick="this.closest('.modal-overlay').classList.remove('open')"></button>
<h2 id="modal-coeff-title">Новый коэффициент</h2>
<form method="post" id="modal-coeff-form" action="/service/coefficients/create">
<input type="hidden" name="id" id="coeff-id">
<div class="form-row">
<div class="form-group"><label>Ключ</label><input type="text" name="key" id="coeff-key" required></div>
<div class="form-group"><label>Значение</label><input type="text" name="value" id="coeff-value" required></div>
</div>
<div class="form-group"><label>Описание</label><input type="text" name="description" id="coeff-description"></div>
<div class="form-group"><label>Формула</label><input type="text" name="formula_ref" id="coeff-formula_ref"></div>
<div class="form-actions"><button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('.modal-overlay').classList.remove('open')">Отмена</button></div>
</form>
</div></div>
<!-- Formula info modal -->
<div class="modal-overlay" id="modal-formula-info"><div class="modal modal-sm">
<button type="button" class="modal-close" onclick="document.getElementById('modal-formula-info').classList.remove('open')"></button>
<h2 id="formula-info-title">Формула</h2>
<div id="formula-info-content" style="margin-top:12px"></div>
</div></div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function switchFormula(key) {
document.querySelectorAll('.formula-test-panel').forEach(function(el) {
el.style.display = 'none';
});
var panel = document.getElementById('formula-' + key);
if (panel) panel.style.display = 'block';
}
function testFormula(event, key) {
event.preventDefault();
var form = event.target;
var data = new URLSearchParams(new FormData(form));
var resultBox = document.getElementById('result-' + key);
resultBox.style.display = 'block';
resultBox.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
resultBox.className = 'formula-result';
fetch('/service/coefficients/test/' + key, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: data.toString()
}).then(function(r) { return r.json() }).then(function(resp) {
resultBox.textContent = 'Результат: ' + (resp.result || 'Ошибка');
resultBox.className = 'formula-result formula-result-success';
}).catch(function(err) {
resultBox.textContent = 'Ошибка: ' + err.message;
resultBox.className = 'formula-result formula-result-error';
});
return false;
}
function toggleAccordion(el) {
var arrow = el.querySelector('.accordion-arrow');
var body = el.nextElementSibling;
if (body.style.display === 'none' || body.style.display === '') {
body.style.display = 'block';
arrow.textContent = '▼';
} else {
body.style.display = 'none';
arrow.textContent = '▶';
}
}
function editCoeff(id,key,value,desc,ref){
document.getElementById('modal-coeff').classList.add('open');
document.getElementById('modal-coeff-title').textContent = 'Редактировать коэффициент';
document.getElementById('modal-coeff-form').action = '/service/coefficients/edit';
document.getElementById('coeff-id').value = id;
document.getElementById('coeff-key').value = key;
document.getElementById('coeff-value').value = value;
document.getElementById('coeff-description').value = desc;
document.getElementById('coeff-formula_ref').value = ref;
}
function showFormulaPopover(el, gkey) {
var formula = el.getAttribute('data-formula');
var result = el.getAttribute('data-result');
var title = el.closest('.accordion-header').querySelector('.accordion-title').textContent;
document.getElementById('formula-info-title').textContent = title;
document.getElementById('formula-info-content').innerHTML =
'<div style="margin-bottom:12px"><strong>Формула:</strong></div>' +
'<div style="background:var(--bg-input);padding:12px;border-radius:8px;font-family:monospace;font-size:13px;margin-bottom:12px;white-space:pre-wrap">' + formula + '</div>' +
'<div><strong>Результат:</strong> ' + result + '</div>';
document.getElementById('modal-formula-info').classList.add('open');
}
// Open first accordion by default
document.addEventListener('DOMContentLoaded', function() {
var first = document.querySelector('.accordion-body');
if (first) { first.style.display = 'block'; first.previousElementSibling.querySelector('.accordion-arrow').textContent = '▼'; }
});
</script>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка портала</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('general')">Общие</div>
<div class="tab" onclick="switchTab('documents')">Управление документами</div>
</div>
<div class="tab-content active" id="tab-general">
<form id="portal-settings-form" class="form-grid">
<h3 style="grid-column:1/-1;margin:0 0 8px;">Yandex GPT</h3>
<div class="form-group"><label>Ключ YandexGPT (пусто = ключевые слова)</label>
<input type="text" name="yandex_gpt_key" id="yandex_gpt_key" placeholder="Api-Key ..."></div>
<div class="form-group"><label>Yandex Folder ID</label>
<input type="text" name="yandex_folder_id" id="yandex_folder_id" placeholder="b1g..."></div>
<h3 style="grid-column:1/-1;margin:16px 0 8px;">Yandex Disk</h3>
<div class="form-group"><label>OAuth-токен Яндекс.Диска</label>
<input type="text" name="yandex_disk_token" id="yandex_disk_token"></div>
<div class="form-group"><label>Корневая папка</label>
<input type="text" name="yandex_disk_root" id="yandex_disk_root" placeholder="/AegisOne_Service"></div>
<div class="form-group"><label>Client ID</label>
<input type="text" name="yandex_disk_client_id" id="yandex_disk_client_id"></div>
<div class="form-group"><label>Client Secret</label>
<input type="text" name="yandex_disk_client_secret" id="yandex_disk_client_secret"></div>
<div class="form-actions" style="grid-column:1/-1;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-documents">
<p>Управление доступом к документам:</p>
<a href="/service/documents/admin/permissions" class="btn btn-primary">Перейти к управлению документами</a>
</div>
</div>
<script>
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
document.getElementById('portal-settings-form').addEventListener('submit', function(e){
e.preventDefault();
var data = {};
this.querySelectorAll('input').forEach(function(el){
if(el.name) data[el.name] = el.value;
});
fetch('/service/api/bot/settings/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) showNotification('Настройки сохранены', 'success');
else showNotification('Ошибка сохранения', 'error');
});
});
fetch('/service/api/bot/settings').then(function(r){ return r.json() }).then(function(d){
['yandex_gpt_key','yandex_folder_id','yandex_disk_token','yandex_disk_root',
'yandex_disk_client_id','yandex_disk_client_secret'].forEach(function(k){
var el = document.getElementById(k);
if(el) el.value = d[k] || '';
});
});
</script>
{% endblock %}
@@ -0,0 +1,126 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tab-bar { display:flex; gap:0; margin-bottom:24px; border-bottom:2px solid var(--border); }
.tab-bar .tab { padding:10px 20px; cursor:pointer; border-bottom:2px solid transparent;
margin-bottom:-2px; color:var(--text-muted); font-weight:500; }
.tab-bar .tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.menu-group { margin-bottom:16px; }
.menu-group h4 { margin:0 0 8px; color:var(--text-muted); font-size:13px; text-transform:uppercase; }
.menu-item { display:flex; align-items:center; gap:8px; padding:8px 12px;
border:1px solid var(--border); border-radius:6px; margin-bottom:4px; cursor:pointer; }
.menu-item:hover { background:var(--bg-secondary); }
.menu-item input[type="checkbox"] { width:18px; height:18px; cursor:pointer; }
.menu-item .label { flex:1; }
</style>
<div class="card">
<div class="card-header"><h2>Настройка Ролей</h2></div>
<div class="tab-bar">
<div class="tab active" onclick="switchTab('engineer')">Инженер</div>
<div class="tab" onclick="switchTab('technician')">Техник</div>
</div>
<div class="tab-content active" id="tab-engineer">
<form id="role-form-engineer" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные инженеру:</p>
<div id="menu-engineer"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
<div class="tab-content" id="tab-technician">
<form id="role-form-technician" class="menu-group">
<p style="margin:0 0 12px;color:var(--text-muted);">Отметьте пункты меню, доступные технику:</p>
<div id="menu-technician"></div>
<div class="form-actions" style="margin-top:16px;">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
var MENU_ITEMS = [
// Панель
{key: 'charts', label: 'Графики', section: 'Панель'},
{key: 'ceo', label: 'CEO дашборд', section: 'Панель'},
// Управление
{key: 'customers', label: 'Клиенты', section: 'Управление'},
{key: 'objects', label: 'Объекты', section: 'Управление'},
{key: 'sla', label: 'SLA контракты', section: 'Управление'},
{key: 'questionnaire', label: 'Опросник', section: 'Управление'},
{key: 'passports', label: 'Паспорта объектов', section: 'Управление'},
// Работа
{key: 'users', label: 'Сотрудники', section: 'Работа'},
{key: 'assignments', label: 'Назначение сотрудников', section: 'Работа'},
{key: 'docs', label: 'Просмотр документации', section: 'Работа'},
{key: 'tasks', label: 'Задачи', section: 'Работа'},
{key: 'reports', label: 'Отчёты', section: 'Работа'},
{key: 'incidents', label: 'Инциденты', section: 'Работа'},
{key: 'checklist', label: 'Чек-лист', section: 'Работа'},
{key: 'tech_access', label: 'Доступ техников к документам', section: 'Работа'},
// Контент
{key: 'blog', label: 'Управление блогом', section: 'Контент'},
{key: 'cases', label: 'Примеры из практики', section: 'Контент'},
];
function switchTab(name){
document.querySelectorAll('.tab-content').forEach(function(t){ t.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t){ t.classList.remove('active'); });
document.getElementById('tab-' + name).classList.add('active');
document.querySelector('.tab[onclick*="' + name + '"]').classList.add('active');
}
function loadPermissions(){
fetch('/service/api/role-permissions').then(function(r){ return r.json() }).then(function(data){
['engineer','technician'].forEach(function(role){
var container = document.getElementById('menu-' + role);
container.innerHTML = '';
var permMap = {};
(data[role] || []).forEach(function(p){ permMap[p.menu_key] = p.visible; });
var currentSection = '';
MENU_ITEMS.forEach(function(item){
if(item.section !== currentSection){
currentSection = item.section;
var hdr = document.createElement('h4');
hdr.textContent = item.section;
container.appendChild(hdr);
}
var div = document.createElement('div');
div.className = 'menu-item';
var checked = permMap[item.key] !== false;
div.innerHTML = '<input type="checkbox" name="' + item.key + '" ' + (checked?'checked':'') + '>' +
'<span class="label">' + item.label + '</span>';
div.querySelector('input[type="checkbox"]').addEventListener('change', function(){
div.style.opacity = this.checked ? '1' : '0.5';
});
div.style.opacity = checked ? '1' : '0.5';
container.appendChild(div);
});
});
});
}
['engineer','technician'].forEach(function(role){
document.getElementById('role-form-' + role).addEventListener('submit', function(e){
e.preventDefault();
var perms = [];
this.querySelectorAll('input[type="checkbox"]').forEach(function(cb){
perms.push({menu_key: cb.name, visible: cb.checked});
});
fetch('/service/api/role-permissions/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role: role, permissions: perms})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok) alert('Настройки роли "' + role + '" сохранены');
});
});
});
loadPermissions();
</script>
{% endblock %}
+8 -3
View File
@@ -5,15 +5,16 @@
<button class="btn btn-primary btn-sm" onclick="document.getElementById('modal-user').classList.add('open')">+ Добавить</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<table><thead><tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Телефон</th><th>Email</th><th>MAX ID</th><th>Статус</th><th>Создан</th><th class="actions"></th></tr></thead>
<tbody>{% for u in users %}<tr>
<td>{{ u.login }}</td><td>{{ u.full_name }}</td>
<td><span class="badge badge-{{ u.role }}">{{ {'owner':'Владелец','engineer':'Инженер','technician':'Техник'}[u.role] }}</span></td>
<td>{{ u.phone }}</td><td>{{ u.email }}</td>
<td>{{ u.max_user_id or '—' }}</td>
<td><span class="badge badge-{{ 'active' if u.is_active else 'inactive' }}">{{ 'Активен' if u.is_active else 'Неактивен' }}</span></td>
<td>{{ u.created_at.strftime('%d.%m.%Y') if u.created_at else '' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}',{{ 'true' if u.is_active else 'false' }})"></button>
<button class="btn btn-secondary btn-sm" onclick="editUser({{ u.id }},'{{ u.login }}','{{ u.full_name }}','{{ u.role }}','{{ u.phone }}','{{ u.email }}','{{ u.max_user_id or '' }}',{{ 'true' if u.is_active else 'false' }})"></button>
{% if u.role != 'owner' %}
<form method="post" action="/service/users/delete" style="display:inline" onsubmit="return confirm('Удалить пользователя?')">
<input type="hidden" name="id" value="{{ u.id }}">
@@ -41,6 +42,9 @@
<div class="form-group"><label>Телефон</label><input type="text" name="phone" id="user-phone"></div>
<div class="form-group"><label>Email</label><input type="email" name="email" id="user-email"></div>
</div>
<div class="form-row">
<div class="form-group"><label>MAX ID (мессенджер)</label><input type="number" name="max_user_id" id="user-max_id" placeholder="ID пользователя в MAX"></div>
</div>
<div class="form-check"><input type="checkbox" name="is_active" id="user-is_active" value="1" checked><label for="user-is_active">Активен</label></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Сохранить</button>
@@ -50,7 +54,7 @@
</div>
</div>
<script>
function editUser(id,login,name,role,phone,email,active){
function editUser(id,login,name,role,phone,email,maxId,active){
document.getElementById('modal-user').classList.add('open');
document.getElementById('modal-user-title').textContent = 'Редактировать сотрудника';
document.getElementById('modal-user-form').action = '/service/users/edit';
@@ -59,6 +63,7 @@ function editUser(id,login,name,role,phone,email,active){
document.getElementById('user-full_name').value = name;
document.getElementById('user-role').value = role;
document.getElementById('user-phone').value = phone; document.getElementById('user-email').value = email;
document.getElementById('user-max_id').value = maxId || '';
document.getElementById('user-is_active').checked = active;
document.getElementById('user-password').required = false;
}