Галерея товаров
На прошлом уроке вы сгенерировали и вывели наши товары на сайте.
Выглядит это пока простенько, поэтому довайте добавим нашим товарам картинки. То есть, напишем галерею товара.
Этот урок доступен бесплатно в рекламных целях, вдруг кто-то почитает и захочет оплатить доступ и к предыдущим?
У 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
👍
👎
❤️
🔥
😮
😢
😀
😡
862
22.06.2022 18:13:36