Начинаем разработку VespShop

Ну что, друзья! Начинается самое интересное - реальная разработка нашего магазина.
Сегодня напишем базовые миграции, модели и контроллеры админки. А потом создадим и страницы для работы со всем этим.
Перед начал разработки я провёл генеральную уборку кода пакета vesp/vesp и добавил новые команды в composer, так что советую пересоздать проект заново, базу данных и настройки .env можно не трогать, только обновить исходники.
По ходу дела я постараюсь прикинуть, сколько времени у меня уходит на те или иные операции.
Переходим в новый проект и запускаем composer db:create Products - это создаст новую миграцию в файле core/db/migrations/меткавремени-products.php.
Мы не ограничены количеством миграций, поэтому сегодня будет минимальный набор колонок в таблицах, а дальше будем добавлять новые по необходимости:
<?php
use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class Products extends Migration
{

    public function up(): void
    {
        // Таблица категорий товаров
        $this->schema->create(
            'categories',
            function (Blueprint $table) {
                $table->id();
                $table->string('title');
                $table->text('description')->nullable();
                $table->boolean('active')->default(true)->index();
                $table->timestamps();
            }
        );

        // Таблица товаров
        $this->schema->create(
            'products',
            function (Blueprint $table) {
                $table->id();
               // Связь товаров с категорией
                $table->foreignId('category_id')
                    // Запрет удаления категории, если в ней есть хотя-бы 1 товар
                    ->constrained('categories')->restrictOnDelete();
                $table->string('title');
                $table->text('description')->nullable();
                // Артикул товара должен быть уникальным
                $table->string('sku')->unique();
                // Цену храним в колонке с цифрами после запятой
                $table->unsignedDecimal('price')->nullable();
                $table->boolean('active')->default(true)->index();
                $table->timestamps();
            }
        );
    }

    public function down(): void
    {
        // При откате миграции удаляем таблицы в обратном порядке
        $this->schema->drop('products');
        $this->schema->drop('categories');
    }
}
Дальше делаем composer db:migrate и создаём наши модели в core/src/Models.

core/src/Models/Category.php

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

/**
 * @property int $id
 * @property string $title
 * @property ?string $description
 * @property bool $active
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read Product[] $products
 */
class Category extends Model
{
    protected $guarded = ['id', 'created_at', 'updated_at'];
    protected $casts = ['active' => 'boolean'];

    // Каждая категория может иметь много товаров
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
}
Как видите - ничего особенного, просто прописываем всё то, что уже написали в миграциях.

core/src/Models/Product.php

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * @property int $id
 * @property int $category_id
 * @property string $title
 * @property ?string $description
 * @property string $sku
 * @property float $price
 * @property bool $active
 * @property Carbon $created_at
 * @property Carbon $updated_at
 *
 * @property-read Category $category
 */
class Product extends Model
{
    protected $guarded = ['id', 'created_at', 'updated_at'];
    protected $casts = [
        'active' => 'boolean',
        'price' => 'float',
    ];

    //  Каждый товар принадлежит одной категории
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
}
Мне кажется, тут вопросов никаких - всё предельно просто. Раз у нас есть модели, можно написать для них и контроллеры.

core/src/Contollers/Admin/Categories.php

<?php

namespace App\Controllers\Admin;

use App\Models\Category;
use Vesp\Controllers\ModelController;

class Categories extends ModelController
{
    protected $scope = 'products';
    protected $model = Category::class;
}
Совершенно минимальная конфигурация - только требование разрешения products и указание модели.

core/src/Contollers/Admin/Products.php

<?php

namespace App\Controllers\Admin;

use App\Models\Product;
use Vesp\Controllers\ModelController;

class Products extends ModelController
{
    protected $scope = 'products';
    protected $model = Product::class;
}
Здесь ровно то же самое.

core/routes.php

Добавляем 2 новых адреса в группу /admin:
$group->group(
    '/admin',
    static function (RouteCollectorProxy $group) {
        // Это было по-умолчанию
        $group->any('/users[/{id}]', App\Controllers\Admin\Users::class);
        $group->any('/user-roles[/{id}]', App\Controllers\Admin\UserRoles::class);

        // А вот это мы добавляем
        $group->any('/categories[/{id}]', App\Controllers\Admin\Categories::class);
        $group->any('/products[/{id}]', App\Controllers\Admin\Products::class);
    }
);
Собственно, с бэкендом мы уже закончили. У меня на создание миграций, моделей и контроллеров ушло примерно 15 минут.

Права доступа

Оба контроллера требуют одно разрешение - products, поэтому нам нужно добавить его группе администраторов.
Это можно сделать как вручную через админку, отредактировав группу Admin, так и через сиды.
Во втором случае меняем core/db/seeds/UserRoles.php и добавляем разрешение на 12й строке:
$roles = [
    'Administrator' => [
        'scope' => ['profile', 'users', 'products'], // Вот здесь
    ],
    'User' => [
        'scope' => ['profile'],
    ],
];
После этого можно сделать composer db:seed-one UserRoles для обновления только групп юзеров.

Фронтенд

Переходим на фронтенд и создаём страницу frontend/src/admin/pages/categories.vue:
<template>
  <div>
    <vesp-table
      :url="url"
      :header-actions="headerActions"
      :table-actions="tableActions"
      :fields="fields"
      :filters="filters"
      :sort="sort"
      :dir="dir"
      :row-class="rowClass"
    />
    <nuxt-child />
  </div>
</template>

<script>
//  Адрес нашего контроллера
export const url = 'admin/categories'

export default {
  name: 'CategoriesPage',
  validate({app}) {
    // Проверка разрешения
    return app.$hasScope('products')
  },
  data() {
    return {
      url,
      filters: {
        query: '',
      },
      sort: 'id',
      dir: 'asc',
    }
  },
  head() {
    return {
      title: [this.$t('models.category.title_many'), this.$t('project')].join(' / '),
    }
  },
  computed: {
    headerActions() {
      return [{route: 'categories-create', icon: 'plus', title: this.$t('actions.create')}]
    },
    tableActions() {
      return [
        {route: 'categories-edit-id', icon: 'edit', title: this.$t('actions.edit')},
        {function: 'onDelete', icon: 'times', title: this.$t('actions.delete'), variant: 'danger'},
      ]
    },
    fields() {
      return [
        {key: 'id', label: this.$t('components.table.columns.id'), sortable: true},
        {key: 'title', label: this.$t('models.category.title'), sortable: true},
        // Эта колонка будет выводить количество товаров категории
        {key: 'products_count', label: this.$t('models.category.products'), sortable: true},
        {
          key: 'created_at',
          label: this.$t('components.table.columns.created_at'),
          formatter: this.$options.filters.datetime,
          sortable: true,
        },
      ]
    },
  },
  methods: {
    rowClass(item) {
      return item && !item.active ? 'text-muted' : ''
    },
  },
}
</script>
Ничего необычного, всё примерно как на странице users.vue. Таким эе образом создаём и страницу products.vue - можно вообще скопировать предыдущую и переименовать внутри все слова categories и category на products и product соответственно. Я лично так и делаю.
Финальный штрих - добавляем новые страницы в frontend/src/admin/plugins/menu.js:
export default [
  // Новые записи ставим в начало
  {
    name: 'products',
    title: 'models.product.title_many',
    scope: 'products',
  },
  {
    name: 'categories',
    title: 'models.category.title_many',
    scope: 'products',
  },
  // Старые, понятно, тоже оставляем как было
  {
    name: 'users',
    title: 'models.user.title_many',
    scope: 'users',
    children: [
      {
        name: 'users-roles',
        title: 'models.user_role.title_many',
        scope: 'users',
      },
    ],
  },
]
Запускаем composer node:dev и заходим в админку как admin.
Всё работает, только нет записей в лексиконах.

Лексиконы

Идём в frontend/admin/src/lexicons и меняем все 3 словаря. Я привожу здесь только ru.js в сокращённом виде:
export default {
  project: 'Vesp',
  // ...
  models: {
    user: {
      // ...
    },
    user_role: {
      // ...
    },
    category: {
      title_one: 'Категория',
      title_many: 'Категории',
      title: 'Название',
      description: 'Описание',
      products: 'Товары',
      active: 'Включено',
    },
    product: {
      title_one: 'Товар',
      title_many: 'Товары',
      title: 'Название',
      description: 'Описание',
      sku: 'Артикул',
      price: 'Цена',
      category: 'Категория',
      active: 'Включено',
    },
  },
  // ...
}
В других словарях нужно добавить точно такие же записи, только на соответствующем языке. Для PhpStorm можно установить плагин Translation, это очень ускоряет переводы.
При каждом редактировании словарей страница разработки обновляется и в итоге всё становится вот так:

Формы редактирования

Дальше идём в frontend/src/admin/components/forms и создаём 2 формы для редактирования категорий и товаров.

forms/category.vue

<template>
  <div>
    <b-form-group :label="$t('models.category.title')">
      <b-form-input v-model.trim="record.title" required autofocus />
    </b-form-group>

    <b-form-group :label="$t('models.category.description')">
      <b-form-textarea v-model.trim="record.description" rows="5" />
    </b-form-group>

    <b-form-group>
      <b-form-checkbox v-model="record.active">
        {{ $t('models.category.active') }}
      </b-form-checkbox>
    </b-form-group>
  </div>
</template>

<script>
// Никакой особой логики
export default {
  // Имя компонента
  name: 'FormCategory',
  // Один-единственный принимаемый параметр через v-model
  props: {
    value: {
      type: Object,
      required: true,
    },
  },
  // И вычисляемый параметр для изменения
  computed: {
    record: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
}
</script>
Логику работы форм мы уже разбирали в уроке про модальные окна.

forms/product.vue

Почти такая же форма, за одним исключением - здесь добавляется обязательный выбор категории товара. Публикую только часть с template:
<template>
  <div>
    <b-form-group :label="$t('models.product.title')">
      <b-form-input v-model.trim="record.title" required autofocus />
    </b-form-group>

    <b-form-group :label="$t('models.product.description')">
      <b-form-textarea v-model.trim="record.description" rows="5" />
    </b-form-group>

    <!--Артикул и цену располагаем в одну строку-->
    <b-row>
      <b-col md="6">
        <b-form-group :label="$t('models.product.sku')">
          <b-form-input v-model.trim="record.sku" required />
        </b-form-group>
      </b-col>
      <b-col md="6">
        <b-form-group :label="$t('models.product.price')">
          <b-form-input v-model.trim="record.price" />
        </b-form-group>
      </b-col>
    </b-row>

    <!--А вот и выбор категории-->
    <b-form-group :label="$t('models.product.category')">
      <vesp-input-combo-box v-model="record.category_id" url="admin/categories" required />
    </b-form-group>

    <b-form-group>
      <b-form-checkbox v-model.trim="record.active">
        {{ $t('models.product.active') }}
      </b-form-checkbox>
    </b-form-group>
  </div>
</template>
Везде используем одни и те же значения для лексиконов.
Формы есть, осталось создать страницы для работы с моделями.

Создание и редактирование моделей

Напоминаю, что вся работа с конкретными моделями находится во вложенных маршрутах по отношению к их таблицам. Поэтому создаём новые страницы для товаров и категорий по следующим адресам:
  • pages/categories/create.vue - создание категории
  • pages/categories/edit/_id.vue - редактирование категории
  • pages/products/create.vue - создание товара
  • pages/products/edit/_id.vue - редактирование товара
Приведу здесь только страницы товаров, у категорий всё ровно так же - можно смело копипастить с небольшими изменениями в наборе полей и строках лексикона.

pages/products/create.vue

<template>
  <vesp-modal v-model="record" :url="url" :title="$t('models.product.title_one')">
    <template #form-fields>
      <form-product v-model="record" />
    </template>
  </vesp-modal>
</template>

<script>
// импорт адреса контроллера из таблицы
import {url} from '../products'
// Импорт формы
import FormProduct from '../../components/forms/product'

export {url}
export default {
  name: 'ProductCreatePage',
  components: {FormProduct},
  data() {
    return {
      url,
      record: {
        // Значения по-умолчанию
        title: '',
        description: '',
        sku: '',
        price: null,
        category_id: null,
        active: true,
      },
    }
  },
}
</script>

pages/products/edit/_id.vue

У страниц редактирования обычно вообще ничего не меняется.
Всё нужное просто импортируется из страницы создания модели и добавляется предварительная загрузка модели из API.
<script>
import Create, {url} from '../create'

export default {
  name: 'ProductEditPage',
  extends: Create,
  validate({params}) {
    return /^\d+$/.test(params.id)
  },
  async asyncData({app, params, error}) {
    try {
      const {data: record} = await app.$axios.get(url + '/' + params.id)
      return {record}
    } catch (e) {
      error({statusCode: e.statusCode, message: e.data})
    }
  },
}
</script>

Всё готово

Действительно, спустя примерно час создания файлов и копирования их туда-сюда, у нас всё готово для проверки работы.
У меня всё запустилось с первого раза. Нажимайте на картинку, чтобы посмотреть GIFку
Итого, спустя примерно 1.5 - 2 часа у вас должно появиться 2 связанных друг сдругом модели товаров и их категорий.
Как видите, нет ничего сложного, всё максимально автоматизировано и предназначего для очень быстрой работы.
Наш новый проект я опубликовал на Github, чтобы вы могли посмотреть, что именно у меня получается, вот все изменения за сегодня.
На следующем уроке будем расширять наши новые модели и страницы.