Refine built-in web app experience
This commit is contained in:
@@ -9,12 +9,11 @@
|
||||
<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 id="app" v-cloak class="max-w-7xl 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">
|
||||
<div class="brand-mark w-16 h-16 mx-auto mb-6 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>
|
||||
@@ -31,398 +30,568 @@
|
||||
</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>
|
||||
<header class="mb-8 md:mb-10 fade-up">
|
||||
<section class="hero-panel rounded-[2rem] p-6 md:p-8 mb-5">
|
||||
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="brand-mark shrink-0 w-14 h-14 rounded-2xl flex items-center justify-center">
|
||||
<svg class="w-7 h-7 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 class="space-y-3 min-w-0">
|
||||
<div>
|
||||
<div class="text-[11px] font-black uppercase tracking-[0.22em] text-orange-300">Ignis Core</div>
|
||||
<h1 class="text-3xl md:text-4xl font-black tracking-tight">{{ serverDisplayName() }}</h1>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px] text-slate-400">
|
||||
<span v-if="serverDisplaySubtitle() !== serverDisplayName()">{{ serverDisplaySubtitle() }}</span>
|
||||
<span v-if="serverInfo?.build?.version && serverDisplaySubtitle() !== serverDisplayName()">·</span>
|
||||
<span v-if="serverInfo?.build?.version">версия {{ serverInfo.build.version }}</span>
|
||||
<span v-if="serverDiscovery?.last_scan_at && (serverInfo?.build?.version || serverDisplaySubtitle() !== serverDisplayName())">·</span>
|
||||
<span v-if="serverDiscovery?.last_scan_at">discovery {{ formatRelativeTimestamp(serverDiscovery.last_scan_at) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="status-pill text-slate-200 border-slate-600/60 bg-black/20">{{ roleLabel }}</span>
|
||||
<span v-if="authName" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ authName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 xl:min-w-[320px]">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Группы</div>
|
||||
<div class="metric-value">{{ groupCount }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Онлайн-лампы</div>
|
||||
<div class="metric-value">{{ onlineDeviceCount }}</div>
|
||||
</div>
|
||||
<div class="metric-card" v-if="isAdmin">
|
||||
<div class="metric-label">Задачи</div>
|
||||
<div class="metric-value">{{ scheduleCount }}</div>
|
||||
</div>
|
||||
<div class="metric-card col-span-2">
|
||||
<div class="metric-label">Запущен</div>
|
||||
<div class="metric-value text-xl">{{ formatRelativeUptime(serverInfo?.uptime_seconds) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<nav class="glass rounded-2xl p-1.5 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="tabItem in visibleTabs"
|
||||
:key="tabItem.id"
|
||||
@click="openTab(tabItem.id)"
|
||||
:class="tab === tabItem.id ? 'active-tab text-white' : 'text-slate-400 hover:text-slate-200'"
|
||||
class="px-4 md:px-5 py-2.5 rounded-xl font-bold text-xs md:text-sm tracking-wide transition-all"
|
||||
>
|
||||
{{ tabItem.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="refreshWorkspace" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide hover:text-orange-300 transition-colors">ОБНОВИТЬ</button>
|
||||
<button v-if="isAdmin" @click="rescan" :disabled="isRescanning" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-200 transition-colors disabled:opacity-50">
|
||||
{{ isRescanning ? 'СКАНИРУЮ...' : 'ПЕРЕСКАНИРОВАТЬ' }}
|
||||
</button>
|
||||
<button @click="logout" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-slate-400 hover:text-red-300 transition-colors">ВЫЙТИ</button>
|
||||
</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 v-if="tab === 'control' && !isLoading" class="space-y-8 fade-up">
|
||||
<section class="glass rounded-3xl p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-5 mb-5">
|
||||
<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 class="section-kicker">Пульт</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">Комнаты, сцены и свет</h2>
|
||||
</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 v-if="featuredScenes.length > 0" class="flex flex-wrap gap-2">
|
||||
<span class="text-[11px] uppercase tracking-[0.22em] text-slate-500 mr-1 self-center">Быстрые сцены</span>
|
||||
<span v-for="scene in featuredScenes" :key="scene" class="status-pill text-slate-200 border-slate-700/70 bg-black/20">{{ scene }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sortedGroups.length === 0" class="empty-state">{{ isAdmin ? 'Создайте группу на вкладке устройств, чтобы управлять домом через веб-интерфейс.' : 'Пока нет доступных групп света.' }}</div>
|
||||
<div v-else class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<article v-for="entry in sortedGroups" :key="entry[0]" class="glass rounded-3xl p-6 transition-all" :class="sliders[entry[0]]?.state ? 'group-active' : ''">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-2xl font-black">{{ entry[1].name }}</h3>
|
||||
<span :class="sliders[entry[0]]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2.5 h-2.5 rounded-full"></span>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 mt-2">
|
||||
{{ getGroupOnlineCount(entry[1]) }}/{{ getGroupDeviceCount(entry[1]) }} ламп онлайн · {{ entry[0] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="toggleGroup(entry[0], true)" :class="sliders[entry[0]]?.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВКЛЮЧИТЬ</button>
|
||||
<button @click="toggleGroup(entry[0], false)" :class="!sliders[entry[0]]?.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВЫКЛЮЧИТЬ</button>
|
||||
<button v-if="isAdmin" @click="setTimer4h(entry[0])" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-100 transition-colors">ТАЙМЕР 4Ч</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="surface-card">
|
||||
<div class="metric-label">Яркость</div>
|
||||
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.brightness || 100 }}%</div>
|
||||
<input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.brightness || 100" @change="setBrightness(entry[0], +$event.target.value)">
|
||||
</div>
|
||||
<div class="surface-card">
|
||||
<div class="metric-label">Температура</div>
|
||||
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.temp || 4000 }}K</div>
|
||||
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.temp || 4000" @change="setTemp(entry[0], +$event.target.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[140px,1fr] gap-4">
|
||||
<div class="surface-card">
|
||||
<div class="metric-label mb-3">Цвет</div>
|
||||
<input type="color" class="w-full h-12 bg-transparent border border-slate-700/50 rounded-xl cursor-pointer" :disabled="!sliders[entry[0]]?.state" @input="setColor(entry[0], $event.target.value)">
|
||||
</div>
|
||||
<div class="surface-card">
|
||||
<div class="metric-label mb-3">Сцены</div>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<button v-for="scene in featuredScenes" :key="scene" @click="setScene(entry[0], scene)" :disabled="!sliders[entry[0]]?.state" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-white transition-colors disabled:opacity-30">
|
||||
{{ scene }}
|
||||
</button>
|
||||
</div>
|
||||
<select @change="setScene(entry[0], $event.target.value); $event.target.value=''" :disabled="!sliders[entry[0]]?.state" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm outline-none focus:border-orange-500 disabled:opacity-30">
|
||||
<option value="" disabled selected>Все сцены...</option>
|
||||
<option v-for="scene in sceneOptions" :key="scene" :value="scene">{{ scene }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'devices' && !isLoading && isAdmin" class="space-y-8 fade-up">
|
||||
<section class="glass rounded-3xl p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="section-kicker">Устройства и группы</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">Устройства</h2>
|
||||
</div>
|
||||
<button @click="rescan" :disabled="isRescanning" class="accent-button disabled:opacity-50">{{ isRescanning ? 'СКАНИРУЮ...' : 'СКАНИРОВАТЬ СЕТЬ' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden space-y-3">
|
||||
<article v-for="device in sortedDevices" :key="device.id" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold truncate">{{ device.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Локация: {{ deviceLocationLabel(device) }}</div>
|
||||
</div>
|
||||
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors shrink-0">BLINK</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 mt-4 text-[11px] text-slate-500 mono">
|
||||
<div>IP: <span class="text-slate-300">{{ device.ip }}</span></div>
|
||||
<div>MAC: <span class="text-slate-300 break-all">{{ device.id }}</span></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
<table class="w-full min-w-[720px] table-fixed ui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Устройство</th>
|
||||
<th>Локация</th>
|
||||
<th>IP</th>
|
||||
<th>MAC</th>
|
||||
<th class="text-right">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in sortedDevices" :key="device.id">
|
||||
<td>
|
||||
<div class="font-bold">{{ device.name }}</div>
|
||||
</td>
|
||||
<td class="text-slate-400">{{ deviceLocationLabel(device) }}</td>
|
||||
<td class="mono text-slate-300">{{ device.ip }}</td>
|
||||
<td class="mono text-slate-500">{{ device.id }}</td>
|
||||
<td class="text-right">
|
||||
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="sortedDevices.length === 0" class="empty-state mt-4">Устройства не найдены. Запустите discovery и проверьте подсеть.</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 xl:grid-cols-[1.2fr,0.8fr] gap-6">
|
||||
<div class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">Новая группа</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Собрать комнату из найденных ламп</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
|
||||
<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.name || !newGroup.macs.length" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ГРУППУ</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div v-for="device in sortedDevices" :key="device.id" class="surface-row flex-col items-stretch sm:flex-row sm:items-center">
|
||||
<label class="flex items-center gap-3 cursor-pointer flex-1 min-w-0">
|
||||
<input type="checkbox" :value="device.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold truncate">{{ device.name }}</div>
|
||||
<div class="text-[11px] mono text-slate-500 truncate">{{ device.ip }} · {{ device.id }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex items-center justify-between gap-2 shrink-0 sm:max-w-[50%]">
|
||||
<span class="text-xs text-slate-500 truncate">{{ deviceLocationLabel(device) }}</span>
|
||||
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">Существующие группы</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Комнаты и их состав</h2>
|
||||
<div class="space-y-4">
|
||||
<article v-for="entry in sortedGroups" :key="entry[0]" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div>
|
||||
<h3 class="font-black text-lg">{{ entry[1].name }}</h3>
|
||||
<div class="text-[11px] mono text-slate-500 mt-1">{{ entry[0] }}</div>
|
||||
</div>
|
||||
<button @click="deleteGroup(entry[0])" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="deviceName in getGroupDeviceNames(entry[1])" :key="deviceName" class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ deviceName }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="sortedGroups.length === 0" class="empty-state">Групп пока нет.</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'automation' && !isLoading && isAdmin" class="space-y-8 fade-up">
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">One-shot</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовая задача</h2>
|
||||
<div class="space-y-4">
|
||||
<select v-model="onceForm.target_id" class="w-full 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="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button @click="onceForm.state = true" :class="onceForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
|
||||
<button @click="onceForm.state = false" :class="!onceForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
|
||||
</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 class="grid grid-cols-2 gap-3">
|
||||
<button @click="onceForm.mode = 'hours'" :class="onceForm.mode === 'hours' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Через N часов</button>
|
||||
<button @click="onceForm.mode = 'datetime'" :class="onceForm.mode === 'datetime' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Точное время</button>
|
||||
</div>
|
||||
|
||||
<div v-if="onceForm.mode === 'hours'">
|
||||
<select v-model="onceForm.hours_from_now" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||
<option :value="1">Через 1 час</option>
|
||||
<option :value="2">Через 2 часа</option>
|
||||
<option :value="4">Через 4 часа</option>
|
||||
<option :value="8">Через 8 часов</option>
|
||||
<option :value="12">Через 12 часов</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else>
|
||||
<input v-model="onceForm.run_at" type="datetime-local" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||
</div>
|
||||
|
||||
<button @click="createOnceTask" :disabled="!onceForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ONE-SHOT</button>
|
||||
</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 class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">Cron</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющееся расписание</h2>
|
||||
<div class="space-y-4">
|
||||
<select v-model="cronForm.target_id" class="w-full 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="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button @click="cronForm.state = true" :class="cronForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
|
||||
<button @click="cronForm.state = false" :class="!cronForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr,24px,1fr] items-center gap-2">
|
||||
<select v-model="cronHour" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
|
||||
<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="text-center text-slate-500 font-bold">:</span>
|
||||
<select v-model="cronMinute" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
|
||||
<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="cronForm.day_of_week" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||
<option value="*">Каждый день</option>
|
||||
<option value="mon-fri">Будни</option>
|
||||
<option value="sat,sun">Выходные</option>
|
||||
<option value="mon">Понедельник</option>
|
||||
<option value="tue">Вторник</option>
|
||||
<option value="wed">Среда</option>
|
||||
<option value="thu">Четверг</option>
|
||||
<option value="fri">Пятница</option>
|
||||
<option value="sat">Суббота</option>
|
||||
<option value="sun">Воскресенье</option>
|
||||
</select>
|
||||
|
||||
<button @click="createCronTask" :disabled="!cronForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОХРАНИТЬ CRON</button>
|
||||
</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>
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">Cron</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющиеся задачи</h2>
|
||||
<div class="space-y-3">
|
||||
<article v-for="task in cronTasks" :key="task.id" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ formatTaskTime(task) }}</span>
|
||||
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
|
||||
</div>
|
||||
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
|
||||
</div>
|
||||
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="cronTasks.length === 0" class="empty-state">Cron-задач пока нет.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">One-shot</div>
|
||||
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовые задачи</h2>
|
||||
<div class="space-y-3">
|
||||
<article v-for="task in onceTasks" :key="task.id" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="status-pill text-blue-200 border-blue-500/30 bg-blue-500/10">{{ formatTaskTime(task) }}</span>
|
||||
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
|
||||
</div>
|
||||
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
|
||||
</div>
|
||||
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="onceTasks.length === 0" class="empty-state">Одноразовых задач пока нет.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'access' && !isLoading && isAdmin" class="space-y-8 fade-up">
|
||||
<section class="grid grid-cols-1 xl:grid-cols-[1.1fr,0.9fr] gap-6">
|
||||
<div class="glass rounded-3xl p-6">
|
||||
<div class="flex items-center justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<div class="section-kicker">Аудит</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">Активность за период</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-for="days in [1, 7, 30]" :key="days" @click="statsDays = days; fetchStats()"
|
||||
:class="statsDays === days ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'"
|
||||
class="px-3 py-2 rounded-xl text-xs font-bold transition-all">
|
||||
{{ days === 1 ? 'Сегодня' : `${days}д` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<article v-for="summary in statsData" :key="summary.target_id" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h3 class="font-black text-lg">{{ getGroupName(summary.target_id) || summary.target_id }}</h3>
|
||||
<div class="text-[11px] text-slate-500 mt-1">{{ summary.total_commands }} команд · {{ summary.estimated_hours }} ч активности</div>
|
||||
</div>
|
||||
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ summary.toggles_on }}/{{ summary.toggles_off }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="(count, actor) in summary.by_user" :key="actor" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ actor }}: {{ count }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="statsData.length === 0" class="empty-state md:col-span-2">Нет статистики за выбранный период.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="isMaster" class="glass rounded-3xl p-6">
|
||||
<div class="section-kicker">Доступ</div>
|
||||
<h2 class="text-2xl font-black tracking-tight mb-5">Гостевые и админ-ключи</h2>
|
||||
<div class="grid grid-cols-1 gap-3 mb-5">
|
||||
<input v-model="newKeyName" placeholder="Имя ключа (гости, кухня, wall-panel)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
|
||||
<label class="surface-row cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
|
||||
<span class="text-sm text-slate-300">Выдать админ-права этому ключу</span>
|
||||
</div>
|
||||
</label>
|
||||
<button @click="createApiKey" :disabled="!newKeyName.trim()" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ КЛЮЧ</button>
|
||||
</div>
|
||||
|
||||
<div v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-2xl mb-5">
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-green-300">Скопируйте новый ключ сейчас</span>
|
||||
<button @click="copyKey(lastCreatedKey)" class="text-xs font-bold text-green-300 hover:text-green-100 transition-colors">КОПИРОВАТЬ</button>
|
||||
</div>
|
||||
<div class="mono text-sm text-green-200 break-all select-all">{{ lastCreatedKey }}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<article v-for="item in apiKeys" :key="item.key" class="surface-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="font-black">{{ item.name }}</span>
|
||||
<span class="status-pill" :class="item.is_admin ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'">{{ item.is_admin ? 'админ' : 'гость' }}</span>
|
||||
<span class="status-pill" :class="item.active ? 'text-green-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'">{{ item.active ? 'активен' : 'отозван' }}</span>
|
||||
</div>
|
||||
<div class="mono text-[11px] text-slate-500">{{ item.display_key || item.key }}</div>
|
||||
</div>
|
||||
<button v-if="item.active" @click="revokeApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">ОТОЗВАТЬ</button>
|
||||
<button v-else @click="activateApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-green-300 hover:text-green-200 transition-colors">АКТИВИРОВАТЬ</button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="apiKeys.length === 0" class="empty-state">Ключей пока нет.</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="glass rounded-3xl p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<div class="section-kicker">Лог событий</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">Действия пользователей и автоматики</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 lg:min-w-[420px]">
|
||||
<select v-model="logFilterActor" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||
<option value="all">Все пользователи</option>
|
||||
<option v-for="actor in availableLogActors" :key="actor" :value="actor">{{ actor }}</option>
|
||||
</select>
|
||||
<select v-model="logFilterAction" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
|
||||
<option value="all">Все действия</option>
|
||||
<option v-for="action in availableLogActions" :key="action" :value="action">{{ action }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 max-h-[36rem] overflow-y-auto pr-1">
|
||||
<article v-for="event in filteredEventLog" :key="event.id" class="surface-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ event.key_name }}</span>
|
||||
<span class="font-bold">{{ event.action }}</span>
|
||||
<span class="text-sm text-slate-400">{{ getGroupName(event.target_id) || event.target_id }}</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500">{{ formatTime(event.timestamp) }} · {{ event.target_type }}</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 mono">{{ event.id }}</div>
|
||||
</article>
|
||||
<div v-if="filteredEventLog.length === 0" class="empty-state">Под выбранные фильтры событий нет.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'server' && !isLoading" class="space-y-8 fade-up">
|
||||
<section v-if="serverInfo" class="glass rounded-3xl p-6 md:p-7">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<div class="section-kicker">О сервере</div>
|
||||
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
|
||||
<div class="text-sm text-slate-400 mt-2">{{ serverDisplaySubtitle() }}</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-[10px]">
|
||||
<span class="status-pill text-green-200 border-green-500/30 bg-green-500/10">онлайн</span>
|
||||
<span :class="isAdmin ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
|
||||
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
|
||||
</span>
|
||||
<span v-if="serverInfo.diagnostics_visible" class="status-pill text-orange-200 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="surface-card">
|
||||
<div class="metric-label">Запущен</div>
|
||||
<div class="text-lg font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}</div>
|
||||
</div>
|
||||
<div class="surface-card">
|
||||
<div class="metric-label">Discovery</div>
|
||||
<div class="text-lg font-bold">{{ serverDiscovery?.last_scan_at ? formatRelativeTimestamp(serverDiscovery.last_scan_at) : 'ещё не выполнялся' }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-2">{{ serverDiscovery?.last_scan_mode ? `режим: ${formatDiscoveryMode(serverDiscovery.last_scan_mode)}` : 'статус появится после первого скана' }}</div>
|
||||
</div>
|
||||
<div class="surface-card">
|
||||
<div class="metric-label">Доступ</div>
|
||||
<div class="text-lg font-bold">{{ roleLabel }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-2">{{ isAdmin ? 'Служебные вкладки открыты в этом сеансе.' : 'Чувствительные параметры скрыты.' }}</div>
|
||||
</div>
|
||||
<div v-if="serverInfo.diagnostics_visible" class="surface-card">
|
||||
<div class="metric-label">Версия</div>
|
||||
<div class="text-lg font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
|
||||
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="serverInfo?.diagnostics_visible" class="glass rounded-3xl p-6 md:p-7">
|
||||
<div class="section-kicker">Диагностика</div>
|
||||
<h2 class="text-2xl font-black tracking-tight 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="surface-card">
|
||||
<div class="metric-label">Сборка</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="surface-card">
|
||||
<div class="metric-label">Таймзона</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 class="surface-card">
|
||||
<div class="metric-label">Публичный 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 class="surface-card">
|
||||
<div class="metric-label">Наблюдаемый 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">
|
||||
<span :class="serverInfo.configuration?.configured ? 'text-green-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'" class="status-pill">
|
||||
{{ 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">
|
||||
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
|
||||
{{ 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">
|
||||
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-200 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
|
||||
{{ 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">
|
||||
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
|
||||
{{ 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 v-for="toast in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-200' : toast.type === 'success' ? 'border-green-500/30 text-green-200' : 'border-slate-700 text-slate-200'" 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user