Управление типами устройств из UI (список + создание)
Раньше device_types заводились только через сидер — теперь owner может посмотреть все типы (карточки: иконка по категории, capabilities тегами, число устройств этого типа) и добавить новый прямо из интерфейса: код, категория, возможности вводятся тегами (можно вписать вручную и добавить по Enter/кнопке). Ссылка на список — со страницы устройств. DeviceTypePolicy зеркалит остальные Policy (view — всем, create — только owner). DeviceTypeResource получил devices_count через whenCounted — опционально, не ломает существующий API-эндпоинт (там withCount не вызывается, поле просто не появляется).
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
const form = useForm({
|
||||
code: '',
|
||||
category: '',
|
||||
capabilities: [],
|
||||
});
|
||||
|
||||
const capabilityInput = ref('');
|
||||
|
||||
function addCapability() {
|
||||
const value = capabilityInput.value.trim();
|
||||
if (value && !form.capabilities.includes(value)) {
|
||||
form.capabilities.push(value);
|
||||
}
|
||||
capabilityInput.value = '';
|
||||
}
|
||||
|
||||
function removeCapability(index) {
|
||||
form.capabilities.splice(index, 1);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
form.post(routes.deviceTypes.store);
|
||||
}
|
||||
</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 @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="code" class="block font-medium text-sm text-gray-700">Код</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="например, smart_socket"
|
||||
class="mt-1 block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">Только латиница, цифры, дефис и подчёркивание — используется как идентификатор.</p>
|
||||
<div v-if="form.errors.code" class="mt-2 text-sm text-red-600">{{ form.errors.code }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="category" class="block font-medium text-sm text-gray-700">Категория</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
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 value="sensor">Датчик</option>
|
||||
<option value="actuator">Исполнитель</option>
|
||||
</select>
|
||||
<div v-if="form.errors.category" class="mt-2 text-sm text-red-600">{{ form.errors.category }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="capability" class="block font-medium text-sm text-gray-700">Возможности</label>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Для датчика — типы показаний (например, temperature). Для исполнителя — команды
|
||||
(например, turn_on, turn_off, set_level).
|
||||
</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input
|
||||
id="capability"
|
||||
v-model="capabilityInput"
|
||||
type="text"
|
||||
placeholder="turn_on"
|
||||
class="block w-full font-mono border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
@keydown.enter.prevent="addCapability"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 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="addCapability"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.capabilities.length" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="(capability, index) in form.capabilities"
|
||||
:key="capability"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{{ capability }}
|
||||
<button type="button" class="text-gray-400 hover:text-red-600" @click="removeCapability(index)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="form.errors.capabilities" class="mt-2 text-sm text-red-600">{{ form.errors.capabilities }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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.deviceTypes.index" class="text-sm text-gray-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeviceTypeIcon from '@/Components/DeviceTypeIcon.vue';
|
||||
import { routes } from '@/routes';
|
||||
|
||||
defineProps({
|
||||
deviceTypes: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const isOwner = computed(() => usePage().props.auth.user?.role === 'owner');
|
||||
|
||||
const categoryLabel = { sensor: 'Датчик', actuator: 'Исполнитель' };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<Link :href="routes.devices.index" class="text-sm text-gray-500 hover:text-gray-700">← Устройства</Link>
|
||||
<h2 class="mt-1 font-semibold text-xl text-gray-800 leading-tight">Типы устройств</h2>
|
||||
</div>
|
||||
<Link
|
||||
v-if="isOwner"
|
||||
:href="routes.deviceTypes.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 v-if="deviceTypes.length === 0" class="bg-white rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
|
||||
Типов устройств пока нет.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="type in deviceTypes"
|
||||
:key="type.id"
|
||||
class="flex flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<DeviceTypeIcon :category="type.category" />
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ type.code }}</div>
|
||||
<div class="text-xs text-gray-500">{{ categoryLabel[type.category] ?? type.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="capability in type.capabilities"
|
||||
:key="capability"
|
||||
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{{ capability }}
|
||||
</span>
|
||||
<span v-if="type.capabilities.length === 0" class="text-xs text-gray-400">Без возможностей</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-3 text-xs text-gray-400">
|
||||
{{ type.devices_count }} {{ type.devices_count === 1 ? 'устройство' : 'устройств' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -23,13 +23,16 @@ function destroy(device) {
|
||||
<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 class="flex items-center gap-3">
|
||||
<Link :href="routes.deviceTypes.index" class="text-sm text-gray-500 hover:text-gray-700">Типы устройств</Link>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@ export const routes = {
|
||||
turnOff: (id) => `/devices/${id}/turn-off`,
|
||||
setLevel: (id) => `/devices/${id}/set-level`,
|
||||
},
|
||||
deviceTypes: {
|
||||
index: '/device-types',
|
||||
create: '/device-types/create',
|
||||
store: '/device-types',
|
||||
},
|
||||
automationRules: {
|
||||
index: '/automation-rules',
|
||||
create: '/automation-rules/create',
|
||||
|
||||
Reference in New Issue
Block a user