Files
ignis-core/static/index.html
2026-03-28 20:11:23 +07:00

612 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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://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">
<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 {
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); }
.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; }
/* Селекты */
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>
</head>
<body>
<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 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">
<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>
<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"
@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">
<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>
</div>
</div>
<!-- ═══════ Основной интерфейс ═══════ -->
<template v-else>
<!-- Хедер -->
<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="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>
<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>
</div>
<nav class="flex glass p-1 rounded-xl">
<button v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]"
:key="t.id" @click="tab = t.id"
: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>
</header>
<!-- Индикатор загрузки -->
<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>
<!-- ═══════ ПУЛЬТ ═══════ -->
<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">
<p class="text-slate-500">Создайте группу в админке</p>
</div>
<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)' : ''">
<!-- Шапка группы -->
<div class="flex justify-between items-start mb-6">
<div>
<h2 class="text-xl font-black text-white flex items-center gap-2">
{{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'"
class="w-2 h-2 rounded-full inline-block"></span>
</h2>
<span class="text-[10px] mono text-slate-600">
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
</span>
</div>
<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="Удалить">
<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 @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 @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 class="space-y-5">
<!-- Яркость -->
<div>
<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>
<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 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>
<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 class="grid grid-cols-2 gap-4">
<div>
<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"
:disabled="!sliders[id]?.state"
@input="setColor(id, $event.target.value)">
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
<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">
<option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════ РАСПИСАНИЯ ═══════ -->
<div v-if="tab === 'schedules' && !isLoading" class="space-y-8 fade-up">
<!-- Создание -->
<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 class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<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"
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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<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">
<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>
<p class="font-bold text-sm mono">{{ dev.id }}</p>
<p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
</div>
</label>
<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 v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">
Устройства не найдены. Нажмите "Сканировать".
</div>
</section>
</div>
</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>
<script>
const { createApp } = Vue
createApp({
data() {
return {
apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '',
tab: 'control',
groups: {},
devices: [],
sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoading: false,
isLoadingStatus: false,
isFetching: false, // защита от параллельных fetchData
isRescanning: false,
taskHour: '22',
taskMin: '00',
newTask: { target_id: '', state: true },
tasks: [],
allScenes: {}, // загружается с бэкенда
toasts: [],
toastCounter: 0,
}
},
methods: {
// ─── Утилиты ─────────────────────────────────
saveKey() {
if (this.tempKey) {
this.apiKey = this.tempKey;
localStorage.setItem('ignis_key', this.tempKey);
this.fetchData();
}
},
logout() {
this.apiKey = '';
localStorage.removeItem('ignis_key');
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) {
let url = path;
if (params) {
const q = new URLSearchParams(params).toString();
url += `?${q}`;
}
try {
const response = await fetch(url, {
method,
headers: {
'X-API-Key': this.apiKey,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
});
if (response.status === 403) {
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();
} catch (e) {
this.toast('Сервер недоступен', 'error');
return null;
}
},
// ─── Данные ──────────────────────────────────
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) {
await this.request(`/control/group/${id}`, 'POST', params);
// Не делаем полный syncGroupStatuses -- ждём следующий цикл
},
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 }); },
setColor(id, hex) {
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() {
const res = await this.request('/devices/groups', 'POST', null, {
id: this.newGroup.id,
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) {
const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) {
await this.request(`/devices/groups/${id}`, 'DELETE');
this.toast(`Группа "${name}" удалена`, 'success');
await this.fetchData();
}
},
async rescan() {
this.isRescanning = true;
await this.request('/devices/rescan', 'POST');
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 syncGroupStatuses() {
if (this.isLoadingStatus) return;
this.isLoadingStatus = true;
try {
// Параллельный опрос всех групп
const groupIds = Object.keys(this.groups);
const results = await Promise.all(
groupIds.map(id => this.request(`/control/group/${id}/status`))
);
groupIds.forEach((id, i) => {
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 {
this.isLoadingStatus = false;
}
},
// ─── Расписания ──────────────────────────────
async fetchTasks() {
const data = await this.request('/schedules/tasks');
if (data) this.tasks = data.tasks || [];
},
async addSchedule() {
if (!this.newTask.target_id) {
this.toast('Выберите группу', 'error');
return;
}
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 setTimer4h(id) {
await this.toggleGroup(id, true);
const res = await this.request('/schedules/once', 'POST', {
target_id: id,
hours_from_now: 4,
is_group: true,
state: false
});
if (res) {
this.toast('Таймер 4ч установлен', 'success');
this.fetchTasks();
}
},
},
async mounted() {
if (this.apiKey) {
this.isLoading = true;
await this.fetchData();
this.isLoading = false;
// Периодический опрос с защитой от наложений
setInterval(() => this.fetchData(), 15000);
}
}
}).mount('#app')
</script>
</body>
</html>