Files
ignis-core/static/index.html
Артём Кокос 62af4e46af Enable for 4 hours feature
2026-03-02 21:44:45 +07:00

411 lines
25 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>
<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=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0f172a);
color: #f8fafc;
font-family: 'Inter', sans-serif;
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); }
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #334155; outline: none; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px;
background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid #f97316;
}
.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; }
</style>
</head>
<body>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-10">
<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 class="text-center mb-8">
<span class="text-5xl mb-4 block">🔐</span>
<h2 class="text-2xl font-black uppercase tracking-tighter">Ignis Access</h2>
<p class="text-slate-400 text-sm mt-2">Введите API ключ из .env</p>
</div>
<input v-model="tempKey" type="password" placeholder="X-API-Key"
@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">
<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>
</div>
</div>
<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">
<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>
<h1 class="text-4xl font-extrabold tracking-tighter uppercase italic">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>
</div>
</div>
<nav class="flex glass p-1.5 rounded-2xl">
<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 @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab' : 'text-slate-400'" class="px-8 py-2.5 rounded-xl font-bold transition-all">ГРАФИК</button>
<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>
</nav>
</header>
<div v-if="tab === 'schedules'" class="space-y-10">
<div class="glass p-8 rounded-[2.5rem]">
<h2 class="text-2xl font-black mb-6 uppercase italic">Новая задача</h2>
<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 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">
<p class="text-xl italic text-slate-400">Создайте группу в админке, чтобы управлять лампами</p>
</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 class="flex justify-between items-start mb-8">
<div>
<h2 class="text-2xl font-black text-white flex items-center gap-2">
{{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-500' : 'bg-slate-600'"
class="w-2 h-2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.5)]"></span>
</h2>
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-tighter">
{{ id }} • {{ group.device_ids?.length || 0 }} ламп
</span>
</div>
<div class="flex gap-2">
<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="Удалить группу">
<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 @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">
<span>🕒</span>
</button>
<button @click="toggleGroup(id, true)" class="bg-orange-600 hover:bg-orange-500 p-3 rounded-xl font-bold px-5 transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)" class="bg-slate-700 hover:bg-slate-600 p-3 rounded-xl font-bold px-5 transition-all">ВЫКЛ</button>
</div>
</div>
<div class="space-y-8">
<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>
<input type="range" min="10" max="100" class="w-full" v-model="sliders[id].brightness" @change="setBrightness(id, $event.target.value)">
</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>
<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)">
</div>
<div class="grid grid-cols-2 gap-6">
<div>
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 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)">
</div>
<div>
<label class="text-[10px] font-black uppercase text-slate-400 mb-3 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">
<option value="" disabled selected>Выбрать пресет...</option>
<option v-for="scene in allScenes" :key="scene" :value="scene">{{ scene }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div v-if="tab === 'admin'" class="space-y-10">
<section class="glass p-8 rounded-[2.5rem]">
<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>
<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">
<input v-model="newGroup.name" placeholder="Имя (например, Спальня)" class="bg-slate-900 border border-slate-700 p-4 rounded-xl focus:border-orange-500 outline-none">
<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>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<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">
<label class="flex items-center gap-4 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-5 h-5 accent-orange-500">
<div>
<p class="font-bold text-sm">{{ dev.id }}</p>
<p class="text-[10px] font-mono text-slate-500">{{ dev.ip }}</p>
</div>
</label>
<button @click="blink(dev.id)" class="p-3 bg-slate-800 rounded-xl hover:bg-orange-600 transition-all shadow-md" title="Мигнуть лампой">👁️</button>
</div>
</div>
</section>
</div>
</template>
</div>
<script>
const { createApp } = Vue
createApp({
data() {
return {
apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '',
tab: 'control',
groups: {},
devices: [],
sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoadingStatus: false,
taskHour: '12', // Начальное значение часа
taskMin: '00', // Начальное значение минут
newTask: { target_id: '', time: '', state: true },
tasks: [],
allScenes: [
"ocean", "romance", "party", "fireplace", "cozy", "forest",
"pastel_colors", "wake_up", "bedtime", "warm_white", "daylight",
"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: {
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();
},
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.logout(); return null; }
if (!response.ok) return null;
return await response.json();
} catch (e) { return null; }
},
async fetchData() {
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 control(id, params) {
await this.request(`/control/group/${id}`, 'POST', params);
await this.syncGroupStatuses();
},
toggleGroup(id, state) {
// Оптимистично меняем состояние в интерфейсе
if (this.sliders[id]) {
this.sliders[id].state = state;
}
this.control(id, { state: state });
},
setBrightness(id, val) { this.control(id, { brightness: val }); },
setTemp(id, val) { this.control(id, { temp: val }); },
setScene(id, scene) { this.control(id, { scene }); },
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);
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.newGroup = { id: '', name: '', macs: [] };
await this.fetchData();
this.tab = 'control';
}
},
async deleteGroup(id) {
if (confirm(`Удалить группу ${id}?`)) {
await this.request(`/devices/groups/${id}`, 'DELETE');
await this.fetchData();
}
},
async rescan() {
await this.request('/devices/rescan', 'POST');
setTimeout(this.fetchData, 2000);
},
async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
async syncGroupStatuses() {
if (this.isLoadingStatus) return;
this.isLoadingStatus = true;
for (const groupId of Object.keys(this.groups)) {
const data = await this.request(`/control/group/${groupId}/status`);
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[groupId] = {
brightness: s.dimming || 100,
temp: s.temp || 3000,
state: s.state
};
}
}
}
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) {
alert("Выбери группу!");
return;
}
// Отправляем данные. Теперь берем их прямо из taskHour и taskMin
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
});
this.fetchTasks(); // Обновляем список
},
async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE');
this.fetchTasks();
},
async setTimer4h(id) {
// 1. Включаем свет сразу
await this.toggleGroup(id, true);
// 2. Шлём запрос на таймер.
const res = await this.request('/schedules/once', 'POST', {
target_id: id,
hours_from_now: 4,
is_group: true,
state: false
});
if (res) {
console.log("Таймер успешно создан:", res);
this.fetchTasks();
} else {
console.error("Бэкенд проигнорировал запрос таймера");
}
},
},
mounted() {
if (this.apiKey) {
this.fetchData();
setInterval(this.fetchData, 15000);
}
}
}).mount('#app')
</script>
</body>
</html>