Начинаем разработку 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>Всё готово
Действительно, спустя примерно час создания файлов и копирования их туда-сюда, у нас всё готово для проверки работы.
Итого, спустя примерно 1.5 - 2 часа у вас должно появиться 2 связанных друг сдругом модели товаров и их категорий.
Как видите, нет ничего сложного, всё максимально автоматизировано и предназначего для очень быстрой работы.
Наш новый проект я опубликовал на Github, чтобы вы могли посмотреть, что именно у меня получается, вот все изменения за сегодня.
На следующем уроке будем расширять наши новые модели и страницы.
1
👍
👎
❤️
🔥
😮
😢
😀
😡
635
17.06.2022 13:25:32