Выводим товары на сайте
Ну что, модели созданы, контроллеры отлажены, пора бы и вывести товары на нашем сайте.
Для удобной работы предлагаю использовать очень известную библиотеку Faker, которая позволит нам нагенерировать нужное количество товаров и категорий без лишних сложностей.
Устаналиваем fakerphp/faker зависимостью для разработки, на рабочем окружении она не будет нужна:
composer require fakerphp/faker --dev
Затем создаём новый seed файл в core/db/seeds/Products.php
<?php
use App\Models\Category;
use App\Models\Product;
use Phinx\Seed\AbstractSeed;
use Vesp\Services\Eloquent;
class Products extends AbstractSeed
{
// Количество генерируемых категорий
protected $categories = 100;
// И товаров
protected $products = 1000;
public function run(): void
{
// Если вдруг Faker не установлен - то просто ничего не делаем
if (!class_exists('Faker\Factory')) {
return;
}
// Временно отключаем проверку в БД по foreign key
(new Eloquent())->getConnection()->getSchemaBuilder()
->disableForeignKeyConstraints();
// Чтобы можно было полностью очистить таблицы
Category::query()->truncate();
Product::query()->truncate();
$faker = Faker\Factory::create();
// Создаём 100 категорий
for ($i = 0; $i < $this->categories; $i++) {
Category::query()->create([
'title' => 'Категория ' . ($i + 1),
// Случайный lorem ipsum
'description' => $faker->text,
// Категория активна с вероятностью 90%
'active' => random_int(0, 9) > 0,
]);
}
// Теперь 1000 товаров
for ($i = 0; $i < $this->products; $i++) {
Product::query()->create([
'title' => 'Товар ' . ($i + 1),
'description' => $faker->text,
// Указываем 1 из 100 категорий случайным образом
'category_id' => random_int(1, $this->categories),
// Артикулом назначаем уникальное 9ти-значное число
'sku' => $faker->unique()->randomNumber(9, true),
// Цена тоже случайная, от 100.00 до 10000.99
'price' => $faker->randomFloat(2, 100, 10000),
// 10% товаров неактивны
'active' => random_int(0, 9) > 0,
]);
}
}
}
При каждом запуске этого сида все товары и категории будут перезаписны случайными значениями.
Запускаем только наш новый сид:
composer db:seed-one Products
Web контроллеры
Теперь нам нужно создать публичные контроллеры товаров и категорий. Их основное отличие - они должны скрывать отключённые товары и не требовать авторизации пользователя.
src/Controllers/Web/Categories.php
<?php
namespace App\Controllers\Web;
use App\Models\Category;
use Illuminate\Database\Eloquent\Builder;
use Vesp\Controllers\ModelController;
class Categories extends ModelController
{
protected $model = Category::class;
// При выводе 1 модели обязательно проверяем её статус
protected function beforeGet(Builder $c): Builder
{
$c->where('active', true);
return $c;
}
// При выводе списка моделей делаем то же самое
protected function beforeCount(Builder $c): Builder
{
// Пока условия одинаковые - можно не дублировать код
return $this->beforeGet($c);
}
}
src/Controllers/Web/Products.php
<?php
namespace App\Controllers\Web;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Vesp\Controllers\ModelController;
class Products extends ModelController
{
protected $model = Product::class;
protected function beforeGet(Builder $c): Builder
{
// Не только товар должен быть включен
$c->where('active', true);
// Но и его категория!
$c->whereHas('category', static function (Builder $c) {
$c->where('active', true);
});
return $c;
}
protected function beforeCount(Builder $c): Builder
{
return $this->beforeGet($c);
}
protected function afterCount(Builder $c): Builder
{
// Не забываем выбрать категорию товара, пригодится
$c->with('category:id,title');
return $c;
}
}
Осталось только добавить новую группу маршрутов в core/routes.php:
$group->group(
'/web',
static function (RouteCollectorProxy $group) {
$group->any('/categories[/{id}]', App\Controllers\Web\Categories::class);
$group->any('/products[/{id}]', App\Controllers\Web\Products::class);
}
);
Проверяем работу контроллера товаров, переходя по http://vesp-shop.test/api/web/products:
Обратите внимание, какие запросы у нас получаются в SQL. Появилась и проверка на статус родительской категории, и большой массив выбираемых категорий для подстановки.
Тем не менее, выборка 845 товаров проходит за ~9 миллисекунд.
Вывод товаров на сайте
В нашем API всё готово, осталось только вывести товары на сайте. Напоминаю, что в приложении site ничего не особенного не подключено. Там нет мультиязычности, компонентов админки из Vesp - один только BootstrapVue.
Так сделано для минимального размера публичного приложения - как вы помните, я предлагаю подключать нужное, а не отключать ненужное.
Единственное, что нам нужно подключить в site/nuxt.config.js - это Axios, для запросов на сервер:
Config.modules = ['bootstrap-vue/nuxt', '@nuxtjs/axios', '@nuxtjs/pwa']
Теперь создаём новый компонент для вывода списка товаров.
src/site/components/list-products.vue
Для начала пишем простейший шаблон, который будет в цикле проходить по массиву товаров и выводить их id, название и цену:
<template>
<div class="products-list">
<div v-for="product in products" :key="product.id" class="product">
{{ product.id }}. {{ product.title }} — {{ product.price }} руб.
</div>
</div>
</template>
Дальше в этом же файле идёт javascript логика:
<script>
export default {
// Название компонента используется для импорта
name: 'ListProducts',
// Список принимаемых параметров
props: {
// Параметр для работы v-model
value: {
type: [String, Number],
default: 1,
},
// Количество выводимых товаров на странице
limit: {
type: [String, Number],
default: 20,
},
// Сортировка
sort: {
type: String,
default: 'id',
},
// Направление сортировки
dir: {
type: String,
default: 'asc',
},
},
// Внутренние данные компонента
data() {
return {
// Количество загруженных товаров
total: 0,
// Пустой массив для товаров
products: [],
}
},
// Функция загрузки для компонентов NuxtJs
async fetch() {
try {
// Определяем параметры запроса для контроллера
const params = {limit: this.limit, page: this.page, sort: this.sort, dir: this.dir}
// Делаем асинхронный запрос
const {data} = await this.$axios.get('web/products', {params})
// Сохраняем ответ
this.products = data.rows
this.total = data.total
// Генерируем событие об успешной загрузке с передачей полученных данных
// Это могут слушать родительские компоненты, чем мы позже и воспользуемся
this.$emit('load', data)
} catch (e) {
// Если есть ошибка - выводим в консоле
console.error(e)
}
},
// Дальше идут вычисляемые значения
computed: {
// Текущая страница вывода товаров
page: {
get() {
return this.value
},
set(newValue) {
// Уведомление родительского компонента об изменении
this.$emit('input', newValue)
},
},
},
// Следим за некоторыми параметрами
watch: {
// Новый запрос на сервер при изменении страницы
page() {
this.$fetch()
},
},
}
</script>
Итак, что здесь происходит? У нас есть разные параметры по умолчанию, из который номер страницы является v-model компонента, то есть он реактивен.
При этом сам компонент следит за номером страницы, и если он меняется - запускает функцию fetch(), которая отправляет новый запрос в API, используя указанные параметры, включая и страницу. Таким образом мы должны будем управлять постраничной загрузкой товаров.
Приводим src/site/pages/index.vue к самому минимальному виду:
<template>
<div>
<!-- вывод подключенного компонента вообще без параметров -->
<list-products />
</div>
</template>
<script>
// Импорт нашего компонента
import ListProducts from '../components/list-products'
export default {
// И подключение для работы
components: {ListProducts},
}
</script>
Появился совершенно минимальный вывод товаров без какого-либо функционала:
Но нам ведь нужно как-то пролистать весь каталог, верно? Для этого потребуется постраничная навигация, так что мы используем соответствующий компонент из BootstrapVue.
Опять меняем nuxt.config.js и добавляем PaginationPlugin в конфигурацию BootstrapVue:
Config.bootstrapVue.componentPlugins = ['LayoutPlugin', /*...*/ 'PaginationPlugin']
Еще нужно добавить и стили в файле src/site/assets/scss/index.css:
@import '~bootstrap/scss/pagination';
Теперь можно использовать пагинацию в index.vue:
<template>
<div class="mt-5">
<!--добавляем новые параметры вместе с событием на загрузку данных -->
<list-products v-model="page" :limit="limit" @load="onLoad" />
<!-- пагинация использьзует те же параметры -->
<b-pagination v-model="page" :total-rows="total" :per-page="limit" class="mt-3" />
</div>
</template>
<script>
import ListProducts from '../components/list-products'
export default {
components: {ListProducts},
// Добавились параметры по умолчанию
data() {
return {
page: 1,
limit: 20,
total: 0,
}
},
methods: {
// Это действие срабатывает при вызове $emit('load') в list-products,
// и передаёт нам загруженное, а мы из него сохраняем количество результатов
onLoad(data) {
this.total = data.total
},
},
}
</script>
Что здесь происходит? Во-первых, мы добавили пагинацию, которая так же, как и наш компонент, принимает v-model номер страницы. Когда мы указываем одно и то же значение обоим компонентам, мы их связываем и при изменении page в пагинации, оно же поменяется и в list-products, что вызовет новый запрос на сервер.
А во-вторых, мы слушаем событие load в компоненте товаров и сохраняем общее количество результатов, чтобы передать их в b-pagination. Таким образом пагинация узнаёт сколько всего у нас всего результатов и может отрисовать нужное количество страниц.
Итого:
- загружается первая страница в list-products
- b-pagination получает количество результатов и отрисовывает кнопки
- при нажатии на кнопки меняется v-model
- происходит новая загрузка list-products
Заключение
Несмотря на то, что мы изолировали логику загрузки и оформления товаров в отдельный компонент, мы можем получать из него все данные и менять параметры.
Нам для этого понадобилось совсем немного строк кода, все изменения на Github.
На следующем уроке добавим товарам картинки.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
406
20.06.2022, 12:29:25
10 комментариев
NightRider
21.06.2022, 00:03:18
Спасибо! Темп уроков не может не радовать)
Из замечаний, наверное все же вместо src/site/index.vue имелось ввиду src/site/pages/index.vue
И код отличается в статье и гитхабе в файле list-products.vue - нету того же this.$emit('load', data) например.
В файле pages/index.vue со статьей вроде различия только в
Игорь
21.06.2022, 02:37:31
Рабочий вариант (при котором работает пагинация)
NightRider
21.06.2022, 02:51:37
На гитхабе как раз тот вариант, который я выше указал.
21.06.2022, 07:58:50
и
В нашем случае одно и то же.
Указание в функции {total} означает, что мы достаём только этот ключ из присланной переменной, потому что нам больше ничего не надо.
Это называется деструктурирующее присваивание. Код я пишу прямо вместе с заметками, поэтому могут быть небольшие расхождения.
21.06.2022, 07:58:04
Да, всё верно, спасибо.
Заметку поправил.
19.09.2022, 11:08:09
Василий, добрый день!
Подскажи, пожалуйста, как подключаются стили Bootstrap-vue?
Вроде бы все очевидно и просто, но что-то не работает и нагуглить быстро не вышло.
На странице с компонентом BootstrapVue Pagination нигде не сказано про стили.
Сделал с подключением компонента BootstrapVue Sidebar по аналогии:
@import '~bootstrap/scss/sidebar;
Но в консоли получил ошибку:
19.09.2022, 11:34:57
Самый простой способ решить все подобные проблемы - просто подключить вообще все стили
Но мне такое не нравится, потому что тогда будут собраны и ненужные стили, а это утяжелит страницу. Поэтому я что из родного Bootstap, что из Bootstrap-Vue, подключаю только нужное.
Компонента Sidebar у Bootstrap нет, значит нужны только стили из Bootstrap-Vue
Стили Bootstrap-Vue только дополняют таковые из оригинального Bootstrap, когда это нужно для функционала.
19.09.2022, 11:56:19
Спасибо большое! Тупанул, понял куда смотреть было нужно.
Такой подход одна из сильных сторон Vesp, если не будет порядка, то в последствии захлебнешься в мусоре.
07.05.2023, 11:30:09
Очень познавательно. Спасибо! Все получилось!
Возник единственный вопрос, но он, подозреваю, связан с моим незнанием темы. При проверке работы контроллера товаров, я получаю следующую картину. По адресу http://my-site.ru/api/web/products я вижу страницу с JSON где выводятся все 830 товаров в 100 категорях, но на этой странице (http://my-site.ru/api/web/products) Clockwork ничего не отображает.
Тут надо оговориться, что сайт у меня висит на VPS - т.е. фактически уже в продакшене, и отображается на домене. И я его перегенерирую после каждого обновления. Clockwork же я могу видеть на странице админки. Т.е. не на странице /api/web/products как у тебя, а на /admin/products . И там у меня Clockwork отображает только 84 категории. Но наверное это все пока не существенно, во всяком случае до тех пор, пока я не научусь в этом предметно разбираться.
09.05.2023, 04:01:47
Не знаю даже, что ответить. По идее работать должно везде, возможно просто глюк, который лечится перезагрузкой страницы.
У меня всё работает, проверил:
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо!
Извини, тупанул.