433 lines
37 KiB
HTML
433 lines
37 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="/static/vendor/tailwindcdn.js"></script>
|
||
<link rel="stylesheet" href="/static/ui.css">
|
||
</head>
|
||
<body>
|
||
<div id="app" v-cloak 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"
|
||
autocomplete="off" spellcheck="false"
|
||
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">
|
||
<label class="flex items-center justify-center gap-2 text-xs text-slate-500 mb-4 cursor-pointer">
|
||
<input v-model="rememberInSession" type="checkbox" class="w-4 h-4 accent-orange-500 rounded">
|
||
Запомнить только в этой вкладке
|
||
</label>
|
||
<p class="text-[11px] text-slate-600 mb-4">По умолчанию ключ хранится только в памяти текущей страницы.</p>
|
||
<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="isMaster" class="text-[9px] text-orange-500 font-bold uppercase">master</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 @click="tab = 'server'" :class="tab === 'server' ? '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>
|
||
<button v-if="isAdmin" @click="tab = 'stats'; fetchStats()" :class="tab === 'stats' ? '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 === 'server' && !isLoading" class="space-y-8 fade-up">
|
||
<section v-if="serverInfo" class="glass p-6 rounded-2xl">
|
||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-[0.2em] text-slate-600 mb-2">О сервере</div>
|
||
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
|
||
<div class="text-sm text-slate-500 mt-1">{{ serverDisplaySubtitle() }}</div>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 text-[10px]">
|
||
<span class="border px-2.5 py-1 rounded-lg font-bold uppercase text-green-300 border-green-500/30 bg-green-500/10">
|
||
онлайн
|
||
</span>
|
||
<span :class="isAdmin ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
|
||
</span>
|
||
<span v-if="serverInfo.diagnostics_visible" class="border px-2.5 py-1 rounded-lg font-bold uppercase text-orange-300 border-orange-500/30 bg-orange-500/10">
|
||
расширенная диагностика
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mt-6">
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Статус</div>
|
||
<div class="text-sm font-bold">Подключено</div>
|
||
<div class="text-[11px] text-slate-500 mt-1">API отвечает и готов к управлению.</div>
|
||
</div>
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Запущен</div>
|
||
<div class="text-sm font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
|
||
<div class="text-[11px] text-slate-500 mt-1">
|
||
{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}
|
||
</div>
|
||
</div>
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Доступ</div>
|
||
<div class="text-sm font-bold">{{ isMaster ? 'Мастер' : (isAdmin ? 'Администратор' : 'Гостевой') }}</div>
|
||
<div class="text-[11px] text-slate-500 mt-1">
|
||
{{ isAdmin ? 'Служебная диагностика доступна на этой вкладке.' : 'Служебные параметры и адреса скрыты.' }}
|
||
</div>
|
||
</div>
|
||
<div v-if="serverInfo.diagnostics_visible" class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Версия</div>
|
||
<div class="text-sm font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
|
||
<div class="text-[11px] text-slate-500 mt-1">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="serverInfo?.diagnostics_visible" class="glass p-6 rounded-2xl">
|
||
<h2 class="text-lg font-black uppercase mb-5">Диагностика</h2>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Сборка</div>
|
||
<div class="text-sm font-bold">{{ formatServerBuild(serverInfo.build) }}</div>
|
||
</div>
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Таймзона</div>
|
||
<div class="text-sm font-bold">{{ serverInfo.timezone || 'не указана' }}</div>
|
||
</div>
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Публичный URL</div>
|
||
<div class="text-xs mono break-all text-slate-300">
|
||
{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}
|
||
</div>
|
||
</div>
|
||
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
|
||
<div class="text-[10px] uppercase text-slate-600 mb-2">Наблюдаемый URL</div>
|
||
<div class="text-xs mono break-all text-slate-300">
|
||
{{ serverInfo.urls?.observed_base_url || 'не определён' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 text-[10px] mt-5">
|
||
<span :class="serverInfo.configuration?.configured ? 'text-green-300 border-green-500/30 bg-green-500/10' : 'text-red-300 border-red-500/30 bg-red-500/10'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||
{{ serverInfo.configuration?.configured ? 'настроен' : 'требует настройки' }}
|
||
</span>
|
||
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||
{{ serverInfo.configuration?.public_base_url_configured ? 'public url задан' : 'public url не задан' }}
|
||
</span>
|
||
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-300 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||
{{ serverInfo.configuration?.scan_network_configured ? 'scan network задан' : 'scan network auto' }}
|
||
</span>
|
||
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-300 border-orange-500/30 bg-orange-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
|
||
{{ serverInfo.configuration?.build_metadata_complete ? 'build metadata полная' : 'build metadata частичная' }}
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</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" :class="sliders[id]?.state ? 'group-active' : ''">
|
||
<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 часа">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>
|
||
|
||
<!-- Гостевые API-ключи -->
|
||
<section v-if="isMaster" class="glass p-6 rounded-2xl">
|
||
<h2 class="text-lg font-black mb-6 uppercase">Гостевые ключи</h2>
|
||
|
||
<!-- Создание -->
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
|
||
<input v-model="newKeyName" placeholder="Имя (Вася, гости...)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
|
||
<label class="flex items-center gap-2 bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm text-slate-400 cursor-pointer">
|
||
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
|
||
Админ-права
|
||
</label>
|
||
<button @click="createApiKey" :disabled="!newKeyName.trim()" 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 v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-xl mb-6">
|
||
<div class="flex justify-between items-center mb-2">
|
||
<span class="text-green-400 text-xs font-bold uppercase">Новый ключ создан -- скопируйте!</span>
|
||
<button @click="copyKey(lastCreatedKey)" class="text-green-400 hover:text-green-300 text-xs font-bold transition-colors">КОПИРОВАТЬ</button>
|
||
</div>
|
||
<div class="mono text-sm text-green-300 break-all select-all bg-black/30 p-3 rounded-lg">{{ lastCreatedKey }}</div>
|
||
</div>
|
||
|
||
<!-- Список ключей -->
|
||
<div class="space-y-2">
|
||
<div v-for="k in apiKeys" :key="k.key" 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-2">
|
||
<span class="font-bold text-sm">{{ k.name }}</span>
|
||
<span v-if="k.is_admin" class="text-[9px] text-orange-400 font-bold uppercase bg-orange-400/10 px-2 py-0.5 rounded">админ</span>
|
||
<span v-else class="text-[9px] text-slate-500 font-bold uppercase bg-slate-500/10 px-2 py-0.5 rounded">гость</span>
|
||
<span :class="k.active ? 'text-green-500' : 'text-red-500'" class="text-[9px] font-bold uppercase">{{ k.active ? 'активен' : 'отозван' }}</span>
|
||
</div>
|
||
<div class="mono text-[10px] text-slate-600 mt-1">{{ k.display_key || (k.key.slice(0, 12) + '...' + k.key.slice(-6)) }}</div>
|
||
</div>
|
||
<div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button v-if="k.active" @click="revokeApiKey(k.key, k.name)" class="p-2 rounded-lg bg-red-900/20 hover:bg-red-900/40 text-red-400 text-xs font-bold transition-all" title="Отозвать">ОТОЗВАТЬ</button>
|
||
<button v-else @click="activateApiKey(k.key, k.name)" class="p-2 rounded-lg bg-green-900/20 hover:bg-green-900/40 text-green-400 text-xs font-bold transition-all" title="Активировать">АКТИВИР.</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="apiKeys.length === 0" class="text-center py-8 text-slate-600 text-sm">Гостевых ключей нет</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- СТАТИСТИКА (только админ) -->
|
||
<div v-if="tab === 'stats' && !isLoading && isAdmin" class="space-y-8 fade-up">
|
||
|
||
<!-- Период -->
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm text-slate-500">Период:</span>
|
||
<button v-for="d in [1, 7, 30]" :key="d" @click="statsDays = d; fetchStats()"
|
||
:class="statsDays === d ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:text-white'"
|
||
class="px-4 py-1.5 rounded-lg text-sm font-bold transition-all">
|
||
{{ d === 1 ? 'Сегодня' : d + 'д' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Карточки по группам -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div v-for="s in statsData" :key="s.target_id" class="glass p-6 rounded-2xl">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h3 class="text-lg font-black">{{ getGroupName(s.target_id) || s.target_id }}</h3>
|
||
<span class="text-[10px] mono text-slate-600">{{ s.target_id }}</span>
|
||
</div>
|
||
<span class="mono text-2xl font-black text-orange-400">{{ s.estimated_hours }}ч</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-3 gap-3 mb-4">
|
||
<div class="bg-black/20 p-3 rounded-xl text-center">
|
||
<div class="text-lg font-bold text-green-400">{{ s.toggles_on }}</div>
|
||
<div class="text-[10px] text-slate-600 uppercase">вкл</div>
|
||
</div>
|
||
<div class="bg-black/20 p-3 rounded-xl text-center">
|
||
<div class="text-lg font-bold text-red-400">{{ s.toggles_off }}</div>
|
||
<div class="text-[10px] text-slate-600 uppercase">выкл</div>
|
||
</div>
|
||
<div class="bg-black/20 p-3 rounded-xl text-center">
|
||
<div class="text-lg font-bold text-slate-300">{{ s.total_commands }}</div>
|
||
<div class="text-[10px] text-slate-600 uppercase">всего</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<span v-if="s.scenes" class="text-[10px] bg-purple-500/10 text-purple-400 px-2 py-1 rounded font-bold">🎨 {{ s.scenes }} сцен</span>
|
||
<span v-if="s.colors" class="text-[10px] bg-pink-500/10 text-pink-400 px-2 py-1 rounded font-bold">🌈 {{ s.colors }} цветов</span>
|
||
<span v-if="s.brightness" class="text-[10px] bg-yellow-500/10 text-yellow-400 px-2 py-1 rounded font-bold">🔆 {{ s.brightness }} яркость</span>
|
||
<span v-if="s.temperature" class="text-[10px] bg-blue-500/10 text-blue-400 px-2 py-1 rounded font-bold">🌡 {{ s.temperature }} темп.</span>
|
||
</div>
|
||
|
||
<!-- Кто управлял -->
|
||
<div v-if="Object.keys(s.by_user).length > 0" class="border-t border-slate-800/50 pt-3">
|
||
<div class="text-[10px] text-slate-600 uppercase mb-2">Кто управлял</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span v-for="(count, user) in s.by_user" :key="user" class="text-[10px] mono bg-slate-800/50 text-slate-400 px-2 py-1 rounded">
|
||
{{ user }}: {{ count }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="statsData.length === 0" class="text-center py-16 glass rounded-2xl text-slate-600 text-sm">
|
||
Нет данных за выбранный период
|
||
</div>
|
||
|
||
<!-- Лог последних событий -->
|
||
<div class="glass p-6 rounded-2xl">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 class="text-lg font-black uppercase">Лог событий</h2>
|
||
<button @click="fetchEventLog" class="text-xs text-slate-500 hover:text-orange-400 font-bold transition-colors">ОБНОВИТЬ</button>
|
||
</div>
|
||
<div class="space-y-1 max-h-96 overflow-y-auto">
|
||
<div v-for="ev in eventLog" :key="ev.id" class="flex items-center gap-3 py-2 border-b border-slate-800/30 text-sm">
|
||
<span class="mono text-[10px] text-slate-600 w-36 shrink-0">{{ formatTime(ev.timestamp) }}</span>
|
||
<span class="mono text-[10px] text-slate-500 w-16 shrink-0">{{ ev.key_name }}</span>
|
||
<span :class="{
|
||
'text-green-400': ev.action === 'toggle_on',
|
||
'text-red-400': ev.action === 'toggle_off',
|
||
'text-purple-400': ev.action === 'scene',
|
||
'text-pink-400': ev.action === 'color',
|
||
'text-yellow-400': ev.action === 'brightness',
|
||
'text-blue-400': ev.action === 'temperature',
|
||
'text-slate-400': !['toggle_on','toggle_off','scene','color','brightness','temperature'].includes(ev.action),
|
||
}" class="text-xs font-bold w-24 shrink-0">{{ ev.action }}</span>
|
||
<span class="text-xs text-slate-500">{{ getGroupName(ev.target_id) || ev.target_id }}</span>
|
||
</div>
|
||
<div v-if="eventLog.length === 0" class="text-center py-8 text-slate-600 text-sm">Событий пока нет</div>
|
||
</div>
|
||
</div>
|
||
</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 src="/static/vendor/vue.global.prod.js"></script>
|
||
<script src="/static/app.js"></script>
|
||
</body>
|
||
</html>
|