feat: Guests API keys. Closes #3

This commit is contained in:
Artem Kokos
2026-03-28 21:20:55 +07:00
parent d024ba78ab
commit 3d8939a6aa
7 changed files with 297 additions and 400 deletions

View File

@@ -1,27 +1,74 @@
import os import os
import logging import logging
from fastapi import Depends, HTTPException, Security from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN from starlette.status import HTTP_403_FORBIDDEN
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
API_KEY = os.getenv("IGNIS_API_KEY") MASTER_KEY = os.getenv("IGNIS_API_KEY")
if not API_KEY: if not MASTER_KEY:
logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!") logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!")
API_KEY_NAME = "X-API-Key" API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
async def verify_token(header_value: str = Depends(api_key_header)): @dataclass
if not API_KEY: class AuthContext:
return None """Результат авторизации -- передаётся в роуты через Depends."""
if header_value == API_KEY: is_master: bool # мастер-ключ из .env
return header_value is_admin: bool # право на CRUD групп, расписания, ресканирование
raise HTTPException( key_name: str # имя ключа (для логов)
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext:
"""
Проверка API-ключа:
1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ
2. Мастер-ключ из .env -- полный доступ
3. Ключ из БД (api_keys) -- проверяем active и is_admin
4. Иначе -- 403
"""
# Авторизация отключена
if not MASTER_KEY:
return AuthContext(is_master=True, is_admin=True, key_name="no-auth")
if not header_value:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="API-ключ не передан")
# Мастер-ключ
if header_value == MASTER_KEY:
return AuthContext(is_master=True, is_admin=True, key_name="master")
# Ищем в БД
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == header_value)
)
api_key = result.scalar_one_or_none()
if api_key and api_key.active:
return AuthContext(
is_master=False,
is_admin=api_key.is_admin,
key_name=api_key.name,
)
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Неверный или деактивированный ключ")
def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext:
"""Dependency для роутов, требующих админских прав."""
if not auth.is_admin:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав")
return auth

View File

@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
from app.api.deps import require_admin, AuthContext
# Все операции с ключами -- только для админов (мастер-ключ)
router = APIRouter(dependencies=[Depends(require_admin)])
@router.get("")
async def list_keys():
"""Список всех гостевых ключей."""
async with async_session() as session:
result = await session.execute(select(ApiKeyModel))
keys = result.scalars().all()
return [
{
"key": k.key,
"name": k.name,
"is_admin": k.is_admin,
"active": k.active,
"created_at": k.created_at,
}
for k in keys
]
@router.post("")
async def create_key(name: str, is_admin: bool = False):
"""Создать гостевой ключ. Возвращает сгенерированный токен."""
new_key = ApiKeyModel(
key=ApiKeyModel.generate_key(),
name=name,
is_admin=is_admin,
)
async with async_session() as session:
session.add(new_key)
await session.commit()
return {
"key": new_key.key,
"name": new_key.name,
"is_admin": new_key.is_admin,
"message": "Сохраните ключ -- он больше не будет показан полностью",
}
@router.delete("/{key}")
async def revoke_key(key: str):
"""Деактивировать (отозвать) гостевой ключ."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
api_key.active = False
session.add(api_key)
await session.commit()
return {"status": "revoked", "name": api_key.name}
@router.post("/{key}/activate")
async def activate_key(key: str):
"""Повторно активировать ключ."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
api_key.active = True
session.add(api_key)
await session.commit()
return {"status": "activated", "name": api_key.name}

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from app.core.state import state_manager, discovery_service from app.core.state import state_manager, discovery_service
from app.core.database import async_session from app.core.database import async_session
from app.models.device import GroupModel, GroupCreateSchema from app.models.device import GroupModel, GroupCreateSchema
from app.api.deps import verify_token from app.api.deps import verify_token, require_admin
from app.drivers.wiz import WizDriver from app.drivers.wiz import WizDriver
# Создаем роутер с защитой # Создаем роутер с защитой
@@ -26,7 +26,7 @@ async def get_scenes():
return wiz.SCENES return wiz.SCENES
@router.post("/groups") @router.post("/groups", dependencies=[Depends(require_admin)])
async def create_group(data: GroupCreateSchema): async def create_group(data: GroupCreateSchema):
async with async_session() as session: async with async_session() as session:
existing = await session.get(GroupModel, data.id) existing = await session.get(GroupModel, data.id)
@@ -40,7 +40,7 @@ async def create_group(data: GroupCreateSchema):
return {"status": "created", "group": data.name} return {"status": "created", "group": data.name}
@router.delete("/groups/{group_id}") @router.delete("/groups/{group_id}", dependencies=[Depends(require_admin)])
async def delete_group(group_id: str): async def delete_group(group_id: str):
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -56,7 +56,7 @@ async def delete_group(group_id: str):
return {"status": "deleted", "id": group_id} return {"status": "deleted", "id": group_id}
@router.post("/rescan") @router.post("/rescan", dependencies=[Depends(require_admin)])
async def rescan_network(): async def rescan_network():
found_devices = await discovery_service.scan_network() found_devices = await discovery_service.scan_network()
for dev_data in found_devices: for dev_data in found_devices:

View File

@@ -8,11 +8,11 @@ from apscheduler.triggers.date import DateTrigger
from app.core.scheduler import app_tz, scheduler from app.core.scheduler import app_tz, scheduler
from app.core.state import state_manager from app.core.state import state_manager
from app.drivers.wiz import WizDriver from app.drivers.wiz import WizDriver
from app.api.deps import verify_token from app.api.deps import require_admin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)]) router = APIRouter(dependencies=[Depends(require_admin)])
async def run_group_command(target_id: str, is_group: bool, params: dict): async def run_group_command(target_id: str, is_group: bool, params: dict):

21
app/models/api_key.py Normal file
View File

@@ -0,0 +1,21 @@
import secrets
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ApiKeyModel(Base):
"""Гостевой API-ключ с ограниченными правами."""
__tablename__ = "api_keys"
key: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String) # "Вася", "гости"
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # доступ к CRUD групп, расписаниям
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[str] = mapped_column(String, default=lambda: datetime.now().isoformat())
@staticmethod
def generate_key() -> str:
"""Генерация безопасного случайного токена."""
return secrets.token_urlsafe(32)

11
main.py
View File

@@ -2,7 +2,7 @@ import logging
import asyncio import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.core.database import init_db, async_session from app.core.database import init_db, async_session
@@ -10,7 +10,8 @@ from app.core.scheduler import start_scheduler
from app.core.state import state_manager, discovery_service from app.core.state import state_manager, discovery_service
from sqlalchemy import select from sqlalchemy import select
from app.models.device import GroupModel from app.models.device import GroupModel
from app.api.routes import devices, control, schedules from app.api.routes import devices, control, schedules, api_keys
from app.api.deps import verify_token
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -48,6 +49,7 @@ app = FastAPI(title="Ignis Core API", lifespan=lifespan)
app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"]) app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"])
app.include_router(control.router, prefix="/control", tags=["Control"]) app.include_router(control.router, prefix="/control", tags=["Control"])
app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"])
app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"])
# Статика # Статика
# Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html
@@ -61,6 +63,11 @@ async def read_index():
return FileResponse("static/index.html") return FileResponse("static/index.html")
@app.get("/auth/me")
async def auth_me(auth = Depends(verify_token)):
return {"is_admin": auth.is_admin, "name": auth.key_name}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -9,75 +9,25 @@
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet">
<style> <style>
:root { :root { --bg-deep: #08090c; --bg-card: rgba(20,22,30,0.8); --border-subtle: rgba(255,255,255,0.06); --border-hover: rgba(249,115,22,0.4); --accent: #f97316; --accent-glow: rgba(249,115,22,0.15); }
--bg-deep: #08090c; body { background: var(--bg-deep); color: #e2e8f0; font-family: 'Outfit', sans-serif; min-height: 100vh; }
--bg-card: rgba(20, 22, 30, 0.8); body::before { content: ''; position: fixed; inset: 0; z-index: -1; background: radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%), radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%); }
--border-subtle: rgba(255,255,255,0.06); .glass { background: var(--bg-card); backdrop-filter: blur(16px); border: 1px solid var(--border-subtle); }
--border-hover: rgba(249, 115, 22, 0.4);
--accent: #f97316;
--accent-glow: rgba(249, 115, 22, 0.15);
}
body {
background: var(--bg-deep);
color: #e2e8f0;
font-family: 'Outfit', sans-serif;
min-height: 100vh;
}
/* Тонкий паттерн на фоне */
body::before {
content: '';
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%);
}
.glass {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
}
.glass:hover { border-color: var(--border-hover); } .glass:hover { border-color: var(--border-hover); }
.active-tab { .active-tab { background: var(--accent); box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15); }
background: var(--accent); input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #1e293b; outline: none; transition: opacity 0.2s; }
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15);
}
/* Слайдеры */
input[type="range"] {
-webkit-appearance: none; height: 6px;
border-radius: 10px; background: #1e293b; outline: none;
transition: opacity 0.2s;
}
input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; } input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid var(--accent); box-shadow: 0 0 8px rgba(0,0,0,0.4); transition: transform 0.15s; }
-webkit-appearance: none; width: 20px; height: 20px;
background: #fff; border-radius: 50%; cursor: pointer;
border: 3px solid var(--accent);
box-shadow: 0 0 8px rgba(0,0,0,0.4);
transition: transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); } input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; } input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; }
.temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; } .temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; }
/* Селекты */ select { appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 1em; }
select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1em;
}
/* Моно для технических данных */
.mono { font-family: 'JetBrains Mono', monospace; } .mono { font-family: 'JetBrains Mono', monospace; }
/* Анимации появления */
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } @keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.fade-up { animation: fadeUp 0.4s ease-out both; } .fade-up { animation: fadeUp 0.4s ease-out both; }
/* Toast-уведомления */
.toast-enter { animation: fadeUp 0.3s ease-out; } .toast-enter { animation: fadeUp 0.3s ease-out; }
.toast-leave { animation: fadeUp 0.3s ease-in reverse; }
/* Пульсация статуса */
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; } .pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
/* Спиннер */
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 0.8s linear infinite; } .spinner { animation: spin 0.8s linear infinite; }
</style> </style>
@@ -85,27 +35,22 @@
<body> <body>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-8"> <div id="app" class="max-w-6xl mx-auto p-4 md:p-8">
<!-- ═══════ Экран авторизации ═══════ --> <!-- Авторизация -->
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4"> <div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
<div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center"> <div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30"> <div class="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg> <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div> </div>
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2> <h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
<p class="text-slate-500 text-xs mb-8">API-ключ из .env сервера</p> <p class="text-slate-500 text-xs mb-8">API-ключ</p>
<input v-model="tempKey" type="password" placeholder="X-API-Key" <input v-model="tempKey" type="password" placeholder="X-API-Key" @keyup.enter="saveKey"
@keyup.enter="saveKey"
class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm"> class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm">
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]"> <button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">ВОЙТИ</button>
ВОЙТИ
</button>
</div> </div>
</div> </div>
<!-- ═══════ Основной интерфейс ═══════ --> <!-- Основной интерфейс -->
<template v-else> <template v-else>
<!-- Хедер -->
<header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up"> <header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-gradient-to-br from-orange-500 to-red-600 p-2.5 rounded-xl shadow-lg shadow-orange-900/20"> <div class="bg-gradient-to-br from-orange-500 to-red-600 p-2.5 rounded-xl shadow-lg shadow-orange-900/20">
@@ -113,101 +58,66 @@
</div> </div>
<div> <div>
<h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1> <h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button> <div class="flex items-center gap-2">
<span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span>
<span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
</div>
</div> </div>
</div> </div>
<nav class="flex glass p-1 rounded-xl"> <nav class="flex glass p-1 rounded-xl">
<button v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]" <button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button>
:key="t.id" @click="tab = t.id" <button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button>
:class="tab === t.id ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" <button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button>
class="px-6 py-2 rounded-lg font-bold text-sm transition-all">
{{ t.label }}
</button>
</nav> </nav>
</header> </header>
<!-- Индикатор загрузки -->
<div v-if="isLoading" class="flex justify-center py-20"> <div v-if="isLoading" class="flex justify-center py-20">
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div> <div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
</div> </div>
<!-- ═══════ ПУЛЬТ ═══════ --> <!-- ПУЛЬТ -->
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl"> <div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl">
<p class="text-slate-500">Создайте группу в админке</p> <p v-if="isAdmin" class="text-slate-500">Создайте группу в админке</p>
<p v-else class="text-slate-500">Нет доступных групп</p>
</div> </div>
<div v-for="(group, id) in groups" :key="id" <div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
class="glass p-6 rounded-2xl transition-all fade-up"
:style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
<!-- Шапка группы -->
<div class="flex justify-between items-start mb-6"> <div class="flex justify-between items-start mb-6">
<div> <div>
<h2 class="text-xl font-black text-white flex items-center gap-2"> <h2 class="text-xl font-black text-white flex items-center gap-2">
{{ group.name }} {{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" <span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2 h-2 rounded-full inline-block"></span>
class="w-2 h-2 rounded-full inline-block"></span>
</h2> </h2>
<span class="text-[10px] mono text-slate-600"> <span class="text-[10px] mono text-slate-600">{{ id }} · {{ group.device_ids?.length || 0 }} ламп</span>
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
</span>
</div> </div>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
<button @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить"> <button v-if="isAdmin" @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
<button @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="Включить на 4 часа"> <button v-if="isAdmin" @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="4 часа"></button>
<button @click="toggleGroup(id, true)" :class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
</button> <button @click="toggleGroup(id, false)" :class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
<button @click="toggleGroup(id, true)"
:class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)"
:class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
</div> </div>
</div> </div>
<!-- Управление -->
<div class="space-y-5"> <div class="space-y-5">
<!-- Яркость -->
<div> <div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"> <div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Яркость</span><span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span></div>
<span>Яркость</span> <input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[id]?.state" :value="sliders[id]?.brightness || 100" @change="setBrightness(id, +$event.target.value)">
<span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span>
</div>
<input type="range" min="10" max="100" step="10" class="w-full"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.brightness || 100"
@change="setBrightness(id, +$event.target.value)">
</div> </div>
<!-- Температура -->
<div> <div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"> <div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Температура</span><span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span></div>
<span>Температура</span> <input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[id]?.state" :value="sliders[id]?.temp || 4000" @change="setTemp(id, +$event.target.value)">
<span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span>
</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.temp || 4000"
@change="setTemp(id, +$event.target.value)">
</div> </div>
<!-- Цвет и Сцена -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label> <label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label>
<input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer" <input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer" :disabled="!sliders[id]?.state" @input="setColor(id, $event.target.value)">
:disabled="!sliders[id]?.state"
@input="setColor(id, $event.target.value)">
</div> </div>
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label> <label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
<select @change="setScene(id, $event.target.value); $event.target.value=''" <select @change="setScene(id, $event.target.value); $event.target.value=''" :disabled="!sliders[id]?.state" class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
:disabled="!sliders[id]?.state"
class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
<option value="" disabled selected>Пресет...</option> <option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option> <option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select> </select>
@@ -217,10 +127,8 @@
</div> </div>
</div> </div>
<!-- ═══════ РАСПИСАНИЯ ═══════ --> <!-- РАСПИСАНИЯ (только админ) -->
<div v-if="tab === 'schedules' && !isLoading" class="space-y-8 fade-up"> <div v-if="tab === 'schedules' && !isLoading && isAdmin" class="space-y-8 fade-up">
<!-- Создание -->
<div class="glass p-6 rounded-2xl"> <div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2> <h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3"> <div class="grid grid-cols-1 md:grid-cols-4 gap-3">
@@ -228,7 +136,6 @@
<option value="" disabled>Группа...</option> <option value="" disabled>Группа...</option>
<option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option> <option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option>
</select> </select>
<div class="flex items-center gap-1 bg-black/30 border border-slate-700/50 p-1 rounded-xl"> <div class="flex items-center gap-1 bg-black/30 border border-slate-700/50 p-1 rounded-xl">
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm"> <select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option> <option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
@@ -238,35 +145,24 @@
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option> <option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select> </select>
</div> </div>
<select v-model="newTask.state" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none text-sm"> <select v-model="newTask.state" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none text-sm">
<option :value="true">ВКЛЮЧИТЬ</option> <option :value="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option> <option :value="false">ВЫКЛЮЧИТЬ</option>
</select> </select>
<button @click="addSchedule" :disabled="!newTask.target_id" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">ДОБАВИТЬ</button>
<button @click="addSchedule"
:disabled="!newTask.target_id"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
ДОБАВИТЬ
</button>
</div> </div>
</div> </div>
<!-- Список задач -->
<div class="glass p-6 rounded-2xl"> <div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2> <h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="task in tasks" :key="task.id" <div v-for="task in tasks" :key="task.id" class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div> <div>
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">
<span class="mono text-orange-400 text-lg font-bold"> <span class="mono text-orange-400 text-lg font-bold">
<template v-if="task.hour != null">{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}</template> <template v-if="task.hour != null">{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}</template>
<template v-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template> <template v-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template>
</span> </span>
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase"> <span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}</span>
{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
</span>
</div> </div>
<div class="flex items-center gap-2 text-[10px] text-slate-600"> <div class="flex items-center gap-2 text-[10px] text-slate-600">
<span class="mono">{{ task.target_id }}</span> <span class="mono">{{ task.target_id }}</span>
@@ -274,8 +170,7 @@
<span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span> <span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span>
</div> </div>
</div> </div>
<button @click="deleteTask(task.id)" <button @click="deleteTask(task.id)" class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
</div> </div>
@@ -284,34 +179,22 @@
</div> </div>
</div> </div>
<!-- ═══════ АДМИНКА ═══════ --> <!-- АДМИНКА (только админ) -->
<div v-if="tab === 'admin' && !isLoading" class="space-y-8 fade-up"> <div v-if="tab === 'admin' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="glass p-6 rounded-2xl"> <section class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-black uppercase">Устройства в сети</h2> <h2 class="text-lg font-black uppercase">Устройства в сети</h2>
<button @click="rescan" :disabled="isRescanning" <button @click="rescan" :disabled="isRescanning" class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
<span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ <span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ
</button> </button>
</div> </div>
<!-- Создание группы -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<input v-model="newGroup.id" placeholder="ID (bedroom)" <input v-model="newGroup.id" placeholder="ID (bedroom)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono"> <input v-model="newGroup.name" placeholder="Название (Спальня)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<input v-model="newGroup.name" placeholder="Название (Спальня)" <button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">СОЗДАТЬ ГРУППУ</button>
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
СОЗДАТЬ ГРУППУ
</button>
</div> </div>
<!-- Список устройств -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="dev in devices" :key="dev.id" <div v-for="dev in devices" :key="dev.id" :class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'" class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
:class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'"
class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<label class="flex items-center gap-3 cursor-pointer flex-1"> <label class="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded"> <input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div> <div>
@@ -324,22 +207,14 @@
</button> </button>
</div> </div>
</div> </div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">Устройства не найдены. Нажмите "Сканировать".</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">
Устройства не найдены. Нажмите "Сканировать".
</div>
</section> </section>
</div> </div>
</template> </template>
<!-- ═══════ Toast-уведомления ═══════ --> <!-- Toast -->
<div class="fixed bottom-6 right-6 z-50 space-y-2"> <div class="fixed bottom-6 right-6 z-50 space-y-2">
<div v-for="(toast, i) in toasts" :key="toast.id" <div v-for="(toast, i) in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'" class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">{{ toast.text }}</div>
:class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'"
class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">
{{ toast.text }}
</div>
</div> </div>
</div> </div>
@@ -351,260 +226,122 @@
apiKey: localStorage.getItem('ignis_key') || '', apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '', tempKey: '',
tab: 'control', tab: 'control',
groups: {}, isAdmin: false,
devices: [], authName: '',
sliders: {}, groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] }, newGroup: { id: '', name: '', macs: [] },
isLoading: false, isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
isLoadingStatus: false, taskHour: '22', taskMin: '00',
isFetching: false, // защита от параллельных fetchData
isRescanning: false,
taskHour: '22',
taskMin: '00',
newTask: { target_id: '', state: true }, newTask: { target_id: '', state: true },
tasks: [], tasks: [], allScenes: {},
allScenes: {}, // загружается с бэкенда toasts: [], toastCounter: 0,
toasts: [],
toastCounter: 0,
} }
}, },
methods: { methods: {
// ─── Утилиты ─────────────────────────────────
saveKey() { saveKey() {
if (this.tempKey) { if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
this.apiKey = this.tempKey;
localStorage.setItem('ignis_key', this.tempKey);
this.fetchData();
}
}, },
logout() { logout() { this.apiKey = ''; this.isAdmin = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
this.apiKey = '';
localStorage.removeItem('ignis_key');
location.reload();
},
// Toast-уведомление
toast(text, type = 'info', duration = 3000) { toast(text, type = 'info', duration = 3000) {
const id = ++this.toastCounter; const id = ++this.toastCounter; this.toasts.push({ id, text, type });
this.toasts.push({ id, text, type }); setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration);
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, duration);
},
// Имя группы по id (для расписаний)
getGroupName(targetId) {
const g = this.groups[targetId];
return g ? g.name : null;
}, },
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
// ─── HTTP ────────────────────────────────────
async request(path, method = 'GET', params = null, body = null) { async request(path, method = 'GET', params = null, body = null) {
let url = path; let url = path;
if (params) { if (params) url += `?${new URLSearchParams(params).toString()}`;
const q = new URLSearchParams(params).toString();
url += `?${q}`;
}
try { try {
const response = await fetch(url, { const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
method, if (r.status === 403) {
headers: { const err = await r.json().catch(() => ({}));
'X-API-Key': this.apiKey, if (err.detail === 'Недостаточно прав') { this.toast('Нет прав', 'error'); return null; }
'Content-Type': 'application/json' this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
},
body: body ? JSON.stringify(body) : null
});
if (response.status === 403) {
this.toast('Неверный API-ключ', 'error');
this.logout();
return null;
} }
if (!response.ok) { if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
const err = await response.json().catch(() => ({})); return await r.json();
this.toast(err.detail || `Ошибка ${response.status}`, 'error'); } catch (e) { this.toast('Сервер недоступен', 'error'); return null; }
return null; },
}
return await response.json(); async initApp() {
} catch (e) { this.isLoading = true;
this.toast('Сервер недоступен', 'error'); const auth = await this.request('/auth/me');
return null; if (!auth) { this.isLoading = false; return; }
} this.isAdmin = auth.is_admin;
this.authName = auth.name;
await this.fetchData();
this.isLoading = false;
setInterval(() => this.fetchData(), 15000);
}, },
// ─── Данные ──────────────────────────────────
async fetchData() { async fetchData() {
if (!this.apiKey || this.isFetching) return; if (!this.apiKey || this.isFetching) return;
this.isFetching = true; this.isFetching = true;
try { try {
const [gData, dData, sData] = await Promise.all([ const [gData, dData, sData] = await Promise.all([
this.request('/devices/groups'), this.request('/devices/groups'), this.request('/devices'), this.request('/devices/scenes'),
this.request('/devices'),
this.request('/devices/scenes'),
]); ]);
if (gData) { if (gData) {
this.groups = gData; this.groups = gData;
// Инициализируем слайдеры для новых групп Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
Object.keys(this.groups).forEach(id => {
if (!this.sliders[id]) {
this.sliders[id] = { brightness: 100, temp: 4000, state: false };
}
});
await this.syncGroupStatuses(); await this.syncGroupStatuses();
} }
if (dData) { if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
// Бэкенд может вернуть dict или list
this.devices = Array.isArray(dData) ? dData : Object.values(dData);
}
// Сцены с бэкенда (dict: {name: sceneId})
if (sData) this.allScenes = sData; if (sData) this.allScenes = sData;
this.fetchTasks(); if (this.isAdmin) this.fetchTasks();
} finally { } finally { this.isFetching = false; }
this.isFetching = false;
}
}, },
// ─── Управление ────────────────────────────── async control(id, params) { await this.request(`/control/group/${id}`, 'POST', params); },
async control(id, params) { toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); },
await this.request(`/control/group/${id}`, 'POST', params); setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); },
// Не делаем полный syncGroupStatuses -- ждём следующий цикл setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); },
},
toggleGroup(id, state) {
if (this.sliders[id]) {
this.sliders[id].state = state;
}
this.control(id, { state });
},
setBrightness(id, val) {
if (this.sliders[id]) this.sliders[id].brightness = val;
this.control(id, { brightness: val });
},
setTemp(id, val) {
if (this.sliders[id]) this.sliders[id].temp = val;
this.control(id, { temp: val });
},
setScene(id, scene) { this.control(id, { scene }); }, setScene(id, scene) { this.control(id, { scene }); },
setColor(id, hex) { setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
this.control(id, { r, g, b });
},
// ─── Группы ──────────────────────────────────
async createGroup() { async createGroup() {
const res = await this.request('/devices/groups', 'POST', null, { const res = await this.request('/devices/groups', 'POST', null, { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs });
id: this.newGroup.id, if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
name: this.newGroup.name,
macs: this.newGroup.macs
});
if (res) {
this.toast(`Группа "${this.newGroup.name}" создана`, 'success');
this.newGroup = { id: '', name: '', macs: [] };
await this.fetchData();
this.tab = 'control';
}
}, },
async deleteGroup(id) { async deleteGroup(id) {
const name = this.groups[id]?.name || id; const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) { if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, 'DELETE'); this.toast(`Удалена`, 'success'); await this.fetchData(); }
await this.request(`/devices/groups/${id}`, 'DELETE');
this.toast(`Группа "${name}" удалена`, 'success');
await this.fetchData();
}
}, },
async rescan() { async rescan() {
this.isRescanning = true; this.isRescanning = true; await this.request('/devices/rescan', 'POST'); this.toast('Сканирование...', 'info');
await this.request('/devices/rescan', 'POST'); setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
this.toast('Сканирование запущено...', 'info');
setTimeout(async () => {
await this.fetchData();
this.isRescanning = false;
this.toast(`Найдено ${this.devices.length} устройств`, 'success');
}, 3000);
}, },
async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); }, async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); },
// ─── Синхронизация состояния ─────────────────
async syncGroupStatuses() { async syncGroupStatuses() {
if (this.isLoadingStatus) return; if (this.isLoadingStatus) return; this.isLoadingStatus = true;
this.isLoadingStatus = true;
try { try {
// Параллельный опрос всех групп const ids = Object.keys(this.groups);
const groupIds = Object.keys(this.groups); const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`)));
const results = await Promise.all( ids.forEach((id, i) => {
groupIds.map(id => this.request(`/control/group/${id}/status`)) const d = results[i];
); if (d?.results?.length > 0) {
const v = d.results.find(r => r.status && !r.error);
groupIds.forEach((id, i) => { if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false };
const data = results[i];
if (data && data.results && data.results.length > 0) {
const firstValid = data.results.find(r => r.status && !r.error);
if (firstValid) {
const s = firstValid.status;
this.sliders[id] = {
brightness: s.dimming || 100,
temp: s.temp || 4000,
state: s.state || false,
};
}
} }
}); });
} finally { } finally { this.isLoadingStatus = false; }
this.isLoadingStatus = false;
}
}, },
// ─── Расписания ────────────────────────────── async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; },
async fetchTasks() {
const data = await this.request('/schedules/tasks');
if (data) this.tasks = data.tasks || [];
},
async addSchedule() { async addSchedule() {
if (!this.newTask.target_id) { if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; }
this.toast('Выберите группу', 'error'); const res = await this.request('/schedules/cron', 'POST', { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state });
return; if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); }
}
const res = await this.request('/schedules/cron', 'POST', {
target_id: this.newTask.target_id,
hour: this.taskHour,
minute: this.taskMin,
is_group: true,
state: this.newTask.state
});
if (res) {
this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success');
this.fetchTasks();
}
},
async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE');
this.toast('Задача отменена', 'success');
this.fetchTasks();
}, },
async deleteTask(id) { await this.request(`/schedules/${id}`, 'DELETE'); this.toast('Отменено', 'success'); this.fetchTasks(); },
async setTimer4h(id) { async setTimer4h(id) {
await this.toggleGroup(id, true); await this.toggleGroup(id, true);
const res = await this.request('/schedules/once', 'POST', { const res = await this.request('/schedules/once', 'POST', { target_id: id, hours_from_now: 4, is_group: true, state: false });
target_id: id, if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
hours_from_now: 4,
is_group: true,
state: false
});
if (res) {
this.toast('Таймер 4ч установлен', 'success');
this.fetchTasks();
}
}, },
}, },
async mounted() { async mounted() { if (this.apiKey) await this.initApp(); }
if (this.apiKey) {
this.isLoading = true;
await this.fetchData();
this.isLoading = false;
// Периодический опрос с защитой от наложений
setInterval(() => this.fetchData(), 15000);
}
}
}).mount('#app') }).mount('#app')
</script> </script>
</body> </body>