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

349 lines
28 KiB
HTML

<!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-enter { animation: fadeUp 0.3s ease-out; }
@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-ключ</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>
<div class="flex items-center gap-2">
<span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span>
<span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
</div>
</div>
</div>
<nav class="flex glass p-1 rounded-xl">
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button>
<button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button>
<button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button>
</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 v-if="isAdmin" class="text-slate-500">Создайте группу в админке</p>
<p v-else 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 v-if="isAdmin" @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button v-if="isAdmin" @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="4 часа"></button>
<button @click="toggleGroup(id, true)" :class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @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 && isAdmin" 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 && isAdmin" 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',
isAdmin: false,
authName: '',
groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoading: false, isLoadingStatus: false, isFetching: false, 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.initApp(); }
},
logout() { this.apiKey = ''; this.isAdmin = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
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);
},
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
async request(path, method = 'GET', params = null, body = null) {
let url = path;
if (params) url += `?${new URLSearchParams(params).toString()}`;
try {
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
if (r.status === 403) {
const err = await r.json().catch(() => ({}));
if (err.detail === 'Недостаточно прав') { this.toast('Нет прав', 'error'); return null; }
this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
}
if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
return await r.json();
} catch (e) { this.toast('Сервер недоступен', 'error'); return null; }
},
async initApp() {
this.isLoading = true;
const auth = await this.request('/auth/me');
if (!auth) { this.isLoading = false; return; }
this.isAdmin = auth.is_admin;
this.authName = auth.name;
await this.fetchData();
this.isLoading = false;
setInterval(() => this.fetchData(), 15000);
},
async fetchData() {
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) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
if (sData) this.allScenes = sData;
if (this.isAdmin) this.fetchTasks();
} finally { this.isFetching = false; }
},
async control(id, params) { await this.request(`/control/group/${id}`, 'POST', params); },
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) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
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(`Удалена`, '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(id) { await this.request(`/control/device/${id}/blink`, 'POST'); },
async syncGroupStatuses() {
if (this.isLoadingStatus) return; this.isLoadingStatus = true;
try {
const ids = Object.keys(this.groups);
const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`)));
ids.forEach((id, i) => {
const d = results[i];
if (d?.results?.length > 0) {
const v = d.results.find(r => r.status && !r.error);
if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false };
}
});
} finally { this.isLoadingStatus = false; }
},
async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.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) await this.initApp(); }
}).mount('#app')
</script>
</body>
</html>