From f7aa0ba88ea9893ff722159c67dddab3f16da24e Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Thu, 13 Aug 2026 13:07:01 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=BE=D0=BD=D1=8B=20=D1=81=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D0=BC=D0=B8=20=D0=B5=D1=91=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=B0=D0=BC=D0=B8=20=D0=B8=20liv?= =?UTF-8?q?e-=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше зона в списке была просто строкой без возможности провалиться внутрь — все устройства смотрелись только через общий плоский список /devices с колонкой "Зона". Теперь клик по зоне открывает её страницу с карточками устройств (иконка по категории, статус online/offline/ unknown точкой с пульсацией для online, external_id) — ровно то, что просили: все устройства и их статус на одном экране при выборе зоны. Заодно список зон переведён с голой таблицы на карточки для консистентности с новой страницей — вся карточка кликабельна и ведёт на show, кнопки изменить/удалить остаются только у owner. ZonePolicy::view уже был публичным (true всем) — новый роут zones.show использует его без изменений. --- .../app/Http/Controllers/ZoneController.php | 16 ++++ .../js/Components/DeviceTypeIcon.vue | 20 +++++ .../resources/js/Components/StatusDot.vue | 31 ++++++++ .../resources/js/Pages/Zones/Index.vue | 51 +++++++------ laravel-app/resources/js/Pages/Zones/Show.vue | 76 +++++++++++++++++++ laravel-app/resources/js/routes.js | 1 + laravel-app/routes/web.php | 2 +- .../tests/Feature/ZoneControllerTest.php | 33 ++++++++ 8 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 laravel-app/resources/js/Components/DeviceTypeIcon.vue create mode 100644 laravel-app/resources/js/Components/StatusDot.vue create mode 100644 laravel-app/resources/js/Pages/Zones/Show.vue diff --git a/laravel-app/app/Http/Controllers/ZoneController.php b/laravel-app/app/Http/Controllers/ZoneController.php index d5d0b2c..b6f6c9a 100644 --- a/laravel-app/app/Http/Controllers/ZoneController.php +++ b/laravel-app/app/Http/Controllers/ZoneController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Http\Requests\ZoneRequest; +use App\Http\Resources\DeviceResource; use App\Http\Resources\ZoneResource; use App\Models\Zone; +use App\Services\DeviceShadow; use Inertia\Inertia; class ZoneController extends Controller @@ -20,6 +22,20 @@ class ZoneController extends Controller ]); } + public function show(Zone $zone, DeviceShadow $shadow) + { + $this->authorize('view', $zone); + + $devices = $zone->devices()->with('deviceType')->orderBy('name')->get(); + $statuses = $shadow->statuses($devices->pluck('external_id')->all()); + $devices->each(fn ($d) => $d->live_status = $statuses[$d->external_id] ?? 'unknown'); + + return Inertia::render('Zones/Show', [ + 'zone' => (new ZoneResource($zone))->resolve(), + 'devices' => DeviceResource::collection($devices)->resolve(), + ]); + } + public function create() { $this->authorize('create', Zone::class); diff --git a/laravel-app/resources/js/Components/DeviceTypeIcon.vue b/laravel-app/resources/js/Components/DeviceTypeIcon.vue new file mode 100644 index 0000000..fc330a4 --- /dev/null +++ b/laravel-app/resources/js/Components/DeviceTypeIcon.vue @@ -0,0 +1,20 @@ + + + diff --git a/laravel-app/resources/js/Components/StatusDot.vue b/laravel-app/resources/js/Components/StatusDot.vue new file mode 100644 index 0000000..345a159 --- /dev/null +++ b/laravel-app/resources/js/Components/StatusDot.vue @@ -0,0 +1,31 @@ + + + diff --git a/laravel-app/resources/js/Pages/Zones/Index.vue b/laravel-app/resources/js/Pages/Zones/Index.vue index 8f7440d..d6fef9b 100644 --- a/laravel-app/resources/js/Pages/Zones/Index.vue +++ b/laravel-app/resources/js/Pages/Zones/Index.vue @@ -34,31 +34,32 @@ function destroy(zone) {
-
- - - - - - - - - - - - - - - - - - - -
НазваниеОписаниеУстройств -
Зон пока нет.
{{ zone.name }}{{ zone.description }}{{ zone.devices_count }} - Изменить - -
+
+ Зон пока нет. +
+ +
+
+ + +
+

{{ zone.name }}

+ + {{ zone.devices_count }} {{ zone.devices_count === 1 ? 'устройство' : 'устройств' }} + +
+ +

{{ zone.description }}

+ +
+ Изменить + +
+
diff --git a/laravel-app/resources/js/Pages/Zones/Show.vue b/laravel-app/resources/js/Pages/Zones/Show.vue new file mode 100644 index 0000000..0242f1b --- /dev/null +++ b/laravel-app/resources/js/Pages/Zones/Show.vue @@ -0,0 +1,76 @@ + + + diff --git a/laravel-app/resources/js/routes.js b/laravel-app/resources/js/routes.js index 9951aad..70cbfbf 100644 --- a/laravel-app/resources/js/routes.js +++ b/laravel-app/resources/js/routes.js @@ -6,6 +6,7 @@ export const routes = { dashboard: '/dashboard', zones: { index: '/zones', + show: (id) => `/zones/${id}`, create: '/zones/create', store: '/zones', edit: (id) => `/zones/${id}/edit`, diff --git a/laravel-app/routes/web.php b/laravel-app/routes/web.php index 82d7f15..919a69d 100644 --- a/laravel-app/routes/web.php +++ b/laravel-app/routes/web.php @@ -30,7 +30,7 @@ Route::get('/dashboard', function (DeviceShadow $shadow) { })->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware(['auth', 'verified'])->group(function () { - Route::resource('zones', ZoneController::class)->except('show'); + Route::resource('zones', ZoneController::class); Route::resource('devices', DeviceController::class); Route::resource('automation-rules', AutomationRuleController::class) ->except('show') diff --git a/laravel-app/tests/Feature/ZoneControllerTest.php b/laravel-app/tests/Feature/ZoneControllerTest.php index 81ba864..d992af8 100644 --- a/laravel-app/tests/Feature/ZoneControllerTest.php +++ b/laravel-app/tests/Feature/ZoneControllerTest.php @@ -2,9 +2,13 @@ namespace Tests\Feature; +use App\Enums\DeviceCategory; use App\Enums\UserRole; +use App\Models\Device; +use App\Models\DeviceType; use App\Models\User; use App\Models\Zone; +use App\Services\DeviceShadow; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia as Assert; use Tests\TestCase; @@ -52,6 +56,35 @@ class ZoneControllerTest extends TestCase ); } + public function test_zone_show_lists_its_devices_with_live_status(): void + { + $viewer = User::factory()->create(['role' => UserRole::Viewer]); + $zone = Zone::forceCreate(['user_id' => $viewer->id, 'name' => 'Гроубокс']); + $type = DeviceType::create([ + 'code' => 'fan', + 'category' => DeviceCategory::Actuator, + 'capabilities' => ['turn_on'], + ]); + Device::forceCreate([ + 'user_id' => $viewer->id, 'zone_id' => $zone->id, 'device_type_id' => $type->id, + 'name' => 'Вентилятор', 'external_id' => 'fan-1', 'protocol' => 'mqtt', + ]); + + $this->mock(DeviceShadow::class, function ($mock) { + $mock->shouldReceive('statuses')->once()->with(['fan-1'])->andReturn(['fan-1' => 'online']); + }); + + $response = $this->actingAs($viewer)->get(route('zones.show', $zone)); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Zones/Show') + ->where('zone.name', 'Гроубокс') + ->where('devices.0.name', 'Вентилятор') + ->where('devices.0.status', 'online') + ); + } + public function test_viewer_cannot_delete_zone(): void { $viewer = User::factory()->create(['role' => UserRole::Viewer]);