Галерея товаров
На прошлом уроке вы сгенерировали и вывели наши товары на сайте.
Выглядит это пока простенько, поэтому довайте добавим нашим товарам картинки. То есть, напишем галерею товара.
Этот урок доступен бесплатно в рекламных целях, вдруг кто-то почитает и захочет оплатить доступ и к предыдущим?
У Vesp предусмотрена система работы с файлами, стандартная модель и таблица для них. Нам нужно будет только использовать эту систему для загрузки картинок товарам.
Я предлагаю создать новую модель ProductFile, которая свяжет товары с файлами. Делаем composer db:create ProductFiles и редактируем новую миграцию:
<?php
use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;
final class ProductFiles extends Migration
{
public function up(): void
{
$this->schema->create(
'product_files',
function (Blueprint $table) {
// Связи с родительскими моделями
$table->foreignId('product_id')
->constrained('products')->cascadeOnDelete();
$table->foreignId('file_id')
->constrained('files')->cascadeOnDelete();
// Порядок вывода картинок в галерее
$table->unsignedInteger('rank')->default(0);
// Картинки можно будет отключать
$table->boolean('active')->default(true)->index();
// Назначаем первичный ключ из 2х колонок
$table->primary(['product_id', 'file_id']);
}
);
}
public function down(): void
{
$this->schema->drop('product_files');
}
}
Так как эта модель связывает файлы с товарами, у неё нет собственного id - первичный ключ составлен из двух чужих колонок: product_id и file_id. При удалении любой родительской модели эта запись так же будет удалена.
Eloquent такие модели по умолчанию не поддерживает, поэтому нам нужно будет поменять кое-какие внутренние методы.
Вот наша модель core/models/ProductFile.php в несколько сокращённом виде:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductFile extends Model
{
// Отметок времени у нас нет
public $timestamps = false;
// Инкрементального ключа тоже нет
public $incrementing = false;
// Зато первичный ключ у нас массив
protected $primaryKey = ['product_id', 'file_id'];
// Переопределяем родительский метод получения первичного ключа
// Вместо строки возвращаем массив
public function getKey(): array
{
$key = [];
foreach ($this->primaryKey as $item) {
$key[$item] = $this->getAttribute($item);
}
return $key;
}
// Эта функция указывает ключ модели в SQL запросах
protected function setKeysForSaveQuery($query): Builder
{
foreach ($this->getKey() as $key => $value) {
$query->where($key, $this->original[$key] ?? $value);
}
return $query;
}
// ...
}
Таким образом, моделям с композитными ключами нужно обязательно указать массив в primaryKey и поменять 2 функции.
Не забываем прописать и связи с новой моделью в core/src/Models/Product.php и core/src/Models/File.php:
public function productFiles(): HasMany
{
return $this->hasMany(ProductFile::class);
}
Да-да, абсолютно одинаковый метод в обеих моделях, Eloquent сам разберётся что как подключать.
Контроллер картинок товара
В отличие от предыдущих контроллеров, новый будет требовать обязательное указание id товара, с картинками которого мы работаем.
Такие контроллеры мы будем размещать в поддиректориях по имени родительской модели в единственном числе, то есть Admin/Product/...
Поэтому мы первым делом создаём новый маршрут с указанием обязательного product_id в src/routes.php:
$group->group(
'/admin',
static function (RouteCollectorProxy $group) {
// ...
$group->any('/product/{product_id}/files[/{file_id}]', App\Controllers\Admin\Product\Files::class);
}
);
Если параметр без квадратных скобочек - он обятелен, а со скобочками - опционален.
И вот теперь, зная, что мы всегда будем получать product_id (потому что без его указания адрес вообще не сработает), пишем наш новый контроллер src/Constollers/Admin/Product/Files.php:
<?php
namespace App\Controllers\Admin\Product;
use App\Models\ProductFile;
use Illuminate\Database\Eloquent\Builder;
use Vesp\Controllers\ModelController;
class Files extends ModelController
{
protected $scope = 'products';
protected $model = ProductFile::class;
// Обязятельно указываем, что у нас составной первичный ключ
protected $primaryKey = ['product_id', 'file_id'];
// Сюда мы будем загружать товар
/** @var Product product */
protected $product;
// Расширяем метод проверки права доступа
public function checkScope(string $method): ?ResponseInterface
{
// Саму проверку оставляем на совести родительского контроллера
if ($check = parent::checkScope($method)) {
// Если там ошибка - возвращаем её и выходим
return $check;
}
// А вот дальше пытаемся загрузить в контроллер модель товара по указанному id
if (!$this->product = Product::query()->find($this->getProperty('product_id'))) {
// Если такой модели нет - возвращаем 404 Not Found
return $this->failure('Not Found', 404);
}
// Все проверки пройдены - ошибок нет
return null;
}
protected function beforeGet(Builder $c): Builder
{
// Модели выдаём только для нашего товара,
// который был ранее загружен в checkScope()
$c->where('product_id', $this->product->id);
// Заодно загружаем и родительский файл
$c->with('file:id,updated_at');
return $c;
}
protected function beforeCount(Builder $c): Builder
{
return $this->beforeGet($c);
}
protected function afterCount(Builder $c): Builder
{
// При выводе списка изображений тоже подключаем родительский файл
$c->with('file:id,updated_at');
// И сортируем по колонке rank
$c->orderBy('rank');
return $c;
}
}
Нам нужно только указать, что в этой модели первичный ключ является массивом, и контроллер Vesp дальше сам разберётся.
Проговорим еще раз, что при переходе на объявленный маршрут мы получаем не 1 параметр id, как обычно, а 2: обязательный product_id и опциональный file_id.
То есть, при запросе /api/admin/product/10/files/25 контроллер получит:
$this->getProperty('product_id'); // здесь будет 10
$this->getProperty('file_id'); // а здесь - 25
Эти же колонки указаны в первичном ключе, поэтому наш контроллер прекрасно работает с композитной моделью.
Страница редактирования галереи
Для загрузки файлов товарам я предлагаю создать отдельную страницу по типу страницы редактирования. Она так же будет загружать данные товара, но вместо формы редактирования выведет нам новый компонент галереи.
Пока создаём только страницу, без галереи, по адресу src/admin/pages/products/files/_id.vue:
<template>
<!-- Модалку выводим в самом минимальном виде -->
<!-- без модели и кнопок -->
<vesp-modal :title="record.title" hide-footer size="xl">
<-- Пока что вместо галереи просто выводим данные товара -->
<div>{{ record }}</div>
</vesp-modal>
</template>
<script>
// Расширяем страницу создания и получаем url для запросов
import Create, {url} from '../create'
export default {
name: 'ProductFilesPage',
extends: Create,
// Проверяем наличие id товара в адресе
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>
Как видите, отличия по сравнению со страницей редактирования минимальны. По большому счёту здесь запрос на сервер нам нужен только для проверки существования запрошенного товара и отображения его названия в заголовке окна.
Страница для галереи есть, нужно как-то на неё теперь зайти. Добавляем в таблицу products.vue новую кнопку:
tableActions() {
return [
// ..
{route: 'products-files-id', icon: 'images', title: this.$t('actions.files')},
// ..
]
},
Указанную строку лексикона нужно добавить в словари, а images импортировать в nuxt.config.js:
Config.fontawesome = merge(Config.fontawesome, {
addCss: false,
icons: {
solid: union(Config.fontawesome.icons.solid, [/*...*/, 'faImages']),
},
})
Компонент галереи
Как вы помните, мы предусмотрели в нашей модели колонку rank для сортировки файлов, так что сразу устанавливаем библиотеку vuedraggable - она позволит нам сортировать файлы перетаскиванием:
cd frontend
yarn add vuedraggable
Ну и создаём новый компонент ProductFiles в файле src/admin/components/product/gallery.vue. Я не буду приводить его полностью, только самые важные части - а в готовом виде можно будет посмотреть на Github.
Для лучшего понимания смотрим части файла в обратном порядке. Сначала script:
<script>
// Импорт компонента сортировки из библиотеки
import draggable from 'vuedraggable'
export default {
name: 'ProductFiles',
// Регистрация компонента
components: {draggable},
// Параметры компонента
props: {
// Требуем обязательно указывать при вызове id товара
productId: {
type: [Number, String],
required: true,
},
// Можно указать свои ширину и высоту для превьюшеккартинок
thumbWidth: {
type: [Number, String],
default: 250,
},
thumbHeight: {
type: [Number, String],
default: 250,
},
},
// Внутренние значения
data() {
return {
// Индикатор загрузки
loading: false,
// Здесь будут наши файлы
files: [],
}
},
// Работа с сервером - загрузка файлов
async fetch() {
this.loading = true
try {
// url определяется динамически в computed
const {data} = await this.$axios.get(this.url)
this.files = data.rows
} catch (e) {
} finally {
this.loading = false
}
},
computed: {
// Наш url зависит от присланного id товара
url() {
return `admin/product/${this.productId}/files`
},
},
methods: {
// Этот метод будет вызываться при нажатии на кнопку загрузки
// Он программно создаёт и нажимает файловыый input
onFileSelect() {
const el = document.createElement('input')
el.type = 'file'
el.multiple = true
el.accept = 'image/*'
el.click()
el.addEventListener('change', (e) => {
// При выборе файлов вызывается общий метод загрузки
this.onAddFiles({dataTransfer: e.target})
})
},
// Общий метод загрузки
// Он вызывается и при нажатии кнопки, и при перетаскивании файлов
async onAddFiles({dataTransfer}) {
// Проверяем, что нам есть с чем работать
const files = Array.from(dataTransfer.files)
if (!files.length) {
return
}
// Пишем очень хитрую функцию загрузки
// Она нужна, чтобы файлы грузились не всей толпой сразу по очереди
const asyncLoad = (file) => {
const reader = new FileReader()
return new Promise((resolve) => {
reader.onload = async ({target}) => {
// Вот здесь идёт отправка файла на сервер в виде строки base64
const {data} = await this.$axios.put(this.url, {
file: target.result,
// вместе с файлов улетает массив его свойств
metadata: {name: file.name, size: file.size, type: file.type},
})
resolve(data)
}
reader.readAsDataURL(file)
})
}
this.loading = true
try {
// Загрузка файлов!
for (const file of files) {
// Проверяем, что это картинка
if (file.type.includes('image/')) {
// И грузим
const res = await asyncLoad(file)
// Ответ от контроллера сохраняем в файлы
this.files.push(res)
}
}
} catch (e) {
} finally {
this.loading = false
}
},
// Функция сортировки
async onSort() {
const files = {}
// Пробегаем по файлам и сохраняем их новый rank
this.files.forEach((i, idx) => {
files[i.file_id] = idx
})
// Отправляем новый порядок файлов метдом POST
await this.$axios.post(this.url, {files})
},
},
}
</script>
Логика работы компонента слудующая:
- файлы могут быть выбраны кнопкой
- файлы могут быть перетащены
- в обоих случаях их грузит один и тот же асинхронный метод
- грузит строго по очереди, чтобы наш контроллер назначил всем правильную очерёдность в rank
- при сортировке файлов на сервер улетает массив file_id => rank методом POST, и контроллер обновляет порядок вывода всех файлов товара.
Все запросы на сервер идут с указанием product_id, потому что он сразу прописывается в динамический this.url.
Естественно, контроллер должен поддерживать новые методы, поэтому добавляем их в него:
// Загрузка файла
public function put(): ResponseInterface
{
// Проверяем, что нам вообще прислали
if (!$data = $this->getProperty('file')) {
return $this->failure('errors.upload.no_file');
}
// Создаём новый файл
$file = new File();
// И загружаем в него данные
// Файлы Vesp понимают base64 из коробки
$file->uploadFile($data, $this->getProperty('metadata'));
// Теперь создаём связь загруженного файла и товара
// То есть нашу модель ProductFile
$this->product->productFiles()->create(
[
'file_id' => $file->id,
// Не забываем указать rank для новой картинки
'rank' => $this->product->productFiles()->max('rank') + 1,
],
);
// Используем метод get(), чтобы вернуть загруженный файл
$this->setProperty('file_id', $file->id);
return $this->get();
}
// Сортировка осуществляется через POST
public function post(): ResponseInterface
{
// Просто проходим по массиву и обвновляем rank
foreach ($this->getProperty('files') as $file_id => $rank) {
$this->product->productFiles()->where('file_id', $file_id)->update(['rank' => $rank]);
}
return $this->success();
}
// Перед удалением файла товара удаляем и родительский файл
// Он больше не нужен
protected function beforeDelete(Model $record): ?ResponseInterface
{
/** @var ProductFile $record */
$record->file->delete();
return null;
}
Контроллер обновили, теперь смотрим оформление нашей галереи:
<template>
<div>
<!-- Кнопка загрузки файла, при нажатии вызовет метод onFileSelect -->
<b-button class="col-12 col-md-auto" @click="onFileSelect">
<!-- Не забудьте импртировать иконку в конфиге -->
<fa icon="upload" /> {{ $t('actions.upload') }}
</b-button>
<!-- Заворачиваем файлы в оверлей, для показа активности -->
<b-overlay :show="loading" opacity="0.5" class="mt-3">
<!-- Общий контейнер с файлами слушает события перетаскивания -->
<!-- и вызывает указанные методы -->
<div
:class="{files: true, 'drag-over': dragCount > 0}"
@drop.prevent="onAddFiles"
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@dragover.prevent
>
<!-- А вот и наша сортировка через vuedraggable -->
<!-- Компонент просто принимает список файлов в v-model -->
<!-- После сортировки он вызовет onSort() -->
<draggable v-model="files" class="files-container" animation="200" @change="onSort">
<!-- Ну и, наконец, сами файлы -->
<div v-for="image in files" :key="image.file_id">
<!-- изображения выводятся через API встроенной функцией $image -->
<b-img
:src="$image(image.file, {fit: 'crop', w: thumbWidth, h: thumbHeight})"
:width="thumbWidth"
:height="thumbHeight"
:class="{image: true, disabled: !image.active}"
alt=""
/>
</div>
</draggable>
</div>
</b-overlay>
</div>
</template>
Я не стал публиковать методы определения перетаскивания на страницу, общий смысл там в том, что когда вы таскаете файл мышкой по галерее, события dragenter и dragleave генерируются больше одного раза, из-за того, что мышка таскается над разными объёктами. Поэтому я просто считаю количество dragenter и минусую от них собатия dragleave.
Если число положительное - значит юзер держит файл мышкой над гелереей. Если нет - то зелёную подсветку можно убирать.
Так же я скрыл SCSS оформление гелереи - посмотрите в Github.
Вызываем нашу галерею в заранее подготовленной странице товара:
<template>
<vesp-modal :title="record.title" hide-footer size="xl">
<product-files :product-id="record.id" />
</vesp-modal>
</template>
<script>
import Create, {url} from '../create'
import ProductFiles from '../../../components/product/gallery'
// ...
Действия с файлами
Отлично, файлы загружаются и сортируются - но удалить и отключить мы их не пока можем. Давайте добавим кнопки с действиями под картинки:
<draggable ...>
<div ...>
<b-img ... />
<div class="d-flex justify-content-between mt-1">
<!-- В зависимости от статуса файла выводим кнопку отключения -->
<b-button v-if="image.active" variant="warning" size="sm" @click="onDisable(image)">
<fa icon="power-off" class="fa-fw" />
</b-button>
<!-- или включения -->
<b-button v-else variant="success" size="sm" @click="onEnable(image)">
<fa icon="check" class="fa-fw" />
</b-button>
<!-- И для всех файлов доступна кнопка удаления -->
<b-button variant="danger" size="sm" @click="onDelete(image)">
<fa icon="times" class="fa-fw" />
</b-button>
</div>
</div>
</draggable>
Теперь добавляем действия в script:
async onDisable(image) {
// Чтобы всё было быстро, отключаем картинку сразу
image.active = false
// А потом делаем запрос на сервер, чтобы это сохранить в БД
await this.$axios.patch(this.url + '/' + image.file_id, {active: false})
},
async onEnable(image) {
image.active = true
await this.$axios.patch(this.url + '/' + image.file_id, {active: true})
},
async onDelete(image) {
// А вот при удалении выводим индикатор загрузки
this.loading = true
// Делаем запрос методом DELETE
await this.$axios.delete(this.url + '/' + image.file_id)
// И удаляем нашу картинку из списка файлов
this.files = this.files.filter((i) => i.file_id !== image.file_id)
this.loading = false
},
Заключение
Мы с нуля создали модели для связи товаров с файлами, и написали компонент для их загрузки, с поддержкой drag-n-drop, сортировкой и разными действиями.
При желании можно добавить выделение файлов и какие-то массовые действиями с ними - все базовые методы для этого прописаны.
Из-за объёма заметки я намеренно не показал разные мелочи, поэтому ищите их в исходниках в нашем репозитории.
На следующем уроке будем украшать наш вывод товаров сортировкой и новыми картинками.
0
👍
👎
❤️
🔥
😮
😢
😀
😡
776
22.06.2022, 18:13:36
21 комментария
Игорь
25.06.2022, 04:06:07
Ошибка в тексте - в миграции ProductFile указано final class ProductImages extends Migration, вместо final class ProductFiles extends Migration... вдруг кто-то не посмотрит исходники репозитория.
Василий Наумкин
25.06.2022, 07:09:45
Поправил, спасибо!
Игорь
25.06.2022, 21:54:54
И еще - в самом уроке ты не упомянул о необходимости внести изменения в 2 файла core/src/Models/File.php и core/src/Models/Product.php. Поэтому, выполняя пошаговые действия, я получил ошибку при загрузке файлов в галлерею и сразу не смог понять, что пропустил сделать или где допустил ошибку))
Пока "экспериментировал", загрузил 10 файлов + все они прописались в таблице app_files базы данных. После редактирования File.php и Product.php все заработало, но ранее загруженные 10 файлов в галереи товара не вывелись. Их удалить и очистить таблицу app_files вручную?
Василий Наумкин
27.06.2022, 09:06:04
Спасибо за замечание, поправил заметку.
Да, конечно.
NightRider
26.06.2022, 22:50:46
Галерея огонь! Можно сказать замена msGallery) Вообще, если проводить аналогии с Modx, то было бы так же интересно рассмотреть что будет использоваться для вывода многоуровневого меню. То, что Eloquent заменяет pdoResources я уже понял, но опять же не хватает примера, как выводить товары из текущей и дочерних категорий. Впрочем наверное я забегаю вперед)
Василий Наумкин
27.06.2022, 09:10:18
Посмотри как работают комментарии на этом сайте - они просто отдаются контроллером в плоском виде, а собираются в ветки уже на фронтенде Vue.
Точно так же будет и с меню и со всем остальным. Задача контроллера - выдать нужные данные, а Vue уже всё оформит как пожелаешь.
Скорее, Eloquent заменяет xPDO, а pdoResources просто становится не нужен.
Александр Наумов
30.07.2022, 14:58:55
Василий, добрый день!
У меня все работает, только нет загруженного изображения.
Перехожу по ссылке на картинку: http://vesp-shop.test/api/image/14?w=250&h=250&fit=crop&t=1659181150000 там сообщение: "Unable to write temp file for 6/2/e/62e5185ebf25d4.83671893.jpg."
У меня где-то стоит запрет на создание папок и файлов, я так понял.
В логе:
Подскажи, пожалуйста, где, что подкрутить нужно?
Я немного в ступоре от лога, в нем сказано:
Захожу на Гитхаб, что бы посмотреть файл Server.php, а там папки vendor нет.
Василий Наумкин
30.07.2022, 17:13:09
Судя по ошибке, у тебя временная папка для php указана в корне сервера, куда сайт не может получить доступ.
Первый вариант: указать такие настройки php-fpm для сайта:
Создать эту директорию и перезапустить процесс.
Второй - просто убрать настройку open_basedir, если сайт крутится не на живом хостинге, а локально.
Александр Наумов
30.07.2022, 22:58:35
Василий, спасибо большое!
Первый вариант помог, не было вот этой настройки: php_value[sys_temp_dir].
Futuris
10.05.2023, 09:10:10
Получилась только первая часть урока, до "Компонент галереи". В админке, на "странице Товаров" появились иконки изображений, при нажатии на которые всплывает модал с сообщением. Ну т.е. как и должно быть:
После того, как проделал остальные операции, при нажатии на иконку, появляется окно с кнопкой "Загрузка" и сразу (еще до нажатия на кнопку) выскакивает "Ошибка сервера 500":
Пробовал смотреть логи сервера, но формируются только access‑логи, логов с ошибками нет. Обращался к хостерам (сайт висит на VPS с Ubuntu 20.04), чтобы посмотрели не на их ли стороне ошибка. Они ответили:
Василий Наумкин
10.05.2023, 09:43:21
Всё правильно тебе ответили, смотри логи сервера или включи вывод ошибок на страницу в PHP.
Как иначе угадать, что там за 500 ошибка?
Futuris
10.05.2023, 09:52:09
Это в php.ini ?
Василий Наумкин
10.05.2023, 10:11:27
Думаю, настройка твоего сервера для вывода ошибок лежит за рамками этого курса.
Futuris
10.05.2023, 10:14:14
да, конечно. Буду разбираться. Спасибо
Futuris
10.05.2023, 10:45:35
В итоге получили такую ошибку:
Василий Наумкин
10.05.2023, 11:05:18
Слушай, ну реально уже не смешно. Я тебе не гугл, чтобы мне все вопросы писать.
На Github лежит рабочий код, на https://shop.vesp.pro крутится рабочий магазин. Если ты что-то опять криво скопировал, или не импортировал используемый класс ResponseInterface - это не ко мне.
Читай внимательно, гугли, проверяй, думай.
Futuris
10.05.2023, 11:14:17
Извини. Мне пока сложно в этом массиве информации вычленить что и почему происходит. Спасибо за наводку! В этот раз вроде вперед не забегал и ничего лишнего не делал. И понять не могу, как я пропустил в уроках ранее импорт класса ResponseInterface, вроде по инструкции все делал. Буду разбираться, смотреть исходники и перечитывать.
Василий Наумкин
10.05.2023, 11:36:56
Строка 10
Futuris
11.05.2023, 10:00:01
Слегка продвинулся. Прошелся по всем файлам, упоминавшимся в уроке, проверил, скопировал с Github. В итоге слегка продвинулся. Ошибка, которая появлялась сразу при нажатии на иконку картинки и появлении модала исчезла.
Теперь модал открывается без ошибок и него появилась рамка.
Но при попытке загрузить фото, выскакивает ошибка 500 и фото не грузятся.
Futuris
14.05.2023, 09:52:31
Разобрался с ошибкой. Блин, конечно, в самом тексте ошибки указано было, что нужно добавить в файл
. Грузятся теперь фотки.
Василий Наумкин
15.05.2023, 09:11:57
Молодец!
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Василий Наумкин
23.12.2024, 05:33:00
В MODX сначала создали проблему, автоматически генерируя адреса, а потом "решили" заморозкой.
Так ч...
Дмитрий
14.12.2024, 09:10:38
Василий, прошу прощения, тупанул, не разобрался сразу. Фреймворк отличный! "Чистый лист" на vue, рис...
Василий Наумкин
05.12.2024, 20:01:14
В итоге основная ошибка была в неправильном общем root в Nginx, из-за чего запросы не улетали на фай...
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен.
Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500
Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи.
...
Уровни подписки
Спасибо!
500 ₽ в месяц
Эта подписка ничего не даёт, просто возможность сказать спасибо за мои заметки. Подписчики отмечаются зелёненьким цветом в комментариях.
Большое спасибо!
1 000 ₽ в месяц
И эта подписка не даёт ничего, кроме оранжевого цвета в комментариях и возможности сказать спасибо, но уже большое!