diff --git a/app/api/deps.py b/app/api/deps.py
index 254cad4..862a67a 100644
--- a/app/api/deps.py
+++ b/app/api/deps.py
@@ -1,27 +1,74 @@
import os
import logging
-from fastapi import Depends, HTTPException, Security
+from dataclasses import dataclass
+from typing import Optional
+from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN
from dotenv import load_dotenv
+from sqlalchemy import select
+
+from app.core.database import async_session
+from app.models.api_key import ApiKeyModel
load_dotenv()
logger = logging.getLogger(__name__)
-API_KEY = os.getenv("IGNIS_API_KEY")
-if not API_KEY:
+MASTER_KEY = os.getenv("IGNIS_API_KEY")
+if not MASTER_KEY:
logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!")
API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
-async def verify_token(header_value: str = Depends(api_key_header)):
- if not API_KEY:
- return None
- if header_value == API_KEY:
- return header_value
- raise HTTPException(
- status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
- )
+@dataclass
+class AuthContext:
+ """Результат авторизации -- передаётся в роуты через Depends."""
+ is_master: bool # мастер-ключ из .env
+ is_admin: bool # право на CRUD групп, расписания, ресканирование
+ key_name: str # имя ключа (для логов)
+
+
+async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext:
+ """
+ Проверка API-ключа:
+ 1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ
+ 2. Мастер-ключ из .env -- полный доступ
+ 3. Ключ из БД (api_keys) -- проверяем active и is_admin
+ 4. Иначе -- 403
+ """
+ # Авторизация отключена
+ if not MASTER_KEY:
+ return AuthContext(is_master=True, is_admin=True, key_name="no-auth")
+
+ if not header_value:
+ raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="API-ключ не передан")
+
+ # Мастер-ключ
+ if header_value == MASTER_KEY:
+ return AuthContext(is_master=True, is_admin=True, key_name="master")
+
+ # Ищем в БД
+ async with async_session() as session:
+ result = await session.execute(
+ select(ApiKeyModel).where(ApiKeyModel.key == header_value)
+ )
+ api_key = result.scalar_one_or_none()
+
+ if api_key and api_key.active:
+ return AuthContext(
+ is_master=False,
+ is_admin=api_key.is_admin,
+ key_name=api_key.name,
+ )
+
+ raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Неверный или деактивированный ключ")
+
+
+def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext:
+ """Dependency для роутов, требующих админских прав."""
+ if not auth.is_admin:
+ raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав")
+ return auth
diff --git a/app/api/routes/api_keys.py b/app/api/routes/api_keys.py
new file mode 100644
index 0000000..2d5aa9c
--- /dev/null
+++ b/app/api/routes/api_keys.py
@@ -0,0 +1,85 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+
+from app.core.database import async_session
+from app.models.api_key import ApiKeyModel
+from app.api.deps import require_admin, AuthContext
+
+# Все операции с ключами -- только для админов (мастер-ключ)
+router = APIRouter(dependencies=[Depends(require_admin)])
+
+
+@router.get("")
+async def list_keys():
+ """Список всех гостевых ключей."""
+ async with async_session() as session:
+ result = await session.execute(select(ApiKeyModel))
+ keys = result.scalars().all()
+
+ return [
+ {
+ "key": k.key,
+ "name": k.name,
+ "is_admin": k.is_admin,
+ "active": k.active,
+ "created_at": k.created_at,
+ }
+ for k in keys
+ ]
+
+
+@router.post("")
+async def create_key(name: str, is_admin: bool = False):
+ """Создать гостевой ключ. Возвращает сгенерированный токен."""
+ new_key = ApiKeyModel(
+ key=ApiKeyModel.generate_key(),
+ name=name,
+ is_admin=is_admin,
+ )
+
+ async with async_session() as session:
+ session.add(new_key)
+ await session.commit()
+
+ return {
+ "key": new_key.key,
+ "name": new_key.name,
+ "is_admin": new_key.is_admin,
+ "message": "Сохраните ключ -- он больше не будет показан полностью",
+ }
+
+
+@router.delete("/{key}")
+async def revoke_key(key: str):
+ """Деактивировать (отозвать) гостевой ключ."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(ApiKeyModel).where(ApiKeyModel.key == key)
+ )
+ api_key = result.scalar_one_or_none()
+ if not api_key:
+ raise HTTPException(status_code=404, detail="Ключ не найден")
+
+ api_key.active = False
+ session.add(api_key)
+ await session.commit()
+
+ return {"status": "revoked", "name": api_key.name}
+
+
+@router.post("/{key}/activate")
+async def activate_key(key: str):
+ """Повторно активировать ключ."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(ApiKeyModel).where(ApiKeyModel.key == key)
+ )
+ api_key = result.scalar_one_or_none()
+ if not api_key:
+ raise HTTPException(status_code=404, detail="Ключ не найден")
+
+ api_key.active = True
+ session.add(api_key)
+ await session.commit()
+
+ return {"status": "activated", "name": api_key.name}
diff --git a/app/api/routes/devices.py b/app/api/routes/devices.py
index 7ebaae4..9d522f3 100644
--- a/app/api/routes/devices.py
+++ b/app/api/routes/devices.py
@@ -3,7 +3,7 @@ from sqlalchemy import select
from app.core.state import state_manager, discovery_service
from app.core.database import async_session
from app.models.device import GroupModel, GroupCreateSchema
-from app.api.deps import verify_token
+from app.api.deps import verify_token, require_admin
from app.drivers.wiz import WizDriver
# Создаем роутер с защитой
@@ -26,7 +26,7 @@ async def get_scenes():
return wiz.SCENES
-@router.post("/groups")
+@router.post("/groups", dependencies=[Depends(require_admin)])
async def create_group(data: GroupCreateSchema):
async with async_session() as session:
existing = await session.get(GroupModel, data.id)
@@ -40,7 +40,7 @@ async def create_group(data: GroupCreateSchema):
return {"status": "created", "group": data.name}
-@router.delete("/groups/{group_id}")
+@router.delete("/groups/{group_id}", dependencies=[Depends(require_admin)])
async def delete_group(group_id: str):
async with async_session() as session:
result = await session.execute(
@@ -56,7 +56,7 @@ async def delete_group(group_id: str):
return {"status": "deleted", "id": group_id}
-@router.post("/rescan")
+@router.post("/rescan", dependencies=[Depends(require_admin)])
async def rescan_network():
found_devices = await discovery_service.scan_network()
for dev_data in found_devices:
diff --git a/app/api/routes/schedules.py b/app/api/routes/schedules.py
index 1e30ce5..4531bdd 100644
--- a/app/api/routes/schedules.py
+++ b/app/api/routes/schedules.py
@@ -8,11 +8,11 @@ from apscheduler.triggers.date import DateTrigger
from app.core.scheduler import app_tz, scheduler
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
-from app.api.deps import verify_token
+from app.api.deps import require_admin
logger = logging.getLogger(__name__)
-router = APIRouter(dependencies=[Depends(verify_token)])
+router = APIRouter(dependencies=[Depends(require_admin)])
async def run_group_command(target_id: str, is_group: bool, params: dict):
diff --git a/app/models/api_key.py b/app/models/api_key.py
new file mode 100644
index 0000000..9601410
--- /dev/null
+++ b/app/models/api_key.py
@@ -0,0 +1,21 @@
+import secrets
+from datetime import datetime
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.orm import Mapped, mapped_column
+from app.core.database import Base
+
+
+class ApiKeyModel(Base):
+ """Гостевой API-ключ с ограниченными правами."""
+ __tablename__ = "api_keys"
+
+ key: Mapped[str] = mapped_column(String, primary_key=True)
+ name: Mapped[str] = mapped_column(String) # "Вася", "гости"
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # доступ к CRUD групп, расписаниям
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[str] = mapped_column(String, default=lambda: datetime.now().isoformat())
+
+ @staticmethod
+ def generate_key() -> str:
+ """Генерация безопасного случайного токена."""
+ return secrets.token_urlsafe(32)
diff --git a/main.py b/main.py
index b8f71cb..57bd550 100644
--- a/main.py
+++ b/main.py
@@ -2,7 +2,7 @@ import logging
import asyncio
import os
from contextlib import asynccontextmanager
-from fastapi import FastAPI
+from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
from app.core.database import init_db, async_session
@@ -10,7 +10,8 @@ from app.core.scheduler import start_scheduler
from app.core.state import state_manager, discovery_service
from sqlalchemy import select
from app.models.device import GroupModel
-from app.api.routes import devices, control, schedules
+from app.api.routes import devices, control, schedules, api_keys
+from app.api.deps import verify_token
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -48,6 +49,7 @@ app = FastAPI(title="Ignis Core API", lifespan=lifespan)
app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"])
app.include_router(control.router, prefix="/control", tags=["Control"])
app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"])
+app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"])
# Статика
# Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html
@@ -61,6 +63,11 @@ async def read_index():
return FileResponse("static/index.html")
+@app.get("/auth/me")
+async def auth_me(auth = Depends(verify_token)):
+ return {"is_admin": auth.is_admin, "name": auth.key_name}
+
+
if __name__ == "__main__":
import uvicorn
diff --git a/static/index.html b/static/index.html
index 4774cc2..97c5381 100644
--- a/static/index.html
+++ b/static/index.html
@@ -9,75 +9,25 @@
@@ -85,27 +35,22 @@
-
+
Ignis
-
API-ключ из .env сервера
-
API-ключ
+
-
+
-
+
-
-
@@ -113,101 +58,66 @@
IgnisCore
-
+
+ {{ authName }}
+ гость
+
+
-
-
-
+
-
-
Создайте группу в админке
+
Создайте группу в админке
+
Нет доступных групп
-
-
-
+
{{ group.name }}
-
+
-
- {{ id }} · {{ group.device_ids?.length || 0 }} ламп
-
+ {{ id }} · {{ group.device_ids?.length || 0 }} ламп
-
-
-
-
-
- Яркость
- {{ sliders[id]?.brightness || 100 }}%
-
-
+
Яркость{{ sliders[id]?.brightness || 100 }}%
+
-
-
- Температура
- {{ sliders[id]?.temp || 4000 }}K
-
-
+
Температура{{ sliders[id]?.temp || 4000 }}K
+
-
-
-
-
-
+
+
Новая задача
@@ -228,7 +136,6 @@
-
-
-
-
- ДОБАВИТЬ
-
+
ДОБАВИТЬ
-
-
Активные задачи
-
+
{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}
{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}
-
- {{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
-
+ {{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
{{ task.target_id }}
@@ -274,8 +170,7 @@
· {{ new Date(task.next_run).toLocaleDateString() }}
-
+
@@ -284,34 +179,22 @@
-
-
+
+
Устройства в сети
-
+
🔄 СКАНИРОВАТЬ
-
-
-
-
-
- СОЗДАТЬ ГРУППУ
-
+
+
+ СОЗДАТЬ ГРУППУ
-
-
-
+
-
-
- Устройства не найдены. Нажмите "Сканировать".
-
+
Устройства не найдены. Нажмите "Сканировать".
-
-
+
-
- {{ toast.text }}
-
+
{{ toast.text }}
@@ -351,260 +226,122 @@
apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '',
tab: 'control',
- groups: {},
- devices: [],
- sliders: {},
+ isAdmin: false,
+ authName: '',
+ groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] },
- isLoading: false,
- isLoadingStatus: false,
- isFetching: false, // защита от параллельных fetchData
- isRescanning: false,
- taskHour: '22',
- taskMin: '00',
+ isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
+ taskHour: '22', taskMin: '00',
newTask: { target_id: '', state: true },
- tasks: [],
- allScenes: {}, // загружается с бэкенда
- toasts: [],
- toastCounter: 0,
+ tasks: [], allScenes: {},
+ toasts: [], toastCounter: 0,
}
},
methods: {
- // ─── Утилиты ─────────────────────────────────
saveKey() {
- if (this.tempKey) {
- this.apiKey = this.tempKey;
- localStorage.setItem('ignis_key', this.tempKey);
- this.fetchData();
- }
+ if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
},
- logout() {
- this.apiKey = '';
- localStorage.removeItem('ignis_key');
- location.reload();
- },
- // Toast-уведомление
+ 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);
- },
- // Имя группы по id (для расписаний)
- getGroupName(targetId) {
- const g = this.groups[targetId];
- return g ? g.name : null;
+ 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; },
- // ─── HTTP ────────────────────────────────────
async request(path, method = 'GET', params = null, body = null) {
let url = path;
- if (params) {
- const q = new URLSearchParams(params).toString();
- url += `?${q}`;
- }
+ if (params) url += `?${new URLSearchParams(params).toString()}`;
try {
- const response = await fetch(url, {
- method,
- headers: {
- 'X-API-Key': this.apiKey,
- 'Content-Type': 'application/json'
- },
- body: body ? JSON.stringify(body) : null
- });
- if (response.status === 403) {
- this.toast('Неверный API-ключ', 'error');
- this.logout();
- return null;
+ 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 (!response.ok) {
- const err = await response.json().catch(() => ({}));
- this.toast(err.detail || `Ошибка ${response.status}`, 'error');
- return null;
- }
- return await response.json();
- } catch (e) {
- this.toast('Сервер недоступен', 'error');
- return null;
- }
+ 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'),
+ 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 };
- }
- });
+ Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
await this.syncGroupStatuses();
}
- if (dData) {
- // Бэкенд может вернуть dict или list
- this.devices = Array.isArray(dData) ? dData : Object.values(dData);
- }
- // Сцены с бэкенда (dict: {name: sceneId})
+ if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
if (sData) this.allScenes = sData;
- this.fetchTasks();
- } finally {
- this.isFetching = false;
- }
+ if (this.isAdmin) this.fetchTasks();
+ } finally { this.isFetching = false; }
},
- // ─── Управление ──────────────────────────────
- async control(id, params) {
- await this.request(`/control/group/${id}`, 'POST', params);
- // Не делаем полный syncGroupStatuses -- ждём следующий цикл
- },
- toggleGroup(id, state) {
- if (this.sliders[id]) {
- this.sliders[id].state = state;
- }
- this.control(id, { state });
- },
- setBrightness(id, val) {
- if (this.sliders[id]) this.sliders[id].brightness = val;
- this.control(id, { brightness: val });
- },
- setTemp(id, val) {
- if (this.sliders[id]) this.sliders[id].temp = val;
- this.control(id, { temp: val });
- },
+ 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) {
- const r = parseInt(hex.slice(1,3),16);
- const g = parseInt(hex.slice(3,5),16);
- const b = parseInt(hex.slice(5,7),16);
- this.control(id, { r, g, b });
- },
+ 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';
- }
+ const res = await this.request('/devices/groups', 'POST', null, { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs });
+ if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
},
async deleteGroup(id) {
const name = this.groups[id]?.name || id;
- if (confirm(`Удалить группу "${name}"?`)) {
- await this.request(`/devices/groups/${id}`, 'DELETE');
- this.toast(`Группа "${name}" удалена`, 'success');
- await this.fetchData();
- }
+ 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);
+ this.isRescanning = true; await this.request('/devices/rescan', 'POST'); this.toast('Сканирование...', 'info');
+ setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
},
- async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
+ async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); },
- // ─── Синхронизация состояния ─────────────────
async syncGroupStatuses() {
- if (this.isLoadingStatus) return;
- this.isLoadingStatus = true;
-
+ if (this.isLoadingStatus) return; this.isLoadingStatus = true;
try {
- // Параллельный опрос всех групп
- const groupIds = Object.keys(this.groups);
- const results = await Promise.all(
- groupIds.map(id => this.request(`/control/group/${id}/status`))
- );
-
- groupIds.forEach((id, i) => {
- const data = results[i];
- if (data && data.results && data.results.length > 0) {
- const firstValid = data.results.find(r => r.status && !r.error);
- if (firstValid) {
- const s = firstValid.status;
- this.sliders[id] = {
- brightness: s.dimming || 100,
- temp: s.temp || 4000,
- state: s.state || false,
- };
- }
+ 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;
- }
+ } finally { this.isLoadingStatus = false; }
},
- // ─── Расписания ──────────────────────────────
- async fetchTasks() {
- const data = await this.request('/schedules/tasks');
- if (data) this.tasks = data.tasks || [];
- },
+ 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();
+ 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();
- }
+ const res = await this.request('/schedules/once', 'POST', { target_id: id, hours_from_now: 4, is_group: true, state: false });
+ if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
},
},
- async mounted() {
- if (this.apiKey) {
- this.isLoading = true;
- await this.fetchData();
- this.isLoading = false;
- // Периодический опрос с защитой от наложений
- setInterval(() => this.fetchData(), 15000);
- }
- }
+ async mounted() { if (this.apiKey) await this.initApp(); }
}).mount('#app')