Vibecoded fixes & readme
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# API-ключ для авторизации (если не задан -- авторизация отключена)
|
||||||
|
IGNIS_API_KEY=
|
||||||
|
|
||||||
|
# Таймзона для расписаний
|
||||||
|
APP_TIMEZONE=Asia/Almaty
|
||||||
|
|
||||||
|
# Подсети для сканирования (через запятую, пусто = автоопределение)
|
||||||
|
SCAN_NETWORK=
|
||||||
|
|
||||||
|
# Уровень логирования (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
139
README.md
Normal file
139
README.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Ignis Core
|
||||||
|
|
||||||
|
Self-hosted сервер для управления умными лампами WiZ по локальной сети. FastAPI бэкенд с веб-интерфейсом, планировщиком расписаний и REST API для мобильного приложения.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Discovery** -- автоматическое обнаружение ламп WiZ в локальной сети (UDP broadcast). Поддержка нескольких подсетей через `SCAN_NETWORK`.
|
||||||
|
- **Группы** -- объединение ламп в именованные группы (спальня, кухня, ...). Хранение в SQLite.
|
||||||
|
- **Управление** -- включение/выключение, яркость, цветовая температура, RGB-цвет, 35+ встроенных сцен.
|
||||||
|
- **Расписания** -- одноразовые таймеры и cron-задачи через APScheduler с персистентным хранилищем.
|
||||||
|
- **Веб-интерфейс** -- SPA на Vue 3 + Tailwind, встроен в сервер как статика.
|
||||||
|
- **API** -- REST API с авторизацией по API-ключу для мобильных клиентов.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонировать
|
||||||
|
git clone https://git.akokos.ru/artem.kokos/ignis-core.git
|
||||||
|
cd ignis-core
|
||||||
|
|
||||||
|
# Виртуальное окружение
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Конфигурация
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактировать .env -- указать API-ключ и таймзону
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервер будет доступен на `http://<ip>:8000`. Веб-интерфейс -- на корневом URL.
|
||||||
|
|
||||||
|
## Конфигурация (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# API-ключ для авторизации (если не задан -- авторизация отключена)
|
||||||
|
IGNIS_API_KEY=your-secret-key
|
||||||
|
|
||||||
|
# Таймзона для расписаний (по умолчанию Asia/Novosibirsk)
|
||||||
|
APP_TIMEZONE=Asia/Almaty
|
||||||
|
|
||||||
|
# Подсети для сканирования (через запятую, по умолчанию -- автоопределение)
|
||||||
|
SCAN_NETWORK=192.168.1.0/24
|
||||||
|
|
||||||
|
# Уровень логирования
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
ignis-core/
|
||||||
|
├── main.py -- точка входа FastAPI, lifespan
|
||||||
|
├── requirements.txt -- зависимости
|
||||||
|
├── .env -- конфигурация (не в git)
|
||||||
|
├── static/
|
||||||
|
│ └── index.html -- веб-интерфейс (Vue 3 SPA)
|
||||||
|
├── app/
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── database.py -- async SQLAlchemy, SQLite
|
||||||
|
│ │ ├── discovery.py -- UDP-сканирование сети WiZ
|
||||||
|
│ │ ├── scheduler.py -- APScheduler + jobstore
|
||||||
|
│ │ └── state.py -- in-memory состояние (устройства, группы)
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── device.py -- модели Device, Group (SQLAlchemy + Pydantic)
|
||||||
|
│ │ └── schedule.py -- модель ScheduleTask
|
||||||
|
│ ├── drivers/
|
||||||
|
│ │ └── wiz.py -- UDP-драйвер протокола WiZ
|
||||||
|
│ └── api/
|
||||||
|
│ ├── deps.py -- авторизация (X-API-Key)
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── devices.py -- CRUD устройств и групп
|
||||||
|
│ ├── control.py -- управление лампами
|
||||||
|
│ └── schedules.py -- расписания (once, cron)
|
||||||
|
└── ignis.db -- SQLite база (создаётся автоматически)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Авторизация: заголовок `X-API-Key`.
|
||||||
|
|
||||||
|
### Устройства и группы
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|--------|--------------------------|------------------------------|
|
||||||
|
| GET | `/devices` | Все обнаруженные лампы |
|
||||||
|
| GET | `/devices/groups` | Все группы |
|
||||||
|
| GET | `/devices/scenes` | Доступные сцены WiZ |
|
||||||
|
| POST | `/devices/groups` | Создать группу |
|
||||||
|
| DELETE | `/devices/groups/{id}` | Удалить группу |
|
||||||
|
| POST | `/devices/rescan` | Пересканировать сеть |
|
||||||
|
|
||||||
|
Создание группы (JSON body):
|
||||||
|
```json
|
||||||
|
{"id": "bedroom", "name": "Спальня", "macs": ["a8bb50aabbcc", "a8bb50ddeeff"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|-------|----------------------------------|------------------------|
|
||||||
|
| POST | `/control/device/{id}` | Управление лампой |
|
||||||
|
| POST | `/control/group/{id}` | Управление группой |
|
||||||
|
| POST | `/control/device/{id}/blink` | Мигнуть лампой |
|
||||||
|
| GET | `/control/device/{id}/status` | Статус лампы |
|
||||||
|
| GET | `/control/group/{id}/status` | Статус группы |
|
||||||
|
|
||||||
|
Query-параметры управления: `state` (bool), `brightness` (int, 10--100), `temp` (int, 2700--6500), `scene` (string), `r`/`g`/`b` (int, 0--255).
|
||||||
|
|
||||||
|
### Расписания
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|--------|-----------------------|-------------------------|
|
||||||
|
| POST | `/schedules/once` | Одноразовый таймер |
|
||||||
|
| POST | `/schedules/cron` | Повторяющаяся задача |
|
||||||
|
| GET | `/schedules/tasks` | Все активные задачи |
|
||||||
|
| DELETE | `/schedules/{job_id}` | Отменить задачу |
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- **FastAPI** -- async HTTP-сервер
|
||||||
|
- **SQLAlchemy 2.0** -- async ORM, SQLite через aiosqlite
|
||||||
|
- **APScheduler** -- планировщик с персистентным хранилищем
|
||||||
|
- **WiZ Protocol** -- UDP-управление лампами (порт 38899)
|
||||||
|
- **Vue 3 + Tailwind** -- встроенный веб-интерфейс
|
||||||
|
|
||||||
|
## Клиенты
|
||||||
|
|
||||||
|
- Веб-интерфейс -- встроен в сервер (`static/index.html`)
|
||||||
|
- [Ignis App](https://git.akokos.ru/artem.kokos/ignis_app) -- мобильное приложение (Flutter)
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Частный проект.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import Depends, HTTPException, Security
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
from starlette.status import HTTP_403_FORBIDDEN
|
from starlette.status import HTTP_403_FORBIDDEN
|
||||||
@@ -6,7 +7,12 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
API_KEY = os.getenv("IGNIS_API_KEY")
|
API_KEY = os.getenv("IGNIS_API_KEY")
|
||||||
|
if not 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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
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 verify_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||||
wiz = WizDriver()
|
wiz = WizDriver()
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
import asyncio
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.date import DateTrigger
|
from apscheduler.triggers.date import DateTrigger
|
||||||
|
|
||||||
from app.core.scheduler import app_tz, scheduler, execute_lamp_command
|
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 verify_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(verify_token)])
|
router = APIRouter(dependencies=[Depends(verify_token)])
|
||||||
wiz = WizDriver()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_delayed_command(ips: list[str], state: bool):
|
async def run_group_command(target_id: str, is_group: bool, params: dict):
|
||||||
"""Вспомогательная функция для разовых задач"""
|
"""
|
||||||
|
Универсальное выполнение команды по расписанию.
|
||||||
|
IP резолвится в момент выполнения, а не создания задачи --
|
||||||
|
корректно работает при смене IP (DHCP) и изменении состава группы.
|
||||||
|
"""
|
||||||
|
if is_group:
|
||||||
|
ips = state_manager.get_group_ips(target_id)
|
||||||
|
else:
|
||||||
|
dev = state_manager.devices.get(target_id)
|
||||||
|
ips = [dev.ip] if dev else []
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
logger.warning(f"Расписание: цель {target_id} не найдена (0 IP)")
|
||||||
|
return
|
||||||
|
|
||||||
local_wiz = WizDriver()
|
local_wiz = WizDriver()
|
||||||
for ip in ips:
|
for ip in ips:
|
||||||
try:
|
try:
|
||||||
await local_wiz.set_pilot(ip, {"state": state})
|
await local_wiz.set_pilot(ip, params)
|
||||||
except Exception:
|
logger.info(f"⏰ Расписание: {target_id} -> {ip}: {params}")
|
||||||
pass # Игнорим ошибки отдельных ламп
|
except Exception as e:
|
||||||
|
logger.error(f"⏰ Расписание: ошибка {ip}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/once")
|
@router.post("/once")
|
||||||
@@ -43,23 +59,21 @@ async def schedule_once(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
|
raise HTTPException(status_code=400, detail="Нужно время или отступ в часах")
|
||||||
|
|
||||||
# 2. Получаем IP
|
# 2. Проверяем что цель существует (но IP резолвится при выполнении)
|
||||||
if is_group:
|
if is_group:
|
||||||
ips = state_manager.get_group_ips(target_id)
|
if target_id not in state_manager.groups:
|
||||||
|
raise HTTPException(status_code=404, detail="Группа не найдена")
|
||||||
else:
|
else:
|
||||||
dev = state_manager.devices.get(target_id)
|
if target_id not in state_manager.devices:
|
||||||
ips = [dev.ip] if dev else []
|
raise HTTPException(status_code=404, detail="Устройство не найдено")
|
||||||
|
|
||||||
if not ips:
|
|
||||||
raise HTTPException(status_code=404, detail="Цель не найдена")
|
|
||||||
|
|
||||||
# 3. Регаем задачу
|
# 3. Регаем задачу
|
||||||
job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
|
job_id = f"once_{target_id}_{int(exec_time.timestamp())}"
|
||||||
|
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
run_delayed_command,
|
run_group_command,
|
||||||
trigger=DateTrigger(run_date=exec_time, timezone=app_tz),
|
trigger=DateTrigger(run_date=exec_time, timezone=app_tz),
|
||||||
args=[ips, state],
|
args=[target_id, is_group, {"state": state}],
|
||||||
id=job_id,
|
id=job_id,
|
||||||
name=f"Once: {target_id} | {state}",
|
name=f"Once: {target_id} | {state}",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
@@ -77,33 +91,31 @@ async def add_cron_task(
|
|||||||
is_group: bool = True,
|
is_group: bool = True,
|
||||||
state: bool = True,
|
state: bool = True,
|
||||||
):
|
):
|
||||||
ips = state_manager.get_group_ips(target_id) if is_group else []
|
# Проверяем что цель существует
|
||||||
if not is_group:
|
if is_group:
|
||||||
dev = state_manager.devices.get(target_id)
|
if target_id not in state_manager.groups:
|
||||||
if dev:
|
raise HTTPException(status_code=404, detail="Группа не найдена")
|
||||||
ips = [dev.ip]
|
else:
|
||||||
|
if target_id not in state_manager.devices:
|
||||||
|
raise HTTPException(status_code=404, detail="Устройство не найдено")
|
||||||
|
|
||||||
if not ips:
|
# Одна задача на всю группу -- IP резолвятся при каждом срабатывании
|
||||||
raise HTTPException(status_code=404, detail="Цель не найдена")
|
|
||||||
|
|
||||||
# Используем таймзону приложения для крона
|
|
||||||
trigger = CronTrigger(
|
trigger = CronTrigger(
|
||||||
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz
|
hour=hour, minute=minute, day_of_week=day_of_week, timezone=app_tz
|
||||||
)
|
)
|
||||||
|
|
||||||
job_ids = []
|
job_id = f"cron_{target_id}_{hour}_{minute}"
|
||||||
for ip in ips:
|
|
||||||
job = scheduler.add_job(
|
|
||||||
execute_lamp_command,
|
|
||||||
trigger,
|
|
||||||
args=[ip, {"state": state}],
|
|
||||||
id=f"cron_{target_id}_{ip}_{hour}_{minute}",
|
|
||||||
name=f"CRON: {target_id} | {hour}:{minute} | {state}",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
job_ids.append(job.id)
|
|
||||||
|
|
||||||
return {"status": "cron_scheduled", "jobs": job_ids}
|
scheduler.add_job(
|
||||||
|
run_group_command,
|
||||||
|
trigger,
|
||||||
|
args=[target_id, is_group, {"state": state}],
|
||||||
|
id=job_id,
|
||||||
|
name=f"CRON: {target_id} | {hour}:{minute} | {state}",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "cron_scheduled", "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
@@ -119,13 +131,13 @@ async def get_all_tasks():
|
|||||||
next_run_str = None
|
next_run_str = None
|
||||||
|
|
||||||
if job.next_run_time:
|
if job.next_run_time:
|
||||||
# ПЕРЕВОДИМ ИЗ UTC В ЛОКАЛЬНУЮ ТАЙМЗОНУ ДЛЯ ВЫВОДА
|
# Переводим из UTC в локальную таймзону для вывода
|
||||||
local_time = job.next_run_time.astimezone(app_tz)
|
local_time = job.next_run_time.astimezone(app_tz)
|
||||||
h = str(local_time.hour).zfill(2)
|
h = str(local_time.hour).zfill(2)
|
||||||
m = str(local_time.minute).zfill(2)
|
m = str(local_time.minute).zfill(2)
|
||||||
next_run_str = local_time.isoformat()
|
next_run_str = local_time.isoformat()
|
||||||
|
|
||||||
# Если это крон, подтягиваем значения из триггера (они там как строки)
|
# Если это крон, подтягиваем значения из триггера
|
||||||
if hasattr(job.trigger, "fields"):
|
if hasattr(job.trigger, "fields"):
|
||||||
h = str(job.trigger.fields[5])
|
h = str(job.trigger.fields[5])
|
||||||
m = str(job.trigger.fields[6])
|
m = str(job.trigger.fields[6])
|
||||||
@@ -148,5 +160,5 @@ async def cancel_task(job_id: str):
|
|||||||
try:
|
try:
|
||||||
scheduler.remove_job(job_id)
|
scheduler.remove_job(job_id)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
except:
|
except Exception:
|
||||||
raise HTTPException(status_code=404, detail="Задача не найдена")
|
raise HTTPException(status_code=404, detail="Задача не найдена")
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class WizDriver:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def send_udp(self, ip: str, payload: dict):
|
async def send_udp(self, ip: str, payload: dict):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
sock.settimeout(2.0)
|
sock.settimeout(2.0)
|
||||||
data = json.dumps(payload).encode()
|
data = json.dumps(payload).encode()
|
||||||
|
|||||||
@@ -4,175 +4,212 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IGNIS | Smart Control</title>
|
<title>IGNIS | Smart Control</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&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 {
|
||||||
|
--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);
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
background: radial-gradient(circle at top right, #1e1b4b, #0f172a);
|
background: var(--bg-deep);
|
||||||
color: #f8fafc;
|
color: #e2e8f0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Outfit', sans-serif;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.glass { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1); }
|
/* Тонкий паттерн на фоне */
|
||||||
.active-tab { background: #f97316; box-shadow: 0 0 20px rgba(249, 115, 22, 0.4); }
|
body::before {
|
||||||
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #334155; outline: none; }
|
content: '';
|
||||||
input[type="range"]::-webkit-slider-thumb {
|
position: fixed; inset: 0; z-index: -1;
|
||||||
-webkit-appearance: none; width: 18px; height: 18px;
|
background:
|
||||||
background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid #f97316;
|
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); }
|
||||||
|
.active-tab {
|
||||||
|
background: var(--accent);
|
||||||
|
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"]::-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;
|
||||||
|
}
|
||||||
|
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; }
|
||||||
.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='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 1rem 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; }
|
||||||
|
/* Анимации появления */
|
||||||
|
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
.fade-up { animation: fadeUp 0.4s ease-out both; }
|
||||||
|
/* Toast-уведомления */
|
||||||
|
.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; } }
|
||||||
|
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
|
||||||
|
/* Спиннер */
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.spinner { animation: spin 0.8s linear infinite; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="max-w-6xl mx-auto p-4 md:p-10">
|
<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-slate-950/90 backdrop-blur-sm p-4">
|
<!-- ═══════ Экран авторизации ═══════ -->
|
||||||
<div class="glass p-8 rounded-[2.5rem] w-full max-w-md shadow-2xl">
|
<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="text-center mb-8">
|
<div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
|
||||||
<span class="text-5xl mb-4 block">🔐</span>
|
<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">
|
||||||
<h2 class="text-2xl font-black uppercase tracking-tighter">Ignis Access</h2>
|
<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>
|
||||||
<p class="text-slate-400 text-sm mt-2">Введите API ключ из .env</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
<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-slate-900 border border-slate-700 p-4 rounded-2xl mb-4 focus:border-orange-500 outline-none text-center tracking-widest">
|
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-4 rounded-2xl font-bold transition-all shadow-lg shadow-orange-900/40">
|
<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-12 gap-6">
|
|
||||||
<div class="flex items-center gap-4">
|
<!-- Хедер -->
|
||||||
<div class="bg-orange-600 p-3 rounded-2xl shadow-lg">
|
<header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
|
||||||
<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 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">
|
||||||
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-extrabold tracking-tighter uppercase italic">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-[10px] text-slate-500 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">Выйти</button>
|
<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>
|
||||||
|
|
||||||
<nav class="flex glass p-1.5 rounded-2xl">
|
<nav class="flex glass p-1 rounded-xl">
|
||||||
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">ПУЛЬТ</button>
|
<button v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]"
|
||||||
<button @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">ГРАФИК</button>
|
:key="t.id" @click="tab = t.id"
|
||||||
<button @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">АДМИНКА</button>
|
:class="tab === t.id ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'"
|
||||||
|
class="px-6 py-2 rounded-lg font-bold text-sm transition-all">
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="tab === 'schedules'" class="space-y-10">
|
<!-- Индикатор загрузки -->
|
||||||
<div class="glass p-8 rounded-[2.5rem]">
|
<div v-if="isLoading" class="flex justify-center py-20">
|
||||||
<h2 class="text-2xl font-black mb-6 uppercase italic">Новая задача</h2>
|
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<select v-model="newTask.target_id" class="bg-slate-900 border border-slate-700 p-4 rounded-xl outline-none focus:border-orange-500">
|
|
||||||
<option value="" disabled>Выберите группу...</option>
|
|
||||||
<option v-for="(g, id) in groups" :value="id">{{ g.name }}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 p-2 rounded-xl">
|
|
||||||
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold">
|
|
||||||
<option v-for="h in 24" :value="(h-1).toString().padStart(2, '0')">{{ (h-1).toString().padStart(2, '0') }}</option>
|
|
||||||
</select>
|
|
||||||
<span class="font-bold">:</span>
|
|
||||||
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold">
|
|
||||||
<option v-for="m in 60" :value="(m-1).toString().padStart(2, '0')">{{ (m-1).toString().padStart(2, '0') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select v-model="newTask.state" class="bg-slate-900 border border-slate-700 p-4 rounded-xl outline-none">
|
|
||||||
<option :value="true">ВКЛЮЧИТЬ</option>
|
|
||||||
<option :value="false">ВЫКЛЮЧИТЬ</option>
|
|
||||||
</select>
|
|
||||||
<button @click="addSchedule" class="bg-orange-600 hover:bg-orange-500 rounded-xl font-black transition-all">ДОБАВИТЬ</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass p-8 rounded-[2.5rem]">
|
|
||||||
<h2 class="text-2xl font-black mb-6 uppercase italic">Активные задачи</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div v-for="task in tasks" :key="task.id" class="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl flex justify-between items-center group">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-3 mb-1">
|
|
||||||
<span class="text-orange-500 text-xl font-black tracking-tighter">
|
|
||||||
<template v-if="task.hour !== null && task.hour !== undefined">
|
|
||||||
{{ 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>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-xs font-bold uppercase">
|
|
||||||
{{ task.state ? 'ВКЛЮЧИТЬ' : 'ВЫКЛЮЧИТЬ' }}
|
|
||||||
</span>
|
|
||||||
<span class="text-[10px] text-slate-600">—</span>
|
|
||||||
<span class="text-[10px] text-slate-500 uppercase tracking-widest">
|
|
||||||
{{ task.next_run ? 'След. запуск: ' + new Date(task.next_run).toLocaleDateString() : 'Разовая задача' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button @click="deleteTask(task.id)" class="text-slate-600 hover:text-red-500 p-2 transition-colors">
|
|
||||||
<svg class="w-6 h-6" 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>
|
|
||||||
</div>
|
|
||||||
<div v-if="tasks.length === 0" class="text-center py-10 text-slate-500 italic">Задач пока нет</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab === 'control'" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<!-- ═══════ ПУЛЬТ ═══════ -->
|
||||||
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-3xl opacity-50">
|
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<p class="text-xl italic text-slate-400">Создайте группу в админке, чтобы управлять лампами</p>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(group, id) in groups" :key="id" class="glass p-8 rounded-[2.5rem] relative group hover:border-orange-500/50 transition-all">
|
<div v-for="(group, id) in groups" :key="id"
|
||||||
<div class="flex justify-between items-start mb-8">
|
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>
|
<div>
|
||||||
<h2 class="text-2xl 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-500' : 'bg-slate-600'"
|
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'"
|
||||||
class="w-2 h-2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.5)]"></span>
|
class="w-2 h-2 rounded-full inline-block"></span>
|
||||||
</h2>
|
</h2>
|
||||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-tighter">
|
<span class="text-[10px] mono text-slate-600">
|
||||||
{{ id }} • {{ group.device_ids?.length || 0 }} ламп
|
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-1.5">
|
||||||
<button @click="deleteGroup(id)" class="bg-slate-800 hover:bg-red-900/40 p-3 rounded-xl transition-all text-slate-500 hover:text-red-500" title="Удалить группу">
|
<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="Удалить">
|
||||||
<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-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="bg-blue-600 hover:bg-blue-500 p-3 rounded-xl font-bold px-4 transition-all text-[10px] flex items-center gap-1">
|
<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 часа">
|
||||||
<span>🕒</span> 4Ч
|
4Ч
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="toggleGroup(id, true)"
|
||||||
<button @click="toggleGroup(id, true)" class="bg-orange-600 hover:bg-orange-500 p-3 rounded-xl font-bold px-5 transition-all">ВКЛ</button>
|
: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'"
|
||||||
<button @click="toggleGroup(id, false)" class="bg-slate-700 hover:bg-slate-600 p-3 rounded-xl font-bold px-5 transition-all">ВЫКЛ</button>
|
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-8">
|
<!-- Управление -->
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Яркость -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between text-[10px] font-black uppercase text-slate-400 mb-3"><span>Яркость</span><span class="text-orange-400">{{ sliders[id]?.brightness }}%</span></div>
|
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
|
||||||
<input type="range" min="10" max="100" class="w-full" v-model="sliders[id].brightness" @change="setBrightness(id, $event.target.value)">
|
<span>Яркость</span>
|
||||||
|
<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-black uppercase text-slate-400 mb-3"><span>Температура</span><span class="text-blue-300">{{ sliders[id]?.temp }}K</span></div>
|
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
|
||||||
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" v-model="sliders[id].temp" @change="setTemp(id, $event.target.value)">
|
<span>Температура</span>
|
||||||
|
<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-6">
|
<!-- Цвет и Сцена -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 block">Цвет</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label>
|
||||||
<input type="color" class="w-full h-12 bg-transparent border-2 border-slate-700 rounded-xl cursor-pointer" @input="setColor(id, $event.target.value)">
|
<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)">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 block">Все сцены</label>
|
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
|
||||||
<select @change="setScene(id, $event.target.value)" class="w-full bg-slate-900 border border-slate-700 p-3 rounded-xl text-sm outline-none focus:border-orange-500">
|
<select @change="setScene(id, $event.target.value); $event.target.value=''"
|
||||||
<option value="" disabled selected>Выбрать пресет...</option>
|
:disabled="!sliders[id]?.state"
|
||||||
<option v-for="scene in allScenes" :key="scene" :value="scene">{{ scene }}</option>
|
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 v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,36 +217,130 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab === 'admin'" class="space-y-10">
|
<!-- ═══════ РАСПИСАНИЯ ═══════ -->
|
||||||
<section class="glass p-8 rounded-[2.5rem]">
|
<div v-if="tab === 'schedules' && !isLoading" class="space-y-8 fade-up">
|
||||||
<div class="flex justify-between items-center mb-8">
|
|
||||||
<h2 class="text-2xl font-bold italic">Устройства в сети</h2>
|
<!-- Создание -->
|
||||||
<button @click="rescan" class="bg-blue-600 hover:bg-blue-500 px-6 py-2 rounded-xl text-xs font-black transition-all">🔄 ПЕРЕСКАНИРОВАТЬ</button>
|
<div class="glass p-6 rounded-2xl">
|
||||||
|
<h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<select v-model="newTask.target_id" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||||
|
<option value="" disabled>Группа...</option>
|
||||||
|
<option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="font-bold text-slate-500">:</span>
|
||||||
|
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
|
||||||
|
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="false">ВЫКЛЮЧИТЬ</option>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Список задач -->
|
||||||
|
<div class="glass p-6 rounded-2xl">
|
||||||
|
<h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-1">
|
||||||
|
<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-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template>
|
||||||
|
</span>
|
||||||
|
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">
|
||||||
|
{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[10px] text-slate-600">
|
||||||
|
<span class="mono">{{ task.target_id }}</span>
|
||||||
|
<span v-if="getGroupName(task.target_id)" class="text-slate-500">· {{ getGroupName(task.target_id) }}</span>
|
||||||
|
<span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="deleteTask(task.id)"
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="tasks.length === 0" class="text-center py-12 text-slate-600 text-sm">Задач пока нет</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════ АДМИНКА ═══════ -->
|
||||||
|
<div v-if="tab === 'admin' && !isLoading" class="space-y-8 fade-up">
|
||||||
|
<section class="glass p-6 rounded-2xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-lg font-black uppercase">Устройства в сети</h2>
|
||||||
|
<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">
|
||||||
|
<span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-10">
|
<!-- Создание группы -->
|
||||||
<input v-model="newGroup.id" placeholder="ID (например, bedroom)" class="bg-slate-900 border border-slate-700 p-4 rounded-xl focus:border-orange-500 outline-none">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
|
||||||
<input v-model="newGroup.name" placeholder="Имя (например, Спальня)" class="bg-slate-900 border border-slate-700 p-4 rounded-xl focus:border-orange-500 outline-none">
|
<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">
|
||||||
|
<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">
|
||||||
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
|
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
|
||||||
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 rounded-xl font-black transition-all shadow-lg">СОЗДАТЬ ГРУППУ</button>
|
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-4">
|
<!-- Список устройств -->
|
||||||
|
<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="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl flex items-center justify-between hover:border-slate-600 transition-all">
|
:class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'"
|
||||||
<label class="flex items-center gap-4 cursor-pointer flex-1">
|
class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
|
||||||
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-5 h-5 accent-orange-500">
|
<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">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-bold text-sm">{{ dev.id }}</p>
|
<p class="font-bold text-sm mono">{{ dev.id }}</p>
|
||||||
<p class="text-[10px] font-mono text-slate-500">{{ dev.ip }}</p>
|
<p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button @click="blink(dev.id)" class="p-3 bg-slate-800 rounded-xl hover:bg-orange-600 transition-all shadow-md" title="Мигнуть лампой">👁️</button>
|
<button @click="blink(dev.id)" class="p-2 rounded-lg bg-slate-800/30 hover:bg-orange-600/30 hover:text-orange-400 transition-all text-slate-600" title="Мигнуть">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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-уведомления ═══════ -->
|
||||||
|
<div class="fixed bottom-6 right-6 z-50 space-y-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -224,22 +355,21 @@
|
|||||||
devices: [],
|
devices: [],
|
||||||
sliders: {},
|
sliders: {},
|
||||||
newGroup: { id: '', name: '', macs: [] },
|
newGroup: { id: '', name: '', macs: [] },
|
||||||
|
isLoading: false,
|
||||||
isLoadingStatus: false,
|
isLoadingStatus: false,
|
||||||
taskHour: '12', // Начальное значение часа
|
isFetching: false, // защита от параллельных fetchData
|
||||||
taskMin: '00', // Начальное значение минут
|
isRescanning: false,
|
||||||
newTask: { target_id: '', time: '', state: true },
|
taskHour: '22',
|
||||||
|
taskMin: '00',
|
||||||
|
newTask: { target_id: '', state: true },
|
||||||
tasks: [],
|
tasks: [],
|
||||||
allScenes: [
|
allScenes: {}, // загружается с бэкенда
|
||||||
"ocean", "romance", "party", "fireplace", "cozy", "forest",
|
toasts: [],
|
||||||
"pastel_colors", "wake_up", "bedtime", "warm_white", "daylight",
|
toastCounter: 0,
|
||||||
"cool_white", "night_light", "focus", "relax", "true_colors",
|
|
||||||
"tv_time", "plant_growth", "spring", "summer", "fall", "deep_dive",
|
|
||||||
"jungle", "mojito", "club", "christmas", "halloween", "candlelight",
|
|
||||||
"golden_white", "pulse", "steampunk"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// ─── Утилиты ─────────────────────────────────
|
||||||
saveKey() {
|
saveKey() {
|
||||||
if (this.tempKey) {
|
if (this.tempKey) {
|
||||||
this.apiKey = this.tempKey;
|
this.apiKey = this.tempKey;
|
||||||
@@ -252,6 +382,21 @@
|
|||||||
localStorage.removeItem('ignis_key');
|
localStorage.removeItem('ignis_key');
|
||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
|
// Toast-уведомление
|
||||||
|
toast(text, type = 'info', duration = 3000) {
|
||||||
|
const id = ++this.toastCounter;
|
||||||
|
this.toasts.push({ id, text, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
// Имя группы по id (для расписаний)
|
||||||
|
getGroupName(targetId) {
|
||||||
|
const g = this.groups[targetId];
|
||||||
|
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) {
|
||||||
@@ -267,141 +412,197 @@
|
|||||||
},
|
},
|
||||||
body: body ? JSON.stringify(body) : null
|
body: body ? JSON.stringify(body) : null
|
||||||
});
|
});
|
||||||
if (response.status === 403) { this.logout(); return null; }
|
if (response.status === 403) {
|
||||||
if (!response.ok) return null;
|
this.toast('Неверный API-ключ', 'error');
|
||||||
|
this.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
this.toast(err.detail || `Ошибка ${response.status}`, 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (e) { return null; }
|
} catch (e) {
|
||||||
},
|
this.toast('Сервер недоступен', 'error');
|
||||||
async fetchData() {
|
return null;
|
||||||
if (!this.apiKey) return;
|
|
||||||
const gData = await this.request('/devices/groups');
|
|
||||||
const dData = await this.request('/devices');
|
|
||||||
|
|
||||||
if (gData) {
|
|
||||||
this.groups = gData;
|
|
||||||
// Инициализируем слайдеры для новых групп
|
|
||||||
Object.keys(this.groups).forEach(id => {
|
|
||||||
if (!this.sliders[id]) {
|
|
||||||
this.sliders[id] = { brightness: 100, temp: 3000, state: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Сразу после получения списка групп — запрашиваем их состояние
|
|
||||||
await this.syncGroupStatuses();
|
|
||||||
}
|
}
|
||||||
if (dData) this.devices = Object.values(dData);
|
|
||||||
this.fetchTasks();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Данные ──────────────────────────────────
|
||||||
|
async fetchData() {
|
||||||
|
if (!this.apiKey || this.isFetching) return;
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [gData, dData, sData] = await Promise.all([
|
||||||
|
this.request('/devices/groups'),
|
||||||
|
this.request('/devices'),
|
||||||
|
this.request('/devices/scenes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (gData) {
|
||||||
|
this.groups = gData;
|
||||||
|
// Инициализируем слайдеры для новых групп
|
||||||
|
Object.keys(this.groups).forEach(id => {
|
||||||
|
if (!this.sliders[id]) {
|
||||||
|
this.sliders[id] = { brightness: 100, temp: 4000, state: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await this.syncGroupStatuses();
|
||||||
|
}
|
||||||
|
if (dData) {
|
||||||
|
// Бэкенд может вернуть dict или list
|
||||||
|
this.devices = Array.isArray(dData) ? dData : Object.values(dData);
|
||||||
|
}
|
||||||
|
// Сцены с бэкенда (dict: {name: sceneId})
|
||||||
|
if (sData) this.allScenes = sData;
|
||||||
|
this.fetchTasks();
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Управление ──────────────────────────────
|
||||||
async control(id, params) {
|
async control(id, params) {
|
||||||
await this.request(`/control/group/${id}`, 'POST', params);
|
await this.request(`/control/group/${id}`, 'POST', params);
|
||||||
await this.syncGroupStatuses();
|
// Не делаем полный syncGroupStatuses -- ждём следующий цикл
|
||||||
},
|
},
|
||||||
toggleGroup(id, state) {
|
toggleGroup(id, state) {
|
||||||
// Оптимистично меняем состояние в интерфейсе
|
|
||||||
if (this.sliders[id]) {
|
if (this.sliders[id]) {
|
||||||
this.sliders[id].state = state;
|
this.sliders[id].state = state;
|
||||||
}
|
}
|
||||||
this.control(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 });
|
||||||
},
|
},
|
||||||
setBrightness(id, val) { this.control(id, { brightness: val }); },
|
|
||||||
setTemp(id, 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) {
|
||||||
const 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 });
|
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,
|
||||||
|
name: this.newGroup.name,
|
||||||
|
macs: this.newGroup.macs
|
||||||
});
|
});
|
||||||
if (res) {
|
if (res) {
|
||||||
|
this.toast(`Группа "${this.newGroup.name}" создана`, 'success');
|
||||||
this.newGroup = { id: '', name: '', macs: [] };
|
this.newGroup = { id: '', name: '', macs: [] };
|
||||||
await this.fetchData();
|
await this.fetchData();
|
||||||
this.tab = 'control';
|
this.tab = 'control';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteGroup(id) {
|
async deleteGroup(id) {
|
||||||
if (confirm(`Удалить группу ${id}?`)) {
|
const name = this.groups[id]?.name || id;
|
||||||
|
if (confirm(`Удалить группу "${name}"?`)) {
|
||||||
await this.request(`/devices/groups/${id}`, 'DELETE');
|
await this.request(`/devices/groups/${id}`, 'DELETE');
|
||||||
|
this.toast(`Группа "${name}" удалена`, 'success');
|
||||||
await this.fetchData();
|
await this.fetchData();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async rescan() {
|
async rescan() {
|
||||||
|
this.isRescanning = true;
|
||||||
await this.request('/devices/rescan', 'POST');
|
await this.request('/devices/rescan', 'POST');
|
||||||
setTimeout(this.fetchData, 2000);
|
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(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
|
||||||
|
|
||||||
|
// ─── Синхронизация состояния ─────────────────
|
||||||
async syncGroupStatuses() {
|
async syncGroupStatuses() {
|
||||||
if (this.isLoadingStatus) return;
|
if (this.isLoadingStatus) return;
|
||||||
this.isLoadingStatus = true;
|
this.isLoadingStatus = true;
|
||||||
|
|
||||||
for (const groupId of Object.keys(this.groups)) {
|
try {
|
||||||
const data = await this.request(`/control/group/${groupId}/status`);
|
// Параллельный опрос всех групп
|
||||||
if (data && data.results && data.results.length > 0) {
|
const groupIds = Object.keys(this.groups);
|
||||||
// Берем состояние первой доступной лампы в группе как эталонное
|
const results = await Promise.all(
|
||||||
const firstValid = data.results.find(r => r.status && !r.error);
|
groupIds.map(id => this.request(`/control/group/${id}/status`))
|
||||||
if (firstValid) {
|
);
|
||||||
const s = firstValid.status;
|
|
||||||
// Обновляем ползунки, если пользователь ими сейчас не двигает
|
groupIds.forEach((id, i) => {
|
||||||
this.sliders[groupId] = {
|
const data = results[i];
|
||||||
brightness: s.dimming || 100,
|
if (data && data.results && data.results.length > 0) {
|
||||||
temp: s.temp || 3000,
|
const firstValid = data.results.find(r => r.status && !r.error);
|
||||||
state: s.state
|
if (firstValid) {
|
||||||
};
|
const s = firstValid.status;
|
||||||
|
this.sliders[id] = {
|
||||||
|
brightness: s.dimming || 100,
|
||||||
|
temp: s.temp || 4000,
|
||||||
|
state: s.state || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
} finally {
|
||||||
|
this.isLoadingStatus = false;
|
||||||
}
|
}
|
||||||
this.isLoadingStatus = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Расписания ──────────────────────────────
|
||||||
async fetchTasks() {
|
async fetchTasks() {
|
||||||
const data = await this.request('/schedules/tasks');
|
const data = await this.request('/schedules/tasks');
|
||||||
if (data) this.tasks = data.tasks;
|
if (data) this.tasks = data.tasks || [];
|
||||||
},
|
},
|
||||||
async addSchedule() {
|
async addSchedule() {
|
||||||
// Проверяем, выбрана ли группа
|
|
||||||
if (!this.newTask.target_id) {
|
if (!this.newTask.target_id) {
|
||||||
alert("Выбери группу!");
|
this.toast('Выберите группу', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const res = await this.request('/schedules/cron', 'POST', {
|
||||||
// Отправляем данные. Теперь берем их прямо из taskHour и taskMin
|
|
||||||
await this.request('/schedules/cron', 'POST', {
|
|
||||||
target_id: this.newTask.target_id,
|
target_id: this.newTask.target_id,
|
||||||
hour: this.taskHour,
|
hour: this.taskHour,
|
||||||
minute: this.taskMin,
|
minute: this.taskMin,
|
||||||
is_group: true,
|
is_group: true,
|
||||||
state: this.newTask.state
|
state: this.newTask.state
|
||||||
});
|
});
|
||||||
|
if (res) {
|
||||||
this.fetchTasks(); // Обновляем список
|
this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success');
|
||||||
|
this.fetchTasks();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async deleteTask(id) {
|
async deleteTask(id) {
|
||||||
await this.request(`/schedules/${id}`, 'DELETE');
|
await this.request(`/schedules/${id}`, 'DELETE');
|
||||||
|
this.toast('Задача отменена', 'success');
|
||||||
this.fetchTasks();
|
this.fetchTasks();
|
||||||
},
|
},
|
||||||
async setTimer4h(id) {
|
async setTimer4h(id) {
|
||||||
// 1. Включаем свет сразу
|
|
||||||
await this.toggleGroup(id, true);
|
await this.toggleGroup(id, true);
|
||||||
|
|
||||||
// 2. Шлём запрос на таймер.
|
|
||||||
const res = await this.request('/schedules/once', 'POST', {
|
const res = await this.request('/schedules/once', 'POST', {
|
||||||
target_id: id,
|
target_id: id,
|
||||||
hours_from_now: 4,
|
hours_from_now: 4,
|
||||||
is_group: true,
|
is_group: true,
|
||||||
state: false
|
state: false
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
console.log("Таймер успешно создан:", res);
|
this.toast('Таймер 4ч установлен', 'success');
|
||||||
this.fetchTasks();
|
this.fetchTasks();
|
||||||
} else {
|
|
||||||
console.error("Бэкенд проигнорировал запрос таймера");
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
if (this.apiKey) {
|
if (this.apiKey) {
|
||||||
this.fetchData();
|
this.isLoading = true;
|
||||||
setInterval(this.fetchData, 15000);
|
await this.fetchData();
|
||||||
|
this.isLoading = false;
|
||||||
|
// Периодический опрос с защитой от наложений
|
||||||
|
setInterval(() => this.fetchData(), 15000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|||||||
Reference in New Issue
Block a user