Начинаем разработку 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, чтобы вы могли посмотреть, что именно у меня получается, вот все изменения за сегодня.

На следующем уроке будем расширять наши новые модели и страницы.

← Предыдущая заметка
Остальные компоненты админки
Следующая заметка →
Отладка SQL запросов контроллеров
Комментарии (38)
bezumkinВасилий Наумкин
19.06.2022 04:31

Проект локально находится на vesp-shop.test, PHP крутится только там, больше нигде - все запросы в API идут туда.

А вот фронт у нас есть в 2х видах:

  • режим разработки, который открывается на локальном IP после команды composer node:dev
  • режим готового приложения, которое собирается в статичные файлы после composer node:generate

Режим разработки нужен, чтобы сразу видеть все изменения, а статические файлы - для выгрузки на хостинг. Или, в нашем случае, на локальный vesp-shop.test

Так при разработке всегда используем режим dev, а generate - если хотим видеть наш проект на локальном домене после разработки, без запуска node.

bezumkinВасилий Наумкин
19.06.2022 10:00

А если напрямую открыть http://vesp-shop.test/api/admin/products?page=1&limit=20&sort=id&dir=asc&query= - ошибки нет, случайно?

Очень похоже, что просто контроллер не работает, и возвращает неправильный JSON ответ.

bezumkinВасилий Наумкин
19.06.2022 10:30

На здоровье!

bezumkinВасилий Наумкин
19.06.2022 04:26

А ты как пересоздал? Надо же все файлы заново загрузить, включая composer.json - там есть новые команды.

bezumkinВасилий Наумкин
19.06.2022 14:17

Эта команда вернёт ошибку, если директория не пустая.

Скорее всего так и было, просто ты не обратил внимания =)

bezumkinВасилий Наумкин
19.06.2022 14:56

А можно как-то обновлять пакет vesp/ через composer?

Да, конечно, composer update и composer node:update - но это обновит только PHP и JS зависимости, которые в core/vendor и frontend/node_modules.

Всё остальное - это уже твои файлы, они не обновляются, и меняются только тобой. Что с ними хочешь, то и делай.

Я просто по ходу курса обучения сам вижу свои косяки и правлю, поэтому сейчас пришлось файлы обновить. А в реальной работе обновляются только зависимости.

bezumkinВасилий Наумкин
22.06.2022 06:47

Забыл ответить на комментарий, исправляюсь.

Загрузка фото точно будет, текстовый редактор ты можешь попробовать подключить и сам. Со списком товаров ничего особенного делать не планирую.

На данный момент я хочу провести вас через полный цикл разработки, заканчивая запуском проекта на хостинге. А дорабатывать его потом можно сколько угодно.

bezumkinВасилий Наумкин
22.06.2022 13:08

Я обычно не пользуюсь RTE редакторами, потому что они пишут всякое непонятное что в HTML.

Но можно вот здесь почитать - https://www.tiny.cloud/blog/best-vue-rich-text-editors/

bezumkinВасилий Наумкин
21.06.2022 04:53

Если ты что-то импортировал, то нужно это использовать внутри template - иначе ESLint будет ругаться на бесполезный импорт. То есть, у тебя там обязятельно должен быть вызван <form-category v-model="record" />

В комментарии можно прикладывать картинки, кстати говоря - это вроде твой скриншот из телеграма

А насчёт ошибки SQL что-то странное. Проверь, есть ли у тебя в БД у категорий такая колонка вообще. Если нет, то ты забыл указать $table->timestamps(); в миграции. Тогда нужно всё указать, и сделать

composer db:rollback
composer db:migrate
bezumkinВасилий Наумкин
21.06.2022 09:34

Но при попытке запустить composer db:rollback получаю следующее:

Тебе там пишут warning, что в в миграции одновременно методы change и up/down - так нельзя. Удали change(), мы его не используем.

в таблице также есть записи.

На 2м скриншоте наоброт видно, что колонок с датами у тебя нет. Разберись с миграциями, в репозитории можно свериться с правильным файлом.

bezumkinВасилий Наумкин
04.07.2022 06:55

Надо всё внимательно проверять, что-то не так сделал. Таблицы создаются сразу при запуске миграции.

Если их нет, у тебя должна быть какая-то ошибка при запуске.

bezumkinВасилий Наумкин
05.07.2022 02:34

Что-то странное у тебя произошло: миграция есть, и вроде как выполнена, но таблицы при этом отсутствуют. Как-будто вручную были удалены после выполнения миграции.

Нужно это привести в порядок, самый простой способ - удалить запись Products из app_migrations и запустить composer db:migrate

Ну или вообще удалить все таблицы и сделать composer db:migrate && composer db:seed

bezumkinВасилий Наумкин
05.07.2022 11:40

На здоровье!

bezumkinВасилий Наумкин
20.10.2022 08:49

Рассказывал в одной из предыдущих заметок, раздел ESLint и Prettier.

bezumkinВасилий Наумкин
03.05.2023 13:14

Ошибки пишут, чтобы их читать и исправлять.

The relative module was not found:

../../components/inputs/alias

Модуль с относительным путём не был найден. Проверяй путь к этому компоненту .

bezumkinВасилий Наумкин
05.05.2023 06:14

Ну так на этом уроке ничего про компонент inputs/alias.vue и не сказано. Ты откуда узнал, что его нужно использовать в формах?

В конце указана ссылка на полный коммит с данными на момент урока. Там про этот компонент ничего не сказано, он появится позже.

Если ты забежал вперёд и копируешь итоговый код после окончания курса - сам себе злобный буратина. Копируй уж тогда всё полностью.

bezumkinВасилий Наумкин
05.05.2023 07:07

Но вот когда создал эти директории и страницы:

Еще раз, в этом уроке нет ничего про компонент alias, он появится позже. Откуда ты его взял? Как его импорт оказался на твоих созданных страницах?

Только если ты скопировал код из репозитория на момент завершения курса. Так делать не нужно - я оставляю ссылки на финальный код урока в конце самого урока.

Смотреть нужно именно его.

bezumkinВасилий Наумкин
05.05.2023 08:57

Но я выполнял урок и дошел до ошибки.

Просто покажи скриншот, где в этом уроке что-то написано про компонент input-alias.

Прямо вот ткни меня носом, где я здесь написал тебе его включить в свою форму, чтобы ты получил ошибку.

Ты видишь на моей картинке где-то <input-alias ...? Нет? Правильно, потому что в этом уроке про него нет ни слова.

Ты просто скопировал более поздний код из репозитория и теперь тратишь моё время зря.

bezumkin
Василий Наумкин
01.06.2023 02:28
Молодец, я очень рад! Мне когда приходится по работе сталкиваться с другими системами - это боль.
inetlover
Александр Наумов
31.05.2023 18:12
Понял, спасибо!
Nurbol Boken
28.05.2023 10:07
Спасибо большое!
futuris
Futuris
26.05.2023 08:05
Можно и так. Главное - варианты есть, если хочешь ковырять VESP). Virtual Box я использовал на дескт...
futuris
Futuris
21.05.2023 14:51
Да, теперь появились! Спасибо за твое терпение!
bezumkin
Василий Наумкин
15.05.2023 06:11
Молодец!
bezumkin
Василий Наумкин
09.05.2023 01:01
Не знаю даже, что ответить. По идее работать должно везде, возможно просто глюк, который лечится пер...
futuris
Futuris
05.05.2023 09:49
Блин, вот оказывается из-за чего! Извини Василий, не было злого умысла!) Я видно уже припарился и ко...
bezumkin
Василий Наумкин
29.04.2023 05:08
Думаю, что не помогут
futuris
Futuris
28.04.2023 14:37
Не то слово!