Files
cacto be1f238f89 Перевод защищённой части приложения на 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 проп на любой странице.
2026-08-12 10:52:04 +05:00

144 lines
7.1 KiB
Vue

<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>