Перевод защищённой части приложения на Vue.js + Inertia
Дашборд, зоны, устройства, правила автоматизации и профиль теперь Vue 3 + Inertia вместо Blade+Alpine — гостевые страницы Breeze (логин/регистрация) сознательно оставлены на Blade, чтобы не трогать уже протестированную аутентификацию. Контроллеры возвращают Inertia::render() вместо view(), переиспользуют существующие API Resources и FormRequest. Ключевой нюанс: Resource/ResourceCollection, переданные напрямую в проп Inertia, заворачиваются в ключ "data" (Inertia вызывает toResponse() у Responsable-объектов) — везде используется ->resolve() и явное резолвление вложенных ресурсов (zone/device_type внутри Device и т.п.), иначе вложенные поля тоже задваивались бы обёрткой. Alpine.js и все Blade-вьюхи защищённой части удалены как мёртвый код. Тесты (ZoneControllerTest/DeviceControllerTest) переведены на assertInertia(). UserFactory теперь явно задаёт role=owner — без этого Eloquent не подтягивает DB-default обратно в модель после create(), что уронило бы общий auth.user проп на любой странице.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
class: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" :class="class">
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
href: { type: String, required: true },
|
||||
active: { type: Boolean, default: false },
|
||||
mobile: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link
|
||||
:href="href"
|
||||
:class="[
|
||||
mobile
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 text-start text-base font-medium transition duration-150 ease-in-out focus:outline-none'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none',
|
||||
active
|
||||
? mobile
|
||||
? 'border-indigo-400 text-indigo-700 bg-indigo-50 focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700'
|
||||
: 'border-indigo-400 text-gray-900 focus:border-indigo-700'
|
||||
: mobile
|
||||
? 'border-transparent text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, required: true },
|
||||
});
|
||||
|
||||
const classes = {
|
||||
online: 'bg-green-100 text-green-800',
|
||||
offline: 'bg-gray-100 text-gray-600',
|
||||
unknown: 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs" :class="classes[status] ?? classes.unknown">{{ status }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import NavLink from '@/Components/NavLink.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
const flash = computed(() => page.props.flash);
|
||||
|
||||
const currentPath = computed(() => new URL(page.url, window.location.origin).pathname);
|
||||
const isActive = (prefix) => currentPath.value === prefix || currentPath.value.startsWith(`${prefix}/`);
|
||||
|
||||
const showingMobileMenu = ref(false);
|
||||
const showingUserDropdown = ref(false);
|
||||
|
||||
function logout() {
|
||||
router.post(routes.logout);
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ href: routes.dashboard, label: 'Dashboard', prefix: routes.dashboard },
|
||||
{ href: routes.zones.index, label: 'Зоны', prefix: routes.zones.index },
|
||||
{ href: routes.devices.index, label: 'Устройства', prefix: routes.devices.index },
|
||||
{ href: routes.automationRules.index, label: 'Правила', prefix: routes.automationRules.index },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<nav class="bg-white border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="shrink-0 flex items-center">
|
||||
<Link :href="routes.dashboard">
|
||||
<ApplicationLogo class="block h-9 w-auto fill-current text-gray-800" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
<NavLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:active="isActive(link.prefix)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex sm:items-center sm:ms-6">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"
|
||||
@click="showingUserDropdown = !showingUserDropdown"
|
||||
>
|
||||
<div>{{ user?.name }}</div>
|
||||
<svg class="ms-1 fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="showingUserDropdown"
|
||||
class="absolute end-0 z-50 mt-2 w-48 rounded-md shadow-lg"
|
||||
@click="showingUserDropdown = false"
|
||||
>
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white">
|
||||
<Link :href="routes.profile.edit" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100">
|
||||
Профиль
|
||||
</Link>
|
||||
<button type="button" class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100" @click="logout">
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none transition duration-150 ease-in-out"
|
||||
@click="showingMobileMenu = !showingMobileMenu"
|
||||
>
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path v-if="!showingMobileMenu" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showingMobileMenu" class="sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<NavLink
|
||||
v-for="link in navLinks"
|
||||
:key="link.href"
|
||||
:href="link.href"
|
||||
:active="isActive(link.prefix)"
|
||||
mobile
|
||||
>
|
||||
{{ link.label }}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 pb-1 border-t border-gray-200">
|
||||
<div class="px-4">
|
||||
<div class="font-medium text-base text-gray-800">{{ user?.name }}</div>
|
||||
<div class="font-medium text-sm text-gray-500">{{ user?.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<NavLink :href="routes.profile.edit" mobile>Профиль</NavLink>
|
||||
<button type="button" class="block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300" @click="logout">
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header v-if="$slots.header" class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div v-if="flash.status || flash.error" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
|
||||
<div v-if="flash.status" class="rounded-md bg-green-50 p-4 text-sm text-green-700">{{ flash.status }}</div>
|
||||
<div v-if="flash.error" class="rounded-md bg-red-50 p-4 text-sm text-red-700">{{ flash.error }}</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/AutomationRules/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое правило</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form
|
||||
:zones="zones"
|
||||
:devices="devices"
|
||||
:action-type-options="actionTypeOptions"
|
||||
:sensor-type-options="sensorTypeOptions"
|
||||
:condition-operator-options="conditionOperatorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/AutomationRules/Form.vue';
|
||||
|
||||
defineProps({
|
||||
rule: { type: Object, required: true },
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить правило</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form
|
||||
:rule="rule"
|
||||
:zones="zones"
|
||||
:devices="devices"
|
||||
:action-type-options="actionTypeOptions"
|
||||
:sensor-type-options="sensorTypeOptions"
|
||||
:condition-operator-options="conditionOperatorOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
rule: { type: Object, default: null },
|
||||
zones: { type: Array, required: true },
|
||||
devices: { type: Array, required: true },
|
||||
actionTypeOptions: { type: Array, required: true },
|
||||
sensorTypeOptions: { type: Array, required: true },
|
||||
conditionOperatorOptions: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isEdit = !!props.rule;
|
||||
|
||||
const form = useForm({
|
||||
zone_id: props.rule?.zone_id ?? '',
|
||||
condition_source_device_id: props.rule?.condition_source_device_id ?? '',
|
||||
condition_sensor_type: props.rule?.condition_sensor_type ?? '',
|
||||
condition_operator: props.rule?.condition_operator ?? '',
|
||||
condition_value: props.rule?.condition_value ?? '',
|
||||
target_device_id: props.rule?.target_device_id ?? '',
|
||||
action_type: props.rule?.action_type ?? '',
|
||||
level: props.rule?.level ?? '',
|
||||
is_active: props.rule?.is_active ?? true,
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.automationRules.update(props.rule.id));
|
||||
} else {
|
||||
form.post(routes.automationRules.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
|
||||
<select
|
||||
id="zone_id"
|
||||
v-model="form.zone_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="condition_source_device_id" class="block font-medium text-sm text-gray-700">Устройство-источник показания</label>
|
||||
<select
|
||||
id="condition_source_device_id"
|
||||
v-model="form.condition_source_device_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ device.zone.name }} — {{ device.name }} ({{ device.device_type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.condition_source_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.condition_source_device_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="condition_sensor_type" class="block font-medium text-sm text-gray-700">Тип показания</label>
|
||||
<input
|
||||
id="condition_sensor_type"
|
||||
v-model="form.condition_sensor_type"
|
||||
list="sensor-types"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<datalist id="sensor-types">
|
||||
<option v-for="option in sensorTypeOptions" :key="option" :value="option" />
|
||||
</datalist>
|
||||
<div v-if="form.errors.condition_sensor_type" class="mt-2 text-sm text-red-600">{{ form.errors.condition_sensor_type }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="condition_operator" class="block font-medium text-sm text-gray-700">Оператор</label>
|
||||
<select
|
||||
id="condition_operator"
|
||||
v-model="form.condition_operator"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="op in conditionOperatorOptions" :key="op" :value="op">{{ op }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.condition_operator" class="mt-2 text-sm text-red-600">{{ form.errors.condition_operator }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="condition_value" class="block font-medium text-sm text-gray-700">Порог</label>
|
||||
<input
|
||||
id="condition_value"
|
||||
v-model="form.condition_value"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.condition_value" class="mt-2 text-sm text-red-600">{{ form.errors.condition_value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="target_device_id" class="block font-medium text-sm text-gray-700">Устройство-исполнитель</label>
|
||||
<select
|
||||
id="target_device_id"
|
||||
v-model="form.target_device_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="device in devices" :key="device.id" :value="device.id">
|
||||
{{ device.zone.name }} — {{ device.name }} ({{ device.device_type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.target_device_id" class="mt-2 text-sm text-red-600">{{ form.errors.target_device_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="action_type" class="block font-medium text-sm text-gray-700">Действие</label>
|
||||
<select
|
||||
id="action_type"
|
||||
v-model="form.action_type"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="option in actionTypeOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.action_type" class="mt-2 text-sm text-red-600">{{ form.errors.action_type }}</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.action_type === 'set_level'">
|
||||
<label for="level" class="block font-medium text-sm text-gray-700">Уровень</label>
|
||||
<input
|
||||
id="level"
|
||||
v-model="form.level"
|
||||
type="number"
|
||||
step="any"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.level" class="mt-2 text-sm text-red-600">{{ form.errors.level }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input v-model="form.is_active" type="checkbox" class="rounded border-gray-300 text-indigo-600 shadow-sm">
|
||||
<span class="ms-2 text-sm text-gray-600">Правило активно</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.automationRules.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
rules: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(rule) {
|
||||
if (confirm('Удалить правило?')) {
|
||||
router.delete(routes.automationRules.destroy(rule.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Правила автоматизации</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.automationRules.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить правило
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Зона</th>
|
||||
<th class="py-2">Условие</th>
|
||||
<th class="py-2">Действие</th>
|
||||
<th class="py-2">Активно</th>
|
||||
<th class="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rules.length === 0">
|
||||
<td colspan="5" class="py-4 text-gray-500">Правил пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="rule in rules" :key="rule.id" class="border-b">
|
||||
<td class="py-2">{{ rule.zone.name }}</td>
|
||||
<td class="py-2">
|
||||
{{ rule.condition_source_device.name }}:
|
||||
{{ rule.condition_sensor_type }}
|
||||
{{ rule.condition_operator }}
|
||||
{{ rule.condition_value }}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
{{ rule.target_device.name }}: {{ rule.action_type }}
|
||||
<template v-if="Object.keys(rule.action_params ?? {}).length">
|
||||
({{ JSON.stringify(rule.action_params) }})
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2">{{ rule.is_active ? 'да' : 'нет' }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<Link v-if="isOwner" :href="routes.automationRules.edit(rule.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(rule)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
zonesCount: Number,
|
||||
devicesCount: Number,
|
||||
onlineDevicesCount: Number,
|
||||
activeRulesCount: Number,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Dashboard</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||||
<Link :href="routes.zones.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Зоны</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ zonesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Устройства</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ devicesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.devices.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Онлайн</div>
|
||||
<div class="text-3xl font-semibold text-green-600">{{ onlineDevicesCount }}</div>
|
||||
</Link>
|
||||
<Link :href="routes.automationRules.index" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6 hover:shadow-md transition">
|
||||
<div class="text-sm text-gray-500">Активных правил</div>
|
||||
<div class="text-3xl font-semibold text-gray-900">{{ activeRulesCount }}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Devices/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новое устройство</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :zones="zones" :device-types="deviceTypes" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Devices/Form.vue';
|
||||
|
||||
defineProps({
|
||||
device: { type: Object, required: true },
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить устройство</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :device="device" :zones="zones" :device-types="deviceTypes" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, default: null },
|
||||
zones: { type: Array, required: true },
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isEdit = !!props.device;
|
||||
|
||||
const form = useForm({
|
||||
name: props.device?.name ?? '',
|
||||
zone_id: props.device?.zone_id ?? '',
|
||||
device_type_id: props.device?.device_type_id ?? '',
|
||||
external_id: props.device?.external_id ?? '',
|
||||
protocol: props.device?.protocol ?? 'mqtt',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.devices.update(props.device.id));
|
||||
} else {
|
||||
form.post(routes.devices.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="zone_id" class="block font-medium text-sm text-gray-700">Зона</label>
|
||||
<select
|
||||
id="zone_id"
|
||||
v-model="form.zone_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="zone in zones" :key="zone.id" :value="zone.id">{{ zone.name }}</option>
|
||||
</select>
|
||||
<div v-if="form.errors.zone_id" class="mt-2 text-sm text-red-600">{{ form.errors.zone_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="device_type_id" class="block font-medium text-sm text-gray-700">Тип устройства</label>
|
||||
<select
|
||||
id="device_type_id"
|
||||
v-model="form.device_type_id"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<option value="">— выбрать —</option>
|
||||
<option v-for="type in deviceTypes" :key="type.id" :value="type.id">{{ type.code }} ({{ type.category }})</option>
|
||||
</select>
|
||||
<div v-if="form.errors.device_type_id" class="mt-2 text-sm text-red-600">{{ form.errors.device_type_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="external_id" class="block font-medium text-sm text-gray-700">External ID (идентификатор физического устройства)</label>
|
||||
<input
|
||||
id="external_id"
|
||||
v-model="form.external_id"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.external_id" class="mt-2 text-sm text-red-600">{{ form.errors.external_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="protocol" class="block font-medium text-sm text-gray-700">Протокол</label>
|
||||
<input
|
||||
id="protocol"
|
||||
v-model="form.protocol"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.protocol" class="mt-2 text-sm text-red-600">{{ form.errors.protocol }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import StatusBadge from '@/Components/StatusBadge.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
devices: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(device) {
|
||||
if (confirm('Удалить устройство?')) {
|
||||
router.delete(routes.devices.destroy(device.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Устройства</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.devices.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить устройство
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Название</th>
|
||||
<th class="py-2">Зона</th>
|
||||
<th class="py-2">Тип</th>
|
||||
<th class="py-2">Статус</th>
|
||||
<th class="py-2">External ID</th>
|
||||
<th class="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="6" class="py-4 text-gray-500">Устройств пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="device in devices" :key="device.id" class="border-b">
|
||||
<td class="py-2">
|
||||
<Link :href="routes.devices.show(device.id)" class="text-indigo-600 hover:underline">{{ device.name }}</Link>
|
||||
</td>
|
||||
<td class="py-2">{{ device.zone.name }}</td>
|
||||
<td class="py-2">{{ device.device_type.code }}</td>
|
||||
<td class="py-2"><StatusBadge :status="device.status" /></td>
|
||||
<td class="py-2 font-mono text-xs">{{ device.external_id }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<Link v-if="isOwner" :href="routes.devices.edit(device.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(device)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import StatusBadge from '@/Components/StatusBadge.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, required: true },
|
||||
shadow: { type: Object, required: true },
|
||||
telemetry: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
const capabilities = computed(() => props.device.device_type.capabilities ?? []);
|
||||
const hasControls = computed(() => capabilities.value.some((c) => ['turn_on', 'turn_off', 'set_level'].includes(c)));
|
||||
|
||||
const lastSeenLabel = computed(() => (
|
||||
props.shadow.last_seen ? new Date(props.shadow.last_seen).toLocaleString('ru-RU') : 'нет данных'
|
||||
));
|
||||
|
||||
const levelForm = useForm({ level: '' });
|
||||
|
||||
function turnOn() {
|
||||
useForm({}).post(routes.devices.turnOn(props.device.id));
|
||||
}
|
||||
|
||||
function turnOff() {
|
||||
useForm({}).post(routes.devices.turnOff(props.device.id));
|
||||
}
|
||||
|
||||
function setLevel() {
|
||||
levelForm.post(routes.devices.setLevel(props.device.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ device.name }}</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Живое состояние (Redis device shadow)</h3>
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500">Статус</dt>
|
||||
<dd class="mt-1"><StatusBadge :status="device.status" /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Последняя активность</dt>
|
||||
<dd>{{ lastSeenLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Желаемое состояние (desired)</dt>
|
||||
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.desired_state) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500">Подтверждённое состояние (reported)</dt>
|
||||
<dd class="font-mono text-xs">{{ JSON.stringify(shadow.reported_state) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="isOwner && hasControls" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Ручное управление</h3>
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<button
|
||||
v-if="capabilities.includes('turn_on')"
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
@click="turnOn"
|
||||
>
|
||||
Включить
|
||||
</button>
|
||||
<button
|
||||
v-if="capabilities.includes('turn_off')"
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
@click="turnOff"
|
||||
>
|
||||
Выключить
|
||||
</button>
|
||||
<form v-if="capabilities.includes('set_level')" class="flex items-center gap-2" @submit.prevent="setLevel">
|
||||
<input
|
||||
v-model="levelForm.level"
|
||||
type="number"
|
||||
step="any"
|
||||
required
|
||||
placeholder="уровень"
|
||||
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Установить уровень
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="device.device_type.category === 'sensor'" class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="font-semibold mb-4">Последние показания (ClickHouse)</h3>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Тип</th>
|
||||
<th class="py-2">Значение</th>
|
||||
<th class="py-2">Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="telemetry.length === 0">
|
||||
<td colspan="3" class="py-4 text-gray-500">Показаний пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in telemetry" :key="i" class="border-b">
|
||||
<td class="py-2">{{ row.sensor_type }}</td>
|
||||
<td class="py-2">{{ row.value }}</td>
|
||||
<td class="py-2">{{ row.recorded_at }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a :href="routes.devices.index" class="text-sm text-gray-600 hover:underline">← Назад к устройствам</a>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||
|
||||
defineProps({
|
||||
user: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Профиль</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<UpdateProfileInformationForm :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<UpdatePasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
<DeleteUserForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const confirmingDeletion = ref(false);
|
||||
|
||||
const form = useForm({ password: '' });
|
||||
|
||||
function confirmDeletion() {
|
||||
confirmingDeletion.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
confirmingDeletion.value = false;
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
form.delete(routes.profile.destroy, {
|
||||
preserveScroll: true,
|
||||
errorBag: 'userDeletion',
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => {},
|
||||
onFinish: () => form.reset('password'),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Удаление аккаунта</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
После удаления аккаунта все связанные данные будут безвозвратно удалены.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500"
|
||||
@click="confirmDeletion"
|
||||
>
|
||||
Удалить аккаунт
|
||||
</button>
|
||||
|
||||
<div v-if="confirmingDeletion" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
|
||||
<form @submit.prevent="submit">
|
||||
<h2 class="text-lg font-medium text-gray-900">Точно удалить аккаунт?</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
Это необратимо. Введите пароль, чтобы подтвердить удаление.
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<label for="delete-password" class="sr-only">Пароль</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
class="mt-1 block w-3/4 border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest hover:bg-gray-50"
|
||||
@click="closeModal"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
Удалить аккаунт
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const form = useForm({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
const flashStatus = computed(() => usePage().props.flash.status);
|
||||
|
||||
function submit() {
|
||||
form.put(routes.password.update, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
onError: () => {
|
||||
if (form.errors.password) form.reset('password', 'password_confirmation');
|
||||
if (form.errors.current_password) form.reset('current_password');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Смена пароля</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Длинный случайный пароль надёжнее короткого запоминающегося.</p>
|
||||
</header>
|
||||
|
||||
<form class="mt-6 space-y-6" @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="current_password" class="block font-medium text-sm text-gray-700">Текущий пароль</label>
|
||||
<input
|
||||
id="current_password"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.current_password" class="mt-2 text-sm text-red-600">{{ form.errors.current_password }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block font-medium text-sm text-gray-700">Новый пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password" class="mt-2 text-sm text-red-600">{{ form.errors.password }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirmation" class="block font-medium text-sm text-gray-700">Подтверждение пароля</label>
|
||||
<input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.password_confirmation" class="mt-2 text-sm text-red-600">{{ form.errors.password_confirmation }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<p v-if="flashStatus === 'password-updated'" class="text-sm text-gray-600">Сохранено.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useForm, usePage } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
name: props.user.name,
|
||||
email: props.user.email,
|
||||
});
|
||||
|
||||
const flashStatus = computed(() => usePage().props.flash.status);
|
||||
|
||||
function submit() {
|
||||
form.patch(routes.profile.update);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">Данные профиля</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Имя и email аккаунта.</p>
|
||||
</header>
|
||||
|
||||
<form class="mt-6 space-y-6" @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Имя</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block font-medium text-sm text-gray-700">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.email" class="mt-2 text-sm text-red-600">{{ form.errors.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<p v-if="flashStatus === 'profile-updated'" class="text-sm text-gray-600">Сохранено.</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Zones/Form.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Новая зона</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import Form from '@/Pages/Zones/Form.vue';
|
||||
|
||||
defineProps({
|
||||
zone: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Изменить зону</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<Form :zone="zone" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const props = defineProps({
|
||||
zone: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const isEdit = !!props.zone;
|
||||
|
||||
const form = useForm({
|
||||
name: props.zone?.name ?? '',
|
||||
description: props.zone?.description ?? '',
|
||||
});
|
||||
|
||||
function submit() {
|
||||
if (isEdit) {
|
||||
form.put(routes.zones.update(props.zone.id));
|
||||
} else {
|
||||
form.post(routes.zones.store);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="name" class="block font-medium text-sm text-gray-700">Название</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<div v-if="form.errors.name" class="mt-2 text-sm text-red-600">{{ form.errors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="description" class="block font-medium text-sm text-gray-700">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
/>
|
||||
<div v-if="form.errors.description" class="mt-2 text-sm text-red-600">{{ form.errors.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<a :href="routes.zones.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
zones: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
function destroy(zone) {
|
||||
if (confirm('Удалить зону?')) {
|
||||
router.delete(routes.zones.destroy(zone.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Зоны</h2>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.zones.create"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700"
|
||||
>
|
||||
Добавить зону
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="py-2">Название</th>
|
||||
<th class="py-2">Описание</th>
|
||||
<th class="py-2">Устройств</th>
|
||||
<th class="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="zones.length === 0">
|
||||
<td colspan="4" class="py-4 text-gray-500">Зон пока нет.</td>
|
||||
</tr>
|
||||
<tr v-for="zone in zones" :key="zone.id" class="border-b">
|
||||
<td class="py-2">{{ zone.name }}</td>
|
||||
<td class="py-2 text-gray-500">{{ zone.description }}</td>
|
||||
<td class="py-2">{{ zone.devices_count }}</td>
|
||||
<td class="py-2 text-right space-x-2">
|
||||
<Link v-if="isOwner" :href="routes.zones.edit(zone.id)" class="text-indigo-600 hover:underline">Изменить</Link>
|
||||
<button v-if="isOwner" type="button" class="text-red-600 hover:underline" @click="destroy(zone)">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -1,7 +1,17 @@
|
||||
import '../css/app.css';
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} — Домашняя автоматизация` : 'Домашняя автоматизация'),
|
||||
resolve: (name) => {
|
||||
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
return pages[`./Pages/${name}.vue`];
|
||||
},
|
||||
setup({ el, App, props, plugin }) {
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.mount(el);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Single source of truth for the app's URLs on the JS side — mirrors
|
||||
// routes/web.php. The route list here is small and stable enough that a
|
||||
// full Ziggy (route-name-to-JS) integration would be more machinery than
|
||||
// the app needs; if the route count grows a lot, revisit that.
|
||||
export const routes = {
|
||||
dashboard: '/dashboard',
|
||||
zones: {
|
||||
index: '/zones',
|
||||
create: '/zones/create',
|
||||
store: '/zones',
|
||||
edit: (id) => `/zones/${id}/edit`,
|
||||
update: (id) => `/zones/${id}`,
|
||||
destroy: (id) => `/zones/${id}`,
|
||||
},
|
||||
devices: {
|
||||
index: '/devices',
|
||||
show: (id) => `/devices/${id}`,
|
||||
create: '/devices/create',
|
||||
store: '/devices',
|
||||
edit: (id) => `/devices/${id}/edit`,
|
||||
update: (id) => `/devices/${id}`,
|
||||
destroy: (id) => `/devices/${id}`,
|
||||
turnOn: (id) => `/devices/${id}/turn-on`,
|
||||
turnOff: (id) => `/devices/${id}/turn-off`,
|
||||
setLevel: (id) => `/devices/${id}/set-level`,
|
||||
},
|
||||
automationRules: {
|
||||
index: '/automation-rules',
|
||||
create: '/automation-rules/create',
|
||||
store: '/automation-rules',
|
||||
edit: (id) => `/automation-rules/${id}/edit`,
|
||||
update: (id) => `/automation-rules/${id}`,
|
||||
destroy: (id) => `/automation-rules/${id}`,
|
||||
},
|
||||
profile: {
|
||||
edit: '/profile',
|
||||
update: '/profile',
|
||||
destroy: '/profile',
|
||||
},
|
||||
password: {
|
||||
update: '/password',
|
||||
},
|
||||
logout: '/logout',
|
||||
};
|
||||
Reference in New Issue
Block a user